From cd8c78d165d6772641807dabf6c3cd55b1e5d560 Mon Sep 17 00:00:00 2001 From: AzuTear Date: Sat, 1 Aug 2026 01:21:33 +0200 Subject: [PATCH] feat(stability): unify readiness and recovery --- .gitea/scripts/deploy-nexus.sh | 33 ++- .gitea/workflows/ci.yaml | 78 ++++-- .gitea/workflows/rollback.yaml | 17 +- .gitleaksignore | 3 + Directory.Build.props | 6 + README.md | 34 ++- VERSION | 2 +- backend-tests/AuthServiceTests.cs | 42 +++ backend-tests/HealthControllerTests.cs | 69 +++++ backend-tests/ProblemDetailsContractTests.cs | 92 +++++++ backend-tests/SecurityBoundaryTests.cs | 31 ++- backend/Controllers/AdminController.cs | 13 +- backend/Controllers/AgentsController.cs | 32 ++- backend/Controllers/AuthController.cs | 60 +++-- backend/Controllers/HealthController.cs | 20 ++ .../OpenClawContentReadEndpoint.cs | 80 +++--- backend/Dockerfile | 6 +- .../PlatformServiceCollectionExtensions.cs | 11 +- .../Extensions/ServiceCollectionExtensions.cs | 30 +-- backend/Http/NexusProblemDetails.cs | 137 ++++++++++ .../NexusProblemDetailsSchemaTransformer.cs | 59 +++++ backend/Program.cs | 10 +- backend/Security/BrowserRequestOriginGuard.cs | 37 +++ backend/openapi/Nexus.Api.json | 153 ++++++++++- compose.yaml | 6 +- docs/AGENT_FIRST_MISSION_CONTROL.md | 2 +- docs/MISSION_CONTROL_ROADMAP.md | 33 +++ docs/QA_AUTOMATION.md | 38 +++ docs/SECURITY_SPOT_CHECK_2026-07-26.md | 6 + .../architecture-board-first-orchestration.md | 4 +- .../IMPLEMENTATION_AND_ACCEPTANCE.md | 188 ++++++++++++++ frontend/Dockerfile | 5 + frontend/e2e/route-smoke.e2e.ts | 27 ++ frontend/e2e/support/nexusApi.ts | 29 +++ frontend/package.json | 2 +- frontend/playwright.config.ts | 3 + frontend/src/api/agentProposals.ts | 8 +- frontend/src/api/contracts.ts | 154 ++++++++++- frontend/src/api/generated/schema.d.ts | 123 ++++++--- frontend/src/api/openclawRuntime.ts | 8 +- .../src/components/dashboard/v2/TaskStrip.vue | 43 +++- .../mission-control/AsyncStatePanel.vue | 241 ++++++++++++++++++ frontend/src/services/api.ts | 14 +- frontend/src/services/browserTelemetry.ts | 12 +- frontend/src/stores/auth.ts | 11 +- frontend/src/views/ActivityView.vue | 45 ++-- frontend/src/views/AgentCreateView.vue | 23 +- frontend/src/views/AgentDetailView.vue | 230 +++++++++++------ .../src/views/AgentProposalDetailView.vue | 22 +- frontend/src/views/AgentsIndexView.vue | 27 +- frontend/src/views/CalendarView.vue | 42 +-- frontend/src/views/Dashboard/FlowBoard.vue | 30 ++- frontend/src/views/DocsView.vue | 80 ++++-- frontend/src/views/IncidentsView.vue | 99 +++++-- frontend/src/views/MemoryView.vue | 120 +++++++-- frontend/src/views/ModelsView.vue | 88 +++---- frontend/src/views/NotificationsView.vue | 31 ++- frontend/src/views/ProjectDetailView.vue | 16 +- frontend/src/views/ProjectsIndexView.vue | 38 +-- frontend/src/views/RunControlView.vue | 42 +-- frontend/src/views/RunDetailView.vue | 24 +- frontend/src/views/SecurityView.vue | 22 +- frontend/src/views/SettingsView.vue | 88 ++++--- frontend/src/views/TaskBoardView.vue | 35 ++- frontend/src/views/TaskDetailView.vue | 25 +- frontend/tests/contracts.test.ts | 71 ++++++ global.json | 7 + 67 files changed, 2616 insertions(+), 601 deletions(-) create mode 100644 .gitleaksignore create mode 100644 Directory.Build.props create mode 100644 backend-tests/HealthControllerTests.cs create mode 100644 backend-tests/ProblemDetailsContractTests.cs create mode 100644 backend/Http/NexusProblemDetails.cs create mode 100644 backend/Http/NexusProblemDetailsSchemaTransformer.cs create mode 100644 backend/Security/BrowserRequestOriginGuard.cs create mode 100644 docs/audits/2026-07-31/stability-v0.2.60/IMPLEMENTATION_AND_ACCEPTANCE.md create mode 100644 frontend/src/components/mission-control/AsyncStatePanel.vue create mode 100644 frontend/tests/contracts.test.ts create mode 100644 global.json diff --git a/.gitea/scripts/deploy-nexus.sh b/.gitea/scripts/deploy-nexus.sh index 25008bb..d4f06fd 100755 --- a/.gitea/scripts/deploy-nexus.sh +++ b/.gitea/scripts/deploy-nexus.sh @@ -125,6 +125,21 @@ docker run --rm \ docker compose --env-file /tmp/nexus-deploy-env ps ' < "$ENV_TMPFILE" +echo "Checking container readiness" +retry=0 +while [ "$retry" -lt 6 ]; do + retry=$((retry + 1)) + if docker exec nexus-api-1 curl -fs --max-time 5 http://localhost:8080/health/ready >/dev/null; then + echo "API container is ready" + break + fi + if [ "$retry" -eq 6 ]; then + echo "API container readiness failed" >&2 + exit 1 + fi + sleep "$retry" +done + echo "Verifying image provenance" for container in nexus-api-1 nexus-web-1; do revision="$(docker inspect --format '{{ index .Config.Labels "org.opencontainers.image.revision" }}' "$container")" @@ -140,9 +155,13 @@ for container in nexus-api-1 nexus-web-1; do echo "$container provenance verified: v$version $revision" done -echo "Checking live health" -health_is_healthy() { - health_body="$(curl -fsS --max-time 10 "$BASE_URL/health")" || return 1 +echo "Checking public readiness and dependency health" +readiness_is_healthy() { + curl -fs --max-time 10 "$BASE_URL/health/ready" >/dev/null +} + +runtime_is_healthy() { + health_body="$(curl -fs --max-time 10 "$BASE_URL/health")" || return 1 printf '%s' "$health_body" | grep -Eq '^[[:space:]]*\{[[:space:]]*"status"[[:space:]]*:[[:space:]]*"Healthy"' } @@ -150,8 +169,8 @@ health_is_healthy() { retry=0 while [ "$retry" -lt 6 ]; do retry=$((retry + 1)) - if health_is_healthy; then - echo "Health check passed with Healthy runtime and database state" + if readiness_is_healthy && runtime_is_healthy; then + echo "Readiness and full dependency health passed" break fi if [ "$retry" -eq 6 ]; then @@ -177,8 +196,8 @@ check() { } check_health() { - if health_is_healthy; then - printf '%-28s HTTP 200 (Healthy)\n' "Health" + if readiness_is_healthy && runtime_is_healthy; then + printf '%-28s HTTP 200 (Ready + Healthy)\n' "Health" pass=$((pass + 1)) else printf '%-28s not healthy\n' "Health" diff --git a/.gitea/workflows/ci.yaml b/.gitea/workflows/ci.yaml index 3b8486d..53c83d9 100644 --- a/.gitea/workflows/ci.yaml +++ b/.gitea/workflows/ci.yaml @@ -35,6 +35,14 @@ jobs: - name: Build run: dotnet build backend-tests/Nexus.Api.Tests.csproj --no-restore --configuration Release + - name: Block high or critical backend vulnerabilities + run: | + dotnet list backend/Nexus.Api.csproj package --vulnerable --include-transitive --format json > /tmp/nexus-dotnet-vulnerabilities.json + if grep -Eiq '"severity"[[:space:]]*:[[:space:]]*"(high|critical)"' /tmp/nexus-dotnet-vulnerabilities.json; then + cat /tmp/nexus-dotnet-vulnerabilities.json + exit 1 + fi + - name: Verify OpenAPI contract run: | test -f backend/openapi/Nexus.Api.json @@ -43,17 +51,36 @@ jobs: - name: Test run: dotnet test backend-tests/Nexus.Api.Tests.csproj --no-build --configuration Release --verbosity normal - - name: Docker integration tests - if: ${{ vars.NEXUS_RUN_DOCKER_INTEGRATION_TESTS == 'true' }} - timeout-minutes: 15 - env: - NEXUS_RUN_DOCKER_INTEGRATION_TESTS: "true" - NEXUS_RUN_TOXIPROXY_INTEGRATION_TESTS: ${{ vars.NEXUS_RUN_TOXIPROXY_INTEGRATION_TESTS }} + backend-integration: + name: Backend integration (PostgreSQL/Toxiproxy) + runs-on: linux + timeout-minutes: 20 + env: + NEXUS_RUN_DOCKER_INTEGRATION_TESTS: "true" + NEXUS_RUN_TOXIPROXY_INTEGRATION_TESTS: "true" + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup .NET SDK + uses: actions/setup-dotnet@v4 + with: + dotnet-version: '10.0.x' + + - name: Verify Docker endpoint + run: docker info + + - name: Restore and build + run: | + dotnet restore backend-tests/Nexus.Api.Tests.csproj + dotnet build backend-tests/Nexus.Api.Tests.csproj --no-restore --configuration Release + + - name: Run all container-backed contracts run: >- dotnet test backend-tests/Nexus.Api.Tests.csproj --no-build --configuration Release - --filter "Category=DockerIntegration" + --filter "Category=DockerIntegration|Category=ToxiproxyIntegration" --verbosity normal # ─── Frontend ────────────────────────────────── @@ -78,6 +105,14 @@ jobs: run: pnpm install --frozen-lockfile working-directory: frontend + - name: Verify release version + run: test "$(node -p "require('./package.json').version")" = "$(tr -d '[:space:]' < ../VERSION)" + working-directory: frontend + + - name: Block high or critical production vulnerabilities + run: pnpm audit --prod --audit-level high + working-directory: frontend + - name: Type check run: pnpm typecheck working-directory: frontend @@ -112,31 +147,22 @@ jobs: steps: - name: Checkout uses: actions/checkout@v4 + with: + fetch-depth: 0 - - name: Check for .env leaks + - name: Gitleaks v8.30.1 + env: + GITLEAKS_SHA256: 551f6fc83ea457d62a0d98237cbad105af8d557003051f41f3e7ca7b3f2470eb run: | - echo "🔍 Scanning for potential secrets in source code..." - HITS=$(grep -rPn "(API_KEY|SECRET|PASSWORD|TOKEN)\s*[:=]\s*['\"][^'\"]{8,}" --include="*.cs" --include="*.ts" --include="*.vue" backend/ frontend/src/ 2>/dev/null || true) - if [ -n "$HITS" ]; then - echo "❌ SECRET LEAK DETECTED — the following lines look like hardcoded credentials:" - echo "$HITS" - echo "" - echo "Remove these values and use environment variables or a secrets manager instead." - exit 1 - fi - # Secondary pass: catch bare assign patterns that are suspicious regardless of length - LOOSE=$(grep -rPn "(API_KEY|SECRET|PASSWORD|TOKEN)\s*[:=]\s*['\"]" --include="*.cs" --include="*.ts" --include="*.vue" backend/ frontend/src/ 2>/dev/null || true) - if [ -n "$LOOSE" ]; then - echo "⚠️ WARNING — potential secrets found (short values may be false positives, review manually):" - echo "$LOOSE" - else - echo "✅ No obvious secrets found" - fi + curl -fsSL https://github.com/gitleaks/gitleaks/releases/download/v8.30.1/gitleaks_8.30.1_linux_x64.tar.gz -o /tmp/gitleaks.tar.gz + echo "$GITLEAKS_SHA256 /tmp/gitleaks.tar.gz" | sha256sum -c - + tar -xzf /tmp/gitleaks.tar.gz -C /tmp gitleaks + /tmp/gitleaks git --redact --no-banner --exit-code 1 . deploy: name: Deploy Nexus runs-on: linux - needs: [backend, frontend, security] + needs: [backend, backend-integration, frontend, security] concurrency: group: deploy-production cancel-in-progress: false diff --git a/.gitea/workflows/rollback.yaml b/.gitea/workflows/rollback.yaml index e3ad95d..b28b0e8 100644 --- a/.gitea/workflows/rollback.yaml +++ b/.gitea/workflows/rollback.yaml @@ -188,8 +188,16 @@ jobs: WAIT=1 while [ $RETRY -lt $MAX ]; do RETRY=$((RETRY + 1)) + READY_CODE=$(curl -s -o /dev/null -w "%{http_code}" --max-time 10 https://nexus.noveria.net/health/ready || true) HEALTH_BODY=$(curl -sf --max-time 10 https://nexus.noveria.net/health || true) - if printf '%s' "$HEALTH_BODY" | grep -Eq '^[[:space:]]*\{[[:space:]]*"status"[[:space:]]*:[[:space:]]*"Healthy"'; then + # Releases before v0.2.60 have no readiness endpoint; retain a + # visible compatibility fallback for emergency rollback only. + if [ "$READY_CODE" = "200" ] || [ "$READY_CODE" = "404" ]; then + READY_OK=true + else + READY_OK=false + fi + if [ "$READY_OK" = "true" ] && printf '%s' "$HEALTH_BODY" | grep -Eq '^[[:space:]]*\{[[:space:]]*"status"[[:space:]]*:[[:space:]]*"Healthy"'; then echo "" echo "✅ Health check passed with Healthy runtime and database state (attempt $RETRY/$MAX)" exit 0 @@ -227,11 +235,12 @@ jobs: } check_health() { - local body + local body ready_code + ready_code=$(curl -s -o /dev/null -w "%{http_code}" --max-time 10 "${BASE}/health/ready" || true) body=$(curl -sf --max-time 10 "${BASE}/health" || true) printf " %-25s" "Health API:" - if printf '%s' "$body" | grep -Eq '^[[:space:]]*\{[[:space:]]*"status"[[:space:]]*:[[:space:]]*"Healthy"'; then - echo " HTTP 200 Healthy ✅" + if { [ "$ready_code" = "200" ] || [ "$ready_code" = "404" ]; } && printf '%s' "$body" | grep -Eq '^[[:space:]]*\{[[:space:]]*"status"[[:space:]]*:[[:space:]]*"Healthy"'; then + echo " ready=${ready_code} full=Healthy ✅" PASS=$((PASS + 1)) else echo " not healthy ❌" diff --git a/.gitleaksignore b/.gitleaksignore new file mode 100644 index 0000000..58f3c64 --- /dev/null +++ b/.gitleaksignore @@ -0,0 +1,3 @@ +b7b44494f07ac95283894e1da7e20e6b63d8f36b:docs/gateway-api-research.md:generic-api-key:29 +b7b44494f07ac95283894e1da7e20e6b63d8f36b:docs/gateway-api-research.md:generic-api-key:333 +eeb6174de0b806b04ffc0dec5353d2ee67f8e36f:.env.example:generic-api-key:6 diff --git a/Directory.Build.props b/Directory.Build.props new file mode 100644 index 0000000..3908988 --- /dev/null +++ b/Directory.Build.props @@ -0,0 +1,6 @@ + + + $([System.IO.File]::ReadAllText('$(MSBuildThisFileDirectory)VERSION').Trim()) + $(NexusVersion) + + diff --git a/README.md b/README.md index 3084572..ded6c21 100644 --- a/README.md +++ b/README.md @@ -44,6 +44,13 @@ isolated from the frontend and the Nexus domain model. > Gitea run 362 deployed commit `144edf58fe5928a3e04816f4435ea574d64211a1`; > PostgreSQL and the OpenClaw HTTP runtime are healthy. Productive Protocol-v4 > management remains intentionally blocked by the external Client-ID gate. +> +> **Stability and recovery v0.2.60 candidate (2026-07-31):** +> [implementation and acceptance](docs/audits/2026-07-31/stability-v0.2.60/IMPLEMENTATION_AND_ACCEPTANCE.md). +> Readiness, browser-origin protection, generated ProblemDetails metadata, +> shared UI recovery states, version provenance and mandatory container-backed +> CI are implemented. The audit separates locally proven behavior from the +> Linux/Docker, deployment, credentialed-production and OpenClaw-write gates. > 📋 **Architektur-Review** (2026-06-22): Board-first Orchestrierung, sichere > Backend-Brücke und Gateway-Integration geprüft. Siehe @@ -73,6 +80,10 @@ isolated from the frontend and the Nexus domain model. - keyset-paginated Task Board with targeted live card reconciliation - structured mutation results with entity references, trace metadata and cross-page frontend deep links +- one generated `ProblemDetails` contract and shared loading, empty, error, + offline, stale and partial presentation across all authenticated views +- separate process liveness, database-backed readiness and full runtime + diagnostics used consistently by deploy and rollback - Responsive dark-mode operations dashboard - Traefik reverse-proxy with Let's Encrypt TLS on `nexus.noveria.net` @@ -96,6 +107,8 @@ cp .env.template .env # BOOTSTRAP_OWNER_EMAIL, BOOTSTRAP_OWNER_PASSWORD and the OpenClaw credential. # Pin OPENCLAW_REQUIRED_VERSION for a production deployment. docker compose up --build -d +curl http://127.0.0.1:18880/health/live +curl http://127.0.0.1:18880/health/ready curl http://127.0.0.1:18880/health ``` @@ -193,11 +206,17 @@ The dashboard prioritizes the live agent topology: - Access tokens expire after 15 minutes and are held only in browser memory. - Refresh tokens are random, stored only as SHA-256 hashes in PostgreSQL, rotated on use and checked for reuse. - The browser receives the refresh token only as a `HttpOnly`, `Secure`, `SameSite=Strict` cookie. +- Cookie-backed refresh and logout reject explicit cross-site browser requests + through `Origin` and `Sec-Fetch-Site` validation. Non-browser API clients + without provenance headers still require a valid refresh cookie and the + normal rate limit. - Login and refresh endpoints are rate-limited per forwarded client IP (5 attempts/minute). - The API uses an authenticated-by-default fallback policy; only the login and recovery flow plus the explicit liveness/Gateway-health probes are anonymous. - Swagger is enabled only in the Development environment. -- CSRF protection via `X-CSRF-TOKEN` header and `nexus-csrf` cookie (not HttpOnly). +- The unused antiforgery-token endpoint was removed. Nexus does not advertise a + token that no mutation validates; strict cookies plus the refresh/logout + origin guard form the browser boundary for those anonymous cookie calls. ### Security @@ -464,8 +483,9 @@ Response-Format (TaskBridgeCommandResponse): | Method | Path | Auth | Description | |---|---|---|---| -| `GET` | `/health` | No | Health check with runtime + PostgreSQL | -| `GET` | `/api/v1/auth/csrf` | No | Get CSRF token | +| `GET` | `/health/live` | No | Process liveness only; no dependency probe | +| `GET` | `/health/ready` | No | `200` only when Nexus can serve through PostgreSQL; otherwise `503` | +| `GET` | `/health` | No | Full diagnostic projection for PostgreSQL and OpenClaw runtime; may return `Degraded` with HTTP 200 so the UI remains available for recovery | | `POST` | `/api/v1/auth/login` | No (rate-limited) | Login with email/password | | `POST` | `/api/v1/auth/refresh` | No (rate-limited) | Refresh access token | | `POST` | `/api/v1/auth/logout` | No | Clear refresh token | @@ -473,6 +493,14 @@ Response-Format (TaskBridgeCommandResponse): | `PATCH` | `/api/v1/auth/profile` | Yes | Update display name | | `POST` | `/api/v1/auth/change-password` | Yes | Change password (min 10 chars) | +Ordinary API failures use `application/problem+json` with a stable `code`, HTTP +`status`, `title`, `detail` and `traceId`. Conflict revisions, operation IDs and +retry delays are included only when applicable. The generated OpenAPI contract +is the frontend source for these fields; mutations are never automatically +retried. Durable operation endpoints retain their typed operation envelope for +states such as `partial`, `failed` and `in_doubt`; those states must remain +inspectable and are not collapsed into a transient HTTP exception. + ### Operations | Method | Path | Description | diff --git a/VERSION b/VERSION index b873c1f..9cc5016 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.2.59 +0.2.60 diff --git a/backend-tests/AuthServiceTests.cs b/backend-tests/AuthServiceTests.cs index 1b9ec05..a9a148e 100644 --- a/backend-tests/AuthServiceTests.cs +++ b/backend-tests/AuthServiceTests.cs @@ -1,6 +1,7 @@ using System.Reflection; using System.Security.Claims; using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Storage; using Microsoft.Extensions.Primitives; using Nexus.Api.Data; using Nexus.Api.DTOs; @@ -209,6 +210,47 @@ public sealed class AuthServiceTests Assert.Equal(originalHash, updated.PasswordHash); } + [Fact] + public async Task RefreshToken_SurvivesServiceRestart_WhenDatabasePersists() + { + var databaseRoot = new InMemoryDatabaseRoot(); + var databaseName = Guid.NewGuid().ToString(); + var configuration = new MemoryConfig(new Dictionary + { + ["Jwt:Key"] = "this-is-a-test-key-that-is-at-least-32-bytes-long!", + ["Jwt:Issuer"] = "nexus-test", + ["Jwt:Audience"] = "nexus-test-web", + }); + var logger = Microsoft.Extensions.Logging.Abstractions.NullLogger.Instance; + + AuthSession initialSession; + await using (var firstDb = new NexusDbContext( + new DbContextOptionsBuilder() + .UseInMemoryDatabase(databaseName, databaseRoot) + .Options)) + { + await SeedUserAsync(firstDb, "restart@example.com", "RestartPassword123!"); + var firstService = new AuthService(new UserRepository(firstDb), configuration, logger); + initialSession = Assert.IsType( + await firstService.LoginAsync(Login("restart@example.com", "RestartPassword123!"))); + } + + await using var restartedDb = new NexusDbContext( + new DbContextOptionsBuilder() + .UseInMemoryDatabase(databaseName, databaseRoot) + .Options); + var restartedService = new AuthService( + new UserRepository(restartedDb), + configuration, + logger); + + var refreshed = await restartedService.RefreshAsync(initialSession.RefreshToken); + + Assert.NotNull(refreshed); + Assert.NotEqual(initialSession.RefreshToken, refreshed.RefreshToken); + Assert.Equal(initialSession.User.Id, refreshed.User.Id); + } + // ══════════════════════════════════════════════════════════════════ // Change Password Tests // ══════════════════════════════════════════════════════════════════ diff --git a/backend-tests/HealthControllerTests.cs b/backend-tests/HealthControllerTests.cs new file mode 100644 index 0000000..9f34e03 --- /dev/null +++ b/backend-tests/HealthControllerTests.cs @@ -0,0 +1,69 @@ +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Diagnostics.HealthChecks; +using Nexus.Api.Controllers; +using Xunit; + +namespace Nexus.Api.Tests; + +public sealed class HealthControllerTests +{ + [Fact] + public async Task Ready_Returns200_WhenRequiredChecksAreHealthy() + { + using var provider = BuildProvider(HealthCheckResult.Healthy()); + var controller = new HealthController( + runtime: null!, + provider.GetRequiredService()); + + var result = await controller.Ready(CancellationToken.None); + + Assert.Equal(StatusCodes.Status200OK, Assert.IsAssignableFrom(result).StatusCode); + } + + [Fact] + public async Task Ready_Returns503_WhenRequiredCheckIsUnhealthy() + { + using var provider = BuildProvider(HealthCheckResult.Unhealthy("database unavailable")); + var controller = new HealthController( + runtime: null!, + provider.GetRequiredService()); + + var result = await controller.Ready(CancellationToken.None); + + Assert.Equal( + StatusCodes.Status503ServiceUnavailable, + Assert.IsAssignableFrom(result).StatusCode); + } + + [Fact] + public async Task Ready_Returns503_WhenPostgreSqlCannotBeReached() + { + var services = new ServiceCollection(); + services.AddLogging(); + services.AddHealthChecks() + .AddNpgSql( + "Host=127.0.0.1;Port=1;Database=nexus;Username=nexus;Password=unused;Timeout=1;Command Timeout=1", + name: "postgresql", + tags: ["database", "ready"]); + using var provider = services.BuildServiceProvider(); + var controller = new HealthController( + runtime: null!, + provider.GetRequiredService()); + + var result = await controller.Ready(CancellationToken.None); + + Assert.Equal( + StatusCodes.Status503ServiceUnavailable, + Assert.IsAssignableFrom(result).StatusCode); + } + + private static ServiceProvider BuildProvider(HealthCheckResult result) + { + var services = new ServiceCollection(); + services.AddLogging(); + services.AddHealthChecks() + .AddCheck("required", () => result, tags: ["ready"]); + return services.BuildServiceProvider(); + } +} diff --git a/backend-tests/ProblemDetailsContractTests.cs b/backend-tests/ProblemDetailsContractTests.cs new file mode 100644 index 0000000..4190c1f --- /dev/null +++ b/backend-tests/ProblemDetailsContractTests.cs @@ -0,0 +1,92 @@ +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Nexus.Api.Http; +using Xunit; + +namespace Nexus.Api.Tests; + +public sealed class ProblemDetailsContractTests +{ + [Theory] + [InlineData(400, NexusProblemCodes.ValidationFailed)] + [InlineData(401, NexusProblemCodes.Unauthenticated)] + [InlineData(403, NexusProblemCodes.Forbidden)] + [InlineData(409, NexusProblemCodes.Conflict)] + [InlineData(429, NexusProblemCodes.RateLimited)] + [InlineData(503, NexusProblemCodes.DependencyUnavailable)] + [InlineData(504, NexusProblemCodes.Timeout)] + [InlineData(500, NexusProblemCodes.InternalError)] + public void Defaults_AddStableCodeAndTraceId(int status, string expectedCode) + { + var context = new DefaultHttpContext { TraceIdentifier = "trace-contract" }; + var problem = new ProblemDetails { Status = status }; + + NexusProblemDetailsDefaults.Apply(problem, context); + + Assert.Equal(expectedCode, problem.Extensions["code"]); + Assert.Equal("trace-contract", problem.Extensions["traceId"]); + Assert.False(string.IsNullOrWhiteSpace(problem.Title)); + Assert.False(string.IsNullOrWhiteSpace(problem.Type)); + } + + [Fact] + public void Defaults_PreserveExplicitMachineCode() + { + var context = new DefaultHttpContext { TraceIdentifier = "trace-contract" }; + var problem = new ProblemDetails { Status = 409 }; + problem.Extensions["code"] = "config_hash_conflict"; + + NexusProblemDetailsDefaults.Apply(problem, context); + + Assert.Equal("config_hash_conflict", problem.Extensions["code"]); + } + + [Fact] + public void LegacyError_IsConvertedWithoutLosingTheStableContract() + { + var context = new DefaultHttpContext { TraceIdentifier = "trace-legacy" }; + + var problem = NexusProblemDetailsDefaults.FromLegacyError( + new { error = "Task not found." }, + StatusCodes.Status404NotFound, + context); + + Assert.NotNull(problem); + Assert.Equal("Task not found.", problem.Detail); + Assert.Equal(NexusProblemCodes.NotFound, problem.Extensions["code"]); + Assert.Equal("trace-legacy", problem.Extensions["traceId"]); + } + + [Fact] + public void LegacySuccessPayload_IsNotConverted() + { + var context = new DefaultHttpContext(); + + var problem = NexusProblemDetailsDefaults.FromLegacyError( + new { error = "informational field" }, + StatusCodes.Status200OK, + context); + + Assert.Null(problem); + } + + [Fact] + public void SharedResult_UsesProblemJsonAndPreservesTypedExtensions() + { + var result = NexusHttpResults.Problem( + StatusCodes.Status409Conflict, + "The agent file changed.", + extensions: new Dictionary + { + ["currentHash"] = "hash-current" + }); + + Assert.Equal( + "application/problem+json", + Assert.IsAssignableFrom(result).ContentType); + var problem = Assert.IsType( + Assert.IsAssignableFrom(result).Value); + Assert.Equal(NexusProblemCodes.Conflict, problem.Extensions["code"]); + Assert.Equal("hash-current", problem.Extensions["currentHash"]); + } +} diff --git a/backend-tests/SecurityBoundaryTests.cs b/backend-tests/SecurityBoundaryTests.cs index 3fd98b6..411b9a4 100644 --- a/backend-tests/SecurityBoundaryTests.cs +++ b/backend-tests/SecurityBoundaryTests.cs @@ -10,6 +10,7 @@ using Microsoft.Extensions.Options; using Nexus.Api.Controllers; using Nexus.Api.Extensions; using Nexus.Api.Middleware; +using Nexus.Api.Security; using Nexus.Api.Services; using Xunit; @@ -90,7 +91,6 @@ public sealed class SecurityBoundaryTests } [Theory] - [InlineData(nameof(AuthController.GetCsrfToken))] [InlineData(nameof(AuthController.Login))] [InlineData(nameof(AuthController.Refresh))] [InlineData(nameof(AuthController.Logout))] @@ -104,6 +104,7 @@ public sealed class SecurityBoundaryTests [Theory] [InlineData(nameof(HealthController.Live))] + [InlineData(nameof(HealthController.Ready))] [InlineData(nameof(HealthController.Get))] public void PublicHealthEndpoints_AreExplicitlyAnonymous(string methodName) { @@ -123,6 +124,34 @@ public sealed class SecurityBoundaryTests Assert.Empty(method!.GetCustomAttributes(typeof(AllowAnonymousAttribute), inherit: true)); } + [Fact] + public void CookieBackedAuth_AllowsSameOriginBrowserRequests() + { + var context = new DefaultHttpContext(); + context.Request.Scheme = "https"; + context.Request.Host = new HostString("nexus.noveria.net"); + context.Request.Headers.Origin = "https://nexus.noveria.net"; + context.Request.Headers["Sec-Fetch-Site"] = "same-origin"; + + Assert.True(BrowserRequestOriginGuard.IsAllowed(context.Request)); + } + + [Theory] + [InlineData("https://attacker.invalid", "cross-site")] + [InlineData("https://attacker.invalid", "")] + [InlineData("not-an-origin", "same-origin")] + public void CookieBackedAuth_RejectsExplicitCrossSiteRequests(string origin, string fetchSite) + { + var context = new DefaultHttpContext(); + context.Request.Scheme = "https"; + context.Request.Host = new HostString("nexus.noveria.net"); + context.Request.Headers.Origin = origin; + if (fetchSite.Length > 0) + context.Request.Headers["Sec-Fetch-Site"] = fetchSite; + + Assert.False(BrowserRequestOriginGuard.IsAllowed(context.Request)); + } + [Fact] public async Task AgentIdentityHeader_IsRejectedWithoutVerifiedAuthentication() { diff --git a/backend/Controllers/AdminController.cs b/backend/Controllers/AdminController.cs index bea7da1..d7b3f78 100644 --- a/backend/Controllers/AdminController.cs +++ b/backend/Controllers/AdminController.cs @@ -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)) diff --git a/backend/Controllers/AgentsController.cs b/backend/Controllers/AgentsController.cs index 1094a81..6e9724f 100644 --- a/backend/Controllers/AgentsController.cs +++ b/backend/Controllers/AgentsController.cs @@ -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 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 + { + ["content"] = ["Content is required."] + }); if (string.IsNullOrWhiteSpace(request.ExpectedHash)) - return Results.BadRequest(new { error = "ExpectedHash is required." }); + return Results.ValidationProblem(new Dictionary + { + ["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 + { + ["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 { - code = ex.Code, - message = ex.Message, - ex.ExpectedHash, - ex.CurrentHash - }, - statusCode: StatusCodes.Status409Conflict); + ["expectedHash"] = ex.ExpectedHash, + ["currentHash"] = ex.CurrentHash + }); } catch (OpenClawAgentConfigurationValidationException ex) { diff --git a/backend/Controllers/AuthController.cs b/backend/Controllers/AuthController.cs index c614d38..6dbf418 100644 --- a/backend/Controllers/AuthController.cs +++ b/backend/Controllers/AuthController.cs @@ -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 + { + ["code"] = NexusProblemCodes.Unauthenticated, + ["remaining"] = remaining, + ["retryAfterSeconds"] = retryAfterSeconds + }); } // Success — reset attempt counter @@ -75,14 +69,17 @@ public class AuthController( [EnableRateLimiting("auth")] public async Task 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 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 + { + ["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 + { + ["code"] = NexusProblemCodes.Forbidden + }); + private void SetRefreshCookie(HttpResponse response, string token) { var days = config.GetValue("Jwt:RefreshTokenExpirationDays") ?? 7; diff --git a/backend/Controllers/HealthController.cs b/backend/Controllers/HealthController.cs index 2338257..7752365 100644 --- a/backend/Controllers/HealthController.cs +++ b/backend/Controllers/HealthController.cs @@ -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 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 Get(CancellationToken ct) diff --git a/backend/Controllers/OpenClawContentReadEndpoint.cs b/backend/Controllers/OpenClawContentReadEndpoint.cs index 5979486..069eb0d 100644 --- a/backend/Controllers/OpenClawContentReadEndpoint.cs +++ b/backend/Controllers/OpenClawContentReadEndpoint.cs @@ -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 + { + ["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 + { + ["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 + { + ["state"] = code.ToLowerInvariant() + }); } } } diff --git a/backend/Dockerfile b/backend/Dockerfile index 920756d..f51ef79 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -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 diff --git a/backend/Extensions/PlatformServiceCollectionExtensions.cs b/backend/Extensions/PlatformServiceCollectionExtensions.cs index a0cbf46..bbb01ce 100644 --- a/backend/Extensions/PlatformServiceCollectionExtensions.cs +++ b/backend/Extensions/PlatformServiceCollectionExtensions.cs @@ -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()); 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() diff --git a/backend/Extensions/ServiceCollectionExtensions.cs b/backend/Extensions/ServiceCollectionExtensions.cs index 5d442ab..1962e80 100644 --- a/backend/Extensions/ServiceCollectionExtensions.cs +++ b/backend/Extensions/ServiceCollectionExtensions.cs @@ -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 { /// - /// Configures JWT authentication, authorization, and antiforgery. + /// Configures JWT authentication and authorization. /// 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 /// public static IServiceCollection AddNexusApplicationServices( this IServiceCollection services, - bool includeHostedServices = true) + bool includeHostedServices = true, + bool includeMcp = true) { - services.AddMcpServer() - .WithHttpTransport(options => options.Stateless = true) - .WithTools(); + if (includeMcp) + { + services.AddMcpServer() + .WithHttpTransport(options => options.Stateless = true) + .WithTools(); + } services.AddOptions() .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; diff --git a/backend/Http/NexusProblemDetails.cs b/backend/Http/NexusProblemDetails.cs new file mode 100644 index 0000000..487f2ff --- /dev/null +++ b/backend/Http/NexusProblemDetails.cs @@ -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? extensions = null) + { + var values = extensions is null + ? new Dictionary() + : new Dictionary(extensions); + values.TryAdd("code", code ?? NexusProblemCodes.ForStatus(statusCode)); + + return Results.Problem( + statusCode: statusCode, + title: title, + detail: detail, + extensions: values); + } +} + +/// +/// Ensures MVC-produced ProblemDetails values use the same extensions as the +/// global exception and status-code writers. +/// +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) + { + } +} diff --git a/backend/Http/NexusProblemDetailsSchemaTransformer.cs b/backend/Http/NexusProblemDetailsSchemaTransformer.cs new file mode 100644 index 0000000..aa238c9 --- /dev/null +++ b/backend/Http/NexusProblemDetailsSchemaTransformer.cs @@ -0,0 +1,59 @@ +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.OpenApi; +using Microsoft.OpenApi; + +namespace Nexus.Api.Http; + +/// +/// Publishes the stable Nexus ProblemDetails extensions in the generated +/// OpenAPI contract so the frontend does not have to guess error metadata. +/// +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(); + 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(); + 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 + }; +} diff --git a/backend/Program.cs b/backend/Program.cs index bd2f8eb..64f920c 100644 --- a/backend/Program.cs +++ b/backend/Program.cs @@ -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()); 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(); diff --git a/backend/Security/BrowserRequestOriginGuard.cs b/backend/Security/BrowserRequestOriginGuard.cs new file mode 100644 index 0000000..301f3c0 --- /dev/null +++ b/backend/Security/BrowserRequestOriginGuard.cs @@ -0,0 +1,37 @@ +namespace Nexus.Api.Security; + +/// +/// 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. +/// +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; +} diff --git a/backend/openapi/Nexus.Api.json b/backend/openapi/Nexus.Api.json index 55d10e2..0443c1c 100644 --- a/backend/openapi/Nexus.Api.json +++ b/backend/openapi/Nexus.Api.json @@ -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." } } }, diff --git a/compose.yaml b/compose.yaml index aa963c7..e9f3fcb 100644 --- a/compose.yaml +++ b/compose.yaml @@ -74,10 +74,10 @@ services: - host.docker.internal:host-gateway depends_on: postgres: - condition: service_started + condition: service_healthy restart: true healthcheck: - test: ["CMD-SHELL", "wget --no-verbose --tries=1 --spider http://localhost:8080/health/live || exit 1"] + test: ["CMD-SHELL", "wget --no-verbose --tries=1 --spider http://localhost:8080/health/ready || exit 1"] interval: 30s timeout: 10s retries: 3 @@ -115,7 +115,7 @@ services: - "127.0.0.1:18880:80" depends_on: api: - condition: service_started + condition: service_healthy restart: true healthcheck: test: ["CMD-SHELL", "curl -f http://localhost:80/ || exit 1"] diff --git a/docs/AGENT_FIRST_MISSION_CONTROL.md b/docs/AGENT_FIRST_MISSION_CONTROL.md index 60993e4..7100b77 100644 --- a/docs/AGENT_FIRST_MISSION_CONTROL.md +++ b/docs/AGENT_FIRST_MISSION_CONTROL.md @@ -1,6 +1,6 @@ # Nexus Agent-First Mission Control -**Status:** Zielvertrag mit produktiv ausgeliefertem v0.2.59-Control-Plane-Slice; Gateway-Writes, credentialed Live-Abnahme und Lastnachweis offen +**Status:** Zielvertrag mit produktivem v0.2.59-Control-Plane-Stand und implementiertem v0.2.60-Stabilitätskandidaten; Linux-/Docker-Releasegate, Gateway-Writes, credentialed Live-Abnahme und Lastnachweis offen **Stand:** 2026-07-31 **Geltungsbereich:** Produkt, Frontend, Backend, Runtime-Adapter und Agenten-Schnittstellen diff --git a/docs/MISSION_CONTROL_ROADMAP.md b/docs/MISSION_CONTROL_ROADMAP.md index f1270b5..237dd62 100644 --- a/docs/MISSION_CONTROL_ROADMAP.md +++ b/docs/MISSION_CONTROL_ROADMAP.md @@ -129,6 +129,31 @@ Produktionscheckpoint v0.2.59 vom 2026-07-31: Die vollständige Evidenz und der daraus abgeleitete Plan stehen in [Production release v0.2.59](audits/2026-07-31/production-release-v0.2.59/PRODUCTION_VALIDATION_AND_NEXT_PLAN.md). +Stabilitätscheckpoint v0.2.60 (Release Candidate) vom 2026-07-31: + +- `/health/live`, `/health/ready` und `/health` trennen Prozesszustand, + PostgreSQL-Readiness und vollständige OpenClaw-Diagnose. Deploy und Rollback + verwenden dieselben Gates; ein OpenClaw-Ausfall hält die Recovery-Oberfläche + erreichbar. +- Die ungenutzte Antiforgery-Route ist entfernt. Refresh und Logout behalten + das strikte Secure-/HttpOnly-Cookie und weisen explizite Cross-Site-Browser- + Aufrufe anhand von `Origin` und `Sec-Fetch-Site` ab. +- OpenAPI beschreibt den gemeinsamen `ProblemDetails`-Vertrag einschließlich + stabilem Code und Trace-ID. Alle 20 authentifizierten Views verwenden die + gemeinsame Loading-/Empty-/Error-/Offline-/Stale-/Partial-Schicht und + erhalten sichtbare Daten bei Background-Refresh. +- `global.json`, `VERSION`-Parität, OCI-Provenienz, Browsertelemetrie-Schalter, + High/Critical-Abhängigkeitsgates, checksum-verifiziertes Gitleaks und ein + verpflichtender PostgreSQL-/Toxiproxy-CI-Job bilden den neuen Releasevertrag. +- Lokal bestehen 383 nicht-containerisierte Backendtests, 42 Frontendtests, + Typecheck, Produktionsbuild und 26 kontrollierte Playwright-Tests. Die fünf + Containerfälle sind lokal mangels Docker übersprungen und müssen im Linux-CI + mit null Skips bestehen. Credentialed Produktion, Task-Board-Last und echte + OpenClaw-Schreibpfade bleiben eigene Gates. + +Die genaue Implementierungs- und Evidenzgrenze steht in +[Stability v0.2.60](audits/2026-07-31/stability-v0.2.60/IMPLEMENTATION_AND_ACCEPTANCE.md). + ## 4. Prioritätsdefinition - **P0:** Blockiert sicheren End-to-End-Betrieb oder das Kernversprechen @@ -450,6 +475,14 @@ regressionsfest. - Last-, Reconnect-, Timeout-, Queue-, Recovery- und Chaos-Szenarien prüfen. - Runbooks, Backup/Restore und Rollback regelmäßig als Game Day testen. +**Checkpoint 2026-07-31:** Das v0.2.60-Stabilitätsfundament implementiert +separate Readiness, einen generierten Fehlervertrag, gemeinsame UI-Recovery, +ein SDK-/Versionsgate, Supply-Chain-Scans und verpflichtende Containerverträge. +Der kontrollierte Browserlauf deckt alle 20 authentifizierten Views und fünf +Breiten ab. Offen bleiben der grüne Linux-/Docker-Run, credentialed +Produktionsprüfung, der 1.000/10.000-Task-Board-Lastnachweis sowie reale +OpenClaw-/OpenAI-End-to-End-Evidenz. + **Abnahme:** Ein Release ist nur möglich, wenn technische Checks und ein repräsentativer realer Agenten-Workflow gemeinsam grün sind. diff --git a/docs/QA_AUTOMATION.md b/docs/QA_AUTOMATION.md index 82b3ff7..51753cc 100644 --- a/docs/QA_AUTOMATION.md +++ b/docs/QA_AUTOMATION.md @@ -10,6 +10,44 @@ OpenClaw, PostgreSQL, browser, or load-test boundary. Archive the command, versions, sanitized output, dataset provenance, and timestamp for every acceptance run. +## Release CI gates + +Every push now runs four independent pre-deployment jobs: + +- the normal .NET 10 build and test suite plus a High/Critical NuGet + vulnerability gate; +- a mandatory Linux runner job with both + `NEXUS_RUN_DOCKER_INTEGRATION_TESTS=true` and + `NEXUS_RUN_TOXIPROXY_INTEGRATION_TESTS=true`; +- frontend version parity, High/Critical production dependency audit, + typecheck, generated OpenAPI drift, unit tests, production build and the full + Playwright route matrix; and +- a full-history Gitleaks v8.30.1 scan downloaded from the upstream release and + checked against its pinned SHA-256 before execution. + +Deployment depends on all four jobs. A missing Docker endpoint is therefore a +failing integration job, not a successful skip. Local runs without Docker may +still show five explicit skips, but they are not release acceptance evidence. +The checked-in `.gitleaksignore` contains only exact fingerprints for reviewed +historical findings; it is not a pattern-based bypass for new secrets. + +The repository SDK contract is `global.json`: .NET `10.0.100` with +`latestFeature` roll-forward. `VERSION` is the release source of truth; +frontend package version and OCI image labels are checked against it. + +## Browser route and recovery gate + +The fixture Playwright profile sets `VITE_BROWSER_TELEMETRY_ENABLED=false`, so +expected telemetry proxy failures cannot mask application regressions. +Production container builds enable the allow-listed browser metrics explicitly. + +The route suite covers Login plus all 20 authenticated views at 375, 768, 1024, +1440 and 1920 px. It also checks deep links, shared-query deduplication, one +targeted resync after an SSE sequence gap, retained Task Board content during a +refresh, Done pagination, drag-and-drop, and distinguishable dependency +outage/retry recovery. These are controlled browser contracts, not a +credentialed production or real OpenClaw acceptance run. + ## Task Board load gate The full k6 profile holds ten virtual users for two minutes and fails when: diff --git a/docs/SECURITY_SPOT_CHECK_2026-07-26.md b/docs/SECURITY_SPOT_CHECK_2026-07-26.md index 1ebbb58..0bf682b 100644 --- a/docs/SECURITY_SPOT_CHECK_2026-07-26.md +++ b/docs/SECURITY_SPOT_CHECK_2026-07-26.md @@ -1,5 +1,11 @@ # Nexus – Security Spot Check +> **Historische Evidenz:** Diese Datei hält den Stand vom 2026-07-26 fest. Der +> v0.2.60-Stabilitätsslice entfernte die ungenutzte CSRF-Tokenroute, ergänzte +> Browser-Origin-Prüfungen für Refresh/Logout und ersetzte den einfachen CI-Grep +> durch checksum-gepinntes Full-History-Gitleaks. Aktuelle Abnahmegrenzen stehen +> in `docs/audits/2026-07-31/stability-v0.2.60/IMPLEMENTATION_AND_ACCEPTANCE.md`. + **Datum:** 2026-07-26 **Commit:** `3bc7622977f4a6c2f2e98ab4aa856a2e45c3cf49` **Vertraulichkeit:** Intern diff --git a/docs/architecture-board-first-orchestration.md b/docs/architecture-board-first-orchestration.md index f68f8eb..cac5d0f 100644 --- a/docs/architecture-board-first-orchestration.md +++ b/docs/architecture-board-first-orchestration.md @@ -184,7 +184,9 @@ Ebene 4: X-Agent-Id Header (Agent-Identität für Task-State-Enforcement) - JWT-Sicherheit entspricht Best Practices (PBKDF2-SHA256, 210k Iterationen, Rotating Refresh Tokens) - Refresh-Token-Reuse-Detection verhindert Token-Theft - Rate-Limiting auf Login und Refresh -- CSRF-Protection via `X-CSRF-TOKEN` + `nexus-csrf` Cookie +- Cookie-backed Refresh/Logout über `Secure`, `HttpOnly`, `SameSite=Strict` + plus `Origin`-/`Sec-Fetch-Site`-Prüfung; die unvalidierte Legacy-CSRF-Route + wurde in v0.2.60 entfernt - Security Headers (HSTS, CSP, XFO, Referrer-Policy) **Kritisch:** diff --git a/docs/audits/2026-07-31/stability-v0.2.60/IMPLEMENTATION_AND_ACCEPTANCE.md b/docs/audits/2026-07-31/stability-v0.2.60/IMPLEMENTATION_AND_ACCEPTANCE.md new file mode 100644 index 0000000..87f709b --- /dev/null +++ b/docs/audits/2026-07-31/stability-v0.2.60/IMPLEMENTATION_AND_ACCEPTANCE.md @@ -0,0 +1,188 @@ +# Nexus v0.2.60 — Stabilität und Recovery + +**Stand:** 2026-07-31 + +**Status:** Release Candidate; lokale Verträge grün, Linux-/Docker-CI und +Produktionsdeployment noch auszuführen + +**Scope:** Nexus-Repository und kontrollierte Browser-Fixtures. Keine +produktive OpenClaw-Mutation; Maxis Ressourcen lagen vollständig außerhalb des +Prüfbereichs. + +## Ergebnis + +Der erste Stabilitäts- und Fehlerabbau-Slice ist implementiert. Nexus trennt +jetzt Prozess-Liveness, Datenbank-Readiness und vollständige Runtime-Diagnose, +liefert einen generierten gemeinsamen Fehlervertrag und zeigt auf allen 20 +authentifizierten Views einheitliche Loading-, Empty-, Error-, Offline-, Stale- +und Partial-Zustände. Releaseversion, Containerprovenienz, Dependency- und +Secret-Scans sowie die echten PostgreSQL-/Toxiproxy-Verträge sind Teil der +CI-Freigabe. + +Das ist noch keine vollständige Produktions- oder OpenClaw-Abnahme. Der +credentialed Owner-Audit, Task-Board-Lastnachweis und jeder produktive +OpenClaw-Schreibvorgang bleiben getrennte Gates. + +## Implementierter Vertrag + +### Health, Deployment und Version + +- `GET /health/live` ist ein reiner Prozesscheck. +- `GET /health/ready` prüft ausschließlich Pflichtabhängigkeiten mit dem Tag + `ready`; PostgreSQL-Ausfall liefert HTTP 503. +- `GET /health` bleibt die vollständige Diagnose. Ein OpenClaw-Ausfall ergibt + `Degraded`, ohne die Nexus-Recovery-Oberfläche durch Readiness zu sperren. +- Der API-Container wird über `/health/ready` geprüft. Web wartet auf einen + gesunden API-Container; API wartet auf gesundes PostgreSQL. +- Deployment prüft zuerst die lokale Container-Readiness, danach öffentliche + Readiness und den vollständigen Runtimezustand. Rollback verwendet dieselben + Gates und akzeptiert HTTP 404 für `/health/ready` nur bei einem sichtbaren + Notfall-Rollback auf Versionen vor v0.2.60. +- `global.json` verlangt .NET `10.0.100` mit `latestFeature`-Roll-forward. + `VERSION` ist die Releasequelle; Frontendpaket und OCI-Labels werden dagegen + geprüft. Images tragen Version und exakten Git-SHA. + +### Auth und Browsergrenze + +- Die ungenutzte `/api/v1/auth/csrf`-Route und Antiforgery-Registrierung sind + entfernt. Kein Client sendete das Token und keine Mutation validierte es. +- Das Refresh-Cookie bleibt `Secure`, `HttpOnly` und `SameSite=Strict`. +- Refresh und Logout weisen explizite Cross-Site-Browseraufrufe anhand von + `Origin` und `Sec-Fetch-Site` ab. API-Clients ohne Browser-Provenienzheader + benötigen weiterhin das gültige Cookie und unterliegen den normalen Limits. +- Login-, Refresh- und Rate-Limit-Fehler verwenden denselben strukturierten + Problemvertrag. +- Nach einem fehlgeschlagenen Refresh leitet das Frontend genau einmal zum + Login um und erhält das Rückkehrziel. +- Ein persistierter Refresh-Hash bleibt über eine neu erzeugte Serviceinstanz + verwendbar und wird anschließend rotiert. Ein echter Containerneustart wird + zusätzlich im produktionsnahen Releaseprofil geprüft. + +### Gemeinsame Fehler- und Recovery-Schicht + +Backendfehler verwenden `application/problem+json` mit: + +- `code`, `status`, `title`, `detail`, `traceId`; +- optional `operationId`, `currentRevision`, `retryAfterSeconds`, `remaining`; +- den stabilen Codes `validation_failed`, `unauthenticated`, `forbidden`, + `conflict`, `not_found`, `unsupported_capability`, + `dependency_unavailable`, `timeout`, `rate_limited` und `internal_error`. + +Durable Agent-Proposal-, Run- und andere Operationsendpunkte behalten ihre +typisierten Envelopes für Zustände wie `partial`, `failed` und `in_doubt`. +Diese Zustände müssen dauerhaft untersuchbar bleiben und werden nicht als +flüchtige HTTP-Ausnahme versteckt. Der Frontendadapter normalisiert während der +Kompatibilitätsphase zusätzlich ältere `message`-/`error`-Payloads. + +Ein OpenAPI-Schema-Transformer beschreibt diese Erweiterungen im +eingecheckten 3.1-Vertrag. Der generierte TypeScript-Client speist `AppProblem` +und `toAppProblem`; Views raten die Transportstruktur nicht selbst. + +`AsyncStatePanel` stellt Loading, Empty, Error, Offline, Stale und Partial +semantisch dar, zeigt nur technische Trace-/Operation-Metadaten und bietet die +passende Recovery-Aktion. Sichere GETs dürfen begrenzt wiederholt werden; +Mutationen werden nie automatisch wiederholt. Sichtbare Daten bleiben bei +Background-Refresh oder einem Fehler einer sekundären Detailabfrage erhalten. + +Migriert wurden: + +- Dashboard und Task Strip; +- Agents, Agent Detail, Agent Create und Proposal Detail; +- Projects, Project Detail, Task Board und Task Detail; +- Run Control und Run Detail; +- Calendar, Memory, Docs und Incidents; +- Models, Activity, Notifications, Security und Settings. + +Memory, Docs und Incidents verlieren ihre bereits sichtbare Liste nicht mehr, +wenn nur eine Detail- oder Suchabfrage fehlschlägt. Agent Detail blockiert die +Primäransicht nicht länger wegen einer fehlerhaften Sekundärabfrage. + +### CI und Supply Chain + +- Ein separater verpflichtender Linux-Job führt alle als + `DockerIntegration` oder `ToxiproxyIntegration` markierten Tests mit beiden + Opt-ins aus. Fehlendes Docker ist ein Jobfehler, kein Skip. +- Frontend und Backend blockieren High/Critical-Produktionsabhängigkeiten. +- Gitleaks `8.30.1` wird als Upstream-Artefakt geladen, über einen gepinnten + SHA-256 geprüft und gegen die vollständige Git-Historie ausgeführt. +- Drei überprüfte historische Fingerprints sind exakt baselined; die aktuelle + Arbeitskopie ist redigiert. Eine eventuelle Credential-Rotation oder + History-Rewrite bleibt ein gesonderter, ausdrücklich freizugebender + Sicherheitsvorgang. +- Das Fixture-Playwright-Profil deaktiviert Browsertelemetrie. Das + Produktionsimage aktiviert nur den allow-listeten bestehenden + Browsermetrikpfad über `VITE_BROWSER_TELEMETRY_ENABLED=true`. + +## Lokale Abnahme + +| Gate | Ergebnis | +|---|---| +| Frontend Typecheck | grün | +| Frontend Unit Tests | 13 Dateien, 42 Tests bestanden | +| Frontend Production Build | grün | +| Playwright | 26/26 bestanden | +| Geschützte Routen | alle 20 in kontrollierten Fixtures geprüft | +| Viewports | 375, 768, 1024, 1440 und 1920 px ohne Seitenoverflow | +| Browserverträge | Deep Links, Query-Deduplizierung, ein SSE-Resync, Task-Board-Refresh, Done-Pagination, Drag-and-drop und 503-Recovery grün | +| Backend | 383 bestanden; fünf Containerfälle mangels lokalem Docker explizit übersprungen | +| PostgreSQL-Ausfall | echter Npgsql-Healthcheck gegen einen nicht erreichbaren Endpoint liefert im Readiness-Vertrag 503 | +| Version | `VERSION` und Frontendpaket beide `0.2.60`; .NET-Pin wird als 10.0.101 aus dem erlaubten Feature-Band aufgelöst | +| Abhängigkeiten | keine bekannten pnpm-Produktionslücken; keine High/Critical-NuGet-Funde | +| Compose/Workflows | Compose valide, Deploy-Shell syntaktisch valide, CI- und Rollback-YAML parsebar | +| OpenAPI | neu generiert; entfernte CSRF-Route, neue Readiness-Route und Problemfelder enthalten | + +Playwright arbeitet mit kontrollierten API-Fixtures. Diese Ergebnisse beweisen +die UI- und Transportverträge, nicht die echte Produktionsdatenqualität oder +OpenClaw-/OpenAI-Ausführung. + +## Offene Abnahmegates + +### Vor Deployment + +1. Der neue Gitea-Linuxjob muss alle fünf vorhandenen PostgreSQL-/Toxiproxy- + Containerfälle bestehen, null überspringen und Docker als erreichbar melden. +2. Der Gitleaks-Vollhistorienjob, OpenAPI-Diff, Dependency-Gates und die übrigen + Backend-/Frontendjobs müssen grün sein. +3. Das versionierte Image muss mit SHA-Provenienz gebaut und durch dieselben + Readiness-/Diagnosegates deployt werden. + +### Nach Deployment, ohne Owner-Sitzung + +- `/health/live`, `/health/ready`, `/health`, `/login`, Security-Header und ein + unauthentifizierter geschützter API-Aufruf werden öffentlich geprüft. +- Die Produktion muss `v0.2.60` und den ausgelieferten Git-SHA melden. + +### Mit separater Owner-Sitzung + +- Alle 20 authentifizierten Produktionsviews werden read-only auf Navigation, + Requests, Konsole, Refresh/Reload/Logout, echte Agent-/Cron-/Modell-/Security- + Daten, SSE-Freshness, Tastatur und Overflow geprüft. +- Kein OpenClaw-Schreibtest wird dabei ausgeführt. + +### Noch nicht Teil dieses Release-Slices + +- Task Board mit 1.000 Tasks und 10.000 Activities: k6-p95, + SQL-Statementzählung, `EXPLAIN (ANALYZE, BUFFERS)` und wiederholte + Navigation-bis-Karten-sichtbar-Messung. +- Produktive Protocol-v4-Verbindung und Management. OpenClaw `2026.7.1` + registriert weiterhin keine offiziell unterstützte externe Nexus-/Generic- + Operator-ID. Die neuen offiziellen Seiten zu + [Gateway clients](https://docs.openclaw.ai/gateway/clients) und + [external apps](https://docs.openclaw.ai/gateway/external-apps) ändern den + geschlossenen Connect-Schema-/Client-ID-Vertrag noch nicht. +- Ein echter `Nexus -> OpenClaw -> OpenAI -> Nexus`-Run, Wegwerf-Cron oder + Testagent. Diese Mutationen benötigen nach erfülltem Identity-Gate eine + separate Owner-Freigabe. + +## Nächste Reihenfolge + +1. Linux-/Docker-CI und Deployment-Smoke für v0.2.60 abschließen. +2. Bao meldet sich im In-App-Browser als Owner an; danach 20 Seiten read-only + auditieren und jeden Fund mit Route, API, Trace-ID und Regressionstest + erfassen. +3. Task-Board-Datensatz und Last-/SQL-/Browserbudgets isoliert beweisen; nur + gemessene Engpässe optimieren. +4. OpenClaw-Client-ID auf dem ersten kompatiblen Stable-Tag erneut prüfen und + erst dann read-only pairen. +5. Danach Run Explorer, Agent Lifecycle, Notifications/Incidents, Calendar, + Knowledge und Security in der bestehenden Roadmap-Reihenfolge schließen. diff --git a/frontend/Dockerfile b/frontend/Dockerfile index cf3b0c6..1b0918b 100644 --- a/frontend/Dockerfile +++ b/frontend/Dockerfile @@ -1,4 +1,9 @@ FROM node:24-alpine AS build +ARG NEXUS_VERSION=dev +ARG NEXUS_GIT_SHA=unknown +ENV VITE_BUILD_VERSION=${NEXUS_VERSION} +ENV VITE_BUILD_SHA=${NEXUS_GIT_SHA} +ENV VITE_BROWSER_TELEMETRY_ENABLED=true WORKDIR /app RUN corepack enable COPY package.json pnpm-lock.yaml* ./ diff --git a/frontend/e2e/route-smoke.e2e.ts b/frontend/e2e/route-smoke.e2e.ts index 0bc2c1c..f19cb50 100644 --- a/frontend/e2e/route-smoke.e2e.ts +++ b/frontend/e2e/route-smoke.e2e.ts @@ -131,6 +131,33 @@ test.describe('authenticated route and deep-link smoke', () => { })).toBeVisible() await expect.poll(() => api.openClawOverviewRequestCount).toBe(1) + expect(api.browserTelemetryRequestCount).toBe(0) + }) + + test('distinguishes a dependency outage and recovers without hiding the route', async ({ page }, testInfo) => { + await mockNexusApi(page, { + role: 'owner', + apiFailure: { + path: '/api/v1/projects', + status: 503, + // Vue Query performs two bounded retries for safe reads. The visible + // recovery action is the next, explicit request. + attempts: 3, + }, + }) + + await page.goto('/projects') + const recovery = page.locator('[data-state="offline"][data-problem-kind="offline"]') + await expect(recovery).toBeVisible() + await expect(recovery).toContainText('Abhängigkeit nicht erreichbar') + await page.screenshot({ + path: testInfo.outputPath('dependency-recovery.png'), + fullPage: true, + }) + await recovery.getByRole('button', { name: 'Erneut prüfen' }).click() + + await expect(page.getByRole('link', { name: /Release Readiness/ })).toBeVisible() + await expect(recovery).toHaveCount(0) }) test('keeps Iris and run mutation entry points unavailable to non-owners', async ({ page }) => { diff --git a/frontend/e2e/support/nexusApi.ts b/frontend/e2e/support/nexusApi.ts index db14d10..106dccc 100644 --- a/frontend/e2e/support/nexusApi.ts +++ b/frontend/e2e/support/nexusApi.ts @@ -23,6 +23,11 @@ interface MockOptions { domainResyncSequence?: number boardRefreshDelayMs?: number boardRefreshTitle?: string + apiFailure?: { + path: string + status: number + attempts?: number + } } interface CapturedRequest { @@ -40,6 +45,7 @@ export interface NexusApiHarness { readonly chatRequests: CapturedRequest[] readonly resyncResponseCount: number readonly openClawOverviewRequestCount: number + readonly browserTelemetryRequestCount: number readonly proposal: Record } @@ -453,6 +459,8 @@ export async function mockNexusApi( let resyncResponseCount = 0 let boardInitialRequestCount = 0 let openClawOverviewRequestCount = 0 + let browserTelemetryRequestCount = 0 + let injectedFailureCount = 0 let openTaskState = 'Backlog' await page.route('**/api/**', async route => { @@ -469,6 +477,23 @@ export async function mockNexusApi( return } + if ( + options.apiFailure + && path === options.apiFailure.path + && injectedFailureCount < (options.apiFailure.attempts ?? 1) + ) { + injectedFailureCount += 1 + await fulfillJson( + route, + problem( + 'The requested Nexus dependency is temporarily unavailable.', + options.apiFailure.status, + ), + options.apiFailure.status, + ) + return + } + if (path === '/api/v1/auth/refresh' && method === 'POST') { if (!authenticated) { await fulfillJson(route, problem('No active E2E session.', 401), 401) @@ -861,6 +886,7 @@ export async function mockNexusApi( return } if (path === '/api/v1/telemetry/browser') { + browserTelemetryRequestCount += 1 await route.fulfill({ status: 202 }) return } @@ -1192,6 +1218,9 @@ export async function mockNexusApi( get openClawOverviewRequestCount() { return openClawOverviewRequestCount }, + get browserTelemetryRequestCount() { + return browserTelemetryRequestCount + }, get proposal() { return proposal }, diff --git a/frontend/package.json b/frontend/package.json index 14a6cd9..e754259 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,7 +1,7 @@ { "name": "nexus-web", "private": true, - "version": "0.1.0", + "version": "0.2.60", "type": "module", "scripts": { "dev": "vite --host 0.0.0.0", diff --git a/frontend/playwright.config.ts b/frontend/playwright.config.ts index 7f3b2bb..754744e 100644 --- a/frontend/playwright.config.ts +++ b/frontend/playwright.config.ts @@ -33,6 +33,9 @@ export default defineConfig({ webServer: { command: `pnpm exec vite --host 127.0.0.1 --port ${port} --strictPort`, url: baseURL, + env: { + VITE_BROWSER_TELEMETRY_ENABLED: 'false', + }, reuseExistingServer: !process.env.CI, timeout: 120_000, }, diff --git a/frontend/src/api/agentProposals.ts b/frontend/src/api/agentProposals.ts index 93c7186..e6834a1 100644 --- a/frontend/src/api/agentProposals.ts +++ b/frontend/src/api/agentProposals.ts @@ -2,7 +2,7 @@ import { computed, toValue, type MaybeRefOrGetter } from 'vue' import { useMutation, useQuery, useQueryClient } from '@tanstack/vue-query' import type { components } from './generated/schema' import { apiClient } from './client' -import { ApiProblem, type ProblemDetailsDto } from './contracts' +import { ApiProblem, asProblemDetails } from './contracts' import { queryKeys } from './queryClient' import { createMutationRequestContext } from '../services/mutationContext' @@ -73,12 +73,8 @@ const STATUS_META: Record = { }, } -function asProblem(value: unknown): ProblemDetailsDto | null { - return value && typeof value === 'object' ? value as ProblemDetailsDto : null -} - function fail(response: Response, error: unknown, fallback: string): never { - throw new ApiProblem(response.status, asProblem(error), fallback) + throw new ApiProblem(response.status, asProblemDetails(error, response.status), fallback) } export function getAgentProposalStatusMeta(status: string): AgentProposalStatusMeta { diff --git a/frontend/src/api/contracts.ts b/frontend/src/api/contracts.ts index 624b851..bdbd0c8 100644 --- a/frontend/src/api/contracts.ts +++ b/frontend/src/api/contracts.ts @@ -1,3 +1,5 @@ +import type { components } from './generated/schema' + export type EntityType = | 'activity' | 'agent' @@ -57,37 +59,171 @@ export function normalizeOperationResult( } } -export interface ProblemDetailsDto { - type?: string - title?: string +export type ProblemDetailsDto = Omit< + components['schemas']['ProblemDetails'], + | 'status' + | 'code' + | 'traceId' + | 'operationId' + | 'currentRevision' + | 'retryAfterSeconds' + | 'remaining' +> & { status?: number - detail?: string - instance?: string + code?: string traceId?: string + operationId?: string currentRevision?: number + retryAfterSeconds?: number + remaining?: number errors?: Record } -export class ApiProblem extends Error { +export type AppProblemKind = + | 'validation' + | 'authentication' + | 'permission' + | 'conflict' + | 'unsupported' + | 'offline' + | 'timeout' + | 'rate-limit' + | 'not-found' + | 'internal' + +export type AppRecoveryAction = + | 'retry' + | 'reauthenticate' + | 'reload-conflict' + | 'inspect-connection' + | 'none' + +export class AppProblem extends Error { readonly status: number readonly problem: ProblemDetailsDto | null + readonly code: string + readonly kind: AppProblemKind + readonly traceId: string | null + readonly operationId: string | null + readonly currentRevision: number | null + readonly retryAfterSeconds: number | null + readonly recoveryAction: AppRecoveryAction constructor(status: number, problem: ProblemDetailsDto | null, fallback: string) { super(problem?.detail || problem?.title || fallback) - this.name = 'ApiProblem' + this.name = 'AppProblem' this.status = status this.problem = problem + this.code = problem?.code || codeForStatus(status) + this.kind = kindForCode(this.code, status) + this.traceId = problem?.traceId || null + this.operationId = problem?.operationId || null + this.currentRevision = finiteNumber(problem?.currentRevision) + this.retryAfterSeconds = finiteNumber(problem?.retryAfterSeconds) + this.recoveryAction = recoveryForKind(this.kind) } } +/** @deprecated Prefer AppProblem. Kept while API modules migrate. */ +export class ApiProblem extends AppProblem { + constructor(status: number, problem: ProblemDetailsDto | null, fallback: string) { + super(status, problem, fallback) + this.name = 'ApiProblem' + } +} + +function finiteNumber(value: unknown): number | null { + if (value === null || value === undefined || value === '') return null + const normalized = Number(value) + return Number.isFinite(normalized) ? normalized : null +} + +function codeForStatus(status: number): string { + if (status === 400 || status === 422) return 'validation_failed' + if (status === 401) return 'unauthenticated' + if (status === 403) return 'forbidden' + if (status === 404) return 'not_found' + if (status === 409) return 'conflict' + if (status === 429) return 'rate_limited' + if (status === 501) return 'unsupported_capability' + if (status === 502 || status === 503 || status === 0) return 'dependency_unavailable' + if (status === 504) return 'timeout' + return 'internal_error' +} + +function kindForCode(code: string, status: number): AppProblemKind { + if (code === 'validation_failed') return 'validation' + if (code === 'unauthenticated' || status === 401) return 'authentication' + if (code === 'forbidden' || status === 403) return 'permission' + if (code === 'conflict' || status === 409) return 'conflict' + if (code === 'unsupported_capability' || status === 501) return 'unsupported' + if (code === 'dependency_unavailable' || status === 0 || status === 502 || status === 503) return 'offline' + if (code === 'timeout' || status === 504) return 'timeout' + if (code === 'rate_limited' || status === 429) return 'rate-limit' + if (code === 'not_found' || status === 404) return 'not-found' + return status >= 500 ? 'internal' : 'validation' +} + +function recoveryForKind(kind: AppProblemKind): AppRecoveryAction { + if (kind === 'authentication') return 'reauthenticate' + if (kind === 'conflict') return 'reload-conflict' + if (kind === 'offline' || kind === 'unsupported') return 'inspect-connection' + if (kind === 'permission' || kind === 'not-found') return 'none' + return 'retry' +} + +export function asProblemDetails(value: unknown, status?: number): ProblemDetailsDto | null { + if (!value || typeof value !== 'object') return null + + const source = value as Record + const detail = typeof source.detail === 'string' + ? source.detail + : typeof source.message === 'string' + ? source.message + : typeof source.error === 'string' + ? source.error + : undefined + const normalizedStatus = finiteNumber(source.status) ?? status + + return { + ...(source as ProblemDetailsDto), + status: normalizedStatus ?? undefined, + detail, + code: typeof source.code === 'string' ? source.code : undefined, + traceId: typeof source.traceId === 'string' ? source.traceId : undefined, + } +} + +export function toAppProblem(error: unknown, fallback = 'Die Anfrage ist fehlgeschlagen.'): AppProblem { + if (error instanceof AppProblem) return error + if (error instanceof DOMException && error.name === 'AbortError') { + return new AppProblem(504, { + code: 'timeout', + detail: 'Die Anfrage wurde abgebrochen oder hat ihr Zeitlimit überschritten.', + }, fallback) + } + if (error instanceof TypeError) { + return new AppProblem(0, { + code: 'dependency_unavailable', + detail: error.message || 'Nexus konnte den Dienst nicht erreichen.', + }, fallback) + } + if (error instanceof Error) { + return new AppProblem(500, { + code: 'internal_error', + detail: error.message, + }, fallback) + } + return new AppProblem(500, { code: 'internal_error', detail: fallback }, fallback) +} + export async function throwApiProblem(response: Response, fallback: string): Promise { let problem: ProblemDetailsDto | null = null try { - problem = await response.json() as ProblemDetailsDto + problem = asProblemDetails(await response.json(), response.status) } catch { // A structured ProblemDetails response is preferred, but legacy endpoints // can still return an empty error body during the migration. } throw new ApiProblem(response.status, problem, fallback) } -import type { components } from './generated/schema' diff --git a/frontend/src/api/generated/schema.d.ts b/frontend/src/api/generated/schema.d.ts index bea5a56..60dab49 100644 --- a/frontend/src/api/generated/schema.d.ts +++ b/frontend/src/api/generated/schema.d.ts @@ -518,39 +518,6 @@ export interface paths { patch?: never; trace?: never; }; - "/api/v1/auth/csrf": { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - get: { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - requestBody?: never; - responses: { - /** @description OK */ - 200: { - headers: { - [name: string]: unknown; - }; - content?: never; - }; - }; - }; - put?: never; - post?: never; - delete?: never; - options?: never; - head?: never; - patch?: never; - trace?: never; - }; "/api/v1/auth/login": { parameters: { query?: never; @@ -2767,6 +2734,46 @@ export interface paths { patch?: never; trace?: never; }; + "/health/ready": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Service Unavailable */ + 503: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/health": { parameters: { query?: never; @@ -8207,6 +8214,31 @@ export interface components { status?: null | number | string; detail?: null | string; instance?: null | string; + /** @description Stable machine-readable Nexus error code. */ + code: null | string; + /** @description Privacy-safe server trace identifier. */ + traceId: null | string; + /** @description Durable operation identifier when the request started an operation. */ + operationId?: null | string; + /** + * Format: int32 + * @description Current server revision for a stale-write conflict. + */ + currentRevision?: null | number; + /** + * Format: int32 + * @description Minimum retry delay advertised by Nexus. + */ + retryAfterSeconds?: null | number; + /** + * Format: int32 + * @description Remaining attempts when a bounded policy exposes that value. + */ + remaining?: null | number; + /** @description Client-supplied content hash for a stale-write conflict. */ + expectedHash?: null | string; + /** @description Current server content hash for a stale-write conflict. */ + currentHash?: null | string; }; ProjectDto: { /** Format: uuid */ @@ -8422,6 +8454,31 @@ export interface components { errors?: { [key: string]: string[]; }; + /** @description Stable machine-readable Nexus error code. */ + code: null | string; + /** @description Privacy-safe server trace identifier. */ + traceId: null | string; + /** @description Durable operation identifier when the request started an operation. */ + operationId?: null | string; + /** + * Format: int32 + * @description Current server revision for a stale-write conflict. + */ + currentRevision?: null | number; + /** + * Format: int32 + * @description Minimum retry delay advertised by Nexus. + */ + retryAfterSeconds?: null | number; + /** + * Format: int32 + * @description Remaining attempts when a bounded policy exposes that value. + */ + remaining?: null | number; + /** @description Client-supplied content hash for a stale-write conflict. */ + expectedHash?: null | string; + /** @description Current server content hash for a stale-write conflict. */ + currentHash?: null | string; }; VerifyOpenClawRequest: { /** Format: int32 */ diff --git a/frontend/src/api/openclawRuntime.ts b/frontend/src/api/openclawRuntime.ts index 2b3e18a..6d69629 100644 --- a/frontend/src/api/openclawRuntime.ts +++ b/frontend/src/api/openclawRuntime.ts @@ -2,7 +2,7 @@ import { computed, toValue, type MaybeRefOrGetter } from 'vue' import { useQuery } from '@tanstack/vue-query' import type { paths } from './generated/schema' import { apiClient } from './client' -import { ApiProblem, type ProblemDetailsDto } from './contracts' +import { ApiProblem, asProblemDetails } from './contracts' import { queryClient, queryKeys } from './queryClient' import type { AgentNodeData } from '../composables/useFlowLayout' @@ -27,12 +27,8 @@ export type OpenClawCapabilitiesDto = GeneratedCapabilities export type OpenClawAgentsDto = GeneratedAgents type OpenClawAgentDto = OpenClawAgentsDto['items'][number] -function asProblem(value: unknown): ProblemDetailsDto | null { - return value && typeof value === 'object' ? value as ProblemDetailsDto : null -} - function fail(response: Response, error: unknown, fallback: string): never { - throw new ApiProblem(response.status, asProblem(error), fallback) + throw new ApiProblem(response.status, asProblemDetails(error, response.status), fallback) } export async function fetchOpenClawOverview( diff --git a/frontend/src/components/dashboard/v2/TaskStrip.vue b/frontend/src/components/dashboard/v2/TaskStrip.vue index a26cef3..de45cb1 100644 --- a/frontend/src/components/dashboard/v2/TaskStrip.vue +++ b/frontend/src/components/dashboard/v2/TaskStrip.vue @@ -2,13 +2,16 @@ import { ListTodo } from '@lucide/vue' import { RouterLink } from 'vue-router' import type { TaskItem } from './types' +import AsyncStatePanel from '../../mission-control/AsyncStatePanel.vue' defineProps<{ tasks: TaskItem[] loading?: boolean - error?: string | null + error?: unknown }>() +defineEmits<{ retry: [] }>() + function priorityLabel(priority: TaskItem['priority']): string { return priority === 'high' ? 'P0' : priority === 'medium' ? 'P1' : 'P2' } @@ -37,14 +40,35 @@ function taskLabel(task: TaskItem): string { Fokus -
- -
+ -
{{ error }}
-
Keine aktiven Tasks
+ + -
+
+import { computed } from 'vue' +import { + AlertTriangle, + CircleOff, + CloudOff, + FileQuestion, + Loader2, + RefreshCw, +} from '@lucide/vue' +import { toAppProblem, type AppProblem } from '../../api/contracts' + +export type AsyncStateKind = 'loading' | 'empty' | 'error' | 'offline' | 'stale' | 'partial' + +const props = withDefaults(defineProps<{ + state: AsyncStateKind + title?: string + message?: string + problem?: unknown + actionLabel?: string + busy?: boolean + compact?: boolean + inline?: boolean +}>(), { + title: '', + message: '', + problem: undefined, + actionLabel: '', + busy: false, + compact: false, + inline: false, +}) + +defineEmits<{ action: [] }>() + +const normalizedProblem = computed(() => + props.problem === undefined ? null : toAppProblem(props.problem), +) + +const resolvedState = computed(() => + props.state === 'error' && normalizedProblem.value?.kind === 'offline' + ? 'offline' + : props.state, +) + +const problemLabel = computed(() => { + const kind = normalizedProblem.value?.kind + if (kind === 'authentication') return 'Sitzung abgelaufen' + if (kind === 'permission') return 'Keine Berechtigung' + if (kind === 'conflict') return 'Versionskonflikt' + if (kind === 'unsupported') return 'Capability fehlt' + if (kind === 'offline') return 'Abhängigkeit nicht erreichbar' + if (kind === 'timeout') return 'Zeitüberschreitung' + if (kind === 'rate-limit') return 'Rate Limit' + if (kind === 'not-found') return 'Nicht gefunden' + if (kind === 'validation') return 'Eingabe ungültig' + if (kind === 'internal') return 'Interner Fehler' + return '' +}) + +const presentation = computed(() => { + const problem = normalizedProblem.value + const defaults = { + loading: ['Daten werden geladen', 'Nexus synchronisiert den aktuellen Stand.'], + empty: ['Noch keine Daten', 'Für diesen Bereich liegen derzeit keine Einträge vor.'], + error: ['Daten konnten nicht geladen werden', problem?.message || 'Die Anfrage ist fehlgeschlagen.'], + offline: ['Verbindung nicht verfügbar', problem?.message || 'Nexus kann den abhängigen Dienst derzeit nicht erreichen.'], + stale: ['Daten möglicherweise veraltet', 'Die letzte bestätigte Momentaufnahme bleibt sichtbar.'], + partial: ['Daten teilweise verfügbar', 'Nexus zeigt den bestätigten Teilbestand und hält fehlende Bereiche sichtbar.'], + } satisfies Record + + return { + title: props.title || defaults[props.state][0], + message: props.message || defaults[props.state][1], + } +}) + +const resolvedActionLabel = computed(() => { + if (props.actionLabel) return props.actionLabel + const action = normalizedProblem.value?.recoveryAction + if (action === 'reauthenticate') return 'Neu anmelden' + if (action === 'reload-conflict') return 'Aktuellen Stand laden' + if (action === 'inspect-connection') return 'Erneut prüfen' + if (action === 'retry') return 'Erneut versuchen' + return '' +}) + +const icon = computed(() => { + if (resolvedState.value === 'loading') return Loader2 + if (resolvedState.value === 'empty') return FileQuestion + if (resolvedState.value === 'offline') return CloudOff + if (resolvedState.value === 'stale' || resolvedState.value === 'partial') return CircleOff + return AlertTriangle +}) + +const liveRole = computed(() => + resolvedState.value === 'error' || resolvedState.value === 'offline' ? 'alert' : 'status', +) + + + + + diff --git a/frontend/src/services/api.ts b/frontend/src/services/api.ts index 593275e..b013ef5 100644 --- a/frontend/src/services/api.ts +++ b/frontend/src/services/api.ts @@ -1,5 +1,17 @@ import { useAuthStore } from '../stores/auth' +let loginRedirectStarted = false + +function redirectToLogin(): void { + if (loginRedirectStarted || window.location.pathname === '/login') return + loginRedirectStarted = true + + const currentTarget = `${window.location.pathname}${window.location.search}${window.location.hash}` + const loginUrl = new URL('/login', window.location.origin) + if (currentTarget !== '/') loginUrl.searchParams.set('redirect', currentTarget) + window.location.assign(`${loginUrl.pathname}${loginUrl.search}`) +} + export async function apiFetch(input: RequestInfo | URL, init: RequestInit = {}) { const auth = useAuthStore() if (!auth.initialized) await auth.initialize() @@ -28,7 +40,7 @@ export async function apiFetch(input: RequestInfo | URL, init: RequestInit = {}) const refreshed = await auth.refresh() if (!refreshed) { - if (window.location.pathname !== '/login') window.location.assign('/login') + redirectToLogin() return response } diff --git a/frontend/src/services/browserTelemetry.ts b/frontend/src/services/browserTelemetry.ts index bf1bdb9..fa4cba0 100644 --- a/frontend/src/services/browserTelemetry.ts +++ b/frontend/src/services/browserTelemetry.ts @@ -28,13 +28,17 @@ const STANDARD_METRICS = new Set(['CLS', 'FCP', 'INP', 'LCP', let activeRouter: Router | null = null let started = false +export function isBrowserTelemetryEnabled(): boolean { + return String(import.meta.env.VITE_BROWSER_TELEMETRY_ENABLED ?? 'true').toLowerCase() !== 'false' +} + function safeRouteName(): string { const name = activeRouter?.currentRoute.value.name return typeof name === 'string' && /^[A-Za-z0-9 _-]{1,80}$/.test(name) ? name : 'unknown' } async function report(envelope: BrowserMetricEnvelope): Promise { - if (safeRouteName() === 'Login') return + if (!isBrowserTelemetryEnabled() || safeRouteName() === 'Login') return try { await apiFetch('/api/v1/telemetry/browser', { method: 'POST', @@ -53,7 +57,7 @@ function fromWebVital(metric: Metric): BrowserMetricEnvelope | null { value: metric.value, rating: metric.rating, routeName: safeRouteName(), - buildVersion: String(import.meta.env.VITE_BUILD_SHA || 'development').slice(0, 80), + buildVersion: String(import.meta.env.VITE_BUILD_VERSION || import.meta.env.VITE_BUILD_SHA || 'development').slice(0, 80), navigationType: String(metric.navigationType || 'navigate').slice(0, 40), liveMode: 'unknown', correlationId: null, @@ -62,7 +66,7 @@ function fromWebVital(metric: Metric): BrowserMetricEnvelope | null { export function startBrowserTelemetry(router: Router): void { activeRouter = router - if (started || typeof window === 'undefined') return + if (!isBrowserTelemetryEnabled() || started || typeof window === 'undefined') return started = true const callback = (metric: Metric) => { @@ -91,7 +95,7 @@ export async function reportCustomMetric( value, rating: 'custom', routeName: safeRouteName(), - buildVersion: String(import.meta.env.VITE_BUILD_SHA || 'development').slice(0, 80), + buildVersion: String(import.meta.env.VITE_BUILD_VERSION || import.meta.env.VITE_BUILD_SHA || 'development').slice(0, 80), navigationType: 'spa', liveMode: options.liveMode ?? 'unknown', correlationId: options.correlationId?.slice(0, 128) ?? null, diff --git a/frontend/src/stores/auth.ts b/frontend/src/stores/auth.ts index 78d21bf..16d2273 100644 --- a/frontend/src/stores/auth.ts +++ b/frontend/src/stores/auth.ts @@ -108,16 +108,23 @@ export const useAuthStore = defineStore('auth', { const body = await response.json() as Record if (typeof body.remaining === 'number') remaining = body.remaining if (typeof body.retryAfterSeconds === 'number') retryAfter = body.retryAfterSeconds + const problemMessage = typeof body.detail === 'string' + ? body.detail + : typeof body.message === 'string' + ? body.message + : typeof body.title === 'string' + ? body.title + : null if (response.status === 429) { this.remainingAttempts = 0 this.retryAfterSeconds = retryAfter - throw new LoginError(body.message as string || 'Too many attempts.', 0, retryAfter) + throw new LoginError(problemMessage || 'Too many attempts.', 0, retryAfter) } else if (response.status === 401) { this.remainingAttempts = remaining this.retryAfterSeconds = retryAfter throw new LoginError( - body.message as string || 'Invalid email or password.', + problemMessage || 'Invalid email or password.', remaining ?? 4, retryAfter, ) diff --git a/frontend/src/views/ActivityView.vue b/frontend/src/views/ActivityView.vue index 7a48d2c..ac114e8 100644 --- a/frontend/src/views/ActivityView.vue +++ b/frontend/src/views/ActivityView.vue @@ -12,11 +12,12 @@ import { X, } from '@lucide/vue' import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue' -import { RouterLink, useRoute } from 'vue-router' +import { RouterLink, useRoute, useRouter } from 'vue-router' import type { EntityRefDto } from '../api/contracts' import { useActivity } from '../api/activity' import { useOpenClawOverviewQuery } from '../api/openclawRuntime' import EntityLink from '../components/mission-control/EntityLink.vue' +import AsyncStatePanel from '../components/mission-control/AsyncStatePanel.vue' interface UnifiedActivity { id: string @@ -37,6 +38,7 @@ interface UnifiedActivity { const overviewQuery = useOpenClawOverviewQuery() const nexusActivity = useActivity() const route = useRoute() +const router = useRouter() const query = ref('') const sourceFilter = ref<'all' | 'OpenClaw' | 'Nexus'>('all') const severityFilter = ref('all') @@ -220,19 +222,15 @@ onUnmounted(() => { -
-
+ :state="['disconnected', 'error'].includes(runtimeCollection.state) ? 'offline' : 'partial'" + :title="`OpenClaw-Auditfeed: ${runtimeCollection.state}`" + :message="runtimeCollection.recovery || runtimeCollection.message || 'Runtime-Aktivität ist derzeit nicht vollständig verfügbar.'" + action-label="Diagnose öffnen" + compact + @action="router.push('/settings')" + />
-
-
+
-
-
+
diff --git a/frontend/src/views/AgentCreateView.vue b/frontend/src/views/AgentCreateView.vue index 43d1a0b..a479314 100644 --- a/frontend/src/views/AgentCreateView.vue +++ b/frontend/src/views/AgentCreateView.vue @@ -17,6 +17,7 @@ import { type CreateAgentProposalRequest, } from '../api/agentProposals' import { useAuthStore } from '../stores/auth' +import AsyncStatePanel from '../components/mission-control/AsyncStatePanel.vue' const router = useRouter() const auth = useAuthStore() @@ -137,15 +138,19 @@ async function submitProposal() { Agent-Vorschläge enthalten OpenClaw-Konfiguration und sind deshalb ausschließlich für Owner sichtbar. Zur Agentenübersicht
-
-
- + +