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

This commit is contained in:
AzuTear
2026-08-01 01:21:33 +02:00
parent 38282e4f7f
commit cd8c78d165
67 changed files with 2616 additions and 601 deletions
+10 -3
View File
@@ -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))
+21 -11
View File
@@ -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)
{
+40 -20
View File
@@ -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;
+20
View File
@@ -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()
});
}
}
}