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()
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user