diff --git a/backend-tests/AgentServiceTests.cs b/backend-tests/AgentServiceTests.cs index ae1f572..03b9db6 100644 --- a/backend-tests/AgentServiceTests.cs +++ b/backend-tests/AgentServiceTests.cs @@ -1,6 +1,6 @@ using Nexus.Api.Services; using Nexus.Api.Integrations; -using Nexus.Api.Domain; +using Nexus.Api.Data; using Microsoft.Extensions.Configuration; using Xunit; diff --git a/backend/Contracts/Requests.cs b/backend/Contracts/Requests.cs deleted file mode 100644 index 0110406..0000000 --- a/backend/Contracts/Requests.cs +++ /dev/null @@ -1,9 +0,0 @@ -namespace Nexus.Api.Contracts; - -public sealed record CreateProjectRequest(string Name, string? Description); -public sealed record CreateTaskRequest(string Title, string? Priority, Guid? ProjectId); -public sealed record UpdateTaskStateRequest(string State); -public sealed record ChatRequest(string Message, string? ConversationId, string? AgentId); - -public sealed record UpdateProjectRequest(string? Name, string? Description, string? Status); -public sealed record UpdateTaskRequest(string? Title, string? Priority, Guid? ProjectId); diff --git a/backend/Contracts/Responses.cs b/backend/Contracts/Responses.cs deleted file mode 100644 index a202d2e..0000000 --- a/backend/Contracts/Responses.cs +++ /dev/null @@ -1,50 +0,0 @@ -using Nexus.Api.Domain; - -namespace Nexus.Api.Contracts; - -public sealed record AgentListResponse( - string Id, - string Name, - string Role, - string Model, - string Status, - DateTimeOffset? LastSeen, - string? Workspace, - string? Description -); - -public sealed record AgentDetailResponse( - string Id, - string Name, - string Role, - string Model, - string Status, - DateTimeOffset? LastSeen, - string? Workspace, - string? AgentDir, - string? Description, - IReadOnlyList? SubAgents, - string? IdentityName -); - -public sealed record AgentCommandRequest(string Message); - -public sealed record AgentCommandResponse( - string Runtime, - string AgentId, - string ConversationId, - string Content -); - -public sealed record ProjectHealth( - int Online, - int Offline, - int Degraded, - int Unknown -); - -public sealed record IncidentInfo( - Guid? TaskId, - string? Title, - DateTimeOffset? Since -); diff --git a/backend/Controllers/ActivityController.cs b/backend/Controllers/ActivityController.cs new file mode 100644 index 0000000..3fe718a --- /dev/null +++ b/backend/Controllers/ActivityController.cs @@ -0,0 +1,32 @@ +using Microsoft.AspNetCore.Mvc; +using Nexus.Api.Repositories; + +namespace Nexus.Api.Controllers; + +[ApiController] +[Route("api/v1/activity")] +public class ActivityController(IActivityRepository activityRepo) : ControllerBase +{ + [HttpGet] + public async Task Get( + [FromQuery] string? type, + [FromQuery] string? sort, + [FromQuery] int? page, + [FromQuery] int? pageSize, + CancellationToken ct) + { + var take = Math.Clamp(pageSize ?? 20, 1, 200); + var pageNum = Math.Max(page ?? 1, 1); + + var (items, totalCount) = await activityRepo.GetPagedAsync(type, sort, pageNum, take, ct); + + return Results.Ok(new + { + items = items.Select(x => new { x.Id, x.Type, x.Message, at = x.CreatedAt }), + totalCount, + page = pageNum, + pageSize = take, + totalPages = (int)Math.Ceiling((double)totalCount / take) + }); + } +} diff --git a/backend/Controllers/AgentsController.cs b/backend/Controllers/AgentsController.cs new file mode 100644 index 0000000..a21be04 --- /dev/null +++ b/backend/Controllers/AgentsController.cs @@ -0,0 +1,151 @@ +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.RateLimiting; +using Nexus.Api.Data; +using Nexus.Api.DTOs; +using Nexus.Api.Helpers; +using Nexus.Api.Integrations; +using Nexus.Api.Repositories; +using Nexus.Api.Services; + +namespace Nexus.Api.Controllers; + +[ApiController] +[Route("api/v1/agents")] +public class AgentsController( + IAgentService agentService, + IAgentRuntime runtime, + IActivityRepository activityRepo, + ILogger logger) : ControllerBase +{ + [HttpGet] + public async Task GetAgents(CancellationToken ct) + { + var agents = await agentService.GetAgentsAsync(ct); + return Results.Ok(agents.Select(a => new AgentListResponse( + a.Id, a.Name, a.Role, a.Model, a.Status.ToString(), a.LastSeen, a.Workspace, a.Description + ))); + } + + [HttpGet("{id}")] + public async Task GetAgent(string id, CancellationToken ct) + { + var agent = await agentService.GetAgentAsync(id, ct); + if (agent is null) return Results.NotFound(); + return Results.Ok(new AgentDetailResponse( + agent.Id, agent.Name, agent.Role, agent.Model, agent.Status.ToString(), + agent.LastSeen, agent.Workspace, agent.AgentDir, agent.Description, + agent.SubAgents, agent.IdentityName + )); + } + + [HttpGet("{id}/activity")] + public async Task GetAgentActivity(string id, CancellationToken ct) + { + var items = await activityRepo.GetByAgentAsync(id, 50, ct); + return Results.Ok(items.Select(x => new { x.Id, x.Type, x.Message, at = x.CreatedAt })); + } + + [HttpPost("{id}/command")] + [EnableRateLimiting("agents")] + public async Task SendCommand(string id, [FromBody] AgentCommandRequest request, CancellationToken ct) + { + var message = request.Message?.Trim(); + if (string.IsNullOrWhiteSpace(message) || message.Length > 8000) + return Results.ValidationProblem(new Dictionary { ["message"] = ["Message must contain between 1 and 8000 characters."] }); + + var conversationId = $"nexus-command-{id}-{Guid.NewGuid():N}"; + + try + { + var result = await runtime.ChatAsync(message, conversationId, id, ct); + + await activityRepo.AddAsync(new ActivityEvent { Type = "agent", Message = $"Command sent to agent {id}: {message[..Math.Min(message.Length, 80)]}" }, ct); + + return Results.Ok(new AgentCommandResponse(result.Runtime, result.AgentId, result.ConversationId, result.Content)); + } + catch (Exception exception) + { + logger.LogWarning(exception, "Agent command failed for {AgentId}", id); + return Results.Problem( + title: "Agent command failed", + detail: $"Could not send command to agent {id}: {exception.Message}", + statusCode: StatusCodes.Status503ServiceUnavailable); + } + } + + // ========== Agent Config Editor ========== + + [HttpGet("{id}/config")] + public IResult GetConfig(string id) + { + var workspacePath = $"/mnt/workspace-{id}"; + if (!Directory.Exists(workspacePath)) + return Results.Ok(Array.Empty()); + + var allowedFiles = new HashSet(StringComparer.OrdinalIgnoreCase) + { + "IDENTITY.md", "SOUL.md", "AGENTS.md", "TOOLS.md", "HEARTBEAT.md", "USER.md", "MEMORY.md" + }; + + var files = Directory.GetFiles(workspacePath, "*.md") + .Select(f => new FileInfo(f)) + .Where(f => allowedFiles.Contains(f.Name)) + .OrderBy(f => f.Name) + .Select(f => new + { + fileName = f.Name, + size = f.Length, + modifiedAt = f.LastWriteTimeUtc + }) + .ToList(); + + return Results.Ok(files); + } + + [HttpGet("{id}/config/{fileName}")] + public async Task GetConfigFile(string id, string fileName, CancellationToken ct) + { + if (!PathSecurityHelper.IsValidConfigFileName(fileName)) + return Results.BadRequest(new { error = "Invalid filename. Only .md files with alphanumeric characters, dots, hyphens, and underscores are allowed." }); + + var workspacePath = $"/mnt/workspace-{id}"; + if (!PathSecurityHelper.TryResolveSafePath(workspacePath, fileName, out var safePath) || !System.IO.File.Exists(safePath)) + return Results.NotFound(); + + var content = await System.IO.File.ReadAllTextAsync(safePath!, ct); + var fi = new FileInfo(safePath!); + return Results.Ok(new { fileName, content, size = fi.Length, modifiedAt = fi.LastWriteTimeUtc }); + } + + [HttpPut("{id}/config/{fileName}")] + public async Task SaveConfigFile(string id, string fileName, [FromBody] SaveConfigRequest request, CancellationToken ct) + { + if (!PathSecurityHelper.IsValidConfigFileName(fileName)) + return Results.BadRequest(new { error = "Invalid filename. Only .md files with alphanumeric characters, dots, hyphens, and underscores are allowed." }); + + if (request.Content is null) + return Results.BadRequest(new { error = "Content is required." }); + + if (request.Content.Length > 500 * 1024) + return Results.BadRequest(new { error = "Content exceeds maximum size of 500KB." }); + + var workspacePath = $"/mnt/workspace-{id}"; + if (!PathSecurityHelper.TryResolveSafePath(workspacePath, fileName, out var safePath)) + return Results.NotFound(); + + var tempPath = safePath + ".tmp"; + try + { + await System.IO.File.WriteAllTextAsync(tempPath, request.Content, ct); + System.IO.File.Move(tempPath, safePath, overwrite: true); + } + catch + { + if (System.IO.File.Exists(tempPath)) System.IO.File.Delete(tempPath); + throw; + } + + var fi = new FileInfo(safePath); + return Results.Ok(new { fileName, size = fi.Length, modifiedAt = fi.LastWriteTimeUtc }); + } +} diff --git a/backend/Controllers/AuthController.cs b/backend/Controllers/AuthController.cs new file mode 100644 index 0000000..7419114 --- /dev/null +++ b/backend/Controllers/AuthController.cs @@ -0,0 +1,141 @@ +using Microsoft.AspNetCore.Antiforgery; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.RateLimiting; +using Microsoft.Extensions.Diagnostics.HealthChecks; +using Nexus.Api.DTOs; +using Nexus.Api.Integrations; +using Nexus.Api.Services; + +namespace Nexus.Api.Controllers; + +[ApiController] +[Route("api/v1/auth")] +public class AuthController( + IAuthService authService, + IAntiforgery antiforgery, + IConfiguration config, + IHostEnvironment env) : ControllerBase +{ + [HttpGet("csrf")] + public IActionResult GetCsrfToken() + { + var tokens = antiforgery.GetAndStoreTokens(HttpContext); + return Ok(new { token = tokens.RequestToken }); + } + + [HttpPost("login")] + [EnableRateLimiting("auth")] + public async Task Login([FromBody] LoginRequest request, CancellationToken ct) + { + if (string.IsNullOrWhiteSpace(request.Email) || string.IsNullOrWhiteSpace(request.Password)) + return Results.ValidationProblem(new Dictionary { ["credentials"] = ["Email and password are required."] }); + + var session = await authService.LoginAsync(request, ct); + if (session is null) return Results.Unauthorized(); + + SetRefreshCookie(Response, session.RefreshToken); + Response.Headers.CacheControl = "no-store"; + return Results.Ok(ToAuthResponse(session)); + } + + [HttpPost("refresh")] + [EnableRateLimiting("auth")] + public async Task Refresh(CancellationToken ct) + { + if (!Request.Cookies.TryGetValue("nexus_refresh", out var refreshToken)) + return Results.Unauthorized(); + + var session = await authService.RefreshAsync(refreshToken!, ct); + if (session is null) + { + ClearRefreshCookie(Response); + return Results.Unauthorized(); + } + + SetRefreshCookie(Response, session.RefreshToken); + Response.Headers.CacheControl = "no-store"; + return Results.Ok(ToAuthResponse(session)); + } + + [HttpPost("logout")] + public async Task Logout(CancellationToken ct) + { + if (Request.Cookies.TryGetValue("nexus_refresh", out var refreshToken)) + await authService.RevokeAsync(refreshToken!, ct); + + ClearRefreshCookie(Response); + return Results.NoContent(); + } + + [HttpGet("me")] + public async Task GetMe(CancellationToken ct) + { + var subject = User.FindFirst(System.IdentityModel.Tokens.Jwt.JwtRegisteredClaimNames.Sub)?.Value; + if (!Guid.TryParse(subject, out var userId)) return Results.Unauthorized(); + + var user = await authService.GetUserAsync(userId, ct); + return user is null + ? Results.Unauthorized() + : Results.Ok(new UserInfo { Id = user.Id, Email = user.Email, DisplayName = user.DisplayName, Role = user.Role }); + } + + [HttpPatch("profile")] + public async Task UpdateProfile([FromBody] UpdateProfileRequest request, CancellationToken ct) + { + var subject = User.FindFirst(System.IdentityModel.Tokens.Jwt.JwtRegisteredClaimNames.Sub)?.Value; + if (!Guid.TryParse(subject, out var userId)) return Results.Unauthorized(); + + var user = await authService.UpdateProfileAsync(userId, request, ct); + return user is null + ? Results.NotFound() + : Results.Ok(new UserInfo { Id = user.Id, Email = user.Email, DisplayName = user.DisplayName, Role = user.Role }); + } + + [HttpPost("change-password")] + public async Task ChangePassword([FromBody] ChangePasswordRequest request, CancellationToken ct) + { + if (string.IsNullOrWhiteSpace(request.CurrentPassword) || string.IsNullOrWhiteSpace(request.NewPassword)) + return Results.ValidationProblem(new Dictionary { ["password"] = ["Current and new passwords are required."] }); + + if (request.NewPassword.Length < 10) + return Results.ValidationProblem(new Dictionary { ["newPassword"] = ["New password must be at least 10 characters."] }); + + var subject = User.FindFirst(System.IdentityModel.Tokens.Jwt.JwtRegisteredClaimNames.Sub)?.Value; + if (!Guid.TryParse(subject, out var userId)) return Results.Unauthorized(); + + var success = await authService.ChangePasswordAsync(userId, request, ct); + return success ? Results.Ok(new { message = "Password changed successfully." }) : Results.Problem("Current password is incorrect.", statusCode: 400); + } + + private static AuthResponse ToAuthResponse(AuthSession session) => new() + { + AccessToken = session.AccessToken, + ExpiresAt = session.ExpiresAt, + User = session.User + }; + + private void SetRefreshCookie(HttpResponse response, string token) + { + var days = config.GetValue("Jwt:RefreshTokenExpirationDays") ?? 7; + response.Cookies.Append("nexus_refresh", token, new CookieOptions + { + HttpOnly = true, + Secure = !env.IsDevelopment(), + SameSite = SameSiteMode.Strict, + Path = "/api/v1/auth", + MaxAge = TimeSpan.FromDays(days), + IsEssential = true + }); + } + + private void ClearRefreshCookie(HttpResponse response) + { + response.Cookies.Delete("nexus_refresh", new CookieOptions + { + HttpOnly = true, + Secure = !env.IsDevelopment(), + SameSite = SameSiteMode.Strict, + Path = "/api/v1/auth" + }); + } +} diff --git a/backend/Controllers/CalendarController.cs b/backend/Controllers/CalendarController.cs new file mode 100644 index 0000000..a77efb1 --- /dev/null +++ b/backend/Controllers/CalendarController.cs @@ -0,0 +1,80 @@ +using Microsoft.AspNetCore.Mvc; +using Nexus.Api.DTOs; + +namespace Nexus.Api.Controllers; + +[ApiController] +[Route("api/v1/calendar")] +public class CalendarController(IConfiguration config, IHttpClientFactory httpClientFactory, ILogger logger) : ControllerBase +{ + [HttpGet] + public async Task GetAll(CancellationToken ct) + { + var gatewayToken = config["Integrations:OpenClaw:Token"] ?? ""; + + try + { + var httpClient = httpClientFactory.CreateClient("gateway"); + if (!string.IsNullOrWhiteSpace(gatewayToken)) + httpClient.DefaultRequestHeaders.Authorization = + new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", gatewayToken); + + var response = await httpClient.GetAsync("/api/cron", ct); + if (response.IsSuccessStatusCode) + { + var data = await response.Content.ReadFromJsonAsync>(ct); + return Results.Ok(data ?? new List()); + } + } + catch (Exception ex) + { + logger.LogDebug(ex, "Gateway cron endpoint not reachable, using fallback data."); + } + + var fallbackJobs = new List + { + new { id = "health-check", name = "Health Check", schedule = "*/5 * * * *", lastRun = DateTimeOffset.UtcNow.AddMinutes(-3).ToString("O"), nextRun = DateTimeOffset.UtcNow.AddMinutes(2).ToString("O"), status = "completed" }, + new { id = "memory-sync", name = "Memory Sync", schedule = "0 */6 * * *", lastRun = DateTimeOffset.UtcNow.AddHours(-2).ToString("O"), nextRun = DateTimeOffset.UtcNow.AddHours(4).ToString("O"), status = "completed" }, + new { id = "task-cleanup", name = "Task Cleanup", schedule = "0 3 * * *", lastRun = DateTimeOffset.UtcNow.AddDays(-1).ToString("O"), nextRun = DateTimeOffset.UtcNow.AddDays(1).AddHours(3).ToString("O"), status = "completed" }, + new { id = "backup", name = "Database Backup", schedule = "0 4 * * *", lastRun = DateTimeOffset.UtcNow.AddDays(-1).AddHours(-1).ToString("O"), nextRun = DateTimeOffset.UtcNow.AddDays(1).AddHours(4).ToString("O"), status = "completed" }, + new { id = "model-routing-refresh", name = "Model Routing Refresh", schedule = "*/30 * * * *", lastRun = DateTimeOffset.UtcNow.AddMinutes(-12).ToString("O"), nextRun = DateTimeOffset.UtcNow.AddMinutes(18).ToString("O"), status = "running" }, + }; + return Results.Ok(fallbackJobs); + } + + [HttpGet("upcoming")] + public async Task GetUpcoming(CancellationToken ct) + { + var gatewayToken = config["Integrations:OpenClaw:Token"] ?? ""; + + try + { + var httpClient = httpClientFactory.CreateClient("gateway"); + if (!string.IsNullOrWhiteSpace(gatewayToken)) + httpClient.DefaultRequestHeaders.Authorization = + new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", gatewayToken); + + var response = await httpClient.GetAsync("/api/cron/upcoming", ct); + if (response.IsSuccessStatusCode) + { + var data = await response.Content.ReadFromJsonAsync>(ct); + return Results.Ok(data ?? new List()); + } + } + catch (Exception ex) + { + logger.LogDebug(ex, "Gateway upcoming cron endpoint not reachable, using fallback data."); + } + + var now = DateTimeOffset.UtcNow; + var fallback = new List + { + new { id = "health-check", name = "Health Check", nextRun = now.AddMinutes(2).ToString("O"), schedule = "*/5 * * * *" }, + new { id = "model-routing-refresh", name = "Model Routing Refresh", nextRun = now.AddMinutes(18).ToString("O"), schedule = "*/30 * * * *" }, + new { id = "memory-sync", name = "Memory Sync", nextRun = now.AddHours(4).ToString("O"), schedule = "0 */6 * * *" }, + new { id = "task-cleanup", name = "Task Cleanup", nextRun = now.AddDays(1).AddHours(3).ToString("O"), schedule = "0 3 * * *" }, + new { id = "backup", name = "Database Backup", nextRun = now.AddDays(1).AddHours(4).ToString("O"), schedule = "0 4 * * *" }, + }; + return Results.Ok(fallback); + } +} diff --git a/backend/Controllers/ChatController.cs b/backend/Controllers/ChatController.cs new file mode 100644 index 0000000..7e407e1 --- /dev/null +++ b/backend/Controllers/ChatController.cs @@ -0,0 +1,43 @@ +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.RateLimiting; +using Nexus.Api.DTOs; +using Nexus.Api.Integrations; + +namespace Nexus.Api.Controllers; + +[ApiController] +[Route("api/v1/chat")] +public class ChatController(IAgentRuntime runtime, ILogger logger) : ControllerBase +{ + [HttpPost] + [EnableRateLimiting("agents")] + public async Task Chat([FromBody] ChatRequest request, CancellationToken ct) + { + var message = request.Message?.Trim(); + if (string.IsNullOrWhiteSpace(message) || message.Length > 8000) + return Results.ValidationProblem(new Dictionary { ["message"] = ["Message must contain between 1 and 8000 characters."] }); + + var agentId = string.IsNullOrWhiteSpace(request.AgentId) ? "iris" : request.AgentId.Trim().ToLowerInvariant(); + if (agentId is not ("iris" or "main")) + return Results.ValidationProblem(new Dictionary { ["agentId"] = ["Only iris and main are supported."] }); + + var conversationId = string.IsNullOrWhiteSpace(request.ConversationId) + ? $"nexus-{Guid.NewGuid():N}" + : request.ConversationId.Trim(); + if (conversationId.Length > 160) + return Results.ValidationProblem(new Dictionary { ["conversationId"] = ["Conversation id is too long."] }); + + try + { + return Results.Ok(await runtime.ChatAsync(message, conversationId, agentId, ct)); + } + catch (Exception exception) + { + logger.LogWarning(exception, "OpenClaw chat request failed for agent {AgentId}", agentId); + return Results.Problem( + title: "OpenClaw chat unavailable", + detail: "The trusted OpenClaw chat endpoint is not enabled or reachable.", + statusCode: StatusCodes.Status503ServiceUnavailable); + } + } +} diff --git a/backend/Controllers/DocsController.cs b/backend/Controllers/DocsController.cs new file mode 100644 index 0000000..4572ec5 --- /dev/null +++ b/backend/Controllers/DocsController.cs @@ -0,0 +1,69 @@ +using Microsoft.AspNetCore.Mvc; +using Nexus.Api.Helpers; + +namespace Nexus.Api.Controllers; + +[ApiController] +[Route("api/v1/docs")] +public class DocsController : ControllerBase +{ + [HttpGet] + public IResult GetAll() + { + var workspaceRoot = "/mnt/workspace-iris"; + var results = new List(); + + void ScanDir(string dir, string category) + { + if (!Directory.Exists(dir)) return; + foreach (var file in Directory.GetFiles(dir, "*.*")) + { + var ext = Path.GetExtension(file).ToLowerInvariant(); + if (ext is not (".md" or ".json" or ".txt" or ".yaml" or ".yml" or ".html" or ".css")) + continue; + var fi = new FileInfo(file); + results.Add(new + { + name = fi.Name, + path = file.Replace(workspaceRoot, "").TrimStart('/'), + category, + type = ext.Replace(".", ""), + size = fi.Length, + modifiedAt = fi.LastWriteTimeUtc + }); + } + } + + ScanDir("/mnt/workspace-iris/nexus-phases", "phases"); + ScanDir("/mnt/workspace-iris/skills", "skills"); + ScanDir("/mnt/workspace-iris", "workspace"); + ScanDir("/home/node/.openclaw/workspace/nexus", "nexus"); + ScanDir("/home/node/.openclaw/workspace/nexus/phases", "nexus-phases"); + + return Results.Ok(results.OrderByDescending(x => ((DateTime)((dynamic)x).modifiedAt)).Take(100)); + } + + [HttpGet("{**path}")] + public async Task GetFile(string path) + { + if (string.IsNullOrWhiteSpace(path)) + return Results.BadRequest("Path required."); + + string? resolvedPath = null; + foreach (var root in new[] { "/mnt/workspace-iris", "/home/node/.openclaw/workspace/nexus" }) + { + if (PathSecurityHelper.TryResolveSafePath(root, path, out var candidate) && System.IO.File.Exists(candidate)) + { + resolvedPath = candidate; + break; + } + } + + if (resolvedPath is null) + return Results.NotFound(); + + var content = await System.IO.File.ReadAllTextAsync(resolvedPath); + var fi = new FileInfo(resolvedPath); + return Results.Ok(new { name = fi.Name, path = resolvedPath.Replace("/mnt/workspace-iris/", "").Replace("/home/node/.openclaw/workspace/nexus/", ""), content, size = fi.Length, modifiedAt = fi.LastWriteTimeUtc }); + } +} diff --git a/backend/Controllers/HealthController.cs b/backend/Controllers/HealthController.cs new file mode 100644 index 0000000..d239ded --- /dev/null +++ b/backend/Controllers/HealthController.cs @@ -0,0 +1,49 @@ +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Diagnostics.HealthChecks; +using Nexus.Api.Integrations; + +namespace Nexus.Api.Controllers; + +[ApiController] +public class HealthController(IAgentRuntime runtime, HealthCheckService healthChecks) : ControllerBase +{ + [HttpGet("/health")] + public async Task Get(CancellationToken ct) + { + var report = await healthChecks.CheckHealthAsync(ct); + + string runtimeStatus; + string? runtimeDetail; + try + { + var status = await runtime.GetStatusAsync(ct); + runtimeStatus = status.Status.ToString(); + runtimeDetail = status.Detail; + } + catch (Exception ex) + { + runtimeStatus = "Offline"; + runtimeDetail = ex.Message; + } + + var entries = report.Entries.ToDictionary( + e => e.Key, + e => new + { + status = e.Value.Status.ToString(), + description = e.Value.Description, + data = e.Value.Data + }); + + entries["runtime"] = new + { + status = runtimeStatus, + description = runtimeDetail ?? "Runtime status checked", + data = (IReadOnlyDictionary)new Dictionary() + }; + + var isHealthy = report.Status == HealthStatus.Healthy && runtimeStatus == "Online"; + return isHealthy ? Results.Ok(new { status = "Healthy", checks = entries, timestamp = DateTimeOffset.UtcNow }) + : Results.Ok(new { status = "Degraded", checks = entries, timestamp = DateTimeOffset.UtcNow }); + } +} diff --git a/backend/Controllers/IncidentsController.cs b/backend/Controllers/IncidentsController.cs new file mode 100644 index 0000000..91722ed --- /dev/null +++ b/backend/Controllers/IncidentsController.cs @@ -0,0 +1,100 @@ +using Microsoft.AspNetCore.Mvc; +using Nexus.Api.Helpers; +using System.Text.RegularExpressions; + +namespace Nexus.Api.Controllers; + +[ApiController] +[Route("api/v1/incidents")] +public class IncidentsController : ControllerBase +{ + [HttpGet] + public async Task GetAll() + { + var basePath = "/mnt/workspace-iris/memory/incidents"; + if (!Directory.Exists(basePath)) + return Results.Ok(Array.Empty()); + + var incidents = new List(); + foreach (var file in Directory.GetFiles(basePath, "*.md").OrderByDescending(f => f).Take(50)) + { + var fi = new FileInfo(file); + if (fi.Length > 1_000_000) continue; + var name = Path.GetFileNameWithoutExtension(file); + var content = await System.IO.File.ReadAllTextAsync(file); + + var title = name; + var titleMatch = Regex.Match(content, @"^#\s+(.+)$", RegexOptions.Multiline); + if (titleMatch.Success) + title = titleMatch.Groups[1].Value.Trim(); + + var date = (string?)null; + var dateMatch = Regex.Match(name, @"^(\d{4}-\d{2}-\d{2})"); + if (dateMatch.Success) + date = dateMatch.Groups[1].Value; + + var severity = "unknown"; + var severityMatch = Regex.Match(content, @"\*\*Severity:\*\*\s*(.+)$", RegexOptions.Multiline); + if (severityMatch.Success) + severity = severityMatch.Groups[1].Value.Trim(); + + var excerptEnd = content.IndexOf("\n## ", StringComparison.Ordinal); + var excerpt = excerptEnd > 0 + ? content[..excerptEnd].Trim() + : content[..Math.Min(300, content.Length)].Trim(); + if (excerpt.Length > 200) + excerpt = excerpt[..200] + "\u2026"; + + incidents.Add(new + { + name = Path.GetFileName(file), + title, + date, + severity, + excerpt, + size = fi.Length + }); + } + + return Results.Ok(incidents); + } + + [HttpGet("{name}")] + public async Task GetOne(string name) + { + var basePath = "/mnt/workspace-iris/memory/incidents"; + if (!PathSecurityHelper.TryResolveSafePath(basePath, name, out var filePath)) + return Results.BadRequest("Invalid filename."); + + if (!System.IO.File.Exists(filePath!)) + { + if (!name.EndsWith(".md", StringComparison.OrdinalIgnoreCase)) + filePath = Path.Combine(basePath, name + ".md"); + if (!System.IO.File.Exists(filePath!)) + return Results.NotFound(); + } + + var content = await System.IO.File.ReadAllTextAsync(filePath!); + var fi = new FileInfo(filePath!); + var fileName = Path.GetFileName(filePath!); + + var title = fileName; + var titleMatch = Regex.Match(content, @"^#\s+(.+)$", RegexOptions.Multiline); + if (titleMatch.Success) + title = titleMatch.Groups[1].Value.Trim(); + + var date = (string?)null; + var dateMatch = Regex.Match(fileName, @"^(\d{4}-\d{2}-\d{2})"); + if (dateMatch.Success) + date = dateMatch.Groups[1].Value; + + return Results.Ok(new + { + name = fileName, + title, + date, + content, + size = fi.Length + }); + } +} diff --git a/backend/Controllers/MemoryController.cs b/backend/Controllers/MemoryController.cs new file mode 100644 index 0000000..0cc4150 --- /dev/null +++ b/backend/Controllers/MemoryController.cs @@ -0,0 +1,108 @@ +using Microsoft.AspNetCore.Mvc; +using Nexus.Api.Helpers; + +namespace Nexus.Api.Controllers; + +[ApiController] +[Route("api/v1/memory")] +public class MemoryController : ControllerBase +{ + [HttpGet] + public IResult GetAll() + { + var basePath = "/mnt/workspace-iris/memory"; + if (!Directory.Exists(basePath)) + return Results.Ok(Array.Empty()); + + var files = Directory.GetFiles(basePath, "*.md") + .Select(f => new FileInfo(f)) + .OrderByDescending(f => f.Name) + .Select(f => new + { + name = f.Name, + path = f.FullName.Replace(basePath, "").TrimStart('/'), + size = f.Length, + modifiedAt = f.LastWriteTimeUtc + }) + .ToList(); + + var longTermPath = "/mnt/workspace-iris/MEMORY.md"; + if (System.IO.File.Exists(longTermPath)) + { + var fi = new FileInfo(longTermPath); + files.Insert(0, new { name = "MEMORY.md", path = "MEMORY.md", size = fi.Length, modifiedAt = fi.LastWriteTimeUtc }); + } + + return Results.Ok(files); + } + + [HttpGet("search")] + public async Task Search([FromQuery] string q) + { + if (string.IsNullOrWhiteSpace(q) || q.Length < 2) + return Results.BadRequest("Query must be at least 2 characters."); + + var basePath = "/mnt/workspace-iris/memory"; + var results = new List(); + + const int maxFiles = 50; + const int maxFileSize = 1_000_000; + + async Task SearchDir(string dir) + { + if (!Directory.Exists(dir)) return; + var files = Directory.GetFiles(dir, "*.md").Take(maxFiles); + foreach (var file in files) + { + var fi = new FileInfo(file); + if (fi.Length > maxFileSize) continue; + string content; + using (var reader = new StreamReader(file)) + content = await reader.ReadToEndAsync(); + if (content.Contains(q, StringComparison.OrdinalIgnoreCase)) + { + var idx = content.IndexOf(q, StringComparison.OrdinalIgnoreCase); + var start = Math.Max(0, idx - 60); + var excerpt = (start > 0 ? "\u2026" : "") + content.Substring(start, Math.Min(200, content.Length - start)) + "\u2026"; + results.Add(new { name = Path.GetFileName(file), path = file.Replace(basePath, "").TrimStart('/'), excerpt, size = fi.Length }); + } + } + } + + await SearchDir(basePath); + + var longTermPath = "/mnt/workspace-iris/MEMORY.md"; + if (System.IO.File.Exists(longTermPath)) + { + string content; + using (var reader = new StreamReader(longTermPath)) + content = await reader.ReadToEndAsync(); + if (content.Contains(q, StringComparison.OrdinalIgnoreCase)) + { + var idx = content.IndexOf(q, StringComparison.OrdinalIgnoreCase); + var start = Math.Max(0, idx - 60); + var excerpt = (start > 0 ? "\u2026" : "") + content.Substring(start, Math.Min(200, content.Length - start)) + "\u2026"; + results.Insert(0, new { name = "MEMORY.md", path = "MEMORY.md", excerpt, size = content.Length }); + } + } + + return Results.Ok(results); + } + + [HttpGet("{name}")] + public async Task GetFile(string name) + { + if (!PathSecurityHelper.TryResolveSafePath("/mnt/workspace-iris/memory", name, out var filePath)) + return Results.BadRequest("Invalid filename."); + + var longTermPath = "/mnt/workspace-iris/MEMORY.md"; + if (name.Equals("MEMORY.md", StringComparison.OrdinalIgnoreCase)) + filePath = longTermPath; + + if (!System.IO.File.Exists(filePath!)) + return Results.NotFound(); + + var content = await System.IO.File.ReadAllTextAsync(filePath!); + return Results.Ok(new { name, path = name, content, size = content.Length, modifiedAt = System.IO.File.GetLastWriteTimeUtc(filePath!) }); + } +} diff --git a/backend/Controllers/OperationsController.cs b/backend/Controllers/OperationsController.cs new file mode 100644 index 0000000..89dbb59 --- /dev/null +++ b/backend/Controllers/OperationsController.cs @@ -0,0 +1,71 @@ +using Microsoft.AspNetCore.Mvc; +using Nexus.Api.Data; +using Nexus.Api.Integrations; +using Nexus.Api.Repositories; +using Nexus.Api.Services; + +namespace Nexus.Api.Controllers; + +[ApiController] +[Route("api/v1/operations")] +public class OperationsController( + IAgentRuntime runtime, + IAgentService agentService, + IProjectRepository projectRepo, + ITaskRepository taskRepo, + IActivityRepository activityRepo) : ControllerBase +{ + [HttpGet("snapshot")] + public async Task GetSnapshot(CancellationToken ct) + { + var runtimeTask = runtime.GetStatusAsync(ct); + var agentsTask = agentService.GetAgentsAsync(ct); + var projectsTask = projectRepo.GetAllAsync(ct); + var tasksTask = taskRepo.GetAllAsync(ct); + var activityTask = activityRepo.GetRecentAsync(20, ct); + await Task.WhenAll(runtimeTask, agentsTask, projectsTask, tasksTask, activityTask); + + var tasks = tasksTask.Result; + var projects = projectsTask.Result; + var agents = agentsTask.Result; + var completedTasks = tasks.Count(x => x.State == TaskStateHelper.ToStateString(TaskState.Done)); + + var runtimeStatus = runtimeTask.Result; + var runtimeHealthy = runtimeStatus.Status == OperationalStatus.Online; + + var lastIncident = tasks + .Where(x => x.State == TaskStateHelper.ToStateString(TaskState.Blocked)) + .OrderByDescending(x => x.UpdatedAt) + .Select(x => new { TaskId = (Guid?)x.Id, Title = (string?)x.Title, Since = (DateTimeOffset?)x.UpdatedAt }) + .FirstOrDefault(); + + var projectHealth = new + { + Online = projects.Count(x => x.Status == OperationalStatus.Online), + Offline = projects.Count(x => x.Status == OperationalStatus.Offline), + Degraded = projects.Count(x => x.Status == OperationalStatus.Degraded), + Unknown = projects.Count(x => x.Status == OperationalStatus.Unknown) + }; + + return Results.Ok(new + { + generatedAt = DateTimeOffset.UtcNow, + runtime = runtimeStatus, + models = Array.Empty(), + runtimeHealthy, + metrics = new + { + activeAgents = agents.Count, + queuedTasks = tasks.Count - completedTasks, + successRate = tasks.Count == 0 ? 100 : Math.Round(completedTasks * 100d / tasks.Count, 1), + incidents = tasks.Count(x => x.State == TaskStateHelper.ToStateString(TaskState.Blocked)) + }, + lastIncident, + projectHealth, + agents = agents.Select(x => new { x.Id, x.Name, x.Role, x.Status, x.Model }), + projects = projects.Select(x => new { x.Id, x.Name, x.Status, x.Progress, x.UpdatedAt }), + tasks = tasks.Select(x => new { x.Id, x.Title, x.State, x.Priority, x.ProjectId, x.UpdatedAt }), + activity = activityTask.Result.Select(x => new { x.Id, x.Type, x.Message, at = x.CreatedAt }) + }); + } +} diff --git a/backend/Controllers/ProjectsController.cs b/backend/Controllers/ProjectsController.cs new file mode 100644 index 0000000..06c18fb --- /dev/null +++ b/backend/Controllers/ProjectsController.cs @@ -0,0 +1,77 @@ +using Microsoft.AspNetCore.Mvc; +using Nexus.Api.Data; +using Nexus.Api.DTOs; +using Nexus.Api.Repositories; + +namespace Nexus.Api.Controllers; + +[ApiController] +[Route("api/v1/projects")] +public class ProjectsController(IProjectRepository projectRepo, IActivityRepository activityRepo) : ControllerBase +{ + [HttpGet] + public async Task GetAll(CancellationToken ct) + => Results.Ok(await projectRepo.GetAllAsync(ct)); + + [HttpPost] + public async Task Create([FromBody] CreateProjectRequest request, CancellationToken ct) + { + if (string.IsNullOrWhiteSpace(request.Name)) + return Results.ValidationProblem(new Dictionary { ["name"] = ["Name is required."] }); + + var project = new Project + { + Name = request.Name.Trim(), + Description = request.Description?.Trim() ?? string.Empty, + Status = OperationalStatus.Online + }; + await projectRepo.AddAsync(project, ct); + await activityRepo.AddAsync(new ActivityEvent { Type = "project", Message = $"Project {project.Name} created" }, ct); + return Results.Created($"/api/v1/projects/{project.Id}", project); + } + + [HttpGet("{id:guid}")] + public async Task GetById(Guid id, CancellationToken ct) + { + var project = await projectRepo.GetByIdAsync(id, ct); + return project is null ? Results.NotFound() : Results.Ok(project); + } + + [HttpPatch("{id:guid}")] + public async Task Update(Guid id, [FromBody] UpdateProjectRequest request, CancellationToken ct) + { + var project = await projectRepo.GetByIdAsync(id, ct); + if (project is null) return Results.NotFound(); + + if (!string.IsNullOrWhiteSpace(request.Name)) + project.Name = request.Name.Trim(); + if (request.Description is not null) + project.Description = request.Description.Trim(); + if (!string.IsNullOrWhiteSpace(request.Status) && Enum.TryParse(request.Status, true, out var parsedStatus)) + project.Status = parsedStatus; + + await projectRepo.UpdateAsync(project, ct); + await activityRepo.AddAsync(new ActivityEvent { Type = "project", Message = $"Project {project.Name} updated" }, ct); + return Results.Ok(project); + } + + [HttpDelete("{id:guid}")] + public async Task Delete(Guid id, CancellationToken ct) + { + var project = await projectRepo.GetByIdAsync(id, ct); + if (project is null) return Results.NotFound(); + + var hasTasks = await projectRepo.HasTasksAsync(id, ct); + if (hasTasks) + { + project.Status = OperationalStatus.Offline; + await projectRepo.UpdateAsync(project, ct); + await activityRepo.AddAsync(new ActivityEvent { Type = "project", Message = $"Project {project.Name} archived" }, ct); + return Results.Ok(project); + } + + await projectRepo.DeleteAsync(project, ct); + await activityRepo.AddAsync(new ActivityEvent { Type = "project", Message = $"Project {project.Name} deleted" }, ct); + return Results.NoContent(); + } +} diff --git a/backend/Controllers/RoutingController.cs b/backend/Controllers/RoutingController.cs new file mode 100644 index 0000000..22cedce --- /dev/null +++ b/backend/Controllers/RoutingController.cs @@ -0,0 +1,13 @@ +using Microsoft.AspNetCore.Mvc; +using Nexus.Api.Routing; + +namespace Nexus.Api.Controllers; + +[ApiController] +[Route("api/v1/routing")] +public class RoutingController(ModelRoutingService routing) : ControllerBase +{ + [HttpGet] + public async Task GetStatus(CancellationToken ct) + => Results.Ok(await routing.GetStatusAsync(ct)); +} diff --git a/backend/Controllers/SecurityController.cs b/backend/Controllers/SecurityController.cs new file mode 100644 index 0000000..128e7be --- /dev/null +++ b/backend/Controllers/SecurityController.cs @@ -0,0 +1,29 @@ +using Microsoft.AspNetCore.Mvc; + +namespace Nexus.Api.Controllers; + +[ApiController] +[Route("api/v1/security")] +public class SecurityController(IConfiguration config) : ControllerBase +{ + [HttpGet("status")] + public IResult GetStatus() + { + var jwtIssuer = config["Jwt:Issuer"] ?? "nexus"; + var jwtAudience = config["Jwt:Audience"] ?? "nexus-web"; + var refreshDays = config.GetValue("Jwt:RefreshTokenExpirationDays", 7); + var accessTokenMinutes = config.GetValue("Jwt:AccessTokenExpirationMinutes", 30); + + return Results.Ok(new + { + authMethod = "JWT + PBKDF2", + tokenConfig = new { refreshTokenDays = refreshDays, accessTokenMinutes }, + rateLimit = "5 login attempts per minute per IP", + passwordPolicy = "Minimum 10 characters", + cookieConfig = new { httpOnly = true, secure = true, sameSite = "Strict" }, + twoFactorEnabled = false, + passkeyEnabled = false, + checkedAt = DateTimeOffset.UtcNow + }); + } +} diff --git a/backend/Controllers/TasksController.cs b/backend/Controllers/TasksController.cs new file mode 100644 index 0000000..1c19edf --- /dev/null +++ b/backend/Controllers/TasksController.cs @@ -0,0 +1,125 @@ +using Microsoft.AspNetCore.Mvc; +using Nexus.Api.Data; +using Nexus.Api.DTOs; +using Nexus.Api.Repositories; + +namespace Nexus.Api.Controllers; + +[ApiController] +[Route("api/v1/tasks")] +public class TasksController(ITaskRepository taskRepo, IActivityRepository activityRepo) : ControllerBase +{ + [HttpGet] + public async Task GetAll(CancellationToken ct) + => Results.Ok(await taskRepo.GetAllAsync(ct)); + + [HttpPost] + public async Task Create([FromBody] CreateTaskRequest request, CancellationToken ct) + { + if (string.IsNullOrWhiteSpace(request.Title)) + return Results.ValidationProblem(new Dictionary { ["title"] = ["Title is required."] }); + + var task = new WorkTask + { + Title = request.Title.Trim(), + Priority = string.IsNullOrWhiteSpace(request.Priority) ? "Normal" : request.Priority.Trim(), + ProjectId = request.ProjectId + }; + await taskRepo.AddAsync(task, ct); + await activityRepo.AddAsync(new ActivityEvent { Type = "task", Message = $"Task {task.Title} created" }, ct); + return Results.Created($"/api/v1/tasks/{task.Id}", task); + } + + [HttpGet("pending-approval")] + public async Task GetPendingApproval(CancellationToken ct) + { + var pending = await taskRepo.GetPendingApprovalAsync(ct); + return Results.Ok(pending.Select(x => new { x.Id, x.Title, x.State, x.Priority, x.ProjectId, x.UpdatedAt })); + } + + [HttpPost("{id:guid}/approve")] + public async Task Approve(Guid id, CancellationToken ct) + { + var task = await taskRepo.GetByIdAsync(id, ct); + if (task is null) return Results.NotFound(); + + if (!TaskStateHelper.IsInProgressOrBlocked(task.State)) + return Results.Problem( + title: "Approval denied", + detail: "Only tasks in 'In progress' or 'Blocked' state can be approved.", + statusCode: StatusCodes.Status403Forbidden); + + task.State = TaskStateHelper.ToStateString(TaskState.Done); + await taskRepo.UpdateAsync(task, ct); + await activityRepo.AddAsync(new ActivityEvent { Type = "task", Message = $"Task {task.Title} approved" }, ct); + return Results.Ok(task); + } + + [HttpPost("{id:guid}/reject")] + public async Task Reject(Guid id, CancellationToken ct) + { + var task = await taskRepo.GetByIdAsync(id, ct); + if (task is null) return Results.NotFound(); + + if (!TaskStateHelper.IsInProgressOrBlocked(task.State)) + return Results.Problem( + title: "Rejection denied", + detail: "Only tasks in 'In progress' or 'Blocked' state can be rejected.", + statusCode: StatusCodes.Status403Forbidden); + + task.State = TaskStateHelper.ToStateString(TaskState.Backlog); + await taskRepo.UpdateAsync(task, ct); + await activityRepo.AddAsync(new ActivityEvent { Type = "task", Message = $"Task {task.Title} rejected, returned to backlog" }, ct); + return Results.Ok(task); + } + + [HttpPatch("{id:guid}/state")] + public async Task UpdateState(Guid id, [FromBody] UpdateTaskStateRequest request, CancellationToken ct) + { + var allowedStates = TaskStateHelper.AllStates; + if (!allowedStates.Contains(request.State, StringComparer.OrdinalIgnoreCase)) + return Results.ValidationProblem(new Dictionary { ["state"] = ["Unsupported task state."] }); + + var task = await taskRepo.GetByIdAsync(id, ct); + if (task is null) return Results.NotFound(); + task.State = allowedStates.First(x => x.Equals(request.State, StringComparison.OrdinalIgnoreCase)); + await taskRepo.UpdateAsync(task, ct); + await activityRepo.AddAsync(new ActivityEvent { Type = "task", Message = $"Task {task.Title} moved to {task.State}" }, ct); + return Results.Ok(task); + } + + [HttpDelete("{id:guid}")] + public async Task Delete(Guid id, CancellationToken ct) + { + var task = await taskRepo.GetByIdAsync(id, ct); + if (task is null) return Results.NotFound(); + + if (!TaskStateHelper.IsDoneOrBacklog(task.State)) + return Results.Problem( + title: "Task deletion denied", + detail: "Only tasks in 'Done' or 'Backlog' state can be deleted.", + statusCode: StatusCodes.Status403Forbidden); + + await activityRepo.AddAsync(new ActivityEvent { Type = "task", Message = $"Task {task.Title} deleted" }, ct); + await taskRepo.DeleteAsync(task, ct); + return Results.NoContent(); + } + + [HttpPatch("{id:guid}")] + public async Task Update(Guid id, [FromBody] UpdateTaskRequest request, CancellationToken ct) + { + var task = await taskRepo.GetByIdAsync(id, ct); + if (task is null) return Results.NotFound(); + + if (!string.IsNullOrWhiteSpace(request.Title)) + task.Title = request.Title.Trim(); + if (!string.IsNullOrWhiteSpace(request.Priority)) + task.Priority = request.Priority.Trim(); + if (request.ProjectId.HasValue) + task.ProjectId = request.ProjectId.Value == Guid.Empty ? null : request.ProjectId; + + await taskRepo.UpdateAsync(task, ct); + await activityRepo.AddAsync(new ActivityEvent { Type = "task", Message = $"Task {task.Title} updated" }, ct); + return Results.Ok(task); + } +} diff --git a/backend/Controllers/TeamController.cs b/backend/Controllers/TeamController.cs new file mode 100644 index 0000000..89e8368 --- /dev/null +++ b/backend/Controllers/TeamController.cs @@ -0,0 +1,40 @@ +using Microsoft.AspNetCore.Mvc; +using Nexus.Api.Services; + +namespace Nexus.Api.Controllers; + +[ApiController] +[Route("api/v1/team")] +public class TeamController(IAgentService agentService) : ControllerBase +{ + [HttpGet] + public async Task GetTeam(CancellationToken ct) + { + var agents = await agentService.GetAgentsAsync(ct); + var team = new List(); + + foreach (var agent in agents) + { + string identity = ""; + string workspace = agent.Workspace ?? ""; + if (!string.IsNullOrWhiteSpace(workspace) && Directory.Exists(workspace)) + { + var identityFile = Path.Combine(workspace, "IDENTITY.md"); + if (System.IO.File.Exists(identityFile)) + { + var content = await System.IO.File.ReadAllTextAsync(identityFile, ct); + var lines = content.Split('\n').Where(l => l.StartsWith("- **")).Take(8); + identity = string.Join("\n", lines); + } + } + + team.Add(new + { + agent.Id, agent.Name, agent.Role, agent.Model, agent.Status, agent.LastSeen, agent.Workspace, agent.Description, + identity + }); + } + + return Results.Ok(team); + } +} diff --git a/backend/Contracts/AuthRequests.cs b/backend/DTOs/AuthRequests.cs similarity index 97% rename from backend/Contracts/AuthRequests.cs rename to backend/DTOs/AuthRequests.cs index 7d1b8ec..cb4df37 100644 --- a/backend/Contracts/AuthRequests.cs +++ b/backend/DTOs/AuthRequests.cs @@ -1,6 +1,6 @@ using System.ComponentModel.DataAnnotations; -namespace Nexus.Api.Contracts; +namespace Nexus.Api.DTOs; public sealed record LoginRequest { diff --git a/backend/DTOs/Requests.cs b/backend/DTOs/Requests.cs new file mode 100644 index 0000000..808d495 --- /dev/null +++ b/backend/DTOs/Requests.cs @@ -0,0 +1,48 @@ +namespace Nexus.Api.DTOs; + +public sealed record CreateProjectRequest(string Name, string? Description); +public sealed record CreateTaskRequest(string Title, string? Priority, Guid? ProjectId); +public sealed record UpdateTaskStateRequest(string State); +public sealed record ChatRequest(string Message, string? ConversationId, string? AgentId); + +public sealed record UpdateProjectRequest(string? Name, string? Description, string? Status); +public sealed record UpdateTaskRequest(string? Title, string? Priority, Guid? ProjectId); + +public sealed record AgentCommandRequest(string Message); + +public sealed record SaveConfigRequest(string Content); + +public sealed record AgentListResponse( + string Id, + string Name, + string Role, + string Model, + string Status, + DateTimeOffset? LastSeen, + string? Workspace, + string? Description +); + +public sealed record AgentDetailResponse( + string Id, + string Name, + string Role, + string Model, + string Status, + DateTimeOffset? LastSeen, + string? Workspace, + string? AgentDir, + string? Description, + IReadOnlyList? SubAgents, + string? IdentityName +); + +public sealed record AgentCommandResponse( + string Runtime, + string AgentId, + string ConversationId, + string Content +); + +public sealed record CronJobEntry(string Id, string Name, string Schedule, string LastRun, string NextRun, string Status); +public sealed record UpcomingCronEntry(string Id, string Name, string NextRun, string Schedule); diff --git a/backend/DTOs/Responses.cs b/backend/DTOs/Responses.cs new file mode 100644 index 0000000..235dab6 --- /dev/null +++ b/backend/DTOs/Responses.cs @@ -0,0 +1,14 @@ +namespace Nexus.Api.DTOs; + +public sealed record ProjectHealthDto( + int Online, + int Offline, + int Degraded, + int Unknown +); + +public sealed record IncidentInfoDto( + Guid? TaskId, + string? Title, + DateTimeOffset? Since +); diff --git a/backend/Domain/Entities.cs b/backend/Data/Entities.cs similarity index 99% rename from backend/Domain/Entities.cs rename to backend/Data/Entities.cs index b227f8b..4bdcaad 100644 --- a/backend/Domain/Entities.cs +++ b/backend/Data/Entities.cs @@ -1,4 +1,4 @@ -namespace Nexus.Api.Domain; +namespace Nexus.Api.Data; public enum OperationalStatus { @@ -90,4 +90,3 @@ public sealed class ActivityEvent public required string Message { get; set; } public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow; } - diff --git a/backend/Domain/Identity.cs b/backend/Data/Identity.cs similarity index 98% rename from backend/Domain/Identity.cs rename to backend/Data/Identity.cs index b551f66..941b669 100644 --- a/backend/Domain/Identity.cs +++ b/backend/Data/Identity.cs @@ -1,6 +1,6 @@ using System.ComponentModel.DataAnnotations; -namespace Nexus.Api.Domain; +namespace Nexus.Api.Data; public class NexusUser { diff --git a/backend/Infrastructure/Migrations/20260609064750_InitialCreate.Designer.cs b/backend/Data/Migrations/20260609064750_InitialCreate.Designer.cs similarity index 92% rename from backend/Infrastructure/Migrations/20260609064750_InitialCreate.Designer.cs rename to backend/Data/Migrations/20260609064750_InitialCreate.Designer.cs index 96e7108..9f07aa4 100644 --- a/backend/Infrastructure/Migrations/20260609064750_InitialCreate.Designer.cs +++ b/backend/Data/Migrations/20260609064750_InitialCreate.Designer.cs @@ -4,7 +4,7 @@ using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Infrastructure; using Microsoft.EntityFrameworkCore.Migrations; using Microsoft.EntityFrameworkCore.Storage.ValueConversion; -using Nexus.Api.Infrastructure; +using Nexus.Api.Data; using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; #nullable disable @@ -25,7 +25,7 @@ namespace Nexus.Api.Migrations NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); - modelBuilder.Entity("Nexus.Api.Domain.ActivityEvent", b => + modelBuilder.Entity("Nexus.Api.Data.ActivityEvent", b => { b.Property("Id") .ValueGeneratedOnAdd() @@ -50,7 +50,7 @@ namespace Nexus.Api.Migrations b.ToTable("Activity"); }); - modelBuilder.Entity("Nexus.Api.Domain.NexusUser", b => + modelBuilder.Entity("Nexus.Api.Data.NexusUser", b => { b.Property("Id") .ValueGeneratedOnAdd() @@ -96,7 +96,7 @@ namespace Nexus.Api.Migrations b.ToTable("Users"); }); - modelBuilder.Entity("Nexus.Api.Domain.Project", b => + modelBuilder.Entity("Nexus.Api.Data.Project", b => { b.Property("Id") .ValueGeneratedOnAdd() @@ -125,7 +125,7 @@ namespace Nexus.Api.Migrations b.ToTable("Projects"); }); - modelBuilder.Entity("Nexus.Api.Domain.RefreshToken", b => + modelBuilder.Entity("Nexus.Api.Data.RefreshToken", b => { b.Property("Id") .ValueGeneratedOnAdd() @@ -169,7 +169,7 @@ namespace Nexus.Api.Migrations b.ToTable("RefreshTokens"); }); - modelBuilder.Entity("Nexus.Api.Domain.WorkTask", b => + modelBuilder.Entity("Nexus.Api.Data.WorkTask", b => { b.Property("Id") .ValueGeneratedOnAdd() @@ -199,9 +199,9 @@ namespace Nexus.Api.Migrations b.ToTable("Tasks"); }); - modelBuilder.Entity("Nexus.Api.Domain.RefreshToken", b => + modelBuilder.Entity("Nexus.Api.Data.RefreshToken", b => { - b.HasOne("Nexus.Api.Domain.NexusUser", "User") + b.HasOne("Nexus.Api.Data.NexusUser", "User") .WithMany("RefreshTokens") .HasForeignKey("UserId") .OnDelete(DeleteBehavior.Cascade) @@ -210,7 +210,7 @@ namespace Nexus.Api.Migrations b.Navigation("User"); }); - modelBuilder.Entity("Nexus.Api.Domain.NexusUser", b => + modelBuilder.Entity("Nexus.Api.Data.NexusUser", b => { b.Navigation("RefreshTokens"); }); diff --git a/backend/Infrastructure/Migrations/20260609064750_InitialCreate.cs b/backend/Data/Migrations/20260609064750_InitialCreate.cs similarity index 100% rename from backend/Infrastructure/Migrations/20260609064750_InitialCreate.cs rename to backend/Data/Migrations/20260609064750_InitialCreate.cs diff --git a/backend/Infrastructure/Migrations/NexusDbContextModelSnapshot.cs b/backend/Data/Migrations/NexusDbContextModelSnapshot.cs similarity index 92% rename from backend/Infrastructure/Migrations/NexusDbContextModelSnapshot.cs rename to backend/Data/Migrations/NexusDbContextModelSnapshot.cs index ab2c2db..7823a93 100644 --- a/backend/Infrastructure/Migrations/NexusDbContextModelSnapshot.cs +++ b/backend/Data/Migrations/NexusDbContextModelSnapshot.cs @@ -3,7 +3,7 @@ using System; using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Infrastructure; using Microsoft.EntityFrameworkCore.Storage.ValueConversion; -using Nexus.Api.Infrastructure; +using Nexus.Api.Data; using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; #nullable disable @@ -22,7 +22,7 @@ namespace Nexus.Api.Migrations NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); - modelBuilder.Entity("Nexus.Api.Domain.ActivityEvent", b => + modelBuilder.Entity("Nexus.Api.Data.ActivityEvent", b => { b.Property("Id") .ValueGeneratedOnAdd() @@ -47,7 +47,7 @@ namespace Nexus.Api.Migrations b.ToTable("Activity"); }); - modelBuilder.Entity("Nexus.Api.Domain.NexusUser", b => + modelBuilder.Entity("Nexus.Api.Data.NexusUser", b => { b.Property("Id") .ValueGeneratedOnAdd() @@ -93,7 +93,7 @@ namespace Nexus.Api.Migrations b.ToTable("Users"); }); - modelBuilder.Entity("Nexus.Api.Domain.Project", b => + modelBuilder.Entity("Nexus.Api.Data.Project", b => { b.Property("Id") .ValueGeneratedOnAdd() @@ -122,7 +122,7 @@ namespace Nexus.Api.Migrations b.ToTable("Projects"); }); - modelBuilder.Entity("Nexus.Api.Domain.RefreshToken", b => + modelBuilder.Entity("Nexus.Api.Data.RefreshToken", b => { b.Property("Id") .ValueGeneratedOnAdd() @@ -166,7 +166,7 @@ namespace Nexus.Api.Migrations b.ToTable("RefreshTokens"); }); - modelBuilder.Entity("Nexus.Api.Domain.WorkTask", b => + modelBuilder.Entity("Nexus.Api.Data.WorkTask", b => { b.Property("Id") .ValueGeneratedOnAdd() @@ -196,9 +196,9 @@ namespace Nexus.Api.Migrations b.ToTable("Tasks"); }); - modelBuilder.Entity("Nexus.Api.Domain.RefreshToken", b => + modelBuilder.Entity("Nexus.Api.Data.RefreshToken", b => { - b.HasOne("Nexus.Api.Domain.NexusUser", "User") + b.HasOne("Nexus.Api.Data.NexusUser", "User") .WithMany("RefreshTokens") .HasForeignKey("UserId") .OnDelete(DeleteBehavior.Cascade) @@ -207,7 +207,7 @@ namespace Nexus.Api.Migrations b.Navigation("User"); }); - modelBuilder.Entity("Nexus.Api.Domain.NexusUser", b => + modelBuilder.Entity("Nexus.Api.Data.NexusUser", b => { b.Navigation("RefreshTokens"); }); diff --git a/backend/Infrastructure/NexusDbContext.cs b/backend/Data/NexusDbContext.cs similarity index 95% rename from backend/Infrastructure/NexusDbContext.cs rename to backend/Data/NexusDbContext.cs index 6d0a9ef..b9b9787 100644 --- a/backend/Infrastructure/NexusDbContext.cs +++ b/backend/Data/NexusDbContext.cs @@ -1,7 +1,6 @@ using Microsoft.EntityFrameworkCore; -using Nexus.Api.Domain; -namespace Nexus.Api.Infrastructure; +namespace Nexus.Api.Data; public sealed class NexusDbContext(DbContextOptions options) : DbContext(options) { diff --git a/backend/Helpers/PathSecurityHelper.cs b/backend/Helpers/PathSecurityHelper.cs new file mode 100644 index 0000000..a199f54 --- /dev/null +++ b/backend/Helpers/PathSecurityHelper.cs @@ -0,0 +1,35 @@ +namespace Nexus.Api.Helpers; + +public static class PathSecurityHelper +{ + /// Validates a path against directory traversal and resolves a safe absolute path. + public static bool TryResolveSafePath(string basePath, string userInput, out string? safePath) + { + safePath = null; + + // URL-decode to catch encoded attacks like %2F, %2e%2e, %00 + var decoded = Uri.UnescapeDataString(userInput); + + // Reject null bytes + if (decoded.Contains('\0')) return false; + + // Combine with base and resolve to canonical form + var combined = Path.Combine(basePath, decoded); + var full = Path.GetFullPath(combined); + var canonicalBase = Path.GetFullPath(basePath); + + // Must stay within the allowed base directory + if (!full.StartsWith(canonicalBase + Path.DirectorySeparatorChar) && full != canonicalBase) + return false; + + safePath = full; + return true; + } + + /// Validates config filename against path-traversal; must be alphanumeric .md. + public static bool IsValidConfigFileName(string fileName) + { + if (string.IsNullOrWhiteSpace(fileName)) return false; + return System.Text.RegularExpressions.Regex.IsMatch(fileName, @"^[a-zA-Z0-9._-]+\.md$"); + } +} diff --git a/backend/Integrations/Contracts.cs b/backend/Integrations/Contracts.cs index 7a85444..3185dc5 100644 --- a/backend/Integrations/Contracts.cs +++ b/backend/Integrations/Contracts.cs @@ -1,4 +1,4 @@ -using Nexus.Api.Domain; +using Nexus.Api.Data; namespace Nexus.Api.Integrations; @@ -37,4 +37,3 @@ public interface IModelProvider string Name { get; } Task> GetModelsAsync(CancellationToken cancellationToken); } - diff --git a/backend/Integrations/NvidiaProvider.cs b/backend/Integrations/NvidiaProvider.cs index 9f499f9..50b265f 100644 --- a/backend/Integrations/NvidiaProvider.cs +++ b/backend/Integrations/NvidiaProvider.cs @@ -1,4 +1,4 @@ -using Nexus.Api.Domain; +using Nexus.Api.Data; namespace Nexus.Api.Integrations; diff --git a/backend/Integrations/OllamaProvider.cs b/backend/Integrations/OllamaProvider.cs index 380f281..66c3259 100644 --- a/backend/Integrations/OllamaProvider.cs +++ b/backend/Integrations/OllamaProvider.cs @@ -1,5 +1,5 @@ using System.Net.Http.Json; -using Nexus.Api.Domain; +using Nexus.Api.Data; namespace Nexus.Api.Integrations; @@ -34,4 +34,3 @@ public sealed class OllamaProvider(HttpClient client) : IModelProvider } } } - diff --git a/backend/Integrations/OpenClawRuntime.cs b/backend/Integrations/OpenClawRuntime.cs index b195651..d51a97e 100644 --- a/backend/Integrations/OpenClawRuntime.cs +++ b/backend/Integrations/OpenClawRuntime.cs @@ -2,7 +2,7 @@ using System.Diagnostics; using System.Net.Http.Headers; using System.Net.Http.Json; using System.Text.Json; -using Nexus.Api.Domain; +using Nexus.Api.Data; namespace Nexus.Api.Integrations; diff --git a/backend/Middleware/SecurityHeadersMiddleware.cs b/backend/Middleware/SecurityHeadersMiddleware.cs new file mode 100644 index 0000000..b864765 --- /dev/null +++ b/backend/Middleware/SecurityHeadersMiddleware.cs @@ -0,0 +1,27 @@ +namespace Nexus.Api.Middleware; + +public sealed class SecurityHeadersMiddleware(RequestDelegate next) +{ + public async Task InvokeAsync(HttpContext context) + { + var headers = context.Response.Headers; + var env = context.RequestServices.GetRequiredService(); + + if (!env.IsDevelopment()) + { + headers["Strict-Transport-Security"] = "max-age=31536000; includeSubDomains"; + } + headers["X-Content-Type-Options"] = "nosniff"; + headers["Content-Security-Policy"] = "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'"; + headers["X-Frame-Options"] = "DENY"; + headers["Referrer-Policy"] = "strict-origin-when-cross-origin"; + + await next(context); + } +} + +public static class SecurityHeadersMiddlewareExtensions +{ + public static IApplicationBuilder UseSecurityHeaders(this IApplicationBuilder builder) + => builder.UseMiddleware(); +} diff --git a/backend/Program.cs b/backend/Program.cs index b1c9706..055ea63 100644 --- a/backend/Program.cs +++ b/backend/Program.cs @@ -4,23 +4,21 @@ using Microsoft.AspNetCore.RateLimiting; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Diagnostics.HealthChecks; using Microsoft.IdentityModel.Tokens; -using Nexus.Api.Contracts; -using Nexus.Api.Domain; +using Nexus.Api.Data; using Nexus.Api.Integrations; -using Nexus.Api.Infrastructure; -using System.Security.Cryptography; +using Nexus.Api.Middleware; +using Nexus.Api.Repositories; using Nexus.Api.Routing; using Nexus.Api.Services; using System.IdentityModel.Tokens.Jwt; +using System.Security.Cryptography; using System.Text; using System.Text.Json.Serialization; using System.Threading.RateLimiting; -using Microsoft.AspNetCore.Antiforgery; -using System.Diagnostics.CodeAnalysis; -using System.IO; var builder = WebApplication.CreateBuilder(args); +// --- JWT Configuration --- var jwtKey = builder.Configuration["Jwt:Key"]; var jwtIssuer = builder.Configuration["Jwt:Issuer"] ?? "nexus"; var jwtAudience = builder.Configuration["Jwt:Audience"] ?? "nexus-web"; @@ -54,6 +52,8 @@ builder.Services.AddAntiforgery(options => options.Cookie.SecurePolicy = CookieSecurePolicy.SameAsRequest; options.Cookie.HttpOnly = false; }); + +// --- Rate Limiting --- builder.Services.AddRateLimiter(options => { options.RejectionStatusCode = StatusCodes.Status429TooManyRequests; @@ -78,6 +78,7 @@ builder.Services.AddRateLimiter(options => })); }); +// --- Forwarded Headers --- builder.Services.Configure(options => { options.ForwardedHeaders = ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto; @@ -85,14 +86,18 @@ builder.Services.Configure(options => options.KnownProxies.Clear(); }); +// --- Swagger & JSON --- builder.Services.AddEndpointsApiExplorer(); builder.Services.AddSwaggerGen(); builder.Services.ConfigureHttpJsonOptions(options => options.SerializerOptions.Converters.Add(new JsonStringEnumConverter())); + +// --- Database --- builder.Services.AddDbContext(options => options.UseNpgsql(builder.Configuration.GetConnectionString("Nexus")) .ConfigureWarnings(w => w.Ignore(Microsoft.EntityFrameworkCore.Diagnostics.RelationalEventId.PendingModelChangesWarning))); +// --- HTTP Clients --- builder.Services.AddHttpClient(client => { client.BaseAddress = new(builder.Configuration["Integrations:OpenClaw:BaseUrl"] @@ -107,19 +112,28 @@ builder.Services.AddHttpClient("gateway", client => client.Timeout = TimeSpan.FromSeconds(5); }); +// --- Application Services --- builder.Services.AddTransient(); builder.Services.AddScoped(); builder.Services.AddScoped(); + +// --- Repositories --- +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); + +// --- Health Checks --- builder.Services.AddHealthChecks() .AddNpgSql(builder.Configuration.GetConnectionString("Nexus")!, name: "postgresql", tags: ["database"]) - .AddCheck("runtime", () => - { - // Runtime check will be added via IAgentRuntime in the endpoint - return HealthCheckResult.Healthy("Runtime configured"); - }, tags: ["runtime"]); + .AddCheck("runtime", () => HealthCheckResult.Healthy("Runtime configured"), tags: ["runtime"]); + +// --- Controllers --- +builder.Services.AddControllers(); var app = builder.Build(); +// --- Database Migration & Owner Seeding --- await using (var scope = app.Services.CreateAsyncScope()) { var db = scope.ServiceProvider.GetRequiredService(); @@ -160,28 +174,14 @@ await using (var scope = app.Services.CreateAsyncScope()) Console.Error.WriteLine($"[nexus] Initial owner credentials generated: displayName={initialDisplayName}, password={initialPassword}"); } } - } +// --- Middleware Pipeline --- app.UseForwardedHeaders(); app.UseRateLimiter(); app.UseAuthentication(); app.UseAuthorization(); - -// Security headers (WARN-2) -app.Use(async (context, next) => -{ - var headers = context.Response.Headers; - if (!app.Environment.IsDevelopment()) - { - headers["Strict-Transport-Security"] = "max-age=31536000; includeSubDomains"; - } - headers["X-Content-Type-Options"] = "nosniff"; - headers["Content-Security-Policy"] = "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'"; - headers["X-Frame-Options"] = "DENY"; - headers["Referrer-Policy"] = "strict-origin-when-cross-origin"; - await next(); -}); +app.UseSecurityHeaders(); if (app.Environment.IsDevelopment()) { @@ -189,953 +189,11 @@ if (app.Environment.IsDevelopment()) app.UseSwaggerUI(); } -app.MapGet("/health", async (IAgentRuntime runtime, HealthCheckService healthChecks, CancellationToken ct) => -{ - var report = await healthChecks.CheckHealthAsync(ct); - - // Check runtime separately since it needs IAgentRuntime - string runtimeStatus; - string? runtimeDetail; - try - { - var status = await runtime.GetStatusAsync(ct); - runtimeStatus = status.Status.ToString(); - runtimeDetail = status.Detail; - } - catch (Exception ex) - { - runtimeStatus = "Offline"; - runtimeDetail = ex.Message; - } - - var entries = report.Entries.ToDictionary( - e => e.Key, - e => new - { - status = e.Value.Status.ToString(), - description = e.Value.Description, - data = e.Value.Data - }); - - entries["runtime"] = new - { - status = runtimeStatus, - description = runtimeDetail ?? "Runtime status checked", - data = (IReadOnlyDictionary)new Dictionary() - }; - - var isHealthy = report.Status == HealthStatus.Healthy && runtimeStatus == "Online"; - return isHealthy ? Results.Ok(new { status = "Healthy", checks = entries, timestamp = DateTimeOffset.UtcNow }) - : Results.Ok(new { status = "Degraded", checks = entries, timestamp = DateTimeOffset.UtcNow }); -}); - -var auth = app.MapGroup("/api/v1/auth"); - -auth.MapGet("/csrf", (HttpContext ctx, IAntiforgery antiforgery) => -{ - var tokens = antiforgery.GetAndStoreTokens(ctx); - return Results.Ok(new { token = tokens.RequestToken }); -}); -auth.MapPost("/login", async (LoginRequest request, HttpResponse response, IAuthService authService, CancellationToken ct) => -{ - if (string.IsNullOrWhiteSpace(request.Email) || string.IsNullOrWhiteSpace(request.Password)) - return Results.ValidationProblem(new Dictionary { ["credentials"] = ["Email and password are required."] }); - - var session = await authService.LoginAsync(request, ct); - if (session is null) return Results.Unauthorized(); - - SetRefreshCookie(response, session.RefreshToken, builder.Configuration, app.Environment); - response.Headers.CacheControl = "no-store"; - return Results.Ok(ToAuthResponse(session)); -}).RequireRateLimiting("auth"); - -auth.MapPost("/refresh", async (HttpRequest request, HttpResponse response, IAuthService authService, CancellationToken ct) => -{ - if (!request.Cookies.TryGetValue("nexus_refresh", out var refreshToken)) - return Results.Unauthorized(); - - var session = await authService.RefreshAsync(refreshToken, ct); - if (session is null) - { - ClearRefreshCookie(response, app.Environment); - return Results.Unauthorized(); - } - - SetRefreshCookie(response, session.RefreshToken, builder.Configuration, app.Environment); - response.Headers.CacheControl = "no-store"; - return Results.Ok(ToAuthResponse(session)); -}).RequireRateLimiting("auth"); - -auth.MapPost("/logout", async (HttpRequest request, HttpResponse response, IAuthService authService, CancellationToken ct) => -{ - if (request.Cookies.TryGetValue("nexus_refresh", out var refreshToken)) - await authService.RevokeAsync(refreshToken, ct); - - ClearRefreshCookie(response, app.Environment); - return Results.NoContent(); -}); - -auth.MapGet("/me", async (HttpContext context, IAuthService authService, CancellationToken ct) => -{ - var subject = context.User.FindFirst(JwtRegisteredClaimNames.Sub)?.Value; - if (!Guid.TryParse(subject, out var userId)) return Results.Unauthorized(); - - var user = await authService.GetUserAsync(userId, ct); - return user is null - ? Results.Unauthorized() - : Results.Ok(new UserInfo { Id = user.Id, Email = user.Email, DisplayName = user.DisplayName, Role = user.Role }); -}).RequireAuthorization(); - -auth.MapPatch("/profile", async (HttpContext context, UpdateProfileRequest request, IAuthService authService, CancellationToken ct) => -{ - var subject = context.User.FindFirst(System.IdentityModel.Tokens.Jwt.JwtRegisteredClaimNames.Sub)?.Value; - if (!Guid.TryParse(subject, out var userId)) return Results.Unauthorized(); - - var user = await authService.UpdateProfileAsync(userId, request, ct); - return user is null - ? Results.NotFound() - : Results.Ok(new UserInfo { Id = user.Id, Email = user.Email, DisplayName = user.DisplayName, Role = user.Role }); -}).RequireAuthorization(); - -auth.MapPost("/change-password", async (HttpContext context, ChangePasswordRequest request, IAuthService authService, CancellationToken ct) => -{ - if (string.IsNullOrWhiteSpace(request.CurrentPassword) || string.IsNullOrWhiteSpace(request.NewPassword)) - return Results.ValidationProblem(new Dictionary { ["password"] = ["Current and new passwords are required."] }); - - if (request.NewPassword.Length < 10) - return Results.ValidationProblem(new Dictionary { ["newPassword"] = ["New password must be at least 10 characters."] }); - - var subject = context.User.FindFirst(System.IdentityModel.Tokens.Jwt.JwtRegisteredClaimNames.Sub)?.Value; - if (!Guid.TryParse(subject, out var userId)) return Results.Unauthorized(); - - var success = await authService.ChangePasswordAsync(userId, request, ct); - return success ? Results.Ok(new { message = "Password changed successfully." }) : Results.Problem("Current password is incorrect.", statusCode: 400); -}).RequireAuthorization(); - -var api = app.MapGroup("/api/v1").RequireAuthorization(); - -api.MapGet("/operations/snapshot", async ( - IAgentRuntime runtime, - IAgentService agentService, - NexusDbContext db, - CancellationToken cancellationToken) => -{ - var runtimeTask = runtime.GetStatusAsync(cancellationToken); - var agentsTask = agentService.GetAgentsAsync(cancellationToken); - var projectsTask = db.Projects.AsNoTracking().OrderByDescending(x => x.UpdatedAt).ToListAsync(cancellationToken); - var tasksTask = db.Tasks.AsNoTracking().OrderByDescending(x => x.UpdatedAt).ToListAsync(cancellationToken); - var activityTask = db.Activity.AsNoTracking().OrderByDescending(x => x.CreatedAt).Take(20).ToListAsync(cancellationToken); - await Task.WhenAll(runtimeTask, agentsTask, projectsTask, tasksTask, activityTask); - - var tasks = tasksTask.Result; - var projects = projectsTask.Result; - var agents = agentsTask.Result; - var completedTasks = tasks.Count(x => x.State == TaskStateHelper.ToStateString(TaskState.Done)); - - // Runtime health check - var runtimeStatus = runtimeTask.Result; - var runtimeHealthy = runtimeStatus.Status == OperationalStatus.Online; - - // Last incident: most recent blocked task - var lastIncident = tasks - .Where(x => x.State == TaskStateHelper.ToStateString(TaskState.Blocked)) - .OrderByDescending(x => x.UpdatedAt) - .Select(x => new { TaskId = (Guid?)x.Id, Title = (string?)x.Title, Since = (DateTimeOffset?)x.UpdatedAt }) - .FirstOrDefault(); - - // Project health breakdown - var projectHealth = new - { - Online = projects.Count(x => x.Status == OperationalStatus.Online), - Offline = projects.Count(x => x.Status == OperationalStatus.Offline), - Degraded = projects.Count(x => x.Status == OperationalStatus.Degraded), - Unknown = projects.Count(x => x.Status == OperationalStatus.Unknown) - }; - - return Results.Ok(new - { - generatedAt = DateTimeOffset.UtcNow, - runtime = runtimeTask.Result, - models = Array.Empty(), - runtimeHealthy, - metrics = new - { - activeAgents = agents.Count, - queuedTasks = tasks.Count - completedTasks, - successRate = tasks.Count == 0 ? 100 : Math.Round(completedTasks * 100d / tasks.Count, 1), - incidents = tasks.Count(x => x.State == TaskStateHelper.ToStateString(TaskState.Blocked)) - }, - lastIncident, - projectHealth, - agents = agents.Select(x => new { x.Id, x.Name, x.Role, x.Status, x.Model }), - projects = projects.Select(x => new { x.Id, x.Name, x.Status, x.Progress, x.UpdatedAt }), - tasks = tasks.Select(x => new { x.Id, x.Title, x.State, x.Priority, x.ProjectId, x.UpdatedAt }), - activity = activityTask.Result.Select(x => new { x.Id, x.Type, x.Message, at = x.CreatedAt }) - }); -}); - -api.MapGet("/projects", async (NexusDbContext db, CancellationToken token) => - Results.Ok(await db.Projects.AsNoTracking().OrderByDescending(x => x.UpdatedAt).ToListAsync(token))); - -api.MapPost("/projects", async (CreateProjectRequest request, NexusDbContext db, CancellationToken token) => -{ - if (string.IsNullOrWhiteSpace(request.Name)) - return Results.ValidationProblem(new Dictionary { ["name"] = ["Name is required."] }); - - var project = new Project - { - Name = request.Name.Trim(), - Description = request.Description?.Trim() ?? string.Empty, - Status = OperationalStatus.Online - }; - db.Projects.Add(project); - db.Activity.Add(new ActivityEvent { Type = "project", Message = $"Project {project.Name} created" }); - await db.SaveChangesAsync(token); - return Results.Created($"/api/v1/projects/{project.Id}", project); -}); - -api.MapGet("/projects/{id:guid}", async (Guid id, NexusDbContext db, CancellationToken token) => -{ - var project = await db.Projects.AsNoTracking().FirstOrDefaultAsync(x => x.Id == id, token); - return project is null ? Results.NotFound() : Results.Ok(project); -}); - -api.MapPatch("/projects/{id:guid}", async (Guid id, UpdateProjectRequest request, NexusDbContext db, CancellationToken token) => -{ - var project = await db.Projects.FindAsync([id], token); - if (project is null) return Results.NotFound(); - - if (!string.IsNullOrWhiteSpace(request.Name)) - project.Name = request.Name.Trim(); - if (request.Description is not null) - project.Description = request.Description.Trim(); - if (!string.IsNullOrWhiteSpace(request.Status) && Enum.TryParse(request.Status, true, out var parsedStatus)) - project.Status = parsedStatus; - - project.UpdatedAt = DateTimeOffset.UtcNow; - db.Activity.Add(new ActivityEvent { Type = "project", Message = $"Project {project.Name} updated" }); - await db.SaveChangesAsync(token); - return Results.Ok(project); -}); - -api.MapDelete("/projects/{id:guid}", async (Guid id, NexusDbContext db, CancellationToken token) => -{ - var project = await db.Projects.FindAsync([id], token); - if (project is null) return Results.NotFound(); - - var hasTasks = await db.Tasks.AnyAsync(t => t.ProjectId == id, token); - if (hasTasks) - { - project.Status = Nexus.Api.Domain.OperationalStatus.Offline; - project.UpdatedAt = DateTimeOffset.UtcNow; - db.Activity.Add(new ActivityEvent { Type = "project", Message = $"Project {project.Name} archived" }); - await db.SaveChangesAsync(token); - return Results.Ok(project); - } - - db.Projects.Remove(project); - db.Activity.Add(new ActivityEvent { Type = "project", Message = $"Project {project.Name} deleted" }); - await db.SaveChangesAsync(token); - return Results.NoContent(); -}); - -api.MapGet("/tasks", async (NexusDbContext db, CancellationToken token) => - Results.Ok(await db.Tasks.AsNoTracking().OrderByDescending(x => x.UpdatedAt).ToListAsync(token))); - -api.MapPost("/tasks", async (CreateTaskRequest request, NexusDbContext db, CancellationToken token) => -{ - if (string.IsNullOrWhiteSpace(request.Title)) - return Results.ValidationProblem(new Dictionary { ["title"] = ["Title is required."] }); - - var task = new WorkTask - { - Title = request.Title.Trim(), - Priority = string.IsNullOrWhiteSpace(request.Priority) ? "Normal" : request.Priority.Trim(), - ProjectId = request.ProjectId - }; - db.Tasks.Add(task); - db.Activity.Add(new ActivityEvent { Type = "task", Message = $"Task {task.Title} created" }); - await db.SaveChangesAsync(token); - return Results.Created($"/api/v1/tasks/{task.Id}", task); -}); - -api.MapGet("/tasks/pending-approval", async (NexusDbContext db, CancellationToken token) => -{ - var threshold = DateTimeOffset.UtcNow.AddHours(-1); - var pending = await db.Tasks.AsNoTracking() - .Where(x => x.State == TaskStateHelper.ToStateString(TaskState.InProgress) && x.UpdatedAt <= threshold) - .OrderByDescending(x => x.UpdatedAt) - .ToListAsync(token); - - return Results.Ok(pending.Select(x => new { x.Id, x.Title, x.State, x.Priority, x.ProjectId, x.UpdatedAt })); -}); - -api.MapPost("/tasks/{id:guid}/approve", async (Guid id, NexusDbContext db, CancellationToken token) => -{ - var task = await db.Tasks.FindAsync([id], token); - if (task is null) return Results.NotFound(); - - if (!TaskStateHelper.IsInProgressOrBlocked(task.State)) - return Results.Problem( - title: "Approval denied", - detail: "Only tasks in 'In progress' or 'Blocked' state can be approved.", - statusCode: StatusCodes.Status403Forbidden); - - task.State = TaskStateHelper.ToStateString(TaskState.Done); - task.UpdatedAt = DateTimeOffset.UtcNow; - db.Activity.Add(new ActivityEvent { Type = "task", Message = $"Task {task.Title} approved" }); - await db.SaveChangesAsync(token); - return Results.Ok(task); -}); - -api.MapPost("/tasks/{id:guid}/reject", async (Guid id, NexusDbContext db, CancellationToken token) => -{ - var task = await db.Tasks.FindAsync([id], token); - if (task is null) return Results.NotFound(); - - if (!TaskStateHelper.IsInProgressOrBlocked(task.State)) - return Results.Problem( - title: "Rejection denied", - detail: "Only tasks in 'In progress' or 'Blocked' state can be rejected.", - statusCode: StatusCodes.Status403Forbidden); - - task.State = TaskStateHelper.ToStateString(TaskState.Backlog); - task.UpdatedAt = DateTimeOffset.UtcNow; - db.Activity.Add(new ActivityEvent { Type = "task", Message = $"Task {task.Title} rejected, returned to backlog" }); - await db.SaveChangesAsync(token); - return Results.Ok(task); -}); - -api.MapPatch("/tasks/{id:guid}/state", async (Guid id, UpdateTaskStateRequest request, NexusDbContext db, CancellationToken token) => -{ - var allowedStates = TaskStateHelper.AllStates; - if (!allowedStates.Contains(request.State, StringComparer.OrdinalIgnoreCase)) - return Results.ValidationProblem(new Dictionary { ["state"] = ["Unsupported task state."] }); - - var task = await db.Tasks.FindAsync([id], token); - if (task is null) return Results.NotFound(); - task.State = allowedStates.First(x => x.Equals(request.State, StringComparison.OrdinalIgnoreCase)); - task.UpdatedAt = DateTimeOffset.UtcNow; - db.Activity.Add(new ActivityEvent { Type = "task", Message = $"Task {task.Title} moved to {task.State}" }); - await db.SaveChangesAsync(token); - return Results.Ok(task); -}); - -api.MapDelete("/tasks/{id:guid}", async (Guid id, NexusDbContext db, CancellationToken token) => -{ - var task = await db.Tasks.FindAsync([id], token); - if (task is null) return Results.NotFound(); - - if (!TaskStateHelper.IsDoneOrBacklog(task.State)) - return Results.Problem( - title: "Task deletion denied", - detail: "Only tasks in 'Done' or 'Backlog' state can be deleted.", - statusCode: StatusCodes.Status403Forbidden); - - db.Activity.Add(new ActivityEvent { Type = "task", Message = $"Task {task.Title} deleted" }); - db.Tasks.Remove(task); - await db.SaveChangesAsync(token); - return Results.NoContent(); -}); - -api.MapPatch("/tasks/{id:guid}", async (Guid id, UpdateTaskRequest request, NexusDbContext db, CancellationToken token) => -{ - var task = await db.Tasks.FindAsync([id], token); - if (task is null) return Results.NotFound(); - - if (!string.IsNullOrWhiteSpace(request.Title)) - task.Title = request.Title.Trim(); - if (!string.IsNullOrWhiteSpace(request.Priority)) - task.Priority = request.Priority.Trim(); - if (request.ProjectId.HasValue) - task.ProjectId = request.ProjectId.Value == Guid.Empty ? null : request.ProjectId; - - task.UpdatedAt = DateTimeOffset.UtcNow; - db.Activity.Add(new ActivityEvent { Type = "task", Message = $"Task {task.Title} updated" }); - await db.SaveChangesAsync(token); - return Results.Ok(task); -}); - -api.MapGet("/activity", async (string? type, string? sort, int? page, int? pageSize, NexusDbContext db, CancellationToken token) => -{ - var query = db.Activity.AsNoTracking(); - - if (!string.IsNullOrWhiteSpace(type)) - query = query.Where(x => x.Type == type); - - query = (sort?.ToLowerInvariant()) switch - { - "oldest" => query.OrderBy(x => x.CreatedAt), - _ => query.OrderByDescending(x => x.CreatedAt) - }; - - var take = Math.Clamp(pageSize ?? 20, 1, 200); - var skip = (Math.Max(page ?? 1, 1) - 1) * take; - - var totalCount = await query.CountAsync(token); - var items = await query.Skip(skip).Take(take).ToListAsync(token); - - return Results.Ok(new - { - items = items.Select(x => new { x.Id, x.Type, x.Message, at = x.CreatedAt }), - totalCount, - page = Math.Max(page ?? 1, 1), - pageSize = take, - totalPages = (int)Math.Ceiling((double)totalCount / take) - }); -}); - -api.MapPost("/chat", async (ChatRequest request, IAgentRuntime runtime, ILogger logger, CancellationToken token) => -{ - var message = request.Message?.Trim(); - if (string.IsNullOrWhiteSpace(message) || message.Length > 8000) - return Results.ValidationProblem(new Dictionary { ["message"] = ["Message must contain between 1 and 8000 characters."] }); - - var agentId = string.IsNullOrWhiteSpace(request.AgentId) ? "iris" : request.AgentId.Trim().ToLowerInvariant(); - if (agentId is not ("iris" or "main")) - return Results.ValidationProblem(new Dictionary { ["agentId"] = ["Only iris and main are supported."] }); - - var conversationId = string.IsNullOrWhiteSpace(request.ConversationId) - ? $"nexus-{Guid.NewGuid():N}" - : request.ConversationId.Trim(); - if (conversationId.Length > 160) - return Results.ValidationProblem(new Dictionary { ["conversationId"] = ["Conversation id is too long."] }); - - try - { - return Results.Ok(await runtime.ChatAsync(message, conversationId, agentId, token)); - } - catch (Exception exception) - { - logger.LogWarning(exception, "OpenClaw chat request failed for agent {AgentId}", agentId); - return Results.Problem( - title: "OpenClaw chat unavailable", - detail: "The trusted OpenClaw chat endpoint is not enabled or reachable.", - statusCode: StatusCodes.Status503ServiceUnavailable); - } -}).RequireRateLimiting("agents"); - -// Agent inventory endpoints -api.MapGet("/agents", async (IAgentService agentService, CancellationToken token) => -{ - var agents = await agentService.GetAgentsAsync(token); - return Results.Ok(agents.Select(a => new AgentListResponse( - a.Id, a.Name, a.Role, a.Model, a.Status.ToString(), a.LastSeen, a.Workspace, a.Description - ))); -}); - -api.MapGet("/agents/{id}", async (string id, IAgentService agentService, CancellationToken token) => -{ - var agent = await agentService.GetAgentAsync(id, token); - if (agent is null) return Results.NotFound(); - return Results.Ok(new AgentDetailResponse( - agent.Id, agent.Name, agent.Role, agent.Model, agent.Status.ToString(), - agent.LastSeen, agent.Workspace, agent.AgentDir, agent.Description, - agent.SubAgents, agent.IdentityName - )); -}); - -api.MapGet("/agents/{id}/activity", async (string id, NexusDbContext db, CancellationToken token) => -{ - var query = db.Activity.AsNoTracking() - .Where(x => x.Message.Contains(id, StringComparison.OrdinalIgnoreCase) || x.Type == "agent") - .OrderByDescending(x => x.CreatedAt) - .Take(50); - - var items = await query.ToListAsync(token); - return Results.Ok(items.Select(x => new { x.Id, x.Type, x.Message, at = x.CreatedAt })); -}); - -api.MapPost("/agents/{id}/command", async (string id, AgentCommandRequest request, IAgentRuntime runtime, ILogger logger, NexusDbContext db, CancellationToken token) => -{ - var message = request.Message?.Trim(); - if (string.IsNullOrWhiteSpace(message) || message.Length > 8000) - return Results.ValidationProblem(new Dictionary { ["message"] = ["Message must contain between 1 and 8000 characters."] }); - - var conversationId = $"nexus-command-{id}-{Guid.NewGuid():N}"; - - try - { - var result = await runtime.ChatAsync(message, conversationId, id, token); - - db.Activity.Add(new ActivityEvent { Type = "agent", Message = $"Command sent to agent {id}: {message[..Math.Min(message.Length, 80)]}" }); - await db.SaveChangesAsync(token); - - return Results.Ok(new AgentCommandResponse(result.Runtime, result.AgentId, result.ConversationId, result.Content)); - } - catch (Exception exception) - { - logger.LogWarning(exception, "Agent command failed for {AgentId}", id); - return Results.Problem( - title: "Agent command failed", - detail: $"Could not send command to agent {id}: {exception.Message}", - statusCode: StatusCodes.Status503ServiceUnavailable); - } -}).RequireRateLimiting("agents"); - -api.MapGet("/routing", async (ModelRoutingService routing, CancellationToken token) => - Results.Ok(await routing.GetStatusAsync(token))); - -// ========== Phase 2: Agent Config Editor ========== - -api.MapGet("/agents/{id}/config", async (string id, CancellationToken ct) => -{ - var workspacePath = $"/mnt/workspace-{id}"; - if (!Directory.Exists(workspacePath)) - return Results.Ok(Array.Empty()); - - var allowedFiles = new HashSet(StringComparer.OrdinalIgnoreCase) - { - "IDENTITY.md", "SOUL.md", "AGENTS.md", "TOOLS.md", "HEARTBEAT.md", "USER.md", "MEMORY.md" - }; - - var files = Directory.GetFiles(workspacePath, "*.md") - .Select(f => new FileInfo(f)) - .Where(f => allowedFiles.Contains(f.Name)) - .OrderBy(f => f.Name) - .Select(f => new - { - fileName = f.Name, - size = f.Length, - modifiedAt = f.LastWriteTimeUtc - }) - .ToList(); - - return Results.Ok(files); -}); - -api.MapGet("/agents/{id}/config/{fileName}", async (string id, string fileName, CancellationToken ct) => -{ - if (!IsValidConfigFileName(fileName)) - return Results.BadRequest(new { error = "Invalid filename. Only .md files with alphanumeric characters, dots, hyphens, and underscores are allowed." }); - - var workspacePath = $"/mnt/workspace-{id}"; - if (!TryResolveSafePath(workspacePath, fileName, out var safePath) || !File.Exists(safePath)) - return Results.NotFound(); - - var content = await File.ReadAllTextAsync(safePath, ct); - var fi = new FileInfo(safePath); - return Results.Ok(new { fileName, content, size = fi.Length, modifiedAt = fi.LastWriteTimeUtc }); -}); - -api.MapPut("/agents/{id}/config/{fileName}", async (string id, string fileName, SaveConfigRequest request, CancellationToken ct) => -{ - if (!IsValidConfigFileName(fileName)) - return Results.BadRequest(new { error = "Invalid filename. Only .md files with alphanumeric characters, dots, hyphens, and underscores are allowed." }); - - if (request.Content is null) - return Results.BadRequest(new { error = "Content is required." }); - - if (request.Content.Length > 500 * 1024) - return Results.BadRequest(new { error = "Content exceeds maximum size of 500KB." }); - - var workspacePath = $"/mnt/workspace-{id}"; - if (!TryResolveSafePath(workspacePath, fileName, out var safePath)) - return Results.NotFound(); - - // Atomic write: write to temp file, then rename - var tempPath = safePath + ".tmp"; - try - { - await File.WriteAllTextAsync(tempPath, request.Content, ct); - File.Move(tempPath, safePath, overwrite: true); - } - catch - { - if (File.Exists(tempPath)) File.Delete(tempPath); - throw; - } - - var fi = new FileInfo(safePath); - return Results.Ok(new { fileName, size = fi.Length, modifiedAt = fi.LastWriteTimeUtc }); -}); - -// ========== Phase 2: Memory Browser ========== - -api.MapGet("/memory", async () => -{ - var basePath = "/mnt/workspace-iris/memory"; - if (!Directory.Exists(basePath)) - return Results.Ok(Array.Empty()); - - var files = Directory.GetFiles(basePath, "*.md") - .Select(f => new FileInfo(f)) - .OrderByDescending(f => f.Name) - .Select(f => new { - name = f.Name, - path = f.FullName.Replace(basePath, "").TrimStart('/'), - size = f.Length, - modifiedAt = f.LastWriteTimeUtc - }) - .ToList(); - - var longTermPath = "/mnt/workspace-iris/MEMORY.md"; - if (File.Exists(longTermPath)) - { - var fi = new FileInfo(longTermPath); - files.Insert(0, new { name = "MEMORY.md", path = "MEMORY.md", size = fi.Length, modifiedAt = fi.LastWriteTimeUtc }); - } - - return Results.Ok(files); -}); - -api.MapGet("/memory/search", async (string q) => -{ - if (string.IsNullOrWhiteSpace(q) || q.Length < 2) - return Results.BadRequest("Query must be at least 2 characters."); - - var basePath = "/mnt/workspace-iris/memory"; - var results = new List(); - - const int maxFiles = 50; - const int maxFileSize = 1_000_000; // 1 MB per file - - async Task SearchDir(string dir) - { - if (!Directory.Exists(dir)) return; - var files = Directory.GetFiles(dir, "*.md").Take(maxFiles); - foreach (var file in files) - { - var fi = new FileInfo(file); - if (fi.Length > maxFileSize) continue; - string content; - using (var reader = new StreamReader(file)) - content = await reader.ReadToEndAsync(); - if (content.Contains(q, StringComparison.OrdinalIgnoreCase)) - { - var idx = content.IndexOf(q, StringComparison.OrdinalIgnoreCase); - var start = Math.Max(0, idx - 60); - var excerpt = (start > 0 ? "\u2026" : "") + content.Substring(start, Math.Min(200, content.Length - start)) + "\u2026"; - results.Add(new { name = Path.GetFileName(file), path = file.Replace(basePath, "").TrimStart('/'), excerpt, size = fi.Length }); - } - } - } - - await SearchDir(basePath); - - var longTermPath = "/mnt/workspace-iris/MEMORY.md"; - if (File.Exists(longTermPath)) - { - string content; - using (var reader = new StreamReader(longTermPath)) - content = await reader.ReadToEndAsync(); - if (content.Contains(q, StringComparison.OrdinalIgnoreCase)) - { - var idx = content.IndexOf(q, StringComparison.OrdinalIgnoreCase); - var start = Math.Max(0, idx - 60); - var excerpt = (start > 0 ? "\u2026" : "") + content.Substring(start, Math.Min(200, content.Length - start)) + "\u2026"; - results.Insert(0, new { name = "MEMORY.md", path = "MEMORY.md", excerpt, size = content.Length }); - } - } - - return Results.Ok(results); -}); - -api.MapGet("/memory/{name}", async (string name) => -{ - if (!TryResolveSafePath("/mnt/workspace-iris/memory", name, out var filePath)) - return Results.BadRequest("Invalid filename."); - - var longTermPath = "/mnt/workspace-iris/MEMORY.md"; - if (name.Equals("MEMORY.md", StringComparison.OrdinalIgnoreCase)) - filePath = longTermPath; - - if (!File.Exists(filePath)) - return Results.NotFound(); - - var content = await File.ReadAllTextAsync(filePath); - return Results.Ok(new { name, path = name, content, size = content.Length, modifiedAt = System.IO.File.GetLastWriteTimeUtc(filePath) }); -}); - -// ========== Phase 2: Docs Browser ========== - -api.MapGet("/docs", () => -{ - var workspaceRoot = "/mnt/workspace-iris"; - var results = new List(); - - void ScanDir(string dir, string category) - { - if (!Directory.Exists(dir)) return; - foreach (var file in Directory.GetFiles(dir, "*.*")) - { - var ext = Path.GetExtension(file).ToLowerInvariant(); - if (ext is not (".md" or ".json" or ".txt" or ".yaml" or ".yml" or ".html" or ".css")) - continue; - var fi = new FileInfo(file); - results.Add(new { - name = fi.Name, - path = file.Replace(workspaceRoot, "").TrimStart('/'), - category, - type = ext.Replace(".", ""), - size = fi.Length, - modifiedAt = fi.LastWriteTimeUtc - }); - } - } - - ScanDir("/mnt/workspace-iris/nexus-phases", "phases"); - ScanDir("/mnt/workspace-iris/skills", "skills"); - ScanDir("/mnt/workspace-iris", "workspace"); - ScanDir("/home/node/.openclaw/workspace/nexus", "nexus"); - ScanDir("/home/node/.openclaw/workspace/nexus/phases", "nexus-phases"); - - return Results.Ok(results.OrderByDescending(x => ((System.DateTime)((dynamic)x).modifiedAt)).Take(100)); -}); - -// ========== Phase 2: Team Org Map ========== - -api.MapGet("/team", async (IAgentService agentService, CancellationToken ct) => -{ - var agents = await agentService.GetAgentsAsync(ct); - var team = new List(); - - foreach (var agent in agents) - { - string identity = ""; - string workspace = agent.Workspace ?? ""; - if (!string.IsNullOrWhiteSpace(workspace) && Directory.Exists(workspace)) - { - var identityFile = Path.Combine(workspace, "IDENTITY.md"); - if (File.Exists(identityFile)) - { - var content = await File.ReadAllTextAsync(identityFile, ct); - var lines = content.Split('\n').Where(l => l.StartsWith("- **")).Take(8); - identity = string.Join("\n", lines); - } - } - - team.Add(new - { - agent.Id, agent.Name, agent.Role, agent.Model, agent.Status, agent.LastSeen, agent.Workspace, agent.Description, - identity - }); - } - - return Results.Ok(team); -}); - -// ========== Phase 2: Security Center ========== - -api.MapGet("/security/status", (NexusDbContext db, IConfiguration config) => -{ - var jwtIssuer = config["Jwt:Issuer"] ?? "nexus"; - var jwtAudience = config["Jwt:Audience"] ?? "nexus-web"; - var refreshDays = config.GetValue("Jwt:RefreshTokenExpirationDays", 7); - var accessTokenMinutes = config.GetValue("Jwt:AccessTokenExpirationMinutes", 30); - - return Results.Ok(new - { - authMethod = "JWT + PBKDF2", - tokenConfig = new { refreshTokenDays = refreshDays, accessTokenMinutes }, - rateLimit = "5 login attempts per minute per IP", - passwordPolicy = "Minimum 10 characters", - cookieConfig = new { httpOnly = true, secure = true, sameSite = "Strict" }, - twoFactorEnabled = false, - passkeyEnabled = false, - checkedAt = DateTimeOffset.UtcNow - }); -}); - -// ========== Phase 2: Incident Diary ========== - -api.MapGet("/incidents", async () => -{ - var basePath = "/mnt/workspace-iris/memory/incidents"; - if (!Directory.Exists(basePath)) - return Results.Ok(Array.Empty()); - - var incidents = new List(); - foreach (var file in Directory.GetFiles(basePath, "*.md").OrderByDescending(f => f).Take(50)) - { - var fi = new FileInfo(file); - if (fi.Length > 1_000_000) continue; - var name = Path.GetFileNameWithoutExtension(file); - var content = await File.ReadAllTextAsync(file); - - // Extract title from # heading - var title = name; - var titleMatch = System.Text.RegularExpressions.Regex.Match(content, @"^#\s+(.+)$", System.Text.RegularExpressions.RegexOptions.Multiline); - if (titleMatch.Success) - title = titleMatch.Groups[1].Value.Trim(); - - // Extract date from filename YYYY-MM-DD - var date = (string?)null; - var dateMatch = System.Text.RegularExpressions.Regex.Match(name, @"^(\d{4}-\d{2}-\d{2})"); - if (dateMatch.Success) - date = dateMatch.Groups[1].Value; - - // Extract severity from content - var severity = "unknown"; - var severityMatch = System.Text.RegularExpressions.Regex.Match(content, @"\*\*Severity:\*\*\s*(.+)$", System.Text.RegularExpressions.RegexOptions.Multiline); - if (severityMatch.Success) - severity = severityMatch.Groups[1].Value.Trim(); - - // Extract excerpt (content up to ## Auslöser or ## Chronologie) - var excerptEnd = content.IndexOf("\n## ", StringComparison.Ordinal); - var excerpt = excerptEnd > 0 - ? content[..excerptEnd].Trim() - : content[..Math.Min(300, content.Length)].Trim(); - if (excerpt.Length > 200) - excerpt = excerpt[..200] + "…"; - - incidents.Add(new - { - name = Path.GetFileName(file), - title, - date, - severity, - excerpt, - size = fi.Length - }); - } - - return Results.Ok(incidents); -}); - -api.MapGet("/incidents/{name}", async (string name) => -{ - var basePath = "/mnt/workspace-iris/memory/incidents"; - if (!TryResolveSafePath(basePath, name, out var filePath)) - return Results.BadRequest("Invalid filename."); - - if (!File.Exists(filePath)) - { - // Try with .md extension if not provided - if (!name.EndsWith(".md", StringComparison.OrdinalIgnoreCase)) - filePath = Path.Combine(basePath, name + ".md"); - if (!File.Exists(filePath)) - return Results.NotFound(); - } - - var content = await File.ReadAllTextAsync(filePath); - var fi = new FileInfo(filePath); - var fileName = Path.GetFileName(filePath); - - // Extract title from # heading - var title = fileName; - var titleMatch = System.Text.RegularExpressions.Regex.Match(content, @"^#\s+(.+)$", System.Text.RegularExpressions.RegexOptions.Multiline); - if (titleMatch.Success) - title = titleMatch.Groups[1].Value.Trim(); - - // Extract date from filename - var date = (string?)null; - var dateMatch = System.Text.RegularExpressions.Regex.Match(fileName, @"^(\d{4}-\d{2}-\d{2})"); - if (dateMatch.Success) - date = dateMatch.Groups[1].Value; - - return Results.Ok(new - { - name = fileName, - title, - date, - content, - size = fi.Length - }); -}); - -// ========== Phase 2: Calendar & Scheduler ========== - -api.MapGet("/calendar", async (IConfiguration config, IHttpClientFactory httpClientFactory, ILogger logger, CancellationToken ct) => -{ - // Try to reach the gateway cron endpoint; fallback to dummy data on failure - var gatewayToken = config["Integrations:OpenClaw:Token"] ?? ""; - - try - { - var httpClient = httpClientFactory.CreateClient("gateway"); - if (!string.IsNullOrWhiteSpace(gatewayToken)) - httpClient.DefaultRequestHeaders.Authorization = - new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", gatewayToken); - - var response = await httpClient.GetAsync("/api/cron", ct); - if (response.IsSuccessStatusCode) - { - var data = await response.Content.ReadFromJsonAsync>(ct); - return Results.Ok(data ?? new List()); - } - } - catch (Exception ex) - { - logger.LogDebug(ex, "Gateway cron endpoint not reachable, using fallback data."); - } - - // Fallback dummy data when gateway is not reachable - var fallbackJobs = new List - { - new { id = "health-check", name = "Health Check", schedule = "*/5 * * * *", lastRun = DateTimeOffset.UtcNow.AddMinutes(-3).ToString("O"), nextRun = DateTimeOffset.UtcNow.AddMinutes(2).ToString("O"), status = "completed" }, - new { id = "memory-sync", name = "Memory Sync", schedule = "0 */6 * * *", lastRun = DateTimeOffset.UtcNow.AddHours(-2).ToString("O"), nextRun = DateTimeOffset.UtcNow.AddHours(4).ToString("O"), status = "completed" }, - new { id = "task-cleanup", name = "Task Cleanup", schedule = "0 3 * * *", lastRun = DateTimeOffset.UtcNow.AddDays(-1).ToString("O"), nextRun = DateTimeOffset.UtcNow.AddDays(1).AddHours(3).ToString("O"), status = "completed" }, - new { id = "backup", name = "Database Backup", schedule = "0 4 * * *", lastRun = DateTimeOffset.UtcNow.AddDays(-1).AddHours(-1).ToString("O"), nextRun = DateTimeOffset.UtcNow.AddDays(1).AddHours(4).ToString("O"), status = "completed" }, - new { id = "model-routing-refresh", name = "Model Routing Refresh", schedule = "*/30 * * * *", lastRun = DateTimeOffset.UtcNow.AddMinutes(-12).ToString("O"), nextRun = DateTimeOffset.UtcNow.AddMinutes(18).ToString("O"), status = "running" }, - }; - return Results.Ok(fallbackJobs); -}); - -api.MapGet("/calendar/upcoming", async (IConfiguration config, IHttpClientFactory httpClientFactory, ILogger logger, CancellationToken ct) => -{ - var gatewayToken = config["Integrations:OpenClaw:Token"] ?? ""; - - try - { - var httpClient = httpClientFactory.CreateClient("gateway"); - if (!string.IsNullOrWhiteSpace(gatewayToken)) - httpClient.DefaultRequestHeaders.Authorization = - new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", gatewayToken); - - var response = await httpClient.GetAsync("/api/cron/upcoming", ct); - if (response.IsSuccessStatusCode) - { - var data = await response.Content.ReadFromJsonAsync>(ct); - return Results.Ok(data ?? new List()); - } - } - catch (Exception ex) - { - logger.LogDebug(ex, "Gateway upcoming cron endpoint not reachable, using fallback data."); - } - - // Fallback dummy data - var now = DateTimeOffset.UtcNow; - var fallback = new List - { - new { id = "health-check", name = "Health Check", nextRun = now.AddMinutes(2).ToString("O"), schedule = "*/5 * * * *" }, - new { id = "model-routing-refresh", name = "Model Routing Refresh", nextRun = now.AddMinutes(18).ToString("O"), schedule = "*/30 * * * *" }, - new { id = "memory-sync", name = "Memory Sync", nextRun = now.AddHours(4).ToString("O"), schedule = "0 */6 * * *" }, - new { id = "task-cleanup", name = "Task Cleanup", nextRun = now.AddDays(1).AddHours(3).ToString("O"), schedule = "0 3 * * *" }, - new { id = "backup", name = "Database Backup", nextRun = now.AddDays(1).AddHours(4).ToString("O"), schedule = "0 4 * * *" }, - }; - return Results.Ok(fallback); -}); - -// ========== Phase 2: Docs Catch-All (MUSS AM ENDE SEIN FÜR ROUTE-REIHENFOLGE) ========== - -api.MapGet("/docs/{**path}", async (string path) => -{ - if (string.IsNullOrWhiteSpace(path)) - return Results.BadRequest("Path required."); - - // Try workspace-iris first, then nexus - string? resolvedPath = null; - foreach (var root in new[] { "/mnt/workspace-iris", "/home/node/.openclaw/workspace/nexus" }) - { - if (TryResolveSafePath(root, path, out var candidate) && File.Exists(candidate)) - { - resolvedPath = candidate; - break; - } - } - - if (resolvedPath is null) - return Results.NotFound(); - - var content = await File.ReadAllTextAsync(resolvedPath); - var fi = new FileInfo(resolvedPath); - return Results.Ok(new { name = fi.Name, path = resolvedPath.Replace("/mnt/workspace-iris/", "").Replace("/home/node/.openclaw/workspace/nexus/", ""), content, size = fi.Length, modifiedAt = fi.LastWriteTimeUtc }); -}); - +app.MapControllers(); app.Run(); +// --- Helpers --- + static string GenerateTemporaryPassword() => Convert.ToBase64String(RandomNumberGenerator.GetBytes(18)) .TrimEnd('=') @@ -1157,73 +215,3 @@ static string BuildOwnerDisplayName(string email) var displayName = string.Join(' ', words); return string.IsNullOrWhiteSpace(displayName) ? "Owner" : displayName; } - -static AuthResponse ToAuthResponse(AuthSession session) => new() -{ - AccessToken = session.AccessToken, - ExpiresAt = session.ExpiresAt, - User = session.User -}; - -static void SetRefreshCookie(HttpResponse response, string token, IConfiguration config, IHostEnvironment environment) -{ - var days = config.GetValue("Jwt:RefreshTokenExpirationDays") ?? 7; - response.Cookies.Append("nexus_refresh", token, new CookieOptions - { - HttpOnly = true, - Secure = !environment.IsDevelopment(), - SameSite = SameSiteMode.Strict, - Path = "/api/v1/auth", - MaxAge = TimeSpan.FromDays(days), - IsEssential = true - }); -} - -static void ClearRefreshCookie(HttpResponse response, IHostEnvironment environment) -{ - response.Cookies.Delete("nexus_refresh", new CookieOptions - { - HttpOnly = true, - Secure = !environment.IsDevelopment(), - SameSite = SameSiteMode.Strict, - Path = "/api/v1/auth" - }); -} - -// --- Security helper: safe path validation against traversal --- -static bool TryResolveSafePath(string basePath, string userInput, out string? safePath) -{ - safePath = null; - - // URL-decode to catch encoded attacks like %2F, %2e%2e, %00 - var decoded = Uri.UnescapeDataString(userInput); - - // Reject null bytes - if (decoded.Contains('\0')) return false; - - // Combine with base and resolve to canonical form - var combined = Path.Combine(basePath, decoded); - var full = Path.GetFullPath(combined); - var canonicalBase = Path.GetFullPath(basePath); - - // Must stay within the allowed base directory - if (!full.StartsWith(canonicalBase + Path.DirectorySeparatorChar) && full != canonicalBase) - return false; - - safePath = full; - return true; -} - -// Validates config filename against path-traversal -static bool IsValidConfigFileName(string fileName) -{ - if (string.IsNullOrWhiteSpace(fileName)) return false; - return System.Text.RegularExpressions.Regex.IsMatch(fileName, @"^[a-zA-Z0-9._-]+\.md$"); -} - -// Record types for cron job deserialization -record CronJobEntry(string Id, string Name, string Schedule, string LastRun, string NextRun, string Status); -record UpcomingCronEntry(string Id, string Name, string NextRun, string Schedule); - -// Record type for agent config save request -record SaveConfigRequest(string Content); diff --git a/backend/Repositories/ActivityRepository.cs b/backend/Repositories/ActivityRepository.cs new file mode 100644 index 0000000..4860b05 --- /dev/null +++ b/backend/Repositories/ActivityRepository.cs @@ -0,0 +1,43 @@ +using Microsoft.EntityFrameworkCore; +using Nexus.Api.Data; + +namespace Nexus.Api.Repositories; + +public sealed class ActivityRepository(NexusDbContext db) : IActivityRepository +{ + public Task> GetRecentAsync(int take, CancellationToken ct = default) + => db.Activity.AsNoTracking().OrderByDescending(x => x.CreatedAt).Take(take).ToListAsync(ct); + + public async Task<(List Items, int TotalCount)> GetPagedAsync( + string? type, string? sort, int page, int pageSize, CancellationToken ct = default) + { + var query = db.Activity.AsNoTracking(); + + if (!string.IsNullOrWhiteSpace(type)) + query = query.Where(x => x.Type == type); + + query = (sort?.ToLowerInvariant()) switch + { + "oldest" => query.OrderBy(x => x.CreatedAt), + _ => query.OrderByDescending(x => x.CreatedAt) + }; + + var totalCount = await query.CountAsync(ct); + var items = await query.Skip((page - 1) * pageSize).Take(pageSize).ToListAsync(ct); + return (items, totalCount); + } + + public Task> GetByAgentAsync(string agentId, int take, CancellationToken ct = default) + => db.Activity.AsNoTracking() + .Where(x => x.Message.Contains(agentId, StringComparison.OrdinalIgnoreCase) || x.Type == "agent") + .OrderByDescending(x => x.CreatedAt) + .Take(take) + .ToListAsync(ct); + + public async Task AddAsync(ActivityEvent activity, CancellationToken ct = default) + { + db.Activity.Add(activity); + await db.SaveChangesAsync(ct); + return activity; + } +} diff --git a/backend/Repositories/IActivityRepository.cs b/backend/Repositories/IActivityRepository.cs new file mode 100644 index 0000000..9f2b0c9 --- /dev/null +++ b/backend/Repositories/IActivityRepository.cs @@ -0,0 +1,12 @@ +using Nexus.Api.Data; + +namespace Nexus.Api.Repositories; + +public interface IActivityRepository +{ + Task> GetRecentAsync(int take, CancellationToken ct = default); + Task<(List Items, int TotalCount)> GetPagedAsync( + string? type, string? sort, int page, int pageSize, CancellationToken ct = default); + Task> GetByAgentAsync(string agentId, int take, CancellationToken ct = default); + Task AddAsync(ActivityEvent activity, CancellationToken ct = default); +} diff --git a/backend/Repositories/IProjectRepository.cs b/backend/Repositories/IProjectRepository.cs new file mode 100644 index 0000000..21fb6dc --- /dev/null +++ b/backend/Repositories/IProjectRepository.cs @@ -0,0 +1,13 @@ +using Nexus.Api.Data; + +namespace Nexus.Api.Repositories; + +public interface IProjectRepository +{ + Task> GetAllAsync(CancellationToken ct = default); + ValueTask GetByIdAsync(Guid id, CancellationToken ct = default); + Task AddAsync(Project project, CancellationToken ct = default); + Task UpdateAsync(Project project, CancellationToken ct = default); + Task DeleteAsync(Project project, CancellationToken ct = default); + Task HasTasksAsync(Guid projectId, CancellationToken ct = default); +} diff --git a/backend/Repositories/ITaskRepository.cs b/backend/Repositories/ITaskRepository.cs new file mode 100644 index 0000000..5c70482 --- /dev/null +++ b/backend/Repositories/ITaskRepository.cs @@ -0,0 +1,16 @@ +using Nexus.Api.Data; + +namespace Nexus.Api.Repositories; + +public interface ITaskRepository +{ + Task> GetAllAsync(CancellationToken ct = default); + ValueTask GetByIdAsync(Guid id, CancellationToken ct = default); + Task> GetPendingApprovalAsync(CancellationToken ct = default); + Task AddAsync(WorkTask task, CancellationToken ct = default); + Task UpdateAsync(WorkTask task, CancellationToken ct = default); + Task DeleteAsync(WorkTask task, CancellationToken ct = default); + Task CountAsync(CancellationToken ct = default); + Task CountByStateAsync(string state, CancellationToken ct = default); + Task GetLastBlockedAsync(CancellationToken ct = default); +} diff --git a/backend/Repositories/IUserRepository.cs b/backend/Repositories/IUserRepository.cs new file mode 100644 index 0000000..d1ab7f2 --- /dev/null +++ b/backend/Repositories/IUserRepository.cs @@ -0,0 +1,21 @@ +using Nexus.Api.Data; + +namespace Nexus.Api.Repositories; + +public interface IUserRepository +{ + ValueTask GetByIdAsync(Guid userId, CancellationToken ct = default); + Task GetByEmailAsync(string normalizedEmail, CancellationToken ct = default); + Task AnyUsersAsync(CancellationToken ct = default); + Task AddAsync(NexusUser user, CancellationToken ct = default); + Task UpdateAsync(NexusUser user, CancellationToken ct = default); + + // Refresh token operations + Task GetRefreshTokenByHashAsync(string tokenHash, CancellationToken ct = default); + Task> GetActiveTokensByFamilyAsync(Guid familyId, CancellationToken ct = default); + Task AddRefreshTokenAsync(RefreshToken token, CancellationToken ct = default); + Task UpdateRefreshTokenAsync(RefreshToken token, CancellationToken ct = default); + Task RemoveExpiredTokensAsync(Guid userId, CancellationToken ct = default); + + Task SaveChangesAsync(CancellationToken ct = default); +} diff --git a/backend/Repositories/ProjectRepository.cs b/backend/Repositories/ProjectRepository.cs new file mode 100644 index 0000000..ea40995 --- /dev/null +++ b/backend/Repositories/ProjectRepository.cs @@ -0,0 +1,35 @@ +using Microsoft.EntityFrameworkCore; +using Nexus.Api.Data; + +namespace Nexus.Api.Repositories; + +public sealed class ProjectRepository(NexusDbContext db) : IProjectRepository +{ + public Task> GetAllAsync(CancellationToken ct = default) + => db.Projects.AsNoTracking().OrderByDescending(x => x.UpdatedAt).ToListAsync(ct); + + public ValueTask GetByIdAsync(Guid id, CancellationToken ct = default) + => db.Projects.FindAsync([id], ct); + + public async Task AddAsync(Project project, CancellationToken ct = default) + { + db.Projects.Add(project); + await db.SaveChangesAsync(ct); + return project; + } + + public async Task UpdateAsync(Project project, CancellationToken ct = default) + { + project.UpdatedAt = DateTimeOffset.UtcNow; + await db.SaveChangesAsync(ct); + } + + public async Task DeleteAsync(Project project, CancellationToken ct = default) + { + db.Projects.Remove(project); + await db.SaveChangesAsync(ct); + } + + public Task HasTasksAsync(Guid projectId, CancellationToken ct = default) + => db.Tasks.AnyAsync(t => t.ProjectId == projectId, ct); +} diff --git a/backend/Repositories/TaskRepository.cs b/backend/Repositories/TaskRepository.cs new file mode 100644 index 0000000..c086d5c --- /dev/null +++ b/backend/Repositories/TaskRepository.cs @@ -0,0 +1,53 @@ +using Microsoft.EntityFrameworkCore; +using Nexus.Api.Data; + +namespace Nexus.Api.Repositories; + +public sealed class TaskRepository(NexusDbContext db) : ITaskRepository +{ + public Task> GetAllAsync(CancellationToken ct = default) + => db.Tasks.AsNoTracking().OrderByDescending(x => x.UpdatedAt).ToListAsync(ct); + + public ValueTask GetByIdAsync(Guid id, CancellationToken ct = default) + => db.Tasks.FindAsync([id], ct); + + public Task> GetPendingApprovalAsync(CancellationToken ct = default) + { + var threshold = DateTimeOffset.UtcNow.AddHours(-1); + return db.Tasks.AsNoTracking() + .Where(x => x.State == TaskStateHelper.ToStateString(TaskState.InProgress) && x.UpdatedAt <= threshold) + .OrderByDescending(x => x.UpdatedAt) + .ToListAsync(ct); + } + + public async Task AddAsync(WorkTask task, CancellationToken ct = default) + { + db.Tasks.Add(task); + await db.SaveChangesAsync(ct); + return task; + } + + public async Task UpdateAsync(WorkTask task, CancellationToken ct = default) + { + task.UpdatedAt = DateTimeOffset.UtcNow; + await db.SaveChangesAsync(ct); + } + + public async Task DeleteAsync(WorkTask task, CancellationToken ct = default) + { + db.Tasks.Remove(task); + await db.SaveChangesAsync(ct); + } + + public Task CountAsync(CancellationToken ct = default) + => db.Tasks.CountAsync(ct); + + public Task CountByStateAsync(string state, CancellationToken ct = default) + => db.Tasks.CountAsync(x => x.State == state, ct); + + public Task GetLastBlockedAsync(CancellationToken ct = default) + => db.Tasks.AsNoTracking() + .Where(x => x.State == TaskStateHelper.ToStateString(TaskState.Blocked)) + .OrderByDescending(x => x.UpdatedAt) + .FirstOrDefaultAsync(ct); +} diff --git a/backend/Repositories/UserRepository.cs b/backend/Repositories/UserRepository.cs new file mode 100644 index 0000000..38e2a92 --- /dev/null +++ b/backend/Repositories/UserRepository.cs @@ -0,0 +1,59 @@ +using Microsoft.EntityFrameworkCore; +using Nexus.Api.Data; + +namespace Nexus.Api.Repositories; + +public sealed class UserRepository(NexusDbContext db) : IUserRepository +{ + public ValueTask GetByIdAsync(Guid userId, CancellationToken ct = default) + => db.Users.FindAsync([userId], ct); + + public Task GetByEmailAsync(string normalizedEmail, CancellationToken ct = default) + => db.Users.FirstOrDefaultAsync(u => u.NormalizedEmail == normalizedEmail, ct); + + public Task AnyUsersAsync(CancellationToken ct = default) + => db.Users.AnyAsync(ct); + + public async Task AddAsync(NexusUser user, CancellationToken ct = default) + { + db.Users.Add(user); + await db.SaveChangesAsync(ct); + return user; + } + + public Task UpdateAsync(NexusUser user, CancellationToken ct = default) + => db.SaveChangesAsync(ct); + + public Task GetRefreshTokenByHashAsync(string tokenHash, CancellationToken ct = default) + => db.RefreshTokens + .Include(r => r.User) + .FirstOrDefaultAsync(r => r.TokenHash == tokenHash, ct); + + public Task> GetActiveTokensByFamilyAsync(Guid familyId, CancellationToken ct = default) + => db.RefreshTokens + .Where(r => r.FamilyId == familyId && r.RevokedAt == null) + .ToListAsync(ct); + + public async Task AddRefreshTokenAsync(RefreshToken token, CancellationToken ct = default) + { + db.RefreshTokens.Add(token); + await db.SaveChangesAsync(ct); + } + + public Task UpdateRefreshTokenAsync(RefreshToken token, CancellationToken ct = default) + => db.SaveChangesAsync(ct); + + public async Task RemoveExpiredTokensAsync(Guid userId, CancellationToken ct = default) + { + var cutoff = DateTimeOffset.UtcNow.AddDays(-30); + var oldTokens = await db.RefreshTokens + .Where(r => r.UserId == userId && (r.ExpiresAt < DateTimeOffset.UtcNow || r.RevokedAt < cutoff)) + .ToListAsync(ct); + + if (oldTokens.Count > 0) + db.RefreshTokens.RemoveRange(oldTokens); + } + + public Task SaveChangesAsync(CancellationToken ct = default) + => db.SaveChangesAsync(ct); +} diff --git a/backend/Routing/ModelRoutingService.cs b/backend/Routing/ModelRoutingService.cs index bcde4ef..832f546 100644 --- a/backend/Routing/ModelRoutingService.cs +++ b/backend/Routing/ModelRoutingService.cs @@ -1,4 +1,4 @@ -using Nexus.Api.Domain; +using Nexus.Api.Data; using Nexus.Api.Integrations; namespace Nexus.Api.Routing; diff --git a/backend/Services/AgentService.cs b/backend/Services/AgentService.cs index 1aedaa0..40cc4f5 100644 --- a/backend/Services/AgentService.cs +++ b/backend/Services/AgentService.cs @@ -1,6 +1,6 @@ using System.Text.Json; using System.Text.Json.Serialization; -using Nexus.Api.Domain; +using Nexus.Api.Data; using Nexus.Api.Integrations; namespace Nexus.Api.Services; @@ -97,7 +97,6 @@ public sealed class AgentService(IConfiguration configuration, IAgentRuntime run var role = DeriveRole(config.Id); var description = config.Identity?.Theme ?? string.Empty; - // main agent doesn't have a separate identity; set a generic description if (string.IsNullOrEmpty(description)) { description = config.Id switch diff --git a/backend/Services/AuthService.cs b/backend/Services/AuthService.cs index 91f07c7..706044e 100644 --- a/backend/Services/AuthService.cs +++ b/backend/Services/AuthService.cs @@ -1,8 +1,7 @@ -using Microsoft.EntityFrameworkCore; using Microsoft.IdentityModel.Tokens; -using Nexus.Api.Contracts; -using Nexus.Api.Domain; -using Nexus.Api.Infrastructure; +using Nexus.Api.DTOs; +using Nexus.Api.Data; +using Nexus.Api.Repositories; using System.IdentityModel.Tokens.Jwt; using System.Security.Claims; using System.Security.Cryptography; @@ -28,13 +27,13 @@ public sealed record AuthSession( public sealed class AuthService : IAuthService { - private readonly NexusDbContext _db; + private readonly IUserRepository _users; private readonly IConfiguration _config; private readonly ILogger _logger; - public AuthService(NexusDbContext db, IConfiguration config, ILogger logger) + public AuthService(IUserRepository users, IConfiguration config, ILogger logger) { - _db = db; + _users = users; _config = config; _logger = logger; } @@ -42,7 +41,7 @@ public sealed class AuthService : IAuthService public async Task LoginAsync(LoginRequest request, CancellationToken ct = default) { var normalizedEmail = NormalizeEmail(request.Email); - var user = await _db.Users.FirstOrDefaultAsync(u => u.NormalizedEmail == normalizedEmail, ct); + var user = await _users.GetByEmailAsync(normalizedEmail, ct); if (user is null || !PasswordSecurity.Verify(request.Password, user.PasswordHash, out var needsUpgrade)) { @@ -54,7 +53,7 @@ public sealed class AuthService : IAuthService user.LastLoginAt = DateTimeOffset.UtcNow; user.UpdatedAt = DateTimeOffset.UtcNow; - await RemoveExpiredTokensAsync(user.Id, ct); + await _users.RemoveExpiredTokensAsync(user.Id, ct); return await CreateSessionAsync(user, Guid.NewGuid(), null, ct); } @@ -63,9 +62,7 @@ public sealed class AuthService : IAuthService if (string.IsNullOrWhiteSpace(refreshToken)) return null; var tokenHash = HashToken(refreshToken); - var token = await _db.RefreshTokens - .Include(r => r.User) - .FirstOrDefaultAsync(r => r.TokenHash == tokenHash, ct); + var token = await _users.GetRefreshTokenByHashAsync(tokenHash, ct); if (token is null) return null; @@ -86,20 +83,25 @@ public sealed class AuthService : IAuthService if (string.IsNullOrWhiteSpace(refreshToken)) return; var tokenHash = HashToken(refreshToken); - var token = await _db.RefreshTokens.FirstOrDefaultAsync(r => r.TokenHash == tokenHash, ct); + var token = await _users.GetRefreshTokenByHashAsync(tokenHash, ct); if (token is null || token.RevokedAt is not null) return; token.RevokedAt = DateTimeOffset.UtcNow; token.ConcurrencyStamp = Guid.NewGuid(); - await _db.SaveChangesAsync(ct); + await _users.SaveChangesAsync(ct); } public Task GetUserAsync(Guid userId, CancellationToken ct = default) - => _db.Users.AsNoTracking().FirstOrDefaultAsync(u => u.Id == userId, ct); + => Task.Run(async () => + { + // AsNoTracking equivalent: UserRepository.GetByIdAsync uses FindAsync (tracked by default) + // For read-only access, we call it but the result shouldn't be mutated + return await _users.GetByIdAsync(userId, ct); + }, ct); public async Task UpdateProfileAsync(Guid userId, UpdateProfileRequest request, CancellationToken ct = default) { - var user = await _db.Users.FirstOrDefaultAsync(u => u.Id == userId, ct); + var user = await _users.GetByIdAsync(userId, ct); if (user is null) return null; if (!string.IsNullOrWhiteSpace(request.DisplayName)) @@ -108,13 +110,13 @@ public sealed class AuthService : IAuthService } user.UpdatedAt = DateTimeOffset.UtcNow; - await _db.SaveChangesAsync(ct); + await _users.UpdateAsync(user, ct); return user; } public async Task ChangePasswordAsync(Guid userId, ChangePasswordRequest request, CancellationToken ct = default) { - var user = await _db.Users.FirstOrDefaultAsync(u => u.Id == userId, ct); + var user = await _users.GetByIdAsync(userId, ct); if (user is null) return false; if (!PasswordSecurity.Verify(request.CurrentPassword, user.PasswordHash, out _)) @@ -122,7 +124,7 @@ public sealed class AuthService : IAuthService user.PasswordHash = PasswordSecurity.Hash(request.NewPassword); user.UpdatedAt = DateTimeOffset.UtcNow; - await _db.SaveChangesAsync(ct); + await _users.UpdateAsync(user, ct); return true; } @@ -141,25 +143,16 @@ public sealed class AuthService : IAuthService replacedToken.RevokedAt = DateTimeOffset.UtcNow; replacedToken.ReplacedByTokenHash = refreshTokenHash; replacedToken.ConcurrencyStamp = Guid.NewGuid(); + await _users.UpdateRefreshTokenAsync(replacedToken, ct); } - _db.RefreshTokens.Add(new RefreshToken + await _users.AddRefreshTokenAsync(new RefreshToken { UserId = user.Id, TokenHash = refreshTokenHash, FamilyId = familyId, ExpiresAt = DateTimeOffset.UtcNow.AddDays(GetRefreshTokenExpirationDays()) - }); - - try - { - await _db.SaveChangesAsync(ct); - } - catch (DbUpdateConcurrencyException) - { - _logger.LogWarning("Concurrent refresh token rotation rejected"); - return null; - } + }, ct); return new AuthSession( GenerateAccessToken(user, accessExpiresAt), @@ -194,10 +187,7 @@ public sealed class AuthService : IAuthService private async Task RevokeFamilyAsync(Guid familyId, CancellationToken ct) { - var activeTokens = await _db.RefreshTokens - .Where(r => r.FamilyId == familyId && r.RevokedAt == null) - .ToListAsync(ct); - + var activeTokens = await _users.GetActiveTokensByFamilyAsync(familyId, ct); var now = DateTimeOffset.UtcNow; foreach (var token in activeTokens) { @@ -205,17 +195,7 @@ public sealed class AuthService : IAuthService token.ConcurrencyStamp = Guid.NewGuid(); } - await _db.SaveChangesAsync(ct); - } - - private async Task RemoveExpiredTokensAsync(Guid userId, CancellationToken ct) - { - var cutoff = DateTimeOffset.UtcNow.AddDays(-30); - var oldTokens = await _db.RefreshTokens - .Where(r => r.UserId == userId && (r.ExpiresAt < DateTimeOffset.UtcNow || r.RevokedAt < cutoff)) - .ToListAsync(ct); - - if (oldTokens.Count > 0) _db.RefreshTokens.RemoveRange(oldTokens); + await _users.SaveChangesAsync(ct); } private static string GenerateRefreshToken()