refactor: Clean Architecture mit Repository Pattern, Controllern und DTOs
- 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:
@@ -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;
|
||||
|
||||
|
||||
@@ -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);
|
||||
@@ -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<string>? 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
|
||||
);
|
||||
@@ -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)
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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 })
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace Nexus.Api.Contracts;
|
||||
namespace Nexus.Api.DTOs;
|
||||
|
||||
public sealed record LoginRequest
|
||||
{
|
||||
@@ -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<string>? 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);
|
||||
@@ -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
|
||||
);
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace Nexus.Api.Domain;
|
||||
namespace Nexus.Api.Data;
|
||||
|
||||
public class NexusUser
|
||||
{
|
||||
+9
-9
@@ -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<long>("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<Guid>("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<Guid>("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<Guid>("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<Guid>("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");
|
||||
});
|
||||
+9
-9
@@ -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<long>("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<Guid>("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<Guid>("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<Guid>("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<Guid>("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");
|
||||
});
|
||||
@@ -1,7 +1,6 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Nexus.Api.Domain;
|
||||
|
||||
namespace Nexus.Api.Infrastructure;
|
||||
namespace Nexus.Api.Data;
|
||||
|
||||
public sealed class NexusDbContext(DbContextOptions<NexusDbContext> options) : DbContext(options)
|
||||
{
|
||||
@@ -0,0 +1,35 @@
|
||||
namespace Nexus.Api.Helpers;
|
||||
|
||||
public static class PathSecurityHelper
|
||||
{
|
||||
/// <summary>Validates a path against directory traversal and resolves a safe absolute path.</summary>
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>Validates config filename against path-traversal; must be alphanumeric .md.</summary>
|
||||
public static bool IsValidConfigFileName(string fileName)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(fileName)) return false;
|
||||
return System.Text.RegularExpressions.Regex.IsMatch(fileName, @"^[a-zA-Z0-9._-]+\.md$");
|
||||
}
|
||||
}
|
||||
@@ -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<IReadOnlyCollection<ModelProviderStatus>> GetModelsAsync(CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
using Nexus.Api.Domain;
|
||||
using Nexus.Api.Data;
|
||||
|
||||
namespace Nexus.Api.Integrations;
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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<IHostEnvironment>();
|
||||
|
||||
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<SecurityHeadersMiddleware>();
|
||||
}
|
||||
+31
-1043
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,43 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Nexus.Api.Data;
|
||||
|
||||
namespace Nexus.Api.Repositories;
|
||||
|
||||
public sealed class ActivityRepository(NexusDbContext db) : IActivityRepository
|
||||
{
|
||||
public Task<List<ActivityEvent>> GetRecentAsync(int take, CancellationToken ct = default)
|
||||
=> db.Activity.AsNoTracking().OrderByDescending(x => x.CreatedAt).Take(take).ToListAsync(ct);
|
||||
|
||||
public async Task<(List<ActivityEvent> 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<List<ActivityEvent>> 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<ActivityEvent> AddAsync(ActivityEvent activity, CancellationToken ct = default)
|
||||
{
|
||||
db.Activity.Add(activity);
|
||||
await db.SaveChangesAsync(ct);
|
||||
return activity;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
using Nexus.Api.Data;
|
||||
|
||||
namespace Nexus.Api.Repositories;
|
||||
|
||||
public interface IActivityRepository
|
||||
{
|
||||
Task<List<ActivityEvent>> GetRecentAsync(int take, CancellationToken ct = default);
|
||||
Task<(List<ActivityEvent> Items, int TotalCount)> GetPagedAsync(
|
||||
string? type, string? sort, int page, int pageSize, CancellationToken ct = default);
|
||||
Task<List<ActivityEvent>> GetByAgentAsync(string agentId, int take, CancellationToken ct = default);
|
||||
Task<ActivityEvent> AddAsync(ActivityEvent activity, CancellationToken ct = default);
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
using Nexus.Api.Data;
|
||||
|
||||
namespace Nexus.Api.Repositories;
|
||||
|
||||
public interface IProjectRepository
|
||||
{
|
||||
Task<List<Project>> GetAllAsync(CancellationToken ct = default);
|
||||
ValueTask<Project?> GetByIdAsync(Guid id, CancellationToken ct = default);
|
||||
Task<Project> AddAsync(Project project, CancellationToken ct = default);
|
||||
Task UpdateAsync(Project project, CancellationToken ct = default);
|
||||
Task DeleteAsync(Project project, CancellationToken ct = default);
|
||||
Task<bool> HasTasksAsync(Guid projectId, CancellationToken ct = default);
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
using Nexus.Api.Data;
|
||||
|
||||
namespace Nexus.Api.Repositories;
|
||||
|
||||
public interface ITaskRepository
|
||||
{
|
||||
Task<List<WorkTask>> GetAllAsync(CancellationToken ct = default);
|
||||
ValueTask<WorkTask?> GetByIdAsync(Guid id, CancellationToken ct = default);
|
||||
Task<List<WorkTask>> GetPendingApprovalAsync(CancellationToken ct = default);
|
||||
Task<WorkTask> AddAsync(WorkTask task, CancellationToken ct = default);
|
||||
Task UpdateAsync(WorkTask task, CancellationToken ct = default);
|
||||
Task DeleteAsync(WorkTask task, CancellationToken ct = default);
|
||||
Task<int> CountAsync(CancellationToken ct = default);
|
||||
Task<int> CountByStateAsync(string state, CancellationToken ct = default);
|
||||
Task<WorkTask?> GetLastBlockedAsync(CancellationToken ct = default);
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
using Nexus.Api.Data;
|
||||
|
||||
namespace Nexus.Api.Repositories;
|
||||
|
||||
public interface IUserRepository
|
||||
{
|
||||
ValueTask<NexusUser?> GetByIdAsync(Guid userId, CancellationToken ct = default);
|
||||
Task<NexusUser?> GetByEmailAsync(string normalizedEmail, CancellationToken ct = default);
|
||||
Task<bool> AnyUsersAsync(CancellationToken ct = default);
|
||||
Task<NexusUser> AddAsync(NexusUser user, CancellationToken ct = default);
|
||||
Task UpdateAsync(NexusUser user, CancellationToken ct = default);
|
||||
|
||||
// Refresh token operations
|
||||
Task<RefreshToken?> GetRefreshTokenByHashAsync(string tokenHash, CancellationToken ct = default);
|
||||
Task<List<RefreshToken>> 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);
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Nexus.Api.Data;
|
||||
|
||||
namespace Nexus.Api.Repositories;
|
||||
|
||||
public sealed class ProjectRepository(NexusDbContext db) : IProjectRepository
|
||||
{
|
||||
public Task<List<Project>> GetAllAsync(CancellationToken ct = default)
|
||||
=> db.Projects.AsNoTracking().OrderByDescending(x => x.UpdatedAt).ToListAsync(ct);
|
||||
|
||||
public ValueTask<Project?> GetByIdAsync(Guid id, CancellationToken ct = default)
|
||||
=> db.Projects.FindAsync([id], ct);
|
||||
|
||||
public async Task<Project> 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<bool> HasTasksAsync(Guid projectId, CancellationToken ct = default)
|
||||
=> db.Tasks.AnyAsync(t => t.ProjectId == projectId, ct);
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Nexus.Api.Data;
|
||||
|
||||
namespace Nexus.Api.Repositories;
|
||||
|
||||
public sealed class TaskRepository(NexusDbContext db) : ITaskRepository
|
||||
{
|
||||
public Task<List<WorkTask>> GetAllAsync(CancellationToken ct = default)
|
||||
=> db.Tasks.AsNoTracking().OrderByDescending(x => x.UpdatedAt).ToListAsync(ct);
|
||||
|
||||
public ValueTask<WorkTask?> GetByIdAsync(Guid id, CancellationToken ct = default)
|
||||
=> db.Tasks.FindAsync([id], ct);
|
||||
|
||||
public Task<List<WorkTask>> 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<WorkTask> 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<int> CountAsync(CancellationToken ct = default)
|
||||
=> db.Tasks.CountAsync(ct);
|
||||
|
||||
public Task<int> CountByStateAsync(string state, CancellationToken ct = default)
|
||||
=> db.Tasks.CountAsync(x => x.State == state, ct);
|
||||
|
||||
public Task<WorkTask?> GetLastBlockedAsync(CancellationToken ct = default)
|
||||
=> db.Tasks.AsNoTracking()
|
||||
.Where(x => x.State == TaskStateHelper.ToStateString(TaskState.Blocked))
|
||||
.OrderByDescending(x => x.UpdatedAt)
|
||||
.FirstOrDefaultAsync(ct);
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Nexus.Api.Data;
|
||||
|
||||
namespace Nexus.Api.Repositories;
|
||||
|
||||
public sealed class UserRepository(NexusDbContext db) : IUserRepository
|
||||
{
|
||||
public ValueTask<NexusUser?> GetByIdAsync(Guid userId, CancellationToken ct = default)
|
||||
=> db.Users.FindAsync([userId], ct);
|
||||
|
||||
public Task<NexusUser?> GetByEmailAsync(string normalizedEmail, CancellationToken ct = default)
|
||||
=> db.Users.FirstOrDefaultAsync(u => u.NormalizedEmail == normalizedEmail, ct);
|
||||
|
||||
public Task<bool> AnyUsersAsync(CancellationToken ct = default)
|
||||
=> db.Users.AnyAsync(ct);
|
||||
|
||||
public async Task<NexusUser> 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<RefreshToken?> GetRefreshTokenByHashAsync(string tokenHash, CancellationToken ct = default)
|
||||
=> db.RefreshTokens
|
||||
.Include(r => r.User)
|
||||
.FirstOrDefaultAsync(r => r.TokenHash == tokenHash, ct);
|
||||
|
||||
public Task<List<RefreshToken>> 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);
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
using Nexus.Api.Domain;
|
||||
using Nexus.Api.Data;
|
||||
using Nexus.Api.Integrations;
|
||||
|
||||
namespace Nexus.Api.Routing;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<AuthService> _logger;
|
||||
|
||||
public AuthService(NexusDbContext db, IConfiguration config, ILogger<AuthService> logger)
|
||||
public AuthService(IUserRepository users, IConfiguration config, ILogger<AuthService> logger)
|
||||
{
|
||||
_db = db;
|
||||
_users = users;
|
||||
_config = config;
|
||||
_logger = logger;
|
||||
}
|
||||
@@ -42,7 +41,7 @@ public sealed class AuthService : IAuthService
|
||||
public async Task<AuthSession?> 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<NexusUser?> 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<NexusUser?> 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<bool> 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()
|
||||
|
||||
Reference in New Issue
Block a user