using Microsoft.AspNetCore.Authentication.JwtBearer; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.HttpOverrides; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.RateLimiting; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Diagnostics.HealthChecks; using Microsoft.Extensions.Http.Resilience; using Microsoft.IdentityModel.Tokens; using ModelContextProtocol.AspNetCore; using Nexus.Api.Data; using Nexus.Api.Integrations; using Nexus.Api.RateLimiting; using Nexus.Api.Repositories; using Nexus.Api.Routing; using Nexus.Api.Services; using System.IdentityModel.Tokens.Jwt; using System.Net; 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(options => { options.FallbackPolicy = new AuthorizationPolicyBuilder() .RequireAuthenticatedUser() .Build(); }); 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.OnRejected = async (context, ct) => { context.HttpContext.Response.StatusCode = StatusCodes.Status429TooManyRequests; context.HttpContext.Response.Headers.ContentType = "application/problem+json"; var retryAfterSeconds = 60; // Try to read retry-after info from the metadata if (context.Lease.TryGetMetadata(MetadataName.RetryAfter, out var retryAfter)) { retryAfterSeconds = (int)retryAfter.TotalSeconds; } // Set standard headers context.HttpContext.Response.Headers.RetryAfter = retryAfterSeconds.ToString(); context.HttpContext.Response.Headers["X-RateLimit-Remaining"] = "0"; context.HttpContext.Response.Headers["X-RateLimit-Reset"] = DateTimeOffset.UtcNow.AddSeconds(retryAfterSeconds).ToUnixTimeSeconds().ToString(); var body = new ProblemDetails { Type = "https://httpstatuses.com/429", Title = "Rate limit exceeded", Status = StatusCodes.Status429TooManyRequests, Detail = $"Too many attempts. Try again in {retryAfterSeconds} second(s)." }; body.Extensions["code"] = "rate_limit_exceeded"; body.Extensions["remaining"] = 0; body.Extensions["retryAfterSeconds"] = retryAfterSeconds; body.Extensions["traceId"] = System.Diagnostics.Activity.Current?.Id ?? context.HttpContext.TraceIdentifier; await context.HttpContext.Response.WriteAsJsonAsync(body, ct); }; 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, IConfiguration configuration) { services.Configure(options => { options.ForwardedHeaders = ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto; var forwardLimit = configuration.GetValue("ForwardedHeaders:ForwardLimit") ?? 1; if (forwardLimit is < 1 or > 5) throw new InvalidOperationException("ForwardedHeaders:ForwardLimit must be between 1 and 5."); options.ForwardLimit = forwardLimit; foreach (var configuredProxy in configuration .GetSection("ForwardedHeaders:KnownProxies") .Get() ?? []) { if (string.IsNullOrWhiteSpace(configuredProxy)) continue; if (!IPAddress.TryParse(configuredProxy, out var proxy)) throw new InvalidOperationException( $"ForwardedHeaders:KnownProxies contains invalid IP address '{configuredProxy}'."); options.KnownProxies.Add(proxy); } foreach (var configuredNetwork in configuration .GetSection("ForwardedHeaders:KnownNetworks") .Get() ?? []) { if (string.IsNullOrWhiteSpace(configuredNetwork)) continue; if (!System.Net.IPNetwork.TryParse(configuredNetwork, out var network)) throw new InvalidOperationException( $"ForwardedHeaders:KnownNetworks contains invalid CIDR '{configuredNetwork}'."); options.KnownIPNetworks.Add(network); } }); 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) { var runtimeReadClient = services.AddHttpClient(client => { client.BaseAddress = new(configuration["Integrations:OpenClaw:BaseUrl"] ?? "http://127.0.0.1:18789"); client.Timeout = Timeout.InfiniteTimeSpan; }); AddOpenClawReadResilience(runtimeReadClient); var gatewayReadClient = services.AddHttpClient("gateway", client => { client.BaseAddress = new(configuration["Integrations:OpenClaw:BaseUrl"] ?? "http://127.0.0.1:18789"); client.Timeout = Timeout.InfiniteTimeSpan; }); AddOpenClawReadResilience(gatewayReadClient); var historyReadClient = services.AddHttpClient(client => { client.BaseAddress = new(configuration["Integrations:OpenClaw:BaseUrl"] ?? "http://127.0.0.1:18789"); client.Timeout = Timeout.InfiniteTimeSpan; }); AddOpenClawReadResilience(historyReadClient); return services; } private static void AddOpenClawReadResilience(IHttpClientBuilder client) { client.AddStandardResilienceHandler(options => { options.RateLimiter.DefaultRateLimiterOptions.PermitLimit = 4; options.RateLimiter.DefaultRateLimiterOptions.QueueLimit = 0; options.AttemptTimeout.Timeout = TimeSpan.FromSeconds(10); options.TotalRequestTimeout.Timeout = TimeSpan.FromSeconds(30); options.Retry.MaxRetryAttempts = 2; options.Retry.Delay = TimeSpan.FromMilliseconds(250); options.Retry.UseJitter = true; options.Retry.DisableForUnsafeHttpMethods(); options.CircuitBreaker.FailureRatio = 0.5; options.CircuitBreaker.MinimumThroughput = 4; options.CircuitBreaker.SamplingDuration = TimeSpan.FromSeconds(30); options.CircuitBreaker.BreakDuration = TimeSpan.FromSeconds(30); }); } /// /// Registers application domain services (transient, scoped, singleton). /// public static IServiceCollection AddNexusApplicationServices( this IServiceCollection services, bool includeHostedServices = true) { services.AddMcpServer() .WithHttpTransport(options => options.Stateless = true) .WithTools(); services.AddOptions() .BindConfiguration(StaleTaskRecoveryOptions.SectionName); services.AddOptions() .BindConfiguration(AgentProvisioningOptions.SectionName); services.AddHttpContextAccessor(); services.AddSingleton(); services.AddTransient(); services.AddScoped(); services.AddScoped(); services.AddScoped(); services.AddScoped(); services.AddScoped(); services.AddScoped(); services.AddScoped(); services.AddScoped(); services.AddScoped(); services.AddScoped(); services.AddSingleton(); services.AddSingleton(); services.AddSingleton(serviceProvider => serviceProvider.GetRequiredService()); services.AddScoped(); services.AddScoped(); services.AddScoped(); services.AddScoped(); services.AddScoped(); services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); services.AddScoped(); services.AddScoped(); services.AddScoped(); services.AddSingleton(); services.AddScoped(); // ── Gateway WebSocket Connector ── services.AddOptions() .BindConfiguration(GatewayConnectorOptions.SectionName); services.AddSingleton(); services.AddSingleton< IOpenClawOperationAuditStore, PostgresOpenClawOperationAuditStore>(); services.AddSingleton(); if (includeHostedServices) { services.AddHostedService(); services.AddHostedService(serviceProvider => serviceProvider.GetRequiredService()); services.AddHostedService(); services.AddHostedService(); services.AddHostedService(serviceProvider => (GatewayConnector)serviceProvider.GetRequiredService()); services.AddHostedService(); services.AddHostedService(); } // ── Backend Bridge (Agent-Command-Service) ── services.AddScoped(); return services; } /// /// Registers data repositories. /// public static IServiceCollection AddNexusRepositories(this IServiceCollection services) { services.AddScoped(); services.AddScoped(); 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; } }