diff --git a/backend/Extensions/ApplicationBuilderExtensions.cs b/backend/Extensions/ApplicationBuilderExtensions.cs
new file mode 100644
index 0000000..5139dee
--- /dev/null
+++ b/backend/Extensions/ApplicationBuilderExtensions.cs
@@ -0,0 +1,83 @@
+using Microsoft.EntityFrameworkCore;
+using Nexus.Api.Data;
+using Nexus.Api.Helpers;
+using Nexus.Api.Middleware;
+using Nexus.Api.Services;
+
+namespace Nexus.Api.Extensions;
+
+///
+/// Extension methods for configuring the Nexus application pipeline and startup.
+///
+public static class ApplicationBuilderExtensions
+{
+ ///
+ /// Applies pending EF Core migrations and seeds the initial owner account if none exist.
+ ///
+ public static async Task EnsureDatabaseAsync(this WebApplication app)
+ {
+ var configuration = app.Configuration;
+
+ await using (var scope = app.Services.CreateAsyncScope())
+ {
+ var db = scope.ServiceProvider.GetRequiredService();
+ await db.Database.MigrateAsync();
+
+ var ownerEmail = configuration["Owner:Email"]?.Trim().ToLowerInvariant();
+ var ownerPassword = configuration["Owner:Password"];
+ var ownerDisplayName = 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)
+ ? PasswordHelper.BuildOwnerDisplayName(ownerEmail)
+ : ownerDisplayName;
+ var initialPassword = string.IsNullOrWhiteSpace(ownerPassword)
+ ? PasswordHelper.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}");
+ }
+ }
+ }
+ }
+
+ ///
+ /// Configures the HTTP middleware pipeline: forwarded headers, rate limiting, auth, security headers, and Swagger in development.
+ ///
+ public static IApplicationBuilder UseNexusPipeline(this IApplicationBuilder app, IWebHostEnvironment env)
+ {
+ app.UseForwardedHeaders();
+ app.UseRateLimiter();
+ app.UseAuthentication();
+ app.UseAuthorization();
+ app.UseSecurityHeaders();
+
+ if (env.IsDevelopment())
+ {
+ app.UseSwagger();
+ app.UseSwaggerUI();
+ }
+
+ return app;
+ }
+}
diff --git a/backend/Extensions/ServiceCollectionExtensions.cs b/backend/Extensions/ServiceCollectionExtensions.cs
new file mode 100644
index 0000000..4b7d0d9
--- /dev/null
+++ b/backend/Extensions/ServiceCollectionExtensions.cs
@@ -0,0 +1,214 @@
+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.Repositories;
+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;
+
+namespace Nexus.Api.Extensions;
+
+///
+/// Extension methods for registering Nexus application services in the DI container.
+///
+public static class ServiceCollectionExtensions
+{
+ ///
+ /// Configures JWT authentication, authorization, and antiforgery.
+ ///
+ public static IServiceCollection AddNexusAuth(this IServiceCollection services, IConfiguration configuration)
+ {
+ var jwtKey = configuration["Jwt:Key"];
+ var jwtIssuer = configuration["Jwt:Issuer"] ?? "nexus";
+ var jwtAudience = 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.");
+
+ 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)
+ };
+ });
+
+ services.AddAuthorization();
+ services.AddAntiforgery(options =>
+ {
+ options.HeaderName = "X-CSRF-TOKEN";
+ options.Cookie.Name = "nexus-csrf";
+ options.Cookie.SecurePolicy = CookieSecurePolicy.SameAsRequest;
+ options.Cookie.HttpOnly = false;
+ });
+
+ return services;
+ }
+
+ ///
+ /// Configures rate limiting policies (auth and agents).
+ ///
+ public static IServiceCollection AddNexusRateLimiting(this IServiceCollection services)
+ {
+ 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
+ }));
+ });
+
+ return services;
+ }
+
+ ///
+ /// Configures forwarded headers for reverse proxy scenarios.
+ ///
+ public static IServiceCollection AddNexusForwardedHeaders(this IServiceCollection services)
+ {
+ services.Configure(options =>
+ {
+ options.ForwardedHeaders = ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto;
+ options.KnownIPNetworks.Clear();
+ options.KnownProxies.Clear();
+ });
+
+ return services;
+ }
+
+ ///
+ /// Configures Swagger and JSON serialization options.
+ ///
+ public static IServiceCollection AddNexusSwagger(this IServiceCollection services)
+ {
+ services.AddEndpointsApiExplorer();
+ services.AddSwaggerGen();
+ services.ConfigureHttpJsonOptions(options =>
+ options.SerializerOptions.Converters.Add(new JsonStringEnumConverter()));
+
+ return services;
+ }
+
+ ///
+ /// Registers the Entity Framework Core DbContext with Npgsql.
+ ///
+ public static IServiceCollection AddNexusDatabase(this IServiceCollection services, IConfiguration configuration)
+ {
+ services.AddDbContext(options =>
+ options.UseNpgsql(configuration.GetConnectionString("Nexus"))
+ .ConfigureWarnings(w => w.Ignore(
+ Microsoft.EntityFrameworkCore.Diagnostics.RelationalEventId.PendingModelChangesWarning)));
+
+ return services;
+ }
+
+ ///
+ /// Registers typed and named HTTP clients for OpenClaw integration.
+ ///
+ public static IServiceCollection AddNexusHttpClients(this IServiceCollection services, IConfiguration configuration)
+ {
+ services.AddHttpClient(client =>
+ {
+ client.BaseAddress = new(configuration["Integrations:OpenClaw:BaseUrl"]
+ ?? "http://127.0.0.1:18789");
+ client.Timeout = TimeSpan.FromSeconds(120);
+ });
+
+ services.AddHttpClient("gateway", client =>
+ {
+ client.BaseAddress = new(configuration["Integrations:OpenClaw:BaseUrl"]
+ ?? "http://127.0.0.1:18789");
+ client.Timeout = TimeSpan.FromSeconds(120);
+ });
+
+ services.AddHttpClient(client =>
+ {
+ client.BaseAddress = new(configuration["Integrations:OpenClaw:BaseUrl"]
+ ?? "http://127.0.0.1:18789");
+ client.Timeout = TimeSpan.FromSeconds(120);
+ });
+
+ return services;
+ }
+
+ ///
+ /// Registers application domain services (transient, scoped, singleton).
+ ///
+ public static IServiceCollection AddNexusApplicationServices(this IServiceCollection services)
+ {
+ services.AddTransient();
+ services.AddScoped();
+ services.AddScoped();
+ services.AddScoped();
+ services.AddScoped();
+ services.AddScoped();
+ services.AddScoped();
+ services.AddScoped();
+ services.AddSingleton();
+ services.AddSingleton();
+ services.AddSingleton();
+ services.AddSingleton();
+ services.AddScoped();
+
+ return services;
+ }
+
+ ///
+ /// Registers data repositories.
+ ///
+ public static IServiceCollection AddNexusRepositories(this IServiceCollection services)
+ {
+ services.AddScoped();
+ services.AddScoped();
+ services.AddScoped();
+ services.AddScoped();
+
+ return services;
+ }
+
+ ///
+ /// Configures health checks (PostgreSQL connectivity and runtime status).
+ ///
+ public static IServiceCollection AddNexusHealthChecks(this IServiceCollection services, IConfiguration configuration)
+ {
+ services.AddHealthChecks()
+ .AddNpgSql(configuration.GetConnectionString("Nexus")!, name: "postgresql", tags: ["database"])
+ .AddCheck("runtime", () => HealthCheckResult.Healthy("Runtime configured"), tags: ["runtime"]);
+
+ return services;
+ }
+}
diff --git a/backend/Helpers/PasswordHelper.cs b/backend/Helpers/PasswordHelper.cs
new file mode 100644
index 0000000..09eee7b
--- /dev/null
+++ b/backend/Helpers/PasswordHelper.cs
@@ -0,0 +1,37 @@
+using System.Security.Cryptography;
+
+namespace Nexus.Api.Helpers;
+
+///
+/// Helper methods for password generation and name construction.
+///
+public static class PasswordHelper
+{
+ ///
+ /// Generates a cryptographically random temporary password (30 chars, URL-safe base64).
+ ///
+ public static string GenerateTemporaryPassword()
+ => Convert.ToBase64String(RandomNumberGenerator.GetBytes(18))
+ .TrimEnd('=')
+ .Replace('+', '-')
+ .Replace('/', '_');
+
+ ///
+ /// Builds a human-readable display name from an email address.
+ ///
+ public 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;
+ }
+}
diff --git a/backend/Program.cs b/backend/Program.cs
index e96fa1b..e08a69f 100644
--- a/backend/Program.cs
+++ b/backend/Program.cs
@@ -1,234 +1,26 @@
-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;
+using Nexus.Api.Extensions;
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(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(options =>
- options.UseNpgsql(builder.Configuration.GetConnectionString("Nexus"))
- .ConfigureWarnings(w => w.Ignore(Microsoft.EntityFrameworkCore.Diagnostics.RelationalEventId.PendingModelChangesWarning)));
-
-// --- HTTP Clients ---
-builder.Services.AddHttpClient(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(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();
-builder.Services.AddScoped();
-builder.Services.AddScoped();
-builder.Services.AddScoped();
-builder.Services.AddScoped();
-builder.Services.AddScoped();
-builder.Services.AddScoped();
-builder.Services.AddScoped();
-builder.Services.AddSingleton();
-builder.Services.AddSingleton();
-builder.Services.AddSingleton();
-builder.Services.AddSingleton();
-builder.Services.AddScoped();
-
-// --- Repositories ---
-builder.Services.AddScoped();
-builder.Services.AddScoped();
-builder.Services.AddScoped();
-builder.Services.AddScoped();
-
-// --- Health Checks ---
-builder.Services.AddHealthChecks()
- .AddNpgSql(builder.Configuration.GetConnectionString("Nexus")!, name: "postgresql", tags: ["database"])
- .AddCheck("runtime", () => HealthCheckResult.Healthy("Runtime configured"), tags: ["runtime"]);
-
-// --- Controllers ---
+// --- Service Registration ---
+builder.Services.AddNexusAuth(builder.Configuration);
+builder.Services.AddNexusRateLimiting();
+builder.Services.AddNexusForwardedHeaders();
+builder.Services.AddNexusSwagger();
+builder.Services.AddNexusDatabase(builder.Configuration);
+builder.Services.AddNexusHttpClients(builder.Configuration);
+builder.Services.AddNexusApplicationServices();
+builder.Services.AddNexusRepositories();
+builder.Services.AddNexusHealthChecks(builder.Configuration);
builder.Services.AddControllers();
var app = builder.Build();
-// --- Database Migration & Owner Seeding ---
-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}");
- }
- }
-}
+// --- Database Migration & Seeding ---
+await app.EnsureDatabaseAsync();
// --- Middleware Pipeline ---
-app.UseForwardedHeaders();
-app.UseRateLimiter();
-app.UseAuthentication();
-app.UseAuthorization();
-app.UseSecurityHeaders();
-
-if (app.Environment.IsDevelopment())
-{
- app.UseSwagger();
- app.UseSwaggerUI();
-}
+app.UseNexusPipeline(app.Environment);
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;
-}