4ad0f9e493
## Backend — Service Layer & Repository Refactoring ### Neue Services (21 neue Dateien) **Interfaces & Implementierungen:** - `IOpenClawGatewayClient` — Interface für OpenClawGatewayClient (DIP-Fix: DashboardController hing an konkreter Klasse) - `IAgentConfigService` / `AgentConfigService` — Agent-Config-File-I/O aus AgentsController extrahiert - `IProjectService` / `ProjectService` — Projekt-CRUD + Activity-Logging (SRP) - `ITaskService` / `TaskService` — Task-State-Machine, Approve/Reject, Dashboard-Operationen (eliminiert Duplikation zwischen TasksController und DashboardController) - `IDashboardService` / `DashboardService` — Queue-Aggregation, Priority-Normalisierung, Gateway-Delegation - `IOperationsService` / `OperationsService` — Metriken-Berechnung aus OperationsController - `ITeamService` / `TeamService` — IDENTITY.md-Lesen aus TeamController - `IMemoryService` / `MemoryService` — File-I/O aus MemoryController - `IIncidentService` / `IncidentService` — File-Parsing (Regex-Source-Generatoren) aus IncidentsController - `IDocService` / `DocService` — Directory-Scan aus DocsController - `ICalendarService` / `CalendarService` — Gateway-HTTP-Calls + Fallback-Daten aus CalendarController ### Repository-Fixes **IUserRepository / UserRepository:** - `SaveChangesAsync` entfernt (leaky abstraction — Caller sollten nie SaveChanges steuern) - `RevokeTokenAsync(tokenHash)` — atomares Token-Revoke inkl. SaveChanges - `RevokeFamilyAsync(familyId)` — Batch-Revoke einer Token-Familie inkl. SaveChanges - `RemoveExpiredTokensAsync` speichert jetzt selbst (war vorher dependent auf nachfolgenden Save) ### AuthService-Fixes - `GetUserAsync`: unnötiges `Task.Run` entfernt → direkt `_users.GetByIdAsync().AsTask()` - `RevokeAsync`: delegiert jetzt an `IUserRepository.RevokeTokenAsync` - `RefreshAsync`: Token-Reuse-Detection delegiert an `IUserRepository.RevokeFamilyAsync` ### Bug-Fix - `OpenClawGatewayClient.ReadAgentGoalAsync`: pre-existing `CS1656` behoben (`reader` war `using`-Variable und wurde neu zugewiesen — in `reader2` umbenannt) ### Controller (16 Stück — alle slim) Alle Controller reduziert auf: Input validieren → Service aufrufen → HTTP-Result zurückgeben. Kein Business-Logic, kein File-I/O, keine direkte Repository-Nutzung (außer AgentsController für Activity-Log). **Program.cs — neue Registrierungen:** - `AddHttpClient<IOpenClawGatewayClient, OpenClawGatewayClient>` (war vorher konkrete Klasse) - Scoped: IDashboardService, IProjectService, ITaskService, IOperationsService, ITeamService, ICalendarService - Singleton: IAgentConfigService, IMemoryService, IIncidentService, IDocService --- ## Frontend — Dashboard V2 Components **AgentDetailModal.vue, IrisChat.vue, TaskStrip.vue:** - V2 Design-System: Dark Space Theme, Glass-Panels, Gradient-Akzente - Stores (agents, chat, tasks) nutzen Service + Mapper-Pattern - NexusLayout, FlowBoard, Topbar — Layoutfixes für fullHeight-Route-Meta Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
235 lines
8.6 KiB
C#
235 lines
8.6 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.Data;
|
|
using Nexus.Api.Integrations;
|
|
using Nexus.Api.Middleware;
|
|
using Nexus.Api.Repositories;
|
|
using Nexus.Api.Routing;
|
|
using Nexus.Api.Services;
|
|
using System.IdentityModel.Tokens.Jwt;
|
|
using System.Security.Cryptography;
|
|
using System.Text;
|
|
using System.Text.Json.Serialization;
|
|
using System.Threading.RateLimiting;
|
|
|
|
var builder = WebApplication.CreateBuilder(args);
|
|
|
|
// --- JWT Configuration ---
|
|
var jwtKey = builder.Configuration["Jwt:Key"];
|
|
var jwtIssuer = builder.Configuration["Jwt:Issuer"] ?? "nexus";
|
|
var jwtAudience = builder.Configuration["Jwt:Audience"] ?? "nexus-web";
|
|
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;
|
|
});
|
|
|
|
// --- Rate Limiting ---
|
|
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
|
|
}));
|
|
});
|
|
|
|
// --- Forwarded Headers ---
|
|
builder.Services.Configure<ForwardedHeadersOptions>(options =>
|
|
{
|
|
options.ForwardedHeaders = ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto;
|
|
options.KnownIPNetworks.Clear();
|
|
options.KnownProxies.Clear();
|
|
});
|
|
|
|
// --- Swagger & JSON ---
|
|
builder.Services.AddEndpointsApiExplorer();
|
|
builder.Services.AddSwaggerGen();
|
|
builder.Services.ConfigureHttpJsonOptions(options =>
|
|
options.SerializerOptions.Converters.Add(new JsonStringEnumConverter()));
|
|
|
|
// --- Database ---
|
|
builder.Services.AddDbContext<NexusDbContext>(options =>
|
|
options.UseNpgsql(builder.Configuration.GetConnectionString("Nexus"))
|
|
.ConfigureWarnings(w => w.Ignore(Microsoft.EntityFrameworkCore.Diagnostics.RelationalEventId.PendingModelChangesWarning)));
|
|
|
|
// --- HTTP Clients ---
|
|
builder.Services.AddHttpClient<IAgentRuntime, OpenClawRuntime>(client =>
|
|
{
|
|
client.BaseAddress = new(builder.Configuration["Integrations:OpenClaw:BaseUrl"]
|
|
?? "http://127.0.0.1:18789");
|
|
client.Timeout = TimeSpan.FromSeconds(120);
|
|
});
|
|
|
|
builder.Services.AddHttpClient("gateway", client =>
|
|
{
|
|
client.BaseAddress = new(builder.Configuration["Integrations:OpenClaw:BaseUrl"]
|
|
?? "http://127.0.0.1:18789");
|
|
client.Timeout = TimeSpan.FromSeconds(120);
|
|
});
|
|
|
|
builder.Services.AddHttpClient<IOpenClawGatewayClient, OpenClawGatewayClient>(client =>
|
|
{
|
|
client.BaseAddress = new(builder.Configuration["Integrations:OpenClaw:BaseUrl"]
|
|
?? "http://127.0.0.1:18789");
|
|
client.Timeout = TimeSpan.FromSeconds(120);
|
|
});
|
|
|
|
// --- Application Services ---
|
|
builder.Services.AddTransient<ModelRoutingService>();
|
|
builder.Services.AddScoped<IAuthService, AuthService>();
|
|
builder.Services.AddScoped<IAgentService, AgentService>();
|
|
builder.Services.AddScoped<IDashboardService, DashboardService>();
|
|
builder.Services.AddScoped<IProjectService, ProjectService>();
|
|
builder.Services.AddScoped<ITaskService, TaskService>();
|
|
builder.Services.AddScoped<IOperationsService, OperationsService>();
|
|
builder.Services.AddScoped<ITeamService, TeamService>();
|
|
builder.Services.AddSingleton<IAgentConfigService, AgentConfigService>();
|
|
builder.Services.AddSingleton<IMemoryService, MemoryService>();
|
|
builder.Services.AddSingleton<IIncidentService, IncidentService>();
|
|
builder.Services.AddSingleton<IDocService, DocService>();
|
|
builder.Services.AddScoped<ICalendarService, CalendarService>();
|
|
|
|
// --- Repositories ---
|
|
builder.Services.AddScoped<IUserRepository, UserRepository>();
|
|
builder.Services.AddScoped<IProjectRepository, ProjectRepository>();
|
|
builder.Services.AddScoped<ITaskRepository, TaskRepository>();
|
|
builder.Services.AddScoped<IActivityRepository, ActivityRepository>();
|
|
|
|
// --- Health Checks ---
|
|
builder.Services.AddHealthChecks()
|
|
.AddNpgSql(builder.Configuration.GetConnectionString("Nexus")!, name: "postgresql", tags: ["database"])
|
|
.AddCheck("runtime", () => HealthCheckResult.Healthy("Runtime configured"), tags: ["runtime"]);
|
|
|
|
// --- Controllers ---
|
|
builder.Services.AddControllers();
|
|
|
|
var app = builder.Build();
|
|
|
|
// --- Database Migration & Owner Seeding ---
|
|
await using (var scope = app.Services.CreateAsyncScope())
|
|
{
|
|
var db = scope.ServiceProvider.GetRequiredService<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}");
|
|
}
|
|
}
|
|
}
|
|
|
|
// --- Middleware Pipeline ---
|
|
app.UseForwardedHeaders();
|
|
app.UseRateLimiter();
|
|
app.UseAuthentication();
|
|
app.UseAuthorization();
|
|
app.UseSecurityHeaders();
|
|
|
|
if (app.Environment.IsDevelopment())
|
|
{
|
|
app.UseSwagger();
|
|
app.UseSwaggerUI();
|
|
}
|
|
|
|
app.MapControllers();
|
|
app.Run();
|
|
|
|
// --- Helpers ---
|
|
|
|
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;
|
|
}
|