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;
}
}