refactor: Clean Architecture mit Repository Pattern, Controllern und DTOs
CI - Build & Test / Backend (.NET) (push) Successful in 54s
CI - Build & Test / Frontend (Vue/TS) (push) Successful in 19s
CI - Build & Test / Security Check (push) Successful in 2s

- 15 Controller-Klassen ersetzen Minimal APIs in Program.cs
- Repository Pattern mit Interfaces + Implementierungen (Project, Task, Activity, User)
- AuthService verwendet jetzt IUserRepository statt direktem DbContext-Zugriff
- SecurityHeadersMiddleware als eigenständige Middleware-Klasse
- PathSecurityHelper als gemeinsamer Helper für Pfadvalidierung
- DTOs in eigenem Namespace Nexus.Api.DTOs
- EF-Entities in Nexus.Api.Data (vorher Nexus.Api.Domain)
- Program.cs auf DI-Registrierung + Middleware reduziert
- Alle 43 Endpoints unverändert erhalten
- Build + 3/3 Tests erfolgreich
This commit is contained in:
2026-06-09 19:52:58 +02:00
parent 13d4c2f157
commit a79d8282dc
45 changed files with 1590 additions and 1182 deletions
+32
View File
@@ -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<IResult> 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)
});
}
}
+151
View File
@@ -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<AgentsController> logger) : ControllerBase
{
[HttpGet]
public async Task<IResult> 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<IResult> 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<IResult> 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<IResult> 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<string, string[]> { ["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<object>());
var allowedFiles = new HashSet<string>(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<IResult> 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<IResult> 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 });
}
}
+141
View File
@@ -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<IResult> Login([FromBody] LoginRequest request, CancellationToken ct)
{
if (string.IsNullOrWhiteSpace(request.Email) || string.IsNullOrWhiteSpace(request.Password))
return Results.ValidationProblem(new Dictionary<string, string[]> { ["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<IResult> 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<IResult> 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<IResult> 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<IResult> 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<IResult> ChangePassword([FromBody] ChangePasswordRequest request, CancellationToken ct)
{
if (string.IsNullOrWhiteSpace(request.CurrentPassword) || string.IsNullOrWhiteSpace(request.NewPassword))
return Results.ValidationProblem(new Dictionary<string, string[]> { ["password"] = ["Current and new passwords are required."] });
if (request.NewPassword.Length < 10)
return Results.ValidationProblem(new Dictionary<string, string[]> { ["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<int?>("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"
});
}
}
+80
View File
@@ -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<CalendarController> logger) : ControllerBase
{
[HttpGet]
public async Task<IResult> 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<List<CronJobEntry>>(ct);
return Results.Ok(data ?? new List<CronJobEntry>());
}
}
catch (Exception ex)
{
logger.LogDebug(ex, "Gateway cron endpoint not reachable, using fallback data.");
}
var fallbackJobs = new List<object>
{
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<IResult> 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<List<UpcomingCronEntry>>(ct);
return Results.Ok(data ?? new List<UpcomingCronEntry>());
}
}
catch (Exception ex)
{
logger.LogDebug(ex, "Gateway upcoming cron endpoint not reachable, using fallback data.");
}
var now = DateTimeOffset.UtcNow;
var fallback = new List<object>
{
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);
}
}
+43
View File
@@ -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<ChatController> logger) : ControllerBase
{
[HttpPost]
[EnableRateLimiting("agents")]
public async Task<IResult> Chat([FromBody] ChatRequest request, CancellationToken ct)
{
var message = request.Message?.Trim();
if (string.IsNullOrWhiteSpace(message) || message.Length > 8000)
return Results.ValidationProblem(new Dictionary<string, string[]> { ["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<string, string[]> { ["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<string, string[]> { ["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);
}
}
}
+69
View File
@@ -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<object>();
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<IResult> 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 });
}
}
+49
View File
@@ -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<IResult> 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<string, object>)new Dictionary<string, object>()
};
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 });
}
}
+100
View File
@@ -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<IResult> GetAll()
{
var basePath = "/mnt/workspace-iris/memory/incidents";
if (!Directory.Exists(basePath))
return Results.Ok(Array.Empty<object>());
var incidents = new List<object>();
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<IResult> 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
});
}
}
+108
View File
@@ -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<object>());
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<IResult> 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<object>();
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<IResult> 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!) });
}
}
@@ -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<IResult> 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<object>(),
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 })
});
}
}
+77
View File
@@ -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<IResult> GetAll(CancellationToken ct)
=> Results.Ok(await projectRepo.GetAllAsync(ct));
[HttpPost]
public async Task<IResult> Create([FromBody] CreateProjectRequest request, CancellationToken ct)
{
if (string.IsNullOrWhiteSpace(request.Name))
return Results.ValidationProblem(new Dictionary<string, string[]> { ["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<IResult> 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<IResult> 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<OperationalStatus>(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<IResult> 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();
}
}
+13
View File
@@ -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<IResult> GetStatus(CancellationToken ct)
=> Results.Ok(await routing.GetStatusAsync(ct));
}
+29
View File
@@ -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<int>("Jwt:RefreshTokenExpirationDays", 7);
var accessTokenMinutes = config.GetValue<int>("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
});
}
}
+125
View File
@@ -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<IResult> GetAll(CancellationToken ct)
=> Results.Ok(await taskRepo.GetAllAsync(ct));
[HttpPost]
public async Task<IResult> Create([FromBody] CreateTaskRequest request, CancellationToken ct)
{
if (string.IsNullOrWhiteSpace(request.Title))
return Results.ValidationProblem(new Dictionary<string, string[]> { ["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<IResult> 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<IResult> 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<IResult> 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<IResult> UpdateState(Guid id, [FromBody] UpdateTaskStateRequest request, CancellationToken ct)
{
var allowedStates = TaskStateHelper.AllStates;
if (!allowedStates.Contains(request.State, StringComparer.OrdinalIgnoreCase))
return Results.ValidationProblem(new Dictionary<string, string[]> { ["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<IResult> 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<IResult> 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);
}
}
+40
View File
@@ -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<IResult> GetTeam(CancellationToken ct)
{
var agents = await agentService.GetAgentsAsync(ct);
var team = new List<object>();
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);
}
}