eeb6174de0
- ASP.NET Core 10 Backend (JWT Auth, Agent config API) - Vue 3 Frontend (Dashboard, Team, Agents, Config Editor) - PostgreSQL Database - Docker Compose setup - Mission Control Dashboard redesign
1230 lines
49 KiB
C#
1230 lines
49 KiB
C#
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<ForwardedHeadersOptions>(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<NexusDbContext>(options =>
|
|
options.UseNpgsql(builder.Configuration.GetConnectionString("Nexus"))
|
|
.ConfigureWarnings(w => w.Ignore(Microsoft.EntityFrameworkCore.Diagnostics.RelationalEventId.PendingModelChangesWarning)));
|
|
|
|
builder.Services.AddHttpClient<IAgentRuntime, OpenClawRuntime>(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<ModelRoutingService>();
|
|
builder.Services.AddScoped<IAuthService, AuthService>();
|
|
builder.Services.AddScoped<IAgentService, AgentService>();
|
|
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<NexusDbContext>();
|
|
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<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 });
|
|
});
|
|
|
|
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<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, 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<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 = 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<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 })
|
|
});
|
|
});
|
|
|
|
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<string, string[]> { ["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<Nexus.Api.Domain.OperationalStatus>(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<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
|
|
};
|
|
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<string, string[]> { ["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<Program> logger, CancellationToken token) =>
|
|
{
|
|
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, 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<Program> logger, NexusDbContext db, CancellationToken token) =>
|
|
{
|
|
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, 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<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);
|
|
});
|
|
|
|
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<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 (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<object>();
|
|
|
|
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<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 => ((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<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 (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<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
|
|
});
|
|
});
|
|
|
|
// ========== Phase 2: Incident Diary ==========
|
|
|
|
api.MapGet("/incidents", async () =>
|
|
{
|
|
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 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<Program> 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<List<CronJobEntry>>(ct);
|
|
return Results.Ok(data ?? new List<CronJobEntry>());
|
|
}
|
|
}
|
|
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<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);
|
|
});
|
|
|
|
api.MapGet("/calendar/upcoming", async (IConfiguration config, IHttpClientFactory httpClientFactory, ILogger<Program> 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<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.");
|
|
}
|
|
|
|
// Fallback dummy 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);
|
|
});
|
|
|
|
// ========== 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<int?>("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);
|