feat(stability): unify readiness and recovery
CI - Build & Test / Backend (.NET) (push) Successful in 45s
CI - Build & Test / Backend integration (PostgreSQL/Toxiproxy) (push) Failing after 1m0s
CI - Build & Test / Frontend (Vue/TS) (push) Successful in 2m49s
CI - Build & Test / Security Check (push) Successful in 7s
CI - Build & Test / Deploy Nexus (push) Has been skipped
CI - Build & Test / Backend (.NET) (push) Successful in 45s
CI - Build & Test / Backend integration (PostgreSQL/Toxiproxy) (push) Failing after 1m0s
CI - Build & Test / Frontend (Vue/TS) (push) Successful in 2m49s
CI - Build & Test / Security Check (push) Successful in 7s
CI - Build & Test / Deploy Nexus (push) Has been skipped
This commit is contained in:
@@ -2,6 +2,7 @@ using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Nexus.Api.Data;
|
||||
using Nexus.Api.DTOs;
|
||||
using Nexus.Api.Http;
|
||||
using Nexus.Api.Repositories;
|
||||
using Nexus.Api.Services;
|
||||
|
||||
@@ -74,7 +75,9 @@ public class AdminController(
|
||||
var normalizedEmail = AuthService.NormalizeEmail(request.Email);
|
||||
var existing = await userRepository.GetByEmailAsync(normalizedEmail, ct);
|
||||
if (existing is not null)
|
||||
return Results.Conflict(new { error = "A user with this email already exists." });
|
||||
return NexusHttpResults.Problem(
|
||||
StatusCodes.Status409Conflict,
|
||||
"A user with this email already exists.");
|
||||
|
||||
var user = new NexusUser
|
||||
{
|
||||
@@ -108,7 +111,9 @@ public class AdminController(
|
||||
{
|
||||
var user = await userRepository.GetByIdAsync(id, ct);
|
||||
if (user is null)
|
||||
return Results.NotFound(new { error = "User not found." });
|
||||
return NexusHttpResults.Problem(
|
||||
StatusCodes.Status404NotFound,
|
||||
"User not found.");
|
||||
|
||||
if (string.Equals(user.Role, "owner", StringComparison.OrdinalIgnoreCase))
|
||||
return Results.Problem("Owner accounts cannot be deleted via API.", statusCode: 403);
|
||||
@@ -142,7 +147,9 @@ public class AdminController(
|
||||
|
||||
var user = await userRepository.GetByIdAsync(id, ct);
|
||||
if (user is null)
|
||||
return Results.NotFound(new { error = "User not found." });
|
||||
return NexusHttpResults.Problem(
|
||||
StatusCodes.Status404NotFound,
|
||||
"User not found.");
|
||||
|
||||
// Niemals owner überschreiben
|
||||
if (string.Equals(user.Role, "owner", StringComparison.OrdinalIgnoreCase))
|
||||
|
||||
@@ -5,6 +5,7 @@ using System.Diagnostics;
|
||||
using System.Security.Claims;
|
||||
using Nexus.Api.DTOs;
|
||||
using Nexus.Api.Integrations;
|
||||
using Nexus.Api.Http;
|
||||
using Nexus.Api.Repositories;
|
||||
using Nexus.Api.Services;
|
||||
|
||||
@@ -160,9 +161,15 @@ public class AgentsController(
|
||||
public async Task<IResult> SaveConfigFile(string id, string fileName, [FromBody] SaveConfigRequest request, CancellationToken ct)
|
||||
{
|
||||
if (request.Content is null)
|
||||
return Results.BadRequest(new { error = "Content is required." });
|
||||
return Results.ValidationProblem(new Dictionary<string, string[]>
|
||||
{
|
||||
["content"] = ["Content is required."]
|
||||
});
|
||||
if (string.IsNullOrWhiteSpace(request.ExpectedHash))
|
||||
return Results.BadRequest(new { error = "ExpectedHash is required." });
|
||||
return Results.ValidationProblem(new Dictionary<string, string[]>
|
||||
{
|
||||
["expectedHash"] = ["ExpectedHash is required."]
|
||||
});
|
||||
|
||||
try
|
||||
{
|
||||
@@ -170,7 +177,10 @@ public class AgentsController(
|
||||
var idempotencyKey = Request.Headers["Idempotency-Key"].FirstOrDefault()?.Trim();
|
||||
if (string.IsNullOrWhiteSpace(idempotencyKey))
|
||||
{
|
||||
return Results.BadRequest(new { error = "Idempotency-Key header is required." });
|
||||
return Results.ValidationProblem(new Dictionary<string, string[]>
|
||||
{
|
||||
["Idempotency-Key"] = ["Idempotency-Key header is required."]
|
||||
});
|
||||
}
|
||||
|
||||
var invocation = OpenClawInvocationContext.Create(
|
||||
@@ -204,15 +214,15 @@ public class AgentsController(
|
||||
}
|
||||
catch (OpenClawAgentConfigurationConflictException ex)
|
||||
{
|
||||
return Results.Json(
|
||||
new
|
||||
return NexusHttpResults.Problem(
|
||||
StatusCodes.Status409Conflict,
|
||||
ex.Message,
|
||||
NexusProblemCodes.Conflict,
|
||||
extensions: new Dictionary<string, object?>
|
||||
{
|
||||
code = ex.Code,
|
||||
message = ex.Message,
|
||||
ex.ExpectedHash,
|
||||
ex.CurrentHash
|
||||
},
|
||||
statusCode: StatusCodes.Status409Conflict);
|
||||
["expectedHash"] = ex.ExpectedHash,
|
||||
["currentHash"] = ex.CurrentHash
|
||||
});
|
||||
}
|
||||
catch (OpenClawAgentConfigurationValidationException ex)
|
||||
{
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
using Microsoft.AspNetCore.Antiforgery;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.RateLimiting;
|
||||
using Microsoft.Extensions.Diagnostics.HealthChecks;
|
||||
using Nexus.Api.DTOs;
|
||||
using Nexus.Api.Integrations;
|
||||
using Nexus.Api.Http;
|
||||
using Nexus.Api.RateLimiting;
|
||||
using Nexus.Api.Security;
|
||||
using Nexus.Api.Services;
|
||||
|
||||
namespace Nexus.Api.Controllers;
|
||||
@@ -14,19 +15,10 @@ namespace Nexus.Api.Controllers;
|
||||
[Route("api/v1/auth")]
|
||||
public class AuthController(
|
||||
IAuthService authService,
|
||||
IAntiforgery antiforgery,
|
||||
IConfiguration config,
|
||||
IHostEnvironment env,
|
||||
LoginAttemptTracker attemptTracker) : ControllerBase
|
||||
{
|
||||
[HttpGet("csrf")]
|
||||
[AllowAnonymous]
|
||||
public IActionResult GetCsrfToken()
|
||||
{
|
||||
var tokens = antiforgery.GetAndStoreTokens(HttpContext);
|
||||
return Ok(new { token = tokens.RequestToken });
|
||||
}
|
||||
|
||||
[HttpPost("login")]
|
||||
[AllowAnonymous]
|
||||
[EnableRateLimiting("auth")]
|
||||
@@ -51,14 +43,16 @@ public class AuthController(
|
||||
HttpContext.Response.Headers["X-RateLimit-Reset"] =
|
||||
DateTimeOffset.UtcNow.AddSeconds(retryAfterSeconds).ToUnixTimeSeconds().ToString();
|
||||
|
||||
// Return a structured body so the frontend can display remaining attempts
|
||||
return Results.Json(new
|
||||
{
|
||||
error = "invalid_credentials",
|
||||
message = "Invalid email or password.",
|
||||
remaining,
|
||||
retryAfterSeconds
|
||||
}, statusCode: 401);
|
||||
return Results.Problem(
|
||||
statusCode: StatusCodes.Status401Unauthorized,
|
||||
title: "Authentication failed",
|
||||
detail: "Invalid email or password.",
|
||||
extensions: new Dictionary<string, object?>
|
||||
{
|
||||
["code"] = NexusProblemCodes.Unauthenticated,
|
||||
["remaining"] = remaining,
|
||||
["retryAfterSeconds"] = retryAfterSeconds
|
||||
});
|
||||
}
|
||||
|
||||
// Success — reset attempt counter
|
||||
@@ -75,14 +69,17 @@ public class AuthController(
|
||||
[EnableRateLimiting("auth")]
|
||||
public async Task<IResult> Refresh(CancellationToken ct)
|
||||
{
|
||||
if (!BrowserRequestOriginGuard.IsAllowed(Request))
|
||||
return CrossSiteRequestRejected();
|
||||
|
||||
if (!Request.Cookies.TryGetValue("nexus_refresh", out var refreshToken))
|
||||
return Results.Unauthorized();
|
||||
return Unauthenticated("No active refresh session was provided.");
|
||||
|
||||
var session = await authService.RefreshAsync(refreshToken!, ct);
|
||||
if (session is null)
|
||||
{
|
||||
ClearRefreshCookie(Response);
|
||||
return Results.Unauthorized();
|
||||
return Unauthenticated("The refresh session is invalid or expired.");
|
||||
}
|
||||
|
||||
SetRefreshCookie(Response, session.RefreshToken);
|
||||
@@ -96,6 +93,9 @@ public class AuthController(
|
||||
[AllowAnonymous]
|
||||
public async Task<IResult> Logout(CancellationToken ct)
|
||||
{
|
||||
if (!BrowserRequestOriginGuard.IsAllowed(Request))
|
||||
return CrossSiteRequestRejected();
|
||||
|
||||
if (Request.Cookies.TryGetValue("nexus_refresh", out var refreshToken))
|
||||
await authService.RevokeAsync(refreshToken!, ct);
|
||||
|
||||
@@ -168,6 +168,26 @@ public class AuthController(
|
||||
User = session.User
|
||||
};
|
||||
|
||||
private static IResult Unauthenticated(string detail)
|
||||
=> Results.Problem(
|
||||
statusCode: StatusCodes.Status401Unauthorized,
|
||||
title: "Authentication required",
|
||||
detail: detail,
|
||||
extensions: new Dictionary<string, object?>
|
||||
{
|
||||
["code"] = NexusProblemCodes.Unauthenticated
|
||||
});
|
||||
|
||||
private static IResult CrossSiteRequestRejected()
|
||||
=> Results.Problem(
|
||||
statusCode: StatusCodes.Status403Forbidden,
|
||||
title: "Cross-site request rejected",
|
||||
detail: "Refresh and logout requests must originate from the Nexus origin.",
|
||||
extensions: new Dictionary<string, object?>
|
||||
{
|
||||
["code"] = NexusProblemCodes.Forbidden
|
||||
});
|
||||
|
||||
private void SetRefreshCookie(HttpResponse response, string token)
|
||||
{
|
||||
var days = config.GetValue<int?>("Jwt:RefreshTokenExpirationDays") ?? 7;
|
||||
|
||||
@@ -18,6 +18,26 @@ public class HealthController(IAgentRuntime runtime, HealthCheckService healthCh
|
||||
agentSource = "openclaw-rpc"
|
||||
});
|
||||
|
||||
[AllowAnonymous]
|
||||
[HttpGet("/health/ready")]
|
||||
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status503ServiceUnavailable)]
|
||||
public async Task<IResult> Ready(CancellationToken ct)
|
||||
{
|
||||
var report = await healthChecks.CheckHealthAsync(
|
||||
registration => registration.Tags.Contains("ready"),
|
||||
ct);
|
||||
var payload = new
|
||||
{
|
||||
status = report.Status == HealthStatus.Healthy ? "Healthy" : "Unhealthy",
|
||||
timestamp = DateTimeOffset.UtcNow
|
||||
};
|
||||
|
||||
return report.Status == HealthStatus.Healthy
|
||||
? Results.Ok(payload)
|
||||
: Results.Json(payload, statusCode: StatusCodes.Status503ServiceUnavailable);
|
||||
}
|
||||
|
||||
[AllowAnonymous]
|
||||
[HttpGet("/health")]
|
||||
public async Task<IResult> Get(CancellationToken ct)
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
using Nexus.Api.Models;
|
||||
using Nexus.Api.Http;
|
||||
using Nexus.Api.Services;
|
||||
|
||||
namespace Nexus.Api.Controllers;
|
||||
@@ -28,21 +28,32 @@ internal static class OpenClawContentReadEndpoint
|
||||
"disconnected" => StatusCodes.Status503ServiceUnavailable,
|
||||
_ => StatusCodes.Status409Conflict
|
||||
};
|
||||
return Results.Json(
|
||||
new OpenClawAgentConfigurationErrorDto(
|
||||
exception.State,
|
||||
exception.Message,
|
||||
exception.Method,
|
||||
exception.RequiredScope),
|
||||
statusCode: status);
|
||||
return NexusHttpResults.Problem(
|
||||
status,
|
||||
exception.Message,
|
||||
status switch
|
||||
{
|
||||
StatusCodes.Status403Forbidden => NexusProblemCodes.Forbidden,
|
||||
StatusCodes.Status503ServiceUnavailable => NexusProblemCodes.DependencyUnavailable,
|
||||
_ => NexusProblemCodes.Conflict
|
||||
},
|
||||
extensions: new Dictionary<string, object?>
|
||||
{
|
||||
["method"] = exception.Method,
|
||||
["requiredScope"] = exception.RequiredScope,
|
||||
["state"] = exception.State
|
||||
});
|
||||
}
|
||||
catch (OpenClawAgentConfigurationVerificationException)
|
||||
{
|
||||
return Results.Json(
|
||||
new OpenClawAgentConfigurationErrorDto(
|
||||
"verification_failed",
|
||||
"OpenClaw-Antwort konnte nicht sicher verifiziert werden."),
|
||||
statusCode: StatusCodes.Status502BadGateway);
|
||||
return NexusHttpResults.Problem(
|
||||
StatusCodes.Status502BadGateway,
|
||||
"OpenClaw-Antwort konnte nicht sicher verifiziert werden.",
|
||||
NexusProblemCodes.DependencyUnavailable,
|
||||
extensions: new Dictionary<string, object?>
|
||||
{
|
||||
["state"] = "verification_failed"
|
||||
});
|
||||
}
|
||||
catch (OpenClawGatewayRpcException exception)
|
||||
{
|
||||
@@ -60,22 +71,33 @@ internal static class OpenClawContentReadEndpoint
|
||||
StatusCodes.Status504GatewayTimeout,
|
||||
_ => StatusCodes.Status502BadGateway
|
||||
};
|
||||
return Results.Json(
|
||||
new OpenClawAgentConfigurationErrorDto(
|
||||
code.ToLowerInvariant(),
|
||||
status switch
|
||||
{
|
||||
StatusCodes.Status403Forbidden =>
|
||||
"OpenClaw hat Nexus nicht die erforderliche Leseberechtigung gewährt.",
|
||||
StatusCodes.Status409Conflict =>
|
||||
"Die verbundene OpenClaw-Version unterstützt diese Leseoperation nicht.",
|
||||
StatusCodes.Status503ServiceUnavailable =>
|
||||
"OpenClaw Gateway ist nicht verfügbar.",
|
||||
StatusCodes.Status504GatewayTimeout =>
|
||||
"OpenClaw hat nicht rechtzeitig geantwortet.",
|
||||
_ => "OpenClaw-Leseoperation ist fehlgeschlagen."
|
||||
}),
|
||||
statusCode: status);
|
||||
var problemCode = status switch
|
||||
{
|
||||
StatusCodes.Status403Forbidden => NexusProblemCodes.Forbidden,
|
||||
StatusCodes.Status409Conflict => NexusProblemCodes.UnsupportedCapability,
|
||||
StatusCodes.Status503ServiceUnavailable => NexusProblemCodes.DependencyUnavailable,
|
||||
StatusCodes.Status504GatewayTimeout => NexusProblemCodes.Timeout,
|
||||
_ => NexusProblemCodes.DependencyUnavailable
|
||||
};
|
||||
return NexusHttpResults.Problem(
|
||||
status,
|
||||
status switch
|
||||
{
|
||||
StatusCodes.Status403Forbidden =>
|
||||
"OpenClaw hat Nexus nicht die erforderliche Leseberechtigung gewährt.",
|
||||
StatusCodes.Status409Conflict =>
|
||||
"Die verbundene OpenClaw-Version unterstützt diese Leseoperation nicht.",
|
||||
StatusCodes.Status503ServiceUnavailable =>
|
||||
"OpenClaw Gateway ist nicht verfügbar.",
|
||||
StatusCodes.Status504GatewayTimeout =>
|
||||
"OpenClaw hat nicht rechtzeitig geantwortet.",
|
||||
_ => "OpenClaw-Leseoperation ist fehlgeschlagen."
|
||||
},
|
||||
problemCode,
|
||||
extensions: new Dictionary<string, object?>
|
||||
{
|
||||
["state"] = code.ToLowerInvariant()
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+5
-1
@@ -1,9 +1,13 @@
|
||||
FROM mcr.microsoft.com/dotnet/sdk:10.0-alpine AS build
|
||||
ARG NEXUS_VERSION=dev
|
||||
ARG NEXUS_GIT_SHA=unknown
|
||||
WORKDIR /src
|
||||
COPY Nexus.Api.csproj .
|
||||
RUN dotnet restore
|
||||
COPY . .
|
||||
RUN dotnet publish -c Release -o /app/publish
|
||||
RUN dotnet publish -c Release -o /app/publish \
|
||||
/p:Version="${NEXUS_VERSION}" \
|
||||
/p:InformationalVersion="${NEXUS_VERSION}+${NEXUS_GIT_SHA}"
|
||||
|
||||
FROM mcr.microsoft.com/dotnet/aspnet:10.0-alpine
|
||||
ARG NEXUS_VERSION=dev
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using System.Diagnostics;
|
||||
using Nexus.Api.Observability;
|
||||
using Nexus.Api.Http;
|
||||
using Npgsql;
|
||||
using OpenTelemetry;
|
||||
using OpenTelemetry.Exporter;
|
||||
@@ -20,14 +21,14 @@ public static class PlatformServiceCollectionExtensions
|
||||
this IServiceCollection services,
|
||||
IConfiguration configuration)
|
||||
{
|
||||
services.AddOpenApi("v1");
|
||||
services.AddOpenApi("v1", options =>
|
||||
options.AddSchemaTransformer<NexusProblemDetailsSchemaTransformer>());
|
||||
services.AddProblemDetails(options =>
|
||||
{
|
||||
options.CustomizeProblemDetails = context =>
|
||||
{
|
||||
context.ProblemDetails.Extensions["traceId"] =
|
||||
Activity.Current?.Id ?? context.HttpContext.TraceIdentifier;
|
||||
};
|
||||
NexusProblemDetailsDefaults.Apply(
|
||||
context.ProblemDetails,
|
||||
context.HttpContext);
|
||||
});
|
||||
|
||||
var telemetry = services.AddOpenTelemetry()
|
||||
|
||||
@@ -10,6 +10,7 @@ using Microsoft.IdentityModel.Tokens;
|
||||
using ModelContextProtocol.AspNetCore;
|
||||
using Nexus.Api.Data;
|
||||
using Nexus.Api.Integrations;
|
||||
using Nexus.Api.Http;
|
||||
using Nexus.Api.RateLimiting;
|
||||
using Nexus.Api.Repositories;
|
||||
using Nexus.Api.Routing;
|
||||
@@ -28,7 +29,7 @@ namespace Nexus.Api.Extensions;
|
||||
public static class ServiceCollectionExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Configures JWT authentication, authorization, and antiforgery.
|
||||
/// Configures JWT authentication and authorization.
|
||||
/// </summary>
|
||||
public static IServiceCollection AddNexusAuth(this IServiceCollection services, IConfiguration configuration)
|
||||
{
|
||||
@@ -63,14 +64,6 @@ public static class ServiceCollectionExtensions
|
||||
.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;
|
||||
}
|
||||
|
||||
@@ -109,7 +102,7 @@ public static class ServiceCollectionExtensions
|
||||
Status = StatusCodes.Status429TooManyRequests,
|
||||
Detail = $"Too many attempts. Try again in {retryAfterSeconds} second(s)."
|
||||
};
|
||||
body.Extensions["code"] = "rate_limit_exceeded";
|
||||
body.Extensions["code"] = NexusProblemCodes.RateLimited;
|
||||
body.Extensions["remaining"] = 0;
|
||||
body.Extensions["retryAfterSeconds"] = retryAfterSeconds;
|
||||
body.Extensions["traceId"] =
|
||||
@@ -272,11 +265,15 @@ public static class ServiceCollectionExtensions
|
||||
/// </summary>
|
||||
public static IServiceCollection AddNexusApplicationServices(
|
||||
this IServiceCollection services,
|
||||
bool includeHostedServices = true)
|
||||
bool includeHostedServices = true,
|
||||
bool includeMcp = true)
|
||||
{
|
||||
services.AddMcpServer()
|
||||
.WithHttpTransport(options => options.Stateless = true)
|
||||
.WithTools<NexusMcpTools>();
|
||||
if (includeMcp)
|
||||
{
|
||||
services.AddMcpServer()
|
||||
.WithHttpTransport(options => options.Stateless = true)
|
||||
.WithTools<NexusMcpTools>();
|
||||
}
|
||||
|
||||
services.AddOptions<StaleTaskRecoveryOptions>()
|
||||
.BindConfiguration(StaleTaskRecoveryOptions.SectionName);
|
||||
@@ -364,7 +361,10 @@ public static class ServiceCollectionExtensions
|
||||
public static IServiceCollection AddNexusHealthChecks(this IServiceCollection services, IConfiguration configuration)
|
||||
{
|
||||
services.AddHealthChecks()
|
||||
.AddNpgSql(configuration.GetConnectionString("Nexus")!, name: "postgresql", tags: ["database"])
|
||||
.AddNpgSql(
|
||||
configuration.GetConnectionString("Nexus")!,
|
||||
name: "postgresql",
|
||||
tags: ["database", "ready"])
|
||||
.AddCheck("runtime", () => HealthCheckResult.Healthy("Runtime configured"), tags: ["runtime"]);
|
||||
|
||||
return services;
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
using System.Diagnostics;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.Mvc.Filters;
|
||||
using Microsoft.AspNetCore.WebUtilities;
|
||||
|
||||
namespace Nexus.Api.Http;
|
||||
|
||||
public static class NexusProblemCodes
|
||||
{
|
||||
public const string ValidationFailed = "validation_failed";
|
||||
public const string Unauthenticated = "unauthenticated";
|
||||
public const string Forbidden = "forbidden";
|
||||
public const string NotFound = "not_found";
|
||||
public const string Conflict = "conflict";
|
||||
public const string UnsupportedCapability = "unsupported_capability";
|
||||
public const string DependencyUnavailable = "dependency_unavailable";
|
||||
public const string Timeout = "timeout";
|
||||
public const string RateLimited = "rate_limited";
|
||||
public const string InternalError = "internal_error";
|
||||
|
||||
public static string ForStatus(int statusCode) => statusCode switch
|
||||
{
|
||||
StatusCodes.Status400BadRequest or StatusCodes.Status422UnprocessableEntity => ValidationFailed,
|
||||
StatusCodes.Status401Unauthorized => Unauthenticated,
|
||||
StatusCodes.Status403Forbidden => Forbidden,
|
||||
StatusCodes.Status404NotFound => NotFound,
|
||||
StatusCodes.Status409Conflict => Conflict,
|
||||
StatusCodes.Status429TooManyRequests => RateLimited,
|
||||
StatusCodes.Status501NotImplemented => UnsupportedCapability,
|
||||
StatusCodes.Status502BadGateway or StatusCodes.Status503ServiceUnavailable => DependencyUnavailable,
|
||||
StatusCodes.Status504GatewayTimeout => Timeout,
|
||||
_ => InternalError
|
||||
};
|
||||
}
|
||||
|
||||
public static class NexusProblemDetailsDefaults
|
||||
{
|
||||
public static void Apply(ProblemDetails problem, HttpContext httpContext)
|
||||
{
|
||||
var statusCode = problem.Status
|
||||
?? (httpContext.Response.StatusCode >= 400
|
||||
? httpContext.Response.StatusCode
|
||||
: StatusCodes.Status500InternalServerError);
|
||||
|
||||
problem.Status = statusCode;
|
||||
problem.Type ??= $"https://httpstatuses.com/{statusCode}";
|
||||
problem.Title ??= ReasonPhrases.GetReasonPhrase(statusCode);
|
||||
problem.Extensions.TryAdd("code", NexusProblemCodes.ForStatus(statusCode));
|
||||
problem.Extensions.TryAdd(
|
||||
"traceId",
|
||||
Activity.Current?.Id ?? httpContext.TraceIdentifier);
|
||||
}
|
||||
|
||||
public static ProblemDetails? FromLegacyError(
|
||||
object? value,
|
||||
int? statusCode,
|
||||
HttpContext httpContext)
|
||||
{
|
||||
if (value is null || statusCode is null || statusCode < 400)
|
||||
return null;
|
||||
|
||||
var errorProperty = value.GetType().GetProperty(
|
||||
"error",
|
||||
System.Reflection.BindingFlags.Public
|
||||
| System.Reflection.BindingFlags.Instance
|
||||
| System.Reflection.BindingFlags.IgnoreCase);
|
||||
if (errorProperty?.GetValue(value) is not string detail
|
||||
|| string.IsNullOrWhiteSpace(detail))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var problem = new ProblemDetails
|
||||
{
|
||||
Status = statusCode,
|
||||
Detail = detail
|
||||
};
|
||||
Apply(problem, httpContext);
|
||||
return problem;
|
||||
}
|
||||
}
|
||||
|
||||
public static class NexusHttpResults
|
||||
{
|
||||
public static IResult Problem(
|
||||
int statusCode,
|
||||
string detail,
|
||||
string? code = null,
|
||||
string? title = null,
|
||||
IDictionary<string, object?>? extensions = null)
|
||||
{
|
||||
var values = extensions is null
|
||||
? new Dictionary<string, object?>()
|
||||
: new Dictionary<string, object?>(extensions);
|
||||
values.TryAdd("code", code ?? NexusProblemCodes.ForStatus(statusCode));
|
||||
|
||||
return Results.Problem(
|
||||
statusCode: statusCode,
|
||||
title: title,
|
||||
detail: detail,
|
||||
extensions: values);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Ensures MVC-produced ProblemDetails values use the same extensions as the
|
||||
/// global exception and status-code writers.
|
||||
/// </summary>
|
||||
public sealed class NexusProblemDetailsFilter : IResultFilter
|
||||
{
|
||||
public void OnResultExecuting(ResultExecutingContext context)
|
||||
{
|
||||
if (context.Result is not ObjectResult objectResult)
|
||||
return;
|
||||
|
||||
if (objectResult.Value is ProblemDetails problem)
|
||||
{
|
||||
NexusProblemDetailsDefaults.Apply(problem, context.HttpContext);
|
||||
return;
|
||||
}
|
||||
|
||||
var legacyProblem = NexusProblemDetailsDefaults.FromLegacyError(
|
||||
objectResult.Value,
|
||||
objectResult.StatusCode,
|
||||
context.HttpContext);
|
||||
if (legacyProblem is null)
|
||||
return;
|
||||
|
||||
objectResult.Value = legacyProblem;
|
||||
objectResult.ContentTypes.Clear();
|
||||
objectResult.ContentTypes.Add("application/problem+json");
|
||||
}
|
||||
|
||||
public void OnResultExecuted(ResultExecutedContext context)
|
||||
{
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.OpenApi;
|
||||
using Microsoft.OpenApi;
|
||||
|
||||
namespace Nexus.Api.Http;
|
||||
|
||||
/// <summary>
|
||||
/// Publishes the stable Nexus ProblemDetails extensions in the generated
|
||||
/// OpenAPI contract so the frontend does not have to guess error metadata.
|
||||
/// </summary>
|
||||
public sealed class NexusProblemDetailsSchemaTransformer : IOpenApiSchemaTransformer
|
||||
{
|
||||
public Task TransformAsync(
|
||||
OpenApiSchema schema,
|
||||
OpenApiSchemaTransformerContext context,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (!typeof(ProblemDetails).IsAssignableFrom(context.JsonTypeInfo.Type))
|
||||
return Task.CompletedTask;
|
||||
|
||||
schema.Properties ??= new Dictionary<string, IOpenApiSchema>();
|
||||
schema.Properties["code"] = StringSchema(
|
||||
"Stable machine-readable Nexus error code.");
|
||||
schema.Properties["traceId"] = StringSchema(
|
||||
"Privacy-safe server trace identifier.");
|
||||
schema.Properties["operationId"] = StringSchema(
|
||||
"Durable operation identifier when the request started an operation.");
|
||||
schema.Properties["currentRevision"] = IntegerSchema(
|
||||
"Current server revision for a stale-write conflict.");
|
||||
schema.Properties["retryAfterSeconds"] = IntegerSchema(
|
||||
"Minimum retry delay advertised by Nexus.");
|
||||
schema.Properties["remaining"] = IntegerSchema(
|
||||
"Remaining attempts when a bounded policy exposes that value.");
|
||||
schema.Properties["expectedHash"] = StringSchema(
|
||||
"Client-supplied content hash for a stale-write conflict.");
|
||||
schema.Properties["currentHash"] = StringSchema(
|
||||
"Current server content hash for a stale-write conflict.");
|
||||
schema.Required ??= new HashSet<string>();
|
||||
schema.Required.Add("code");
|
||||
schema.Required.Add("traceId");
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
private static OpenApiSchema StringSchema(string description)
|
||||
=> new()
|
||||
{
|
||||
Type = JsonSchemaType.String | JsonSchemaType.Null,
|
||||
Description = description
|
||||
};
|
||||
|
||||
private static OpenApiSchema IntegerSchema(string description)
|
||||
=> new()
|
||||
{
|
||||
Type = JsonSchemaType.Integer | JsonSchemaType.Null,
|
||||
Format = "int32",
|
||||
Description = description
|
||||
};
|
||||
}
|
||||
+7
-3
@@ -1,4 +1,5 @@
|
||||
using Nexus.Api.Extensions;
|
||||
using Nexus.Api.Http;
|
||||
using System.Reflection;
|
||||
|
||||
var builder = WebApplication.CreateBuilder(args);
|
||||
@@ -20,11 +21,13 @@ builder.Services.AddNexusSwagger();
|
||||
builder.Services.AddNexusDatabase(builder.Configuration);
|
||||
builder.Services.AddNexusHttpClients(builder.Configuration);
|
||||
builder.Services.AddNexusApplicationServices(
|
||||
includeHostedServices: !isOpenApiGeneration);
|
||||
includeHostedServices: !isOpenApiGeneration,
|
||||
includeMcp: !isOpenApiGeneration);
|
||||
builder.Services.AddNexusRepositories();
|
||||
builder.Services.AddNexusHealthChecks(builder.Configuration);
|
||||
builder.Services.AddNexusPlatform(builder.Configuration);
|
||||
builder.Services.AddControllers();
|
||||
builder.Services.AddControllers(options =>
|
||||
options.Filters.Add<NexusProblemDetailsFilter>());
|
||||
|
||||
var app = builder.Build();
|
||||
|
||||
@@ -40,7 +43,8 @@ if (!isOpenApiGeneration)
|
||||
// --- Middleware Pipeline ---
|
||||
app.UseNexusPipeline(app.Environment);
|
||||
|
||||
app.MapMcp("/mcp");
|
||||
if (!isOpenApiGeneration)
|
||||
app.MapMcp("/mcp");
|
||||
app.MapOpenApi("/openapi/{documentName}.json");
|
||||
app.MapControllers();
|
||||
app.Run();
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
namespace Nexus.Api.Security;
|
||||
|
||||
/// <summary>
|
||||
/// Rejects browser requests that explicitly identify themselves as cross-site.
|
||||
/// Requests without browser provenance headers remain valid for trusted API
|
||||
/// clients; they still need the normal cookie, authentication and rate limits.
|
||||
/// </summary>
|
||||
public static class BrowserRequestOriginGuard
|
||||
{
|
||||
public static bool IsAllowed(HttpRequest request)
|
||||
{
|
||||
var fetchSite = request.Headers["Sec-Fetch-Site"].ToString().Trim();
|
||||
if (string.Equals(fetchSite, "cross-site", StringComparison.OrdinalIgnoreCase))
|
||||
return false;
|
||||
|
||||
var originValue = request.Headers.Origin.ToString().Trim();
|
||||
if (originValue.Length == 0)
|
||||
return true;
|
||||
|
||||
if (!Uri.TryCreate(originValue, UriKind.Absolute, out var origin))
|
||||
return false;
|
||||
|
||||
var requestHost = request.Host.Host;
|
||||
if (requestHost.Length == 0)
|
||||
return false;
|
||||
|
||||
var originPort = origin.IsDefaultPort ? DefaultPort(origin.Scheme) : origin.Port;
|
||||
var requestPort = request.Host.Port ?? DefaultPort(request.Scheme);
|
||||
|
||||
return string.Equals(origin.Scheme, request.Scheme, StringComparison.OrdinalIgnoreCase)
|
||||
&& string.Equals(origin.Host, requestHost, StringComparison.OrdinalIgnoreCase)
|
||||
&& originPort == requestPort;
|
||||
}
|
||||
|
||||
private static int DefaultPort(string scheme)
|
||||
=> string.Equals(scheme, Uri.UriSchemeHttps, StringComparison.OrdinalIgnoreCase) ? 443 : 80;
|
||||
}
|
||||
+141
-12
@@ -585,18 +585,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/v1/auth/csrf": {
|
||||
"get": {
|
||||
"tags": [
|
||||
"Auth"
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/v1/auth/login": {
|
||||
"post": {
|
||||
"tags": [
|
||||
@@ -3023,6 +3011,21 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"/health/ready": {
|
||||
"get": {
|
||||
"tags": [
|
||||
"Health"
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK"
|
||||
},
|
||||
"503": {
|
||||
"description": "Service Unavailable"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/health": {
|
||||
"get": {
|
||||
"tags": [
|
||||
@@ -15008,6 +15011,10 @@
|
||||
}
|
||||
},
|
||||
"ProblemDetails": {
|
||||
"required": [
|
||||
"code",
|
||||
"traceId"
|
||||
],
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
@@ -15042,6 +15049,65 @@
|
||||
"null",
|
||||
"string"
|
||||
]
|
||||
},
|
||||
"code": {
|
||||
"type": [
|
||||
"null",
|
||||
"string"
|
||||
],
|
||||
"description": "Stable machine-readable Nexus error code."
|
||||
},
|
||||
"traceId": {
|
||||
"type": [
|
||||
"null",
|
||||
"string"
|
||||
],
|
||||
"description": "Privacy-safe server trace identifier."
|
||||
},
|
||||
"operationId": {
|
||||
"type": [
|
||||
"null",
|
||||
"string"
|
||||
],
|
||||
"description": "Durable operation identifier when the request started an operation."
|
||||
},
|
||||
"currentRevision": {
|
||||
"type": [
|
||||
"null",
|
||||
"integer"
|
||||
],
|
||||
"description": "Current server revision for a stale-write conflict.",
|
||||
"format": "int32"
|
||||
},
|
||||
"retryAfterSeconds": {
|
||||
"type": [
|
||||
"null",
|
||||
"integer"
|
||||
],
|
||||
"description": "Minimum retry delay advertised by Nexus.",
|
||||
"format": "int32"
|
||||
},
|
||||
"remaining": {
|
||||
"type": [
|
||||
"null",
|
||||
"integer"
|
||||
],
|
||||
"description": "Remaining attempts when a bounded policy exposes that value.",
|
||||
"format": "int32"
|
||||
},
|
||||
"expectedHash": {
|
||||
"type": [
|
||||
"null",
|
||||
"string"
|
||||
],
|
||||
"description": "Client-supplied content hash for a stale-write conflict."
|
||||
},
|
||||
"currentHash": {
|
||||
"type": [
|
||||
"null",
|
||||
"string"
|
||||
],
|
||||
"description": "Current server content hash for a stale-write conflict."
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -15899,6 +15965,10 @@
|
||||
}
|
||||
},
|
||||
"ValidationProblemDetails": {
|
||||
"required": [
|
||||
"code",
|
||||
"traceId"
|
||||
],
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
@@ -15942,6 +16012,65 @@
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"code": {
|
||||
"type": [
|
||||
"null",
|
||||
"string"
|
||||
],
|
||||
"description": "Stable machine-readable Nexus error code."
|
||||
},
|
||||
"traceId": {
|
||||
"type": [
|
||||
"null",
|
||||
"string"
|
||||
],
|
||||
"description": "Privacy-safe server trace identifier."
|
||||
},
|
||||
"operationId": {
|
||||
"type": [
|
||||
"null",
|
||||
"string"
|
||||
],
|
||||
"description": "Durable operation identifier when the request started an operation."
|
||||
},
|
||||
"currentRevision": {
|
||||
"type": [
|
||||
"null",
|
||||
"integer"
|
||||
],
|
||||
"description": "Current server revision for a stale-write conflict.",
|
||||
"format": "int32"
|
||||
},
|
||||
"retryAfterSeconds": {
|
||||
"type": [
|
||||
"null",
|
||||
"integer"
|
||||
],
|
||||
"description": "Minimum retry delay advertised by Nexus.",
|
||||
"format": "int32"
|
||||
},
|
||||
"remaining": {
|
||||
"type": [
|
||||
"null",
|
||||
"integer"
|
||||
],
|
||||
"description": "Remaining attempts when a bounded policy exposes that value.",
|
||||
"format": "int32"
|
||||
},
|
||||
"expectedHash": {
|
||||
"type": [
|
||||
"null",
|
||||
"string"
|
||||
],
|
||||
"description": "Client-supplied content hash for a stale-write conflict."
|
||||
},
|
||||
"currentHash": {
|
||||
"type": [
|
||||
"null",
|
||||
"string"
|
||||
],
|
||||
"description": "Current server content hash for a stale-write conflict."
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user