using Microsoft.AspNetCore.Authentication.JwtBearer; using Microsoft.AspNetCore.HttpOverrides; using Microsoft.AspNetCore.RateLimiting; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Diagnostics.HealthChecks; using Microsoft.IdentityModel.Tokens; using Nexus.Api.Contracts; using Nexus.Api.Domain; using Nexus.Api.Integrations; using Nexus.Api.Infrastructure; using System.Security.Cryptography; using Nexus.Api.Routing; using Nexus.Api.Services; using System.IdentityModel.Tokens.Jwt; using System.Text; using System.Text.Json.Serialization; using System.Threading.RateLimiting; using Microsoft.AspNetCore.Antiforgery; using System.Diagnostics.CodeAnalysis; using System.IO; var builder = WebApplication.CreateBuilder(args); var jwtKey = builder.Configuration["Jwt:Key"]; var jwtIssuer = builder.Configuration["Jwt:Issuer"] ?? "nexus"; var jwtAudience = builder.Configuration["Jwt:Audience"] ?? "nexus-web"; if (string.IsNullOrWhiteSpace(jwtKey) || Encoding.UTF8.GetByteCount(jwtKey) < 32) throw new InvalidOperationException("Jwt:Key must be configured with at least 32 bytes."); builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme) .AddJwtBearer(options => { options.MapInboundClaims = false; options.TokenValidationParameters = new TokenValidationParameters { ValidateIssuer = true, ValidateAudience = true, ValidateLifetime = true, ValidateIssuerSigningKey = true, ValidIssuer = jwtIssuer, ValidAudience = jwtAudience, IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(jwtKey)), NameClaimType = JwtRegisteredClaimNames.Sub, RoleClaimType = System.Security.Claims.ClaimTypes.Role, ClockSkew = TimeSpan.FromSeconds(30) }; }); builder.Services.AddAuthorization(); builder.Services.AddAntiforgery(options => { options.HeaderName = "X-CSRF-TOKEN"; options.Cookie.Name = "nexus-csrf"; options.Cookie.SecurePolicy = CookieSecurePolicy.SameAsRequest; options.Cookie.HttpOnly = false; }); builder.Services.AddRateLimiter(options => { options.RejectionStatusCode = StatusCodes.Status429TooManyRequests; options.AddPolicy("auth", context => RateLimitPartition.GetFixedWindowLimiter( context.Connection.RemoteIpAddress?.ToString() ?? "unknown", _ => new FixedWindowRateLimiterOptions { PermitLimit = 5, Window = TimeSpan.FromMinutes(1), QueueLimit = 0, AutoReplenishment = true })); options.AddPolicy("agents", context => RateLimitPartition.GetFixedWindowLimiter( context.Connection.RemoteIpAddress?.ToString() ?? "unknown", _ => new FixedWindowRateLimiterOptions { PermitLimit = 30, Window = TimeSpan.FromMinutes(1), QueueLimit = 0, AutoReplenishment = true })); }); builder.Services.Configure(options => { options.ForwardedHeaders = ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto; options.KnownIPNetworks.Clear(); options.KnownProxies.Clear(); }); builder.Services.AddEndpointsApiExplorer(); builder.Services.AddSwaggerGen(); builder.Services.ConfigureHttpJsonOptions(options => options.SerializerOptions.Converters.Add(new JsonStringEnumConverter())); builder.Services.AddDbContext(options => options.UseNpgsql(builder.Configuration.GetConnectionString("Nexus")) .ConfigureWarnings(w => w.Ignore(Microsoft.EntityFrameworkCore.Diagnostics.RelationalEventId.PendingModelChangesWarning))); builder.Services.AddHttpClient(client => { client.BaseAddress = new(builder.Configuration["Integrations:OpenClaw:BaseUrl"] ?? "http://127.0.0.1:18789"); client.Timeout = TimeSpan.FromSeconds(5); }); builder.Services.AddHttpClient("gateway", client => { client.BaseAddress = new(builder.Configuration["Integrations:OpenClaw:BaseUrl"] ?? "http://127.0.0.1:18789"); client.Timeout = TimeSpan.FromSeconds(5); }); builder.Services.AddTransient(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddHealthChecks() .AddNpgSql(builder.Configuration.GetConnectionString("Nexus")!, name: "postgresql", tags: ["database"]) .AddCheck("runtime", () => { // Runtime check will be added via IAgentRuntime in the endpoint return HealthCheckResult.Healthy("Runtime configured"); }, tags: ["runtime"]); var app = builder.Build(); await using (var scope = app.Services.CreateAsyncScope()) { var db = scope.ServiceProvider.GetRequiredService(); await db.Database.MigrateAsync(); var ownerEmail = builder.Configuration["Owner:Email"]?.Trim().ToLowerInvariant(); var ownerPassword = builder.Configuration["Owner:Password"]; var ownerDisplayName = builder.Configuration["Owner:DisplayName"]?.Trim(); var hasUsers = await db.Users.AnyAsync(); if (!hasUsers) { if (string.IsNullOrWhiteSpace(ownerEmail)) throw new InvalidOperationException("Owner:Email is required for initial setup."); var initialDisplayName = string.IsNullOrWhiteSpace(ownerDisplayName) ? BuildOwnerDisplayName(ownerEmail) : ownerDisplayName; var initialPassword = string.IsNullOrWhiteSpace(ownerPassword) ? GenerateTemporaryPassword() : ownerPassword; if (!string.IsNullOrWhiteSpace(ownerPassword) && ownerPassword.Length < 10) throw new InvalidOperationException("Owner:Password must be at least 10 characters when provided explicitly."); db.Users.Add(new NexusUser { Email = ownerEmail, NormalizedEmail = AuthService.NormalizeEmail(ownerEmail), DisplayName = initialDisplayName, PasswordHash = PasswordSecurity.Hash(initialPassword), Role = "owner" }); await db.SaveChangesAsync(); if (string.IsNullOrWhiteSpace(ownerPassword)) { Console.Error.WriteLine($"[nexus] Initial owner credentials generated: displayName={initialDisplayName}, password={initialPassword}"); } } } app.UseForwardedHeaders(); app.UseRateLimiter(); app.UseAuthentication(); app.UseAuthorization(); // Security headers (WARN-2) app.Use(async (context, next) => { var headers = context.Response.Headers; if (!app.Environment.IsDevelopment()) { headers["Strict-Transport-Security"] = "max-age=31536000; includeSubDomains"; } headers["X-Content-Type-Options"] = "nosniff"; headers["Content-Security-Policy"] = "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'"; headers["X-Frame-Options"] = "DENY"; headers["Referrer-Policy"] = "strict-origin-when-cross-origin"; await next(); }); if (app.Environment.IsDevelopment()) { app.UseSwagger(); app.UseSwaggerUI(); } app.MapGet("/health", async (IAgentRuntime runtime, HealthCheckService healthChecks, CancellationToken ct) => { var report = await healthChecks.CheckHealthAsync(ct); // Check runtime separately since it needs IAgentRuntime string runtimeStatus; string? runtimeDetail; try { var status = await runtime.GetStatusAsync(ct); runtimeStatus = status.Status.ToString(); runtimeDetail = status.Detail; } catch (Exception ex) { runtimeStatus = "Offline"; runtimeDetail = ex.Message; } var entries = report.Entries.ToDictionary( e => e.Key, e => new { status = e.Value.Status.ToString(), description = e.Value.Description, data = e.Value.Data }); entries["runtime"] = new { status = runtimeStatus, description = runtimeDetail ?? "Runtime status checked", data = (IReadOnlyDictionary)new Dictionary() }; var isHealthy = report.Status == HealthStatus.Healthy && runtimeStatus == "Online"; return isHealthy ? Results.Ok(new { status = "Healthy", checks = entries, timestamp = DateTimeOffset.UtcNow }) : Results.Ok(new { status = "Degraded", checks = entries, timestamp = DateTimeOffset.UtcNow }); }); var auth = app.MapGroup("/api/v1/auth"); auth.MapGet("/csrf", (HttpContext ctx, IAntiforgery antiforgery) => { var tokens = antiforgery.GetAndStoreTokens(ctx); return Results.Ok(new { token = tokens.RequestToken }); }); auth.MapPost("/login", async (LoginRequest request, HttpResponse response, IAuthService authService, CancellationToken ct) => { if (string.IsNullOrWhiteSpace(request.Email) || string.IsNullOrWhiteSpace(request.Password)) return Results.ValidationProblem(new Dictionary { ["credentials"] = ["Email and password are required."] }); var session = await authService.LoginAsync(request, ct); if (session is null) return Results.Unauthorized(); SetRefreshCookie(response, session.RefreshToken, builder.Configuration, app.Environment); response.Headers.CacheControl = "no-store"; return Results.Ok(ToAuthResponse(session)); }).RequireRateLimiting("auth"); auth.MapPost("/refresh", async (HttpRequest request, HttpResponse response, IAuthService authService, CancellationToken ct) => { if (!request.Cookies.TryGetValue("nexus_refresh", out var refreshToken)) return Results.Unauthorized(); var session = await authService.RefreshAsync(refreshToken, ct); if (session is null) { ClearRefreshCookie(response, app.Environment); return Results.Unauthorized(); } SetRefreshCookie(response, session.RefreshToken, builder.Configuration, app.Environment); response.Headers.CacheControl = "no-store"; return Results.Ok(ToAuthResponse(session)); }).RequireRateLimiting("auth"); auth.MapPost("/logout", async (HttpRequest request, HttpResponse response, IAuthService authService, CancellationToken ct) => { if (request.Cookies.TryGetValue("nexus_refresh", out var refreshToken)) await authService.RevokeAsync(refreshToken, ct); ClearRefreshCookie(response, app.Environment); return Results.NoContent(); }); auth.MapGet("/me", async (HttpContext context, IAuthService authService, CancellationToken ct) => { var subject = context.User.FindFirst(JwtRegisteredClaimNames.Sub)?.Value; if (!Guid.TryParse(subject, out var userId)) return Results.Unauthorized(); var user = await authService.GetUserAsync(userId, ct); return user is null ? Results.Unauthorized() : Results.Ok(new UserInfo { Id = user.Id, Email = user.Email, DisplayName = user.DisplayName, Role = user.Role }); }).RequireAuthorization(); auth.MapPatch("/profile", async (HttpContext context, UpdateProfileRequest request, IAuthService authService, CancellationToken ct) => { var subject = context.User.FindFirst(System.IdentityModel.Tokens.Jwt.JwtRegisteredClaimNames.Sub)?.Value; if (!Guid.TryParse(subject, out var userId)) return Results.Unauthorized(); var user = await authService.UpdateProfileAsync(userId, request, ct); return user is null ? Results.NotFound() : Results.Ok(new UserInfo { Id = user.Id, Email = user.Email, DisplayName = user.DisplayName, Role = user.Role }); }).RequireAuthorization(); auth.MapPost("/change-password", async (HttpContext context, ChangePasswordRequest request, IAuthService authService, CancellationToken ct) => { if (string.IsNullOrWhiteSpace(request.CurrentPassword) || string.IsNullOrWhiteSpace(request.NewPassword)) return Results.ValidationProblem(new Dictionary { ["password"] = ["Current and new passwords are required."] }); if (request.NewPassword.Length < 10) return Results.ValidationProblem(new Dictionary { ["newPassword"] = ["New password must be at least 10 characters."] }); var subject = context.User.FindFirst(System.IdentityModel.Tokens.Jwt.JwtRegisteredClaimNames.Sub)?.Value; if (!Guid.TryParse(subject, out var userId)) return Results.Unauthorized(); var success = await authService.ChangePasswordAsync(userId, request, ct); return success ? Results.Ok(new { message = "Password changed successfully." }) : Results.Problem("Current password is incorrect.", statusCode: 400); }).RequireAuthorization(); var api = app.MapGroup("/api/v1").RequireAuthorization(); api.MapGet("/operations/snapshot", async ( IAgentRuntime runtime, IAgentService agentService, NexusDbContext db, CancellationToken cancellationToken) => { var runtimeTask = runtime.GetStatusAsync(cancellationToken); var agentsTask = agentService.GetAgentsAsync(cancellationToken); var projectsTask = db.Projects.AsNoTracking().OrderByDescending(x => x.UpdatedAt).ToListAsync(cancellationToken); var tasksTask = db.Tasks.AsNoTracking().OrderByDescending(x => x.UpdatedAt).ToListAsync(cancellationToken); var activityTask = db.Activity.AsNoTracking().OrderByDescending(x => x.CreatedAt).Take(20).ToListAsync(cancellationToken); await Task.WhenAll(runtimeTask, agentsTask, projectsTask, tasksTask, activityTask); var tasks = tasksTask.Result; var projects = projectsTask.Result; var agents = agentsTask.Result; var completedTasks = tasks.Count(x => x.State == TaskStateHelper.ToStateString(TaskState.Done)); // Runtime health check var runtimeStatus = runtimeTask.Result; var runtimeHealthy = runtimeStatus.Status == OperationalStatus.Online; // Last incident: most recent blocked task var lastIncident = tasks .Where(x => x.State == TaskStateHelper.ToStateString(TaskState.Blocked)) .OrderByDescending(x => x.UpdatedAt) .Select(x => new { TaskId = (Guid?)x.Id, Title = (string?)x.Title, Since = (DateTimeOffset?)x.UpdatedAt }) .FirstOrDefault(); // Project health breakdown var projectHealth = new { Online = projects.Count(x => x.Status == OperationalStatus.Online), Offline = projects.Count(x => x.Status == OperationalStatus.Offline), Degraded = projects.Count(x => x.Status == OperationalStatus.Degraded), Unknown = projects.Count(x => x.Status == OperationalStatus.Unknown) }; return Results.Ok(new { generatedAt = DateTimeOffset.UtcNow, runtime = runtimeTask.Result, models = Array.Empty(), runtimeHealthy, metrics = new { activeAgents = agents.Count, queuedTasks = tasks.Count - completedTasks, successRate = tasks.Count == 0 ? 100 : Math.Round(completedTasks * 100d / tasks.Count, 1), incidents = tasks.Count(x => x.State == TaskStateHelper.ToStateString(TaskState.Blocked)) }, lastIncident, projectHealth, agents = agents.Select(x => new { x.Id, x.Name, x.Role, x.Status, x.Model }), projects = projects.Select(x => new { x.Id, x.Name, x.Status, x.Progress, x.UpdatedAt }), tasks = tasks.Select(x => new { x.Id, x.Title, x.State, x.Priority, x.ProjectId, x.UpdatedAt }), activity = activityTask.Result.Select(x => new { x.Id, x.Type, x.Message, at = x.CreatedAt }) }); }); api.MapGet("/projects", async (NexusDbContext db, CancellationToken token) => Results.Ok(await db.Projects.AsNoTracking().OrderByDescending(x => x.UpdatedAt).ToListAsync(token))); api.MapPost("/projects", async (CreateProjectRequest request, NexusDbContext db, CancellationToken token) => { if (string.IsNullOrWhiteSpace(request.Name)) return Results.ValidationProblem(new Dictionary { ["name"] = ["Name is required."] }); var project = new Project { Name = request.Name.Trim(), Description = request.Description?.Trim() ?? string.Empty, Status = OperationalStatus.Online }; db.Projects.Add(project); db.Activity.Add(new ActivityEvent { Type = "project", Message = $"Project {project.Name} created" }); await db.SaveChangesAsync(token); return Results.Created($"/api/v1/projects/{project.Id}", project); }); api.MapGet("/projects/{id:guid}", async (Guid id, NexusDbContext db, CancellationToken token) => { var project = await db.Projects.AsNoTracking().FirstOrDefaultAsync(x => x.Id == id, token); return project is null ? Results.NotFound() : Results.Ok(project); }); api.MapPatch("/projects/{id:guid}", async (Guid id, UpdateProjectRequest request, NexusDbContext db, CancellationToken token) => { var project = await db.Projects.FindAsync([id], token); if (project is null) return Results.NotFound(); if (!string.IsNullOrWhiteSpace(request.Name)) project.Name = request.Name.Trim(); if (request.Description is not null) project.Description = request.Description.Trim(); if (!string.IsNullOrWhiteSpace(request.Status) && Enum.TryParse(request.Status, true, out var parsedStatus)) project.Status = parsedStatus; project.UpdatedAt = DateTimeOffset.UtcNow; db.Activity.Add(new ActivityEvent { Type = "project", Message = $"Project {project.Name} updated" }); await db.SaveChangesAsync(token); return Results.Ok(project); }); api.MapDelete("/projects/{id:guid}", async (Guid id, NexusDbContext db, CancellationToken token) => { var project = await db.Projects.FindAsync([id], token); if (project is null) return Results.NotFound(); var hasTasks = await db.Tasks.AnyAsync(t => t.ProjectId == id, token); if (hasTasks) { project.Status = Nexus.Api.Domain.OperationalStatus.Offline; project.UpdatedAt = DateTimeOffset.UtcNow; db.Activity.Add(new ActivityEvent { Type = "project", Message = $"Project {project.Name} archived" }); await db.SaveChangesAsync(token); return Results.Ok(project); } db.Projects.Remove(project); db.Activity.Add(new ActivityEvent { Type = "project", Message = $"Project {project.Name} deleted" }); await db.SaveChangesAsync(token); return Results.NoContent(); }); api.MapGet("/tasks", async (NexusDbContext db, CancellationToken token) => Results.Ok(await db.Tasks.AsNoTracking().OrderByDescending(x => x.UpdatedAt).ToListAsync(token))); api.MapPost("/tasks", async (CreateTaskRequest request, NexusDbContext db, CancellationToken token) => { if (string.IsNullOrWhiteSpace(request.Title)) return Results.ValidationProblem(new Dictionary { ["title"] = ["Title is required."] }); var task = new WorkTask { Title = request.Title.Trim(), Priority = string.IsNullOrWhiteSpace(request.Priority) ? "Normal" : request.Priority.Trim(), ProjectId = request.ProjectId }; db.Tasks.Add(task); db.Activity.Add(new ActivityEvent { Type = "task", Message = $"Task {task.Title} created" }); await db.SaveChangesAsync(token); return Results.Created($"/api/v1/tasks/{task.Id}", task); }); api.MapGet("/tasks/pending-approval", async (NexusDbContext db, CancellationToken token) => { var threshold = DateTimeOffset.UtcNow.AddHours(-1); var pending = await db.Tasks.AsNoTracking() .Where(x => x.State == TaskStateHelper.ToStateString(TaskState.InProgress) && x.UpdatedAt <= threshold) .OrderByDescending(x => x.UpdatedAt) .ToListAsync(token); return Results.Ok(pending.Select(x => new { x.Id, x.Title, x.State, x.Priority, x.ProjectId, x.UpdatedAt })); }); api.MapPost("/tasks/{id:guid}/approve", async (Guid id, NexusDbContext db, CancellationToken token) => { var task = await db.Tasks.FindAsync([id], token); if (task is null) return Results.NotFound(); if (!TaskStateHelper.IsInProgressOrBlocked(task.State)) return Results.Problem( title: "Approval denied", detail: "Only tasks in 'In progress' or 'Blocked' state can be approved.", statusCode: StatusCodes.Status403Forbidden); task.State = TaskStateHelper.ToStateString(TaskState.Done); task.UpdatedAt = DateTimeOffset.UtcNow; db.Activity.Add(new ActivityEvent { Type = "task", Message = $"Task {task.Title} approved" }); await db.SaveChangesAsync(token); return Results.Ok(task); }); api.MapPost("/tasks/{id:guid}/reject", async (Guid id, NexusDbContext db, CancellationToken token) => { var task = await db.Tasks.FindAsync([id], token); if (task is null) return Results.NotFound(); if (!TaskStateHelper.IsInProgressOrBlocked(task.State)) return Results.Problem( title: "Rejection denied", detail: "Only tasks in 'In progress' or 'Blocked' state can be rejected.", statusCode: StatusCodes.Status403Forbidden); task.State = TaskStateHelper.ToStateString(TaskState.Backlog); task.UpdatedAt = DateTimeOffset.UtcNow; db.Activity.Add(new ActivityEvent { Type = "task", Message = $"Task {task.Title} rejected, returned to backlog" }); await db.SaveChangesAsync(token); return Results.Ok(task); }); api.MapPatch("/tasks/{id:guid}/state", async (Guid id, UpdateTaskStateRequest request, NexusDbContext db, CancellationToken token) => { var allowedStates = TaskStateHelper.AllStates; if (!allowedStates.Contains(request.State, StringComparer.OrdinalIgnoreCase)) return Results.ValidationProblem(new Dictionary { ["state"] = ["Unsupported task state."] }); var task = await db.Tasks.FindAsync([id], token); if (task is null) return Results.NotFound(); task.State = allowedStates.First(x => x.Equals(request.State, StringComparison.OrdinalIgnoreCase)); task.UpdatedAt = DateTimeOffset.UtcNow; db.Activity.Add(new ActivityEvent { Type = "task", Message = $"Task {task.Title} moved to {task.State}" }); await db.SaveChangesAsync(token); return Results.Ok(task); }); api.MapDelete("/tasks/{id:guid}", async (Guid id, NexusDbContext db, CancellationToken token) => { var task = await db.Tasks.FindAsync([id], token); if (task is null) return Results.NotFound(); if (!TaskStateHelper.IsDoneOrBacklog(task.State)) return Results.Problem( title: "Task deletion denied", detail: "Only tasks in 'Done' or 'Backlog' state can be deleted.", statusCode: StatusCodes.Status403Forbidden); db.Activity.Add(new ActivityEvent { Type = "task", Message = $"Task {task.Title} deleted" }); db.Tasks.Remove(task); await db.SaveChangesAsync(token); return Results.NoContent(); }); api.MapPatch("/tasks/{id:guid}", async (Guid id, UpdateTaskRequest request, NexusDbContext db, CancellationToken token) => { var task = await db.Tasks.FindAsync([id], token); if (task is null) return Results.NotFound(); if (!string.IsNullOrWhiteSpace(request.Title)) task.Title = request.Title.Trim(); if (!string.IsNullOrWhiteSpace(request.Priority)) task.Priority = request.Priority.Trim(); if (request.ProjectId.HasValue) task.ProjectId = request.ProjectId.Value == Guid.Empty ? null : request.ProjectId; task.UpdatedAt = DateTimeOffset.UtcNow; db.Activity.Add(new ActivityEvent { Type = "task", Message = $"Task {task.Title} updated" }); await db.SaveChangesAsync(token); return Results.Ok(task); }); api.MapGet("/activity", async (string? type, string? sort, int? page, int? pageSize, NexusDbContext db, CancellationToken token) => { var query = db.Activity.AsNoTracking(); if (!string.IsNullOrWhiteSpace(type)) query = query.Where(x => x.Type == type); query = (sort?.ToLowerInvariant()) switch { "oldest" => query.OrderBy(x => x.CreatedAt), _ => query.OrderByDescending(x => x.CreatedAt) }; var take = Math.Clamp(pageSize ?? 20, 1, 200); var skip = (Math.Max(page ?? 1, 1) - 1) * take; var totalCount = await query.CountAsync(token); var items = await query.Skip(skip).Take(take).ToListAsync(token); return Results.Ok(new { items = items.Select(x => new { x.Id, x.Type, x.Message, at = x.CreatedAt }), totalCount, page = Math.Max(page ?? 1, 1), pageSize = take, totalPages = (int)Math.Ceiling((double)totalCount / take) }); }); api.MapPost("/chat", async (ChatRequest request, IAgentRuntime runtime, ILogger logger, CancellationToken token) => { var message = request.Message?.Trim(); if (string.IsNullOrWhiteSpace(message) || message.Length > 8000) return Results.ValidationProblem(new Dictionary { ["message"] = ["Message must contain between 1 and 8000 characters."] }); var agentId = string.IsNullOrWhiteSpace(request.AgentId) ? "iris" : request.AgentId.Trim().ToLowerInvariant(); if (agentId is not ("iris" or "main")) return Results.ValidationProblem(new Dictionary { ["agentId"] = ["Only iris and main are supported."] }); var conversationId = string.IsNullOrWhiteSpace(request.ConversationId) ? $"nexus-{Guid.NewGuid():N}" : request.ConversationId.Trim(); if (conversationId.Length > 160) return Results.ValidationProblem(new Dictionary { ["conversationId"] = ["Conversation id is too long."] }); try { return Results.Ok(await runtime.ChatAsync(message, conversationId, agentId, token)); } catch (Exception exception) { logger.LogWarning(exception, "OpenClaw chat request failed for agent {AgentId}", agentId); return Results.Problem( title: "OpenClaw chat unavailable", detail: "The trusted OpenClaw chat endpoint is not enabled or reachable.", statusCode: StatusCodes.Status503ServiceUnavailable); } }).RequireRateLimiting("agents"); // Agent inventory endpoints api.MapGet("/agents", async (IAgentService agentService, CancellationToken token) => { var agents = await agentService.GetAgentsAsync(token); return Results.Ok(agents.Select(a => new AgentListResponse( a.Id, a.Name, a.Role, a.Model, a.Status.ToString(), a.LastSeen, a.Workspace, a.Description ))); }); api.MapGet("/agents/{id}", async (string id, IAgentService agentService, CancellationToken token) => { var agent = await agentService.GetAgentAsync(id, token); if (agent is null) return Results.NotFound(); return Results.Ok(new AgentDetailResponse( agent.Id, agent.Name, agent.Role, agent.Model, agent.Status.ToString(), agent.LastSeen, agent.Workspace, agent.AgentDir, agent.Description, agent.SubAgents, agent.IdentityName )); }); api.MapGet("/agents/{id}/activity", async (string id, NexusDbContext db, CancellationToken token) => { var query = db.Activity.AsNoTracking() .Where(x => x.Message.Contains(id, StringComparison.OrdinalIgnoreCase) || x.Type == "agent") .OrderByDescending(x => x.CreatedAt) .Take(50); var items = await query.ToListAsync(token); return Results.Ok(items.Select(x => new { x.Id, x.Type, x.Message, at = x.CreatedAt })); }); api.MapPost("/agents/{id}/command", async (string id, AgentCommandRequest request, IAgentRuntime runtime, ILogger logger, NexusDbContext db, CancellationToken token) => { var message = request.Message?.Trim(); if (string.IsNullOrWhiteSpace(message) || message.Length > 8000) return Results.ValidationProblem(new Dictionary { ["message"] = ["Message must contain between 1 and 8000 characters."] }); var conversationId = $"nexus-command-{id}-{Guid.NewGuid():N}"; try { var result = await runtime.ChatAsync(message, conversationId, id, token); db.Activity.Add(new ActivityEvent { Type = "agent", Message = $"Command sent to agent {id}: {message[..Math.Min(message.Length, 80)]}" }); await db.SaveChangesAsync(token); return Results.Ok(new AgentCommandResponse(result.Runtime, result.AgentId, result.ConversationId, result.Content)); } catch (Exception exception) { logger.LogWarning(exception, "Agent command failed for {AgentId}", id); return Results.Problem( title: "Agent command failed", detail: $"Could not send command to agent {id}: {exception.Message}", statusCode: StatusCodes.Status503ServiceUnavailable); } }).RequireRateLimiting("agents"); api.MapGet("/routing", async (ModelRoutingService routing, CancellationToken token) => Results.Ok(await routing.GetStatusAsync(token))); // ========== Phase 2: Agent Config Editor ========== api.MapGet("/agents/{id}/config", async (string id, CancellationToken ct) => { var workspacePath = $"/mnt/workspace-{id}"; if (!Directory.Exists(workspacePath)) return Results.Ok(Array.Empty()); var allowedFiles = new HashSet(StringComparer.OrdinalIgnoreCase) { "IDENTITY.md", "SOUL.md", "AGENTS.md", "TOOLS.md", "HEARTBEAT.md", "USER.md", "MEMORY.md" }; var files = Directory.GetFiles(workspacePath, "*.md") .Select(f => new FileInfo(f)) .Where(f => allowedFiles.Contains(f.Name)) .OrderBy(f => f.Name) .Select(f => new { fileName = f.Name, size = f.Length, modifiedAt = f.LastWriteTimeUtc }) .ToList(); return Results.Ok(files); }); api.MapGet("/agents/{id}/config/{fileName}", async (string id, string fileName, CancellationToken ct) => { if (!IsValidConfigFileName(fileName)) return Results.BadRequest(new { error = "Invalid filename. Only .md files with alphanumeric characters, dots, hyphens, and underscores are allowed." }); var workspacePath = $"/mnt/workspace-{id}"; if (!TryResolveSafePath(workspacePath, fileName, out var safePath) || !File.Exists(safePath)) return Results.NotFound(); var content = await File.ReadAllTextAsync(safePath, ct); var fi = new FileInfo(safePath); return Results.Ok(new { fileName, content, size = fi.Length, modifiedAt = fi.LastWriteTimeUtc }); }); api.MapPut("/agents/{id}/config/{fileName}", async (string id, string fileName, SaveConfigRequest request, CancellationToken ct) => { if (!IsValidConfigFileName(fileName)) return Results.BadRequest(new { error = "Invalid filename. Only .md files with alphanumeric characters, dots, hyphens, and underscores are allowed." }); if (request.Content is null) return Results.BadRequest(new { error = "Content is required." }); if (request.Content.Length > 500 * 1024) return Results.BadRequest(new { error = "Content exceeds maximum size of 500KB." }); var workspacePath = $"/mnt/workspace-{id}"; if (!TryResolveSafePath(workspacePath, fileName, out var safePath)) return Results.NotFound(); // Atomic write: write to temp file, then rename var tempPath = safePath + ".tmp"; try { await File.WriteAllTextAsync(tempPath, request.Content, ct); File.Move(tempPath, safePath, overwrite: true); } catch { if (File.Exists(tempPath)) File.Delete(tempPath); throw; } var fi = new FileInfo(safePath); return Results.Ok(new { fileName, size = fi.Length, modifiedAt = fi.LastWriteTimeUtc }); }); // ========== Phase 2: Memory Browser ========== api.MapGet("/memory", async () => { var basePath = "/mnt/workspace-iris/memory"; if (!Directory.Exists(basePath)) return Results.Ok(Array.Empty()); var files = Directory.GetFiles(basePath, "*.md") .Select(f => new FileInfo(f)) .OrderByDescending(f => f.Name) .Select(f => new { name = f.Name, path = f.FullName.Replace(basePath, "").TrimStart('/'), size = f.Length, modifiedAt = f.LastWriteTimeUtc }) .ToList(); var longTermPath = "/mnt/workspace-iris/MEMORY.md"; if (File.Exists(longTermPath)) { var fi = new FileInfo(longTermPath); files.Insert(0, new { name = "MEMORY.md", path = "MEMORY.md", size = fi.Length, modifiedAt = fi.LastWriteTimeUtc }); } return Results.Ok(files); }); api.MapGet("/memory/search", async (string q) => { if (string.IsNullOrWhiteSpace(q) || q.Length < 2) return Results.BadRequest("Query must be at least 2 characters."); var basePath = "/mnt/workspace-iris/memory"; var results = new List(); const int maxFiles = 50; const int maxFileSize = 1_000_000; // 1 MB per file async Task SearchDir(string dir) { if (!Directory.Exists(dir)) return; var files = Directory.GetFiles(dir, "*.md").Take(maxFiles); foreach (var file in files) { var fi = new FileInfo(file); if (fi.Length > maxFileSize) continue; string content; using (var reader = new StreamReader(file)) content = await reader.ReadToEndAsync(); if (content.Contains(q, StringComparison.OrdinalIgnoreCase)) { var idx = content.IndexOf(q, StringComparison.OrdinalIgnoreCase); var start = Math.Max(0, idx - 60); var excerpt = (start > 0 ? "\u2026" : "") + content.Substring(start, Math.Min(200, content.Length - start)) + "\u2026"; results.Add(new { name = Path.GetFileName(file), path = file.Replace(basePath, "").TrimStart('/'), excerpt, size = fi.Length }); } } } await SearchDir(basePath); var longTermPath = "/mnt/workspace-iris/MEMORY.md"; if (File.Exists(longTermPath)) { string content; using (var reader = new StreamReader(longTermPath)) content = await reader.ReadToEndAsync(); if (content.Contains(q, StringComparison.OrdinalIgnoreCase)) { var idx = content.IndexOf(q, StringComparison.OrdinalIgnoreCase); var start = Math.Max(0, idx - 60); var excerpt = (start > 0 ? "\u2026" : "") + content.Substring(start, Math.Min(200, content.Length - start)) + "\u2026"; results.Insert(0, new { name = "MEMORY.md", path = "MEMORY.md", excerpt, size = content.Length }); } } return Results.Ok(results); }); api.MapGet("/memory/{name}", async (string name) => { if (!TryResolveSafePath("/mnt/workspace-iris/memory", name, out var filePath)) return Results.BadRequest("Invalid filename."); var longTermPath = "/mnt/workspace-iris/MEMORY.md"; if (name.Equals("MEMORY.md", StringComparison.OrdinalIgnoreCase)) filePath = longTermPath; if (!File.Exists(filePath)) return Results.NotFound(); var content = await File.ReadAllTextAsync(filePath); return Results.Ok(new { name, path = name, content, size = content.Length, modifiedAt = System.IO.File.GetLastWriteTimeUtc(filePath) }); }); // ========== Phase 2: Docs Browser ========== api.MapGet("/docs", () => { var workspaceRoot = "/mnt/workspace-iris"; var results = new List(); void ScanDir(string dir, string category) { if (!Directory.Exists(dir)) return; foreach (var file in Directory.GetFiles(dir, "*.*")) { var ext = Path.GetExtension(file).ToLowerInvariant(); if (ext is not (".md" or ".json" or ".txt" or ".yaml" or ".yml" or ".html" or ".css")) continue; var fi = new FileInfo(file); results.Add(new { name = fi.Name, path = file.Replace(workspaceRoot, "").TrimStart('/'), category, type = ext.Replace(".", ""), size = fi.Length, modifiedAt = fi.LastWriteTimeUtc }); } } ScanDir("/mnt/workspace-iris/nexus-phases", "phases"); ScanDir("/mnt/workspace-iris/skills", "skills"); ScanDir("/mnt/workspace-iris", "workspace"); ScanDir("/home/node/.openclaw/workspace/nexus", "nexus"); ScanDir("/home/node/.openclaw/workspace/nexus/phases", "nexus-phases"); return Results.Ok(results.OrderByDescending(x => ((System.DateTime)((dynamic)x).modifiedAt)).Take(100)); }); // ========== Phase 2: Team Org Map ========== api.MapGet("/team", async (IAgentService agentService, CancellationToken ct) => { var agents = await agentService.GetAgentsAsync(ct); var team = new List(); foreach (var agent in agents) { string identity = ""; string workspace = agent.Workspace ?? ""; if (!string.IsNullOrWhiteSpace(workspace) && Directory.Exists(workspace)) { var identityFile = Path.Combine(workspace, "IDENTITY.md"); if (File.Exists(identityFile)) { var content = await File.ReadAllTextAsync(identityFile, ct); var lines = content.Split('\n').Where(l => l.StartsWith("- **")).Take(8); identity = string.Join("\n", lines); } } team.Add(new { agent.Id, agent.Name, agent.Role, agent.Model, agent.Status, agent.LastSeen, agent.Workspace, agent.Description, identity }); } return Results.Ok(team); }); // ========== Phase 2: Security Center ========== api.MapGet("/security/status", (NexusDbContext db, IConfiguration config) => { var jwtIssuer = config["Jwt:Issuer"] ?? "nexus"; var jwtAudience = config["Jwt:Audience"] ?? "nexus-web"; var refreshDays = config.GetValue("Jwt:RefreshTokenExpirationDays", 7); var accessTokenMinutes = config.GetValue("Jwt:AccessTokenExpirationMinutes", 30); return Results.Ok(new { authMethod = "JWT + PBKDF2", tokenConfig = new { refreshTokenDays = refreshDays, accessTokenMinutes }, rateLimit = "5 login attempts per minute per IP", passwordPolicy = "Minimum 10 characters", cookieConfig = new { httpOnly = true, secure = true, sameSite = "Strict" }, twoFactorEnabled = false, passkeyEnabled = false, checkedAt = DateTimeOffset.UtcNow }); }); // ========== Phase 2: Incident Diary ========== api.MapGet("/incidents", async () => { var basePath = "/mnt/workspace-iris/memory/incidents"; if (!Directory.Exists(basePath)) return Results.Ok(Array.Empty()); var incidents = new List(); foreach (var file in Directory.GetFiles(basePath, "*.md").OrderByDescending(f => f).Take(50)) { var fi = new FileInfo(file); if (fi.Length > 1_000_000) continue; var name = Path.GetFileNameWithoutExtension(file); var content = await File.ReadAllTextAsync(file); // Extract title from # heading var title = name; var titleMatch = System.Text.RegularExpressions.Regex.Match(content, @"^#\s+(.+)$", System.Text.RegularExpressions.RegexOptions.Multiline); if (titleMatch.Success) title = titleMatch.Groups[1].Value.Trim(); // Extract date from filename YYYY-MM-DD var date = (string?)null; var dateMatch = System.Text.RegularExpressions.Regex.Match(name, @"^(\d{4}-\d{2}-\d{2})"); if (dateMatch.Success) date = dateMatch.Groups[1].Value; // Extract severity from content var severity = "unknown"; var severityMatch = System.Text.RegularExpressions.Regex.Match(content, @"\*\*Severity:\*\*\s*(.+)$", System.Text.RegularExpressions.RegexOptions.Multiline); if (severityMatch.Success) severity = severityMatch.Groups[1].Value.Trim(); // Extract excerpt (content up to ## Auslöser or ## Chronologie) var excerptEnd = content.IndexOf("\n## ", StringComparison.Ordinal); var excerpt = excerptEnd > 0 ? content[..excerptEnd].Trim() : content[..Math.Min(300, content.Length)].Trim(); if (excerpt.Length > 200) excerpt = excerpt[..200] + "…"; incidents.Add(new { name = Path.GetFileName(file), title, date, severity, excerpt, size = fi.Length }); } return Results.Ok(incidents); }); api.MapGet("/incidents/{name}", async (string name) => { var basePath = "/mnt/workspace-iris/memory/incidents"; if (!TryResolveSafePath(basePath, name, out var filePath)) return Results.BadRequest("Invalid filename."); if (!File.Exists(filePath)) { // Try with .md extension if not provided if (!name.EndsWith(".md", StringComparison.OrdinalIgnoreCase)) filePath = Path.Combine(basePath, name + ".md"); if (!File.Exists(filePath)) return Results.NotFound(); } var content = await File.ReadAllTextAsync(filePath); var fi = new FileInfo(filePath); var fileName = Path.GetFileName(filePath); // Extract title from # heading var title = fileName; var titleMatch = System.Text.RegularExpressions.Regex.Match(content, @"^#\s+(.+)$", System.Text.RegularExpressions.RegexOptions.Multiline); if (titleMatch.Success) title = titleMatch.Groups[1].Value.Trim(); // Extract date from filename var date = (string?)null; var dateMatch = System.Text.RegularExpressions.Regex.Match(fileName, @"^(\d{4}-\d{2}-\d{2})"); if (dateMatch.Success) date = dateMatch.Groups[1].Value; return Results.Ok(new { name = fileName, title, date, content, size = fi.Length }); }); // ========== Phase 2: Calendar & Scheduler ========== api.MapGet("/calendar", async (IConfiguration config, IHttpClientFactory httpClientFactory, ILogger logger, CancellationToken ct) => { // Try to reach the gateway cron endpoint; fallback to dummy data on failure var gatewayToken = config["Integrations:OpenClaw:Token"] ?? ""; try { var httpClient = httpClientFactory.CreateClient("gateway"); if (!string.IsNullOrWhiteSpace(gatewayToken)) httpClient.DefaultRequestHeaders.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", gatewayToken); var response = await httpClient.GetAsync("/api/cron", ct); if (response.IsSuccessStatusCode) { var data = await response.Content.ReadFromJsonAsync>(ct); return Results.Ok(data ?? new List()); } } catch (Exception ex) { logger.LogDebug(ex, "Gateway cron endpoint not reachable, using fallback data."); } // Fallback dummy data when gateway is not reachable var fallbackJobs = new List { new { id = "health-check", name = "Health Check", schedule = "*/5 * * * *", lastRun = DateTimeOffset.UtcNow.AddMinutes(-3).ToString("O"), nextRun = DateTimeOffset.UtcNow.AddMinutes(2).ToString("O"), status = "completed" }, new { id = "memory-sync", name = "Memory Sync", schedule = "0 */6 * * *", lastRun = DateTimeOffset.UtcNow.AddHours(-2).ToString("O"), nextRun = DateTimeOffset.UtcNow.AddHours(4).ToString("O"), status = "completed" }, new { id = "task-cleanup", name = "Task Cleanup", schedule = "0 3 * * *", lastRun = DateTimeOffset.UtcNow.AddDays(-1).ToString("O"), nextRun = DateTimeOffset.UtcNow.AddDays(1).AddHours(3).ToString("O"), status = "completed" }, new { id = "backup", name = "Database Backup", schedule = "0 4 * * *", lastRun = DateTimeOffset.UtcNow.AddDays(-1).AddHours(-1).ToString("O"), nextRun = DateTimeOffset.UtcNow.AddDays(1).AddHours(4).ToString("O"), status = "completed" }, new { id = "model-routing-refresh", name = "Model Routing Refresh", schedule = "*/30 * * * *", lastRun = DateTimeOffset.UtcNow.AddMinutes(-12).ToString("O"), nextRun = DateTimeOffset.UtcNow.AddMinutes(18).ToString("O"), status = "running" }, }; return Results.Ok(fallbackJobs); }); api.MapGet("/calendar/upcoming", async (IConfiguration config, IHttpClientFactory httpClientFactory, ILogger logger, CancellationToken ct) => { var gatewayToken = config["Integrations:OpenClaw:Token"] ?? ""; try { var httpClient = httpClientFactory.CreateClient("gateway"); if (!string.IsNullOrWhiteSpace(gatewayToken)) httpClient.DefaultRequestHeaders.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", gatewayToken); var response = await httpClient.GetAsync("/api/cron/upcoming", ct); if (response.IsSuccessStatusCode) { var data = await response.Content.ReadFromJsonAsync>(ct); return Results.Ok(data ?? new List()); } } catch (Exception ex) { logger.LogDebug(ex, "Gateway upcoming cron endpoint not reachable, using fallback data."); } // Fallback dummy data var now = DateTimeOffset.UtcNow; var fallback = new List { new { id = "health-check", name = "Health Check", nextRun = now.AddMinutes(2).ToString("O"), schedule = "*/5 * * * *" }, new { id = "model-routing-refresh", name = "Model Routing Refresh", nextRun = now.AddMinutes(18).ToString("O"), schedule = "*/30 * * * *" }, new { id = "memory-sync", name = "Memory Sync", nextRun = now.AddHours(4).ToString("O"), schedule = "0 */6 * * *" }, new { id = "task-cleanup", name = "Task Cleanup", nextRun = now.AddDays(1).AddHours(3).ToString("O"), schedule = "0 3 * * *" }, new { id = "backup", name = "Database Backup", nextRun = now.AddDays(1).AddHours(4).ToString("O"), schedule = "0 4 * * *" }, }; return Results.Ok(fallback); }); // ========== Phase 2: Docs Catch-All (MUSS AM ENDE SEIN FÜR ROUTE-REIHENFOLGE) ========== api.MapGet("/docs/{**path}", async (string path) => { if (string.IsNullOrWhiteSpace(path)) return Results.BadRequest("Path required."); // Try workspace-iris first, then nexus string? resolvedPath = null; foreach (var root in new[] { "/mnt/workspace-iris", "/home/node/.openclaw/workspace/nexus" }) { if (TryResolveSafePath(root, path, out var candidate) && File.Exists(candidate)) { resolvedPath = candidate; break; } } if (resolvedPath is null) return Results.NotFound(); var content = await File.ReadAllTextAsync(resolvedPath); var fi = new FileInfo(resolvedPath); return Results.Ok(new { name = fi.Name, path = resolvedPath.Replace("/mnt/workspace-iris/", "").Replace("/home/node/.openclaw/workspace/nexus/", ""), content, size = fi.Length, modifiedAt = fi.LastWriteTimeUtc }); }); app.Run(); static string GenerateTemporaryPassword() => Convert.ToBase64String(RandomNumberGenerator.GetBytes(18)) .TrimEnd('=') .Replace('+', '-') .Replace('/', '_'); static string BuildOwnerDisplayName(string email) { var localPart = email.Split('@', 2)[0].Trim(); if (string.IsNullOrWhiteSpace(localPart)) return "Owner"; var words = localPart .Replace('.', ' ') .Replace('_', ' ') .Replace('-', ' ') .Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) .Select(word => char.ToUpperInvariant(word[0]) + word[1..].ToLowerInvariant()); var displayName = string.Join(' ', words); return string.IsNullOrWhiteSpace(displayName) ? "Owner" : displayName; } static AuthResponse ToAuthResponse(AuthSession session) => new() { AccessToken = session.AccessToken, ExpiresAt = session.ExpiresAt, User = session.User }; static void SetRefreshCookie(HttpResponse response, string token, IConfiguration config, IHostEnvironment environment) { var days = config.GetValue("Jwt:RefreshTokenExpirationDays") ?? 7; response.Cookies.Append("nexus_refresh", token, new CookieOptions { HttpOnly = true, Secure = !environment.IsDevelopment(), SameSite = SameSiteMode.Strict, Path = "/api/v1/auth", MaxAge = TimeSpan.FromDays(days), IsEssential = true }); } static void ClearRefreshCookie(HttpResponse response, IHostEnvironment environment) { response.Cookies.Delete("nexus_refresh", new CookieOptions { HttpOnly = true, Secure = !environment.IsDevelopment(), SameSite = SameSiteMode.Strict, Path = "/api/v1/auth" }); } // --- Security helper: safe path validation against traversal --- static bool TryResolveSafePath(string basePath, string userInput, out string? safePath) { safePath = null; // URL-decode to catch encoded attacks like %2F, %2e%2e, %00 var decoded = Uri.UnescapeDataString(userInput); // Reject null bytes if (decoded.Contains('\0')) return false; // Combine with base and resolve to canonical form var combined = Path.Combine(basePath, decoded); var full = Path.GetFullPath(combined); var canonicalBase = Path.GetFullPath(basePath); // Must stay within the allowed base directory if (!full.StartsWith(canonicalBase + Path.DirectorySeparatorChar) && full != canonicalBase) return false; safePath = full; return true; } // Validates config filename against path-traversal static bool IsValidConfigFileName(string fileName) { if (string.IsNullOrWhiteSpace(fileName)) return false; return System.Text.RegularExpressions.Regex.IsMatch(fileName, @"^[a-zA-Z0-9._-]+\.md$"); } // Record types for cron job deserialization record CronJobEntry(string Id, string Name, string Schedule, string LastRun, string NextRun, string Status); record UpcomingCronEntry(string Id, string Name, string NextRun, string Schedule); // Record type for agent config save request record SaveConfigRequest(string Content);