From 06e9fcbad710f1df1974a2f1e29a961f82c7fcbf Mon Sep 17 00:00:00 2001 From: boban Date: Tue, 23 Jun 2026 01:09:04 +0200 Subject: [PATCH] chore: remove internal/AI docs from the repo (keep local) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The GitHub-private staging repo is a full push-mirror of Gitea, so export-ignore (which only protects the public repo via git archive) does not strip these from the mirror. Untrack the internal/AI-collaboration docs — CLAUDE.md, rules.md, handoff.md, kickoff-prompt.md and docs/ (specs, plans, runbooks, handoffs) — and gitignore them so they stay on the dev box but never reach Gitea, the mirror, or any shipped image. The public repo was already clean via export-ignore. Co-Authored-By: Claude Opus 4.8 --- .gitignore | 7 + CLAUDE.md | 266 ---- docs/install-update-design.md | 215 --- docs/release-pipeline.md | 46 - docs/session-handoff.md | 170 -- .../2026-06-20-clusev-cli-release-handoff.md | 131 -- .../plans/2026-06-14-account-recovery.md | 1098 ------------- .../2026-06-14-optional-pluggable-2fa.md | 1224 --------------- ...6-06-14-server-details-hardening-polish.md | 1073 ------------- .../plans/2026-06-14-webauthn-yubikey.md | 605 ------- ...26-06-15-recovery-codes-airtight-reveal.md | 471 ------ ...-06-19-clusev-cli-and-command-shortcuts.md | 740 --------- .../superpowers/plans/2026-06-19-help-page.md | 459 ------ .../2026-06-20-2fa-challenge-backup-view.md | 921 ----------- ...2026-06-20-control-plane-bruteforce-ban.md | 1388 ----------------- ...026-06-20-wireguard-dashboard-p1-status.md | 752 --------- ...26-06-20-wireguard-dashboard-p2-history.md | 672 -------- .../plans/2026-06-20-wireguard-gate-cli.md | 832 ---------- ...2026-06-21-wireguard-dashboard-p3-peers.md | 860 ---------- ...6-06-21-wireguard-dashboard-p4-settings.md | 407 ----- ...2026-06-21-wireguard-dashboard-p5-setup.md | 315 ---- .../2026-06-22-phase-b1-deploy-to-staging.md | 1187 -------------- .../2026-06-22-release-promotion-phase-a.md | 601 ------- .../runbooks/wireguard-gate-runbook.md | 58 - .../2026-06-14-account-recovery-design.md | 119 -- ...-06-14-accounts-sessions-restart-design.md | 113 -- .../2026-06-14-external-proxy-tls-design.md | 82 - ...026-06-14-optional-pluggable-2fa-design.md | 123 -- ...-server-details-hardening-polish-design.md | 194 --- ...026-06-14-ssh-key-auto-provision-design.md | 91 -- .../2026-06-14-webauthn-yubikey-design.md | 121 -- ...5-confirm-action-token-hardening-design.md | 331 ---- ...-06-15-de-claude-history-rewrite-design.md | 189 --- ...06-15-forgot-password-smtp-aware-design.md | 81 - .../2026-06-15-installer-overhaul-design.md | 81 - ...5-recovery-codes-airtight-reveal-design.md | 176 --- ...06-17-request-certificate-button-design.md | 116 -- ...clusev-cli-and-command-shortcuts-design.md | 76 - .../specs/2026-06-19-help-page-design.md | 100 -- ...-06-20-2fa-challenge-backup-view-design.md | 215 --- ...-20-control-plane-bruteforce-ban-design.md | 237 --- ...26-06-20-wireguard-dashboard-sp2-design.md | 117 -- .../2026-06-20-wireguard-gate-cli-design.md | 191 --- ...6-06-22-3-repo-release-promotion-design.md | 97 -- ...06-22-phase-b1-deploy-to-staging-design.md | 141 -- docs/v1-ui-review.md | 163 -- handoff.md | 306 ---- kickoff-prompt.md | 45 - rules.md | 527 ------- 49 files changed, 7 insertions(+), 18523 deletions(-) delete mode 100644 CLAUDE.md delete mode 100644 docs/install-update-design.md delete mode 100644 docs/release-pipeline.md delete mode 100644 docs/session-handoff.md delete mode 100644 docs/superpowers/handoffs/2026-06-20-clusev-cli-release-handoff.md delete mode 100644 docs/superpowers/plans/2026-06-14-account-recovery.md delete mode 100644 docs/superpowers/plans/2026-06-14-optional-pluggable-2fa.md delete mode 100644 docs/superpowers/plans/2026-06-14-server-details-hardening-polish.md delete mode 100644 docs/superpowers/plans/2026-06-14-webauthn-yubikey.md delete mode 100644 docs/superpowers/plans/2026-06-15-recovery-codes-airtight-reveal.md delete mode 100644 docs/superpowers/plans/2026-06-19-clusev-cli-and-command-shortcuts.md delete mode 100644 docs/superpowers/plans/2026-06-19-help-page.md delete mode 100644 docs/superpowers/plans/2026-06-20-2fa-challenge-backup-view.md delete mode 100644 docs/superpowers/plans/2026-06-20-control-plane-bruteforce-ban.md delete mode 100644 docs/superpowers/plans/2026-06-20-wireguard-dashboard-p1-status.md delete mode 100644 docs/superpowers/plans/2026-06-20-wireguard-dashboard-p2-history.md delete mode 100644 docs/superpowers/plans/2026-06-20-wireguard-gate-cli.md delete mode 100644 docs/superpowers/plans/2026-06-21-wireguard-dashboard-p3-peers.md delete mode 100644 docs/superpowers/plans/2026-06-21-wireguard-dashboard-p4-settings.md delete mode 100644 docs/superpowers/plans/2026-06-21-wireguard-dashboard-p5-setup.md delete mode 100644 docs/superpowers/plans/2026-06-22-phase-b1-deploy-to-staging.md delete mode 100644 docs/superpowers/plans/2026-06-22-release-promotion-phase-a.md delete mode 100644 docs/superpowers/runbooks/wireguard-gate-runbook.md delete mode 100644 docs/superpowers/specs/2026-06-14-account-recovery-design.md delete mode 100644 docs/superpowers/specs/2026-06-14-accounts-sessions-restart-design.md delete mode 100644 docs/superpowers/specs/2026-06-14-external-proxy-tls-design.md delete mode 100644 docs/superpowers/specs/2026-06-14-optional-pluggable-2fa-design.md delete mode 100644 docs/superpowers/specs/2026-06-14-server-details-hardening-polish-design.md delete mode 100644 docs/superpowers/specs/2026-06-14-ssh-key-auto-provision-design.md delete mode 100644 docs/superpowers/specs/2026-06-14-webauthn-yubikey-design.md delete mode 100644 docs/superpowers/specs/2026-06-15-confirm-action-token-hardening-design.md delete mode 100644 docs/superpowers/specs/2026-06-15-de-claude-history-rewrite-design.md delete mode 100644 docs/superpowers/specs/2026-06-15-forgot-password-smtp-aware-design.md delete mode 100644 docs/superpowers/specs/2026-06-15-installer-overhaul-design.md delete mode 100644 docs/superpowers/specs/2026-06-15-recovery-codes-airtight-reveal-design.md delete mode 100644 docs/superpowers/specs/2026-06-17-request-certificate-button-design.md delete mode 100644 docs/superpowers/specs/2026-06-19-clusev-cli-and-command-shortcuts-design.md delete mode 100644 docs/superpowers/specs/2026-06-19-help-page-design.md delete mode 100644 docs/superpowers/specs/2026-06-20-2fa-challenge-backup-view-design.md delete mode 100644 docs/superpowers/specs/2026-06-20-control-plane-bruteforce-ban-design.md delete mode 100644 docs/superpowers/specs/2026-06-20-wireguard-dashboard-sp2-design.md delete mode 100644 docs/superpowers/specs/2026-06-20-wireguard-gate-cli-design.md delete mode 100644 docs/superpowers/specs/2026-06-22-3-repo-release-promotion-design.md delete mode 100644 docs/superpowers/specs/2026-06-22-phase-b1-deploy-to-staging-design.md delete mode 100644 docs/v1-ui-review.md delete mode 100644 handoff.md delete mode 100644 kickoff-prompt.md delete mode 100644 rules.md diff --git a/.gitignore b/.gitignore index 1edfee4..ad024a5 100644 --- a/.gitignore +++ b/.gitignore @@ -39,3 +39,10 @@ yarn-error.log # the dir itself is kept (bind-mount target) but its runtime contents are never committed /run/* !/run/.gitkeep + +# internal / AI-collaboration docs — kept on the dev box, never committed, mirrored or shipped +/CLAUDE.md +/rules.md +/handoff.md +/kickoff-prompt.md +/docs/ diff --git a/CLAUDE.md b/CLAUDE.md deleted file mode 100644 index 596d566..0000000 --- a/CLAUDE.md +++ /dev/null @@ -1,266 +0,0 @@ -# CLAUDE.md — Clusev - -Project context for any agent/developer. **Read `rules.md` first — those rules are -non-negotiable.** This file is the fast ramp-up: what we're building, the stack, where things -live, how to run them, and the conventions. Source of truth for decisions: `handoff.md`. - ---- - -## 1. Product - -**Clusev** — a self-hosted control panel to administer a **fleet of Linux servers from one -dashboard**, connecting **agentless over SSH**. The operator sees and steers the whole fleet -(metrics, services, files, audit) in one security-first UI (2FA, full audit log). - -- **Backend reality:** Laravel is the **control-plane** (UI + API + provisioning). It does **not** - reimplement SSH/daemons — it orchestrates real servers over SSH via **phpseclib** (exec + SFTP). -- **Audience:** developers, self-hosters, sysadmins. -- **Differentiator:** multi-server fleet management with a modern, polished UI. **Multi-server is - free and never paywalled.** Paid = Team/Enterprise (SSO/LDAP, RBAC, audit export, alerting, - backups) + optional managed control-plane. **License:** AGPL core + commercial Pro modules — - architect open-core from day one (clean core, Pro as separate modules/flags). - -### v1 scope (build this first — do NOT boil the ocean) -- **Foundation (once):** auth + **2FA**, **audit log**, encrypted **SSH-credential vault**, - **SSH layer** (exec + SFTP via phpseclib), **Reverb** realtime channel, **queue** workers, - **multi-server switcher**. -- **Features:** (1) **Dashboard / live metrics**, (2) **systemd services** (list + start/stop/ - restart + logs), (3) **SFTP file manager**, plus the **Server-Details** page (identity, - resource gauges, specs, volumes, interfaces, security hardening, SSH keys). - -### Deferred (keep OUT of v1) -- **Web terminal** (xterm.js + ws↔SSH sidecar — PHP can't hold an interactive PTY). -- **Package management** (apt/dnf), firewall / users / cron, app store, push-metrics agent. - ---- - -## 2. Tech stack (hard requirements) - -| Layer | Choice | -|---|---| -| Framework | **Laravel 13** | -| UI / interactivity | **Livewire v3** — class-based, **NOT Volt** | -| CSS | **Tailwind v4** (CSS-first, `@theme` in `app.css`, **no `tailwind.config.js`**) | -| Build | **Vite** | -| Modals | **wire-elements/modal** | -| Realtime | **Laravel Reverb** + Echo (live metrics, broadcasts) | -| Queue / cache | **Redis** | -| DB | **MariaDB** *(confirmed)* | -| SSH | **phpseclib** (exec + SFTP) | -| Charts | JS lib (ApexCharts / Chart.js / uPlot) as an **Alpine island** in Blade | -| Icons | **Lucide**, inline SVG via a Blade `x-icon` component | -| Fonts | **Chakra Petch** (display) · **Space Grotesk** (sans) · **JetBrains Mono** (numbers/paths) — **self-hosted only** (`public/fonts/*.woff2` + `@font-face` in `app.css`), **never a CDN/Google Fonts link or `@import`** (R14) | - -> **Version note (2026-06-11):** handoff §3 specifies Laravel 12; **Laravel 13** is used (it is the current release; user decision). Livewire 3 / Tailwind 4 / wire-elements/modal / Reverb are unaffected; PHP pinned to **8.3**. - -**Composer packages:** `livewire/livewire wire-elements/modal phpseclib/phpseclib laravel/reverb`. -**NPM (Tailwind v4):** `tailwindcss @tailwindcss/vite` (+ Echo + a charts lib). - ---- - -## 3. Architecture notes - -- **Pages = full-page class-based Livewire components** mapped directly in `routes/web.php` - (R1/R2). No page controllers. -- **SSH layer** (`app/Support/Ssh/`): `SshClient` (exec), `Sftp`, `CredentialVault` - (encrypted credentials). Domain services in `app/Services/` (`FleetService`, `MetricsPoller`, - `Provisioner`) orchestrate them; queued long ops go in `app/Jobs/`. -- **Realtime:** `MetricsPoller` → broadcast events (`app/Events/`, e.g. `MetricsTicked`) over - **Reverb**; the Dashboard Livewire component receives them (Echo / `wire:stream`) and updates - the chart island. **Mock data first, real SSH after.** **Convention: every broadcast channel is - PRIVATE** (authenticated via `/broadcasting/auth`) — the app key is bundled into the JS and the - Reverb socket can answer on a stale Caddy host, so a public channel would leak fleet data. Declare - events with `PrivateChannel`/`PresenceChannel` and authorize in `routes/channels.php` (see the - header comment there); per-server channels also scope to records the user may see. -- **Open-core:** keep the core clean; Pro features behind separate modules/flags from day one. - ---- - -## 4. Folder map (§5 — follow exactly, R6) - -``` -app/ - Livewire/ # class-based components (full-page = routes, + nested) - Dashboard.php - Servers/{Index.php, Show.php} # Show = Server-Details page - Services/Index.php - Files/Index.php - Audit/Index.php - Auth/{Login.php, TwoFactor.php} - Modals/ # wire-elements/modal components (ConfirmDelete, ServerForm, …) - Concerns/ # shared traits (e.g. WithFleetContext) - Models/ # Server, AuditEvent, User, … - Support/Ssh/ # phpseclib wrappers: SshClient, Sftp, CredentialVault - Services/ # FleetService, MetricsPoller, Provisioner - Events/ # broadcast events (MetricsTicked, …) - Jobs/ # queued ops -resources/ - views/ - livewire/ # mirrors app/Livewire (kebab-case) - dashboard.blade.php - servers/{index,show}.blade.php - services/index.blade.php - files/index.blade.php - audit/index.blade.php - modals/… - components/ # Blade UI: panel, kpi, status-pill, status-dot, badge, - … # sidebar, topbar, server-item, nav-item, icon, ring - layouts/app.blade.php - css/app.css # @import "tailwindcss"; + @theme { tokens } + base layer - js/app.js # Echo/Reverb bootstrap, Alpine islands (charts; xterm later) -routes/web.php # full-page Livewire routes -config/livewire.php # class_namespace=App\Livewire, view_path=views/livewire -docker/ # Dockerfile bits, php-fpm/nginx config, entrypoint -docker-compose.yml # DEV (build, bind-mount, Vite) -docker-compose.prod.yml # PROD (image from GHCR, no mount) -.dockerignore -handoff.md rules.md CLAUDE.md -``` - ---- - -## 5. Design system → Tailwind v4 `@theme` - -Direction: **"Tactical Terminal"** — dark graphite, **signal-orange** brand (softened on -selection/active tints), **cyan** counter-accent, ops status triad, **mono for every -number/IP/path**. All tokens live in `resources/css/app.css` (R3). Token groups: - -- **Surfaces:** `void, base, surface, raised, inset` → `bg-surface`, `bg-raised`, … -- **Brand:** `accent, accent-bright, accent-deep, accent-text` → `text-accent`, `bg-accent/10` - (use **opacity utilities** for tints, e.g. `bg-accent/10`, `border-accent/25` — no `--accent-dim`). -- **Counter-accent:** `cyan, cyan-bright`. -- **Status:** `online (#35D07F)`, `warning (#E8B931)`, `offline (#FF5247)` → `text-online`, … -- **Text ramp:** `ink, ink-2, ink-3, ink-4` → `text-ink`, `text-ink-2`, … -- **Borders (hairline):** `line, line-soft, line-strong` → `border-line`, … -- **Fonts:** `--font-display` (Chakra Petch), `--font-sans` (Space Grotesk), `--font-mono` - (JetBrains Mono) → `font-display`, `font-mono`. -- **Radii:** `--radius-xs/sm/md/lg` → `rounded-sm`, `rounded-lg`. - -> Full token values are in `handoff.md` §6 — port them verbatim into `app.css`. Selection -> (`::selection`) and active nav/server tints use `bg-accent/25` and `bg-accent/10` + `border-accent/25` -> (keep subtle). **`../bastion` mockup is NOT present on this machine** → rebuild from §6 tokens + -> the §11 component list; pull Lucide SVG paths directly from Lucide. - -**Blade components to build:** `x-panel`, `x-kpi`, `x-status-pill`, `x-status-dot`, `x-badge`, -`x-icon` (Lucide), `x-ring`, `x-server-item`, `x-nav-item`, plus `sidebar` + `topbar` partials. - ---- - -## 6. Conventions - -- **UI copy is localized (DE default + EN)** — never hard-code visible strings; use - `__('group.key')` with translations in `lang/{de,en}/.php` (one group per feature + - shared `common`), keys identical in both languages. Register: terse/operational, **no emoji** - (status = color/dots/pills). Native technical tokens stay as-is (`nginx.service`, `SSH`, `2FA`). (R9, R16) -- **Colors:** only `@theme` token utilities in markup — never raw hex/rgb. (R3) -- **Inline styles:** forbidden except a progress bar's `width`. (R4) -- **Naming:** Livewire class `App\Livewire\Servers\Show` ↔ view `livewire.servers.show` - (kebab, mirrored path). Components in `app/Livewire/…`, views in `resources/views/livewire/…`. -- **Pages:** full-page Livewire components as routes — no page controllers. (R1/R2) -- **Blade:** use block `@php … @endphp` (not inline `@php(...)`); never put the literal tokens - `@php`/`@endphp` in a comment/text — the raw-block matcher eats them. (R17) -- **Destructive actions:** wire-elements/modal only — no `confirm()`/Alpine popups. (R5) -- **URLs / route binding:** records exposed in URLs use a **UUID** route key, never the integer - PK (`getRouteKeyName(): 'uuid'`). (R11) -- **Route paths + names are English**, always — `/settings` not `/einstellungen`, `/files` not - `/dateien`. German lives in the visible nav label, never in the `href`/route name. (R13) -- **Fonts self-hosted only** — `.woff2` in `resources/fonts/` (Vite-bundled, relative - `url('../fonts/…')`) + `@font-face` in `app.css`; never a Google Fonts / CDN `` or - `@import`. (R14) -- **Responsive:** every screen at **375 / 768 / 1280+**; sidebar → drawer on small; KPI grid - 4→2→1; tables scroll/stack; touch targets ≥ 44px. (R7) -- **Docs language:** these meta-docs are in English; **UI strings are German**. - ---- - -## 7. Commands (everything in containers — R8) - -> Dev stack services: `app` (php-fpm + nginx + **vite** via supervisor — ONE container), -> `reverb`, `queue`, `mariadb`, `redis`. App on **:80**, Vite HMR on **:5173** -> (`hmr.host=10.10.90.136`). Vite is **not** a separate service. Containers run as `HOST_UID` -> (1002 on this VM); run write-tooling as -> `docker compose run --rm --no-deps -u "${HOST_UID}:${HOST_GID}" app …`. - -```bash -# ── Stack lifecycle (DEV) ──────────────────────────────────────────────── -docker compose up -d # start the dev stack -docker compose logs -f app # tail app logs -docker compose down # stop - -# ── Run tooling INSIDE the container ───────────────────────────────────── -docker compose exec app php artisan migrate -docker compose exec app php artisan make:livewire Servers/Show # class + view (never make:volt) -docker compose exec app composer require some/package -docker compose exec app php artisan tinker - -# ── One-time bootstrap (host has no PHP/Composer/Node) ─────────────────── -# run in a throwaway container or the app image: -# composer create-project laravel/laravel . -# composer require livewire/livewire wire-elements/modal phpseclib/phpseclib laravel/reverb -# npm install -D tailwindcss @tailwindcss/vite -# php artisan livewire:publish --config # class-based, view_path set - -# ── Realtime / queue (run as their own services; manual run if needed) ─── -docker compose exec app php artisan reverb:start -docker compose exec app php artisan queue:work - -# ── Prod deploy (plain Docker Compose — no Portainer) ──────────────────── -docker compose -f docker-compose.prod.yml pull -docker compose -f docker-compose.prod.yml up -d -docker compose -f docker-compose.prod.yml exec app php artisan migrate --force -``` - ---- - -## 8. Infrastructure & deploy (§8) - -- **Target VM:** Debian 13, **`10.10.90.136`**, **only Docker** + a sudo user. Everything in - containers. **No Portainer, no external orchestrator.** Dev happens on this same VM (prod-parity). -- **`docker-compose.yml` (DEV):** `build:`, **bind-mounts** source. The `app` container runs - php-fpm + nginx + Vite (HMR) via supervisor (one unit). Ports/UID are **env-driven** - (`APP_PORT`, `VITE_PORT`, `REVERB_HOST_PORT`, `DB_PORT`, `HOST_UID`/`HOST_GID` in `.env`) — - nothing hardcoded. App on **:80**, Vite remote HMR (`server.host=0.0.0.0`, - `hmr.host=10.10.90.136`), MariaDB only on `127.0.0.1:3306`. -- **`docker-compose.prod.yml` (PROD):** `image: ghcr.io/OWNER/clusev:` *(GHCR namespace is a - **placeholder `OWNER`** — set via `.env` before the first prod push)*, **no bind-mount**, assets - baked into the image, no Vite. Reverse proxy + TLS (Caddy/Traefik) is just another service. -- **Deploy:** wrap pull+up+migrate in `deploy.sh` (or a CI job that SSHes in). Image flow: - push → CI builds + pushes to **GHCR** → `deploy.sh` pulls onto the VM. Bootstrap before CI: - build on the VM. Use `docker buildx` (multi-arch) only if any dev happens on arm64; prod is amd64. - ---- - -## 9. Repo & workflow - -- **Project root = `/home/nexxo/clusev`** (its own git repo). The parent `$HOME` holds only - `.env.gitea` + home dotfiles. Host user **nexxo = uid 1002** (`wp-test`=1000, `testwp`=1001 - pre-existed) → `HOST_UID/HOST_GID=1002` in `.env`. -- **Remote:** Gitea `git.bave.dev/boban/clusev.git`. Credentials live in **`.env.gitea`** - (untracked) — the `GIT_ACCESS_TOKEN` must later move into the app `.env`. -- **Work on a feature branch, not `main`.** Commit in sensible steps. -- **Secret hygiene:** `.env*` (incl. `.env.gitea`) + keys are gitignored; never commit/echo the - Gitea token (read it only at push time). See `rules.md` → Appendix. -- **Verify before "done" (R12):** load **every touched page in a real browser** (headless ok) → - **HTTP 200** (no 500) + **zero console/network errors**; for lazy `wire:init` pages check the - **loaded** state, not the skeleton. A green `Livewire::test` is **not** enough. Test the 3 breakpoints. - ---- - -## 10. Before you code — checklist - -1. **Read `rules.md`.** R1–R11 are non-negotiable; on any conflict, **stop and ask**. -2. New page? → full-page **class-based** Livewire component + route in `web.php` (R1/R2). Never Volt. -3. Need a color/spacing? → an **`@theme` token utility** (R3). No raw hex, no inline style except - progress `width` (R4). -4. Destructive/confirm? → **wire-elements/modal** (R5). -5. Place files per the **§4 folder map** (R6). Class ↔ mirrored kebab view. -6. Build **responsive**: 375 / 768 / 1280; ≥44px touch targets (R7). -7. Run all tooling **inside the container** (R8). -8. UI copy **German, no emoji**; status via color/dots/pills (R9). -9. **Reuse** the token set + Blade component kit (R10). -10. **Verify in a browser (R12)**: every touched route loads at **HTTP 200** with **zero console - errors** (loaded state of lazy pages, not the skeleton) — a green `Livewire::test` is not enough; - **inspect the rendered DOM** (not just status/console) for leaked `@`/`{{ }}`/`$var`/`group.key` - text — those return 200 too (R17); build + 3 breakpoints; then `git status` for stray secrets, commit. -11. **Codex review (R15)**: run **`/codex:review`** over the change; it must report **no errors and - no security issues**. Fix + re-run until clean. The task is not done until Codex passes. diff --git a/docs/install-update-design.md b/docs/install-update-design.md deleted file mode 100644 index 51370f7..0000000 --- a/docs/install-update-design.md +++ /dev/null @@ -1,215 +0,0 @@ -# Clusev — End-to-End-Design: Install, Domain/TLS & In-Dashboard-Updates - -Entscheidung des Lead-Architekten. Verbindlich, opinionated. Priorität: Korrektheit > Sicherheit > Operator-Einfachheit > wenige bewegliche Teile. - ---- - -## 0. Kurzfassung (TL;DR) - -- **Install** = dünnes `install.sh` (Secrets + Infra + Schema + erster Admin) **plus** ein **authentifizierter** In-App-Onboarding-Wizard (Passwortwechsel, 2FA-Pflicht, erster Server). Ein unauthentifizierter `/install`-Webwizard wird abgelehnt. -- **Ports/Domain** sind vollständig `.env`-getrieben. Eine einzige Stellschraube — `APP_DOMAIN` — entscheidet HTTP-auf-IP vs. HTTPS-auf-Domain. Alles andere (`APP_URL`, `REVERB_*`, `SITE_ADDRESS`) wird daraus **abgeleitet**, nichts hardcoded. -- **Proxy/TLS** = **Caddy** als einziger host-exponierter Dienst im Stack (Auto-Let's-Encrypt, Auto-Renew, Reverb-wss über dasselbe :443). Traefik und certbot+nginx werden verworfen. -- **Admin** wird im Shell-Flow per `artisan clusev:install` mit **Zufallspasswort** angelegt, das **genau einmal** im Operator-Terminal erscheint. Kein Default-Credential, kein „first-visitor-wins"-Claim-Fenster. -- **In-Dashboard-Update** = die App schreibt nur eine **HMAC-signierte Intent-Datei**; ein **host-seitiger Updater** (systemd path+oneshot) führt aus. Die App fasst den Docker-Socket **nie** an. Digest-Pinning + cosign-Verify + 2FA + Audit + Backup-vor-Migration + Rollback-by-Digest. - ---- - -## 1. EMPFEHLUNG je Nutzerfrage - -### (1) Install: Anleitung vs. `install.sh` vs. In-App-Wizard - -**Klarer Split — beides, mit Trennlinie an der Trust-Boundary „wer SSH/Docker auf der Box hat":** - -- **`install.sh` (PRIMÄR)** macht genau das, was *vor* Existenz der App nötig ist: Preflight, Repo materialisieren, Secrets idempotent generieren, `.env` schreiben, Stack hochfahren, auf DB-Health warten, `migrate --force`, **ersten Admin** per `artisan clusev:install` anlegen. Dünn, keine Business-Logik. -- **In-App-Wizard (SEKUNDÄR, AUTHENTIFIZIERT)** = Onboarding *nach* dem ersten Login: Passwort-Pflichtwechsel, 2FA-Pflicht-Enrolment, ersten Server hinzufügen, SMTP/Branding. **Kein** unauthentifizierter `/install`-Endpoint. -- **Schriftliche Anleitung (FALLBACK)** = nur als README-Appendix für Operatoren, die `curl|bash` misstrauen und jeden Befehl selbst lesen wollen. - -**Begründung:** Ein offener, privilegierter `/install`-Route auf einem Security-First-Fleet-Panel ist genau die Art Endpoint, die vergessen und ausgenutzt wird (historische „Setup-Wizard"-RCEs). Die Shell hat die gewünschte Trust-Boundary bereits: nur wer SSH/Docker auf der VM hat, kann sie ausführen, und das Einmalpasswort erscheint nur in *dessen* Terminal. - -### (2) Ports/Domain NICHT hardcoded — env-getrieben - -Eine Master-Variable, alles andere abgeleitet: - -| `.env`-Knopf | leer / IP-Modus | gesetzt / Domain-Modus | -|---|---|---| -| `APP_DOMAIN` | *(leer)* | `clusev.example.com` | -| `APP_SCHEME` | `http` | `https` | -| `APP_PORT` | z. B. `80` (host bind) | 80+443 von Caddy belegt | -| `SITE_ADDRESS` (abgeleitet) | `:${APP_PORT}` | `https://${APP_DOMAIN}` | -| `APP_URL` (abgeleitet) | `http://:${APP_PORT}` | `https://${APP_DOMAIN}` | -| `REVERB_HOST` / `REVERB_PORT` / `REVERB_SCHEME` (abgeleitet) | `` / `${APP_PORT}` / `http` | `${APP_DOMAIN}` / `443` / `https` | -| `ACME_EMAIL` | — | Admin-E-Mail | - -`VITE_REVERB_*` interpolieren bereits aus `REVERB_*` (siehe `.env.example`), folgen also automatisch. Der Installer berechnet `SITE_ADDRESS`/`APP_URL`/`REVERB_*` **einmal** aus `APP_DOMAIN` und schreibt sie idempotent. Eine einzige Caddyfile-Vorlage deckt beide Modi ab. - -### (3) Optionale Domain + Auto-TLS — welcher Proxy - -**Caddy 2** (`caddy:2-alpine`, per `@sha256`-Digest gepinnt), als **einziger host-exponierter Dienst**. - -- **Caddy gewinnt** gegen Traefik (Label-Sprawl + Docker-Socket-Mount für den Provider — unnötige Angriffsfläche bei nur 2 Backends) und gegen certbot+nginx (zweites nginx, Renewal-Timer, Reload-Hook — die meisten beweglichen Teile). ~12-Zeilen-Caddyfile, Auto-HTTPS + Auto-Renew + HTTP→HTTPS-Redirect ohne ein einziges Script, WebSocket-Upgrade implizit. -- **Reverb-wss** wird per Pfad (`/app/*`, `/apps/*`) auf `reverb:8080` über **dasselbe :443** geroutet — kein extra exponierter Port, kein Mixed-Content. -- **App & Reverb verlieren ihre `ports:`** und sind nur noch im internen `clusev`-Bridge-Netz erreichbar (`app:80`, `reverb:8080`). Kleinere öffentliche Angriffsfläche. -- **Zertifikate persistieren** im Named Volume `caddy-data` → Stack-Recreate beim Update triggert **kein** erneutes ACME (keine Rate-Limit-Probleme). - -**Scharfe Kante (muss in die Prompts):** Let's Encrypt HTTP-01/TLS-ALPN-01 braucht öffentlich erreichbare 80/443. Die Ziel-IP `10.10.90.136` ist RFC1918-privat → öffentliches Cert schlägt dort fehl. Der Installer muss das erkennen und entweder bare-IP-HTTP anbieten oder einen DNS-01-Caddy-Build (anderes Image + DNS-Credentials) dokumentieren. **Niemals** bei gesetzter Domain still auf HTTP zurückfallen — laut scheitern. - -### (4) Admin-User + Random-Passwort am Ende - -- `artisan clusev:install --email=…` ist **idempotent**: existiert bereits ein Admin → No-op („bereits installiert"). Sonst: Admin mit `Str::password(20)`, Flags `must_change_password=true` + `two_factor`-noch-nicht-enrolled, **nur den bcrypt-Hash** speichern. -- Das **Klartext-Passwort** wird ausschließlich auf stdout (eine Marker-Zeile) ausgegeben; die Shell zeigt es im Schlussbanner — **nie** in `.env`, Datei, DB oder History. -- **Kein Default-Credential** (Umami-Antipattern), **kein Claim-Fenster** (Coolify/Plausible/Kuma). Erzwungener Passwortwechsel + 2FA-Pflicht beim ersten Login sind die kompensierenden Kontrollen gegen Scrollback/tmux-Leaks. -- `installed`-Marker (DB-Flag/Sentinel) deaktiviert den First-Run-Pfad hart nach erstem Lauf (Gitea `INSTALL_LOCK`-Idiom). - -### (5) In-Dashboard-Updates — Mechanismus + Sicherheit + Rollback - -**Gewählter Mechanismus: App schreibt Intent, host-seitiger Updater führt aus (out-of-band).** Verworfen: Socket-Sidecar in/neben der App (ein RCE = Host-Root, und die App hält den Fleet-SSH-Vault) und Watchtower (unbeaufsichtigt, kein 2FA, keine Migration, kein Rollback). - -Löst „ein Container kann seinen eigenen Parent-Stack nicht neu erzeugen", weil der Executor **außerhalb** des `clusev`-Compose-Lifecycles lebt (eigene systemd-Unit) — App/reverb/queue neu zu erzeugen tötet nicht den Prozess, der das Update fährt. **Die App bekommt keinerlei Docker-Privileg** — ihre einzige neue Fähigkeit ist „eine signierte Datei schreiben". - ---- - -## 2. Konkrete Install-UX - -**One-Liner:** `curl -fsSL https://raw.githubusercontent.com//clusev/main/install.sh | bash` — oder aus einem Clone `./install.sh`. Interaktiv nur am TTY; ohne TTY (gepiped) liest er `CLUSEV_DOMAIN` / `CLUSEV_ADMIN_EMAIL` aus env oder fällt auf bare-IP/HTTP zurück. - -**Prompts (Deutsch, kein Emoji):** -``` -Clusev Installer -================ -Domain (leer = Zugriff per IP über HTTP): > clusev.example.com -HTTP-Port (nur ohne Domain, Standard 80): > 80 -Admin E-Mail (Login + Let's Encrypt): > admin@example.com -``` - -**Generiert (idempotent — `set_kv` schreibt nur bei leer/Platzhalter, regeneriert NIE):** -- `APP_KEY` via `docker compose run --rm --no-deps app php artisan key:generate --show` (kein Host-PHP) -- `DB_PASSWORD`, `DB_ROOT_PASSWORD`, `REVERB_APP_SECRET` via `openssl rand -hex 32` -- `REVERB_APP_ID`, `REVERB_APP_KEY` via `openssl rand -hex 16` -- `APP_ENV=production`, `APP_DEBUG=false`, `REDIS_PASSWORD` gesetzt; `HOST_UID/GID` aus `id` -- abgeleitet: `SITE_ADDRESS`, `APP_URL`, `REVERB_HOST/PORT/SCHEME` - -**Phasen-Fortschritt:** -``` -[1/7] Voraussetzungen prüfen … Docker + compose vorhanden, Ports frei — ok -[2/7] Secrets generieren … .env vorhanden, vorhandene Werte bleiben erhalten -[3/7] Images holen … ghcr.io//clusev@sha256:… gezogen -[4/7] Stack starten … 6 Container laufen (inkl. caddy) -[5/7] Datenbank bereit … MariaDB healthy nach 9s -[6/7] Migrationen … 3 ausgeführt; config:cache/route:cache gebaut -[7/7] Admin anlegen … erstellt -``` - -**Finale Ausgabe (Passwort erscheint NUR hier):** -``` -============================================================ - Clusev ist bereit. - URL: https://clusev.example.com - Admin: admin@example.com - Passwort: 7Qf2-Kp9x-Lm4v-Rt8z <-- nur jetzt sichtbar, nicht gespeichert - Beim ersten Login: Passwort ändern + 2FA einrichten (Pflicht). -============================================================ -``` - ---- - -## 3. Reverse-Proxy/TLS-Design - -**Topologie (prod):** Heute publizieren `app:80` und `reverb:8080` direkt auf den Host. Neu: **nur Caddy** bindet Host-Ports; `app`/`reverb` verlieren `ports:`. - -**Caddy-Service** (in `docker-compose.prod.yml`): `image: caddy:2-alpine` (per Digest pinnen), `ports: ${APP_PORT:-80}:80`, `443:443`, `443:443/udp` (HTTP/3); Volumes `./docker/caddy/Caddyfile:ro`, `caddy-data` (ACME-Account + Certs — **muss** persistieren), `caddy-config`; `depends_on: [app, reverb]`. - -**Caddyfile (eine Vorlage, beide Modi):** -``` -{ - email {$ACME_EMAIL} -} -{$SITE_ADDRESS} { - @reverb path /app/* /apps/* - reverse_proxy @reverb reverb:8080 - reverse_proxy app:80 -} -``` - -**Die Magie:** `SITE_ADDRESS=:${APP_PORT}` → Caddy serviert Klartext-HTTP, kein Cert. `SITE_ADDRESS=https://${APP_DOMAIN}` → Auto-Let's-Encrypt, :443, Auto-Renew ~30 Tage vorher, Auto-Redirect 80→443. WebSocket-Upgrade ist implizit, daher funktioniert `/app/*` als wss auf 443 ohne Spezialdirektive. - -**Vor dem Shippen verifizieren (OFFENER PUNKT):** `config/reverb.php` / `config/broadcasting.php` sind im Repo noch nicht publiziert. Die exakten Echo-WS-Pfade (`/app/*` WS, `/apps/*` Event-API) müssen gegen die Config geprüft werden, sonst bricht Realtime still. - -**Bare-IP-Warnung:** IP-Modus serviert Panel inkl. 2FA + Audit über Klartext-HTTP. Für ein Security-First-Produkt: lauter Warnhinweis in Install-Ausgabe **und** UI; optional self-signed/Internal-CA-Option für IP-only. - ---- - -## 4. Self-Update-Mechanismus - -**Kontroll-/Datenfluss:** -1. **UI-Aktion** (`app/Livewire/System/Update.php`, Vollseite, Deutsch): GHCR-API liefert Tags/Digests + Changelog (read-only Token). Zeigt aktuellen Digest vs. Ziel-Digest + Release-Notes. -2. **Gate:** frische **TOTP-2FA-Challenge** (Step-up, nicht aus der Session geerbt) + `can.update`-Authorization. AuditEvent **vor** jeder Aktion. -3. **Intent schreiben (kein Socket):** App schreibt `update-request.json` ins geteilte Volume `clusev-control`: `{target_digest, target_tag, changelog_hash, requested_by, requested_at, nonce, hmac}`. HMAC-Key **separat von `APP_KEY`**, nur App+Updater bekannt → ein Volume-Write-Angreifer kann kein Update fälschen. -4. **Host-Updater führt aus:** **systemd `clusev-updater.path`** (überwacht die Datei) + `clusev-updater.service` (`Type=oneshot`, läuft als docker-group-User, **nicht** als App). Einmalige Human-sudo-Installation zweier Unit-Files — die einzige Host-Konzession (vom Brief erlaubt). -5. **Update-Script (`docker/update.sh`), idempotent:** - - HMAC + Nonce verifizieren (Anti-Replay); abbrechen wenn `target_digest == current`. - - Compose-Kontext aus Labels entdecken (`com.docker.compose.project=clusev`, `config_files`, `working_dir`) — **null Hardcoding**. - - **cosign keyless verify** (GitHub-OIDC, Identity = Release-Workflow) des Ziel-Digests **vor** Pull/Swap. **Fail-closed.** - - `docker pull …@`, Digest nach Pull erneut prüfen (Tag-Race-Schutz). - - **Snapshot:** aktuellen Digest aller Services + `mariadb-dump` nach `./backups/pre-update-.sql.gz` (0600). - - Digest idempotent in `.env` pinnen (`CLUSEV_IMAGE=…@sha256:…`). - - **Migration (expand/contract):** Queue stoppen → `docker compose run --rm app php artisan migrate --force` während alter App-Code noch läuft (nur sicher für additive Changes) → `reverb`+`queue`, dann `app` **zuletzt** neu erzeugen → Queue **zuletzt** starten. Destruktive (contract) Changes erst in einem **späteren** Release. CI-Lint blockt expand+contract in einem Release. - - **Healthcheck + Auto-Rollback:** `/up` pollen; bei Fehler vorigen Digest re-pinnen, `up -d`, DB-Dump restoren falls Migration lief, `update_status='rolled_back'`. -6. **App beobachtet:** Updater schreibt `update-result.json` zurück; App zeigt Fortschritt/Ergebnis, finaler AuditEvent, Request-Datei geräumt. - -**Sicherheits-Controls (verdichtet):** -- App fasst Docker-Socket **nie** an → Laravel-RCE kann keine Container neu erzeugen. -- Digest-Pinning end-to-end (`:latest`-Default + `OWNER`-Platzhalter **müssen weg**), cosign-Verify fail-closed, HMAC+Nonce auf Intent, frisches 2FA, lückenloses Audit. -- **Caddy bleibt aus der Update-Transaktion** (eigener gepinnter Tag, separat aktualisiert) → ein App-Update kann TLS-Terminierung nie mid-flight wegreißen. - -**Rollback-Story:** Jeder Deploy pinnt einen immutablen Digest und snapshottet den vorigen + DB-Dump. Rollback = vorigen Digest re-pinnen + `compose up` (+ optional DB-Restore). Expand-only-Updates sind immer rollback-sicher. Cross-contract-Rollback ist verlustbehaftet (Writes seit Dump gehen verloren) → in der UI explizit warnen + Extra-Bestätigung. - ---- - -## 5. Datei-/Artefakt-Liste fürs Repo - -**Neu:** -- `install.sh` (Repo-Root, `chmod +x`, raw-curlbar) — Preflight, Clone, idempotente `.env` via `set_kv()`, Proxy-Wiring, Pull/Build, Up, Wait-Health, Migrate, `clusev:install`. -- `docker/caddy/Caddyfile` — env-getemplatet, `@reverb`-Matcher + 2 `reverse_proxy`. -- `app/Console/Commands/Install.php` — artisan `clusev:install`, idempotenter Admin-Bootstrap + Einmalpasswort-Marker. -- `database/migrations/…_add_admin_onboarding_columns.php` — `must_change_password` + `two_factor_*`. -- `app/Services/UpdateService.php` — GHCR-Query, Tag→Digest, HMAC-Intent schreiben, Status lesen, AuditEvents. -- `app/Livewire/System/Update.php` (+ kebab-View) — Update/Rollback-UI, 2FA-Step-up, Live-Fortschritt. -- `docker/update.sh` — host-seitiges Update/Rollback-Script. -- `clusev-updater.path` + `clusev-updater.service` — host systemd Units (einmal sudo). -- `docker-compose.prod.yml` (override/Fragment) — Caddy-Service + `caddy-data`/`caddy-config`/`clusev-control`-Volumes. - -**Geändert:** -- `docker-compose.prod.yml` — `ports:` von `app`/`reverb` entfernen; Caddy als einziger Host-Publisher; Image-Ref auf `CLUSEV_IMAGE`-Digest-Var umstellen; `OWNER`+`:latest`-Default raus. -- `.env.example` — `APP_DOMAIN`, `APP_SCHEME`, `ACME_EMAIL`, `SITE_ADDRESS`, `CLUSEV_IMAGE`, `UPDATE_HMAC_KEY` ergänzen; `APP_ENV=production`/`APP_DEBUG=false` für den Prod-Pfad; Platzhalter-Secrets dokumentieren als „werden vom Installer generiert". -- `README.md` — One-Liner + Fallback-Manual-Appendix. -- **CI** (`.github/workflows/…`) — cosign-Signierung der GHCR-Images + Migrations-Lint (expand/contract). - -**Unverändert (referenziert):** `Dockerfile` (`rm -f .env` im Prod-Stage), `docker/entrypoint.sh`, `docker/nginx/nginx.conf`, `docker/supervisor/supervisord.conf`. - ---- - -## 6. Phasenplan - -**JETZT (v1-Scaffold):** -1. `install.sh` + `set_kv()` + `clusev:install` + Onboarding-Migration → near-one-command-Install mit gesetztem Admin. -2. Caddy-Service + Caddyfile + abgeleitete `.env`-Vars → env-getriebenes TLS (IP-HTTP / Domain-HTTPS / Reverb-wss). -3. Prod-Compose härten: `ports:` raus außer Caddy, `OWNER`/`:latest`-Default ersetzen, `APP_ENV=production`/`APP_DEBUG=false`, `REDIS_PASSWORD` setzen. -4. Authentifizierter Onboarding-Wizard (Passwortwechsel + 2FA-Pflicht). -5. **CI cosign-Signierung zuerst verdrahten** (Voraussetzung für (3) unten, fail-closed). - -**SPÄTER (v1.x):** -6. Voller In-Dashboard-Updater (Intent-Protokoll, `update.sh`, systemd Units, UI, Rollback, Backup-Gate). **Nicht** shippen, bevor CI-Signierung steht. -7. DNS-01-Caddy-Variante für private-IP/Internal-Domain-Deployments. -8. Migrations-Lint (CI) erzwingt expand/contract. -9. Blue/Green-App-Replicas für echte Zero-Downtime (über v1-Single-VM-Scope hinaus). - ---- - -## 7. Entscheidungen, die der Owner VOR dem Bauen bestätigen muss - -1. **Updater-Executor:** systemd `path`+`oneshot` (empfohlen, eine einmalige Human-sudo-Installation) — **oder** separater `clusev-ops`-Container mit gehärtetem Socket-Proxy (kein sudo, aber Socket wieder im Spiel)? Empfehlung: systemd. -2. **Private-IP-Realität:** Ist `10.10.90.136` final/intern? Falls ja und trotzdem eine Domain gewünscht → DNS-01-Build nötig (anderes Caddy-Image + DNS-Provider-Credentials). Bestätigen, ob das in v1 gehört. -3. **Bare-IP-HTTP zulassen?** 2FA/Audit über Klartext ist ein Security-Kompromiss. Erlauben (mit lauter Warnung) oder bei fehlender Domain self-signed/Internal-CA erzwingen? -4. **cosign-Signierung zuerst:** Owner bestätigt, dass CI Images keyless signiert, bevor Self-Update ausgeliefert wird (sonst Verify = Theater, Update muss fail-closed blocken). -5. **Image-Referenz-Variable:** Umstieg von `IMAGE_TAG=latest` auf eine Digest-gepinnte `CLUSEV_IMAGE` in Prod-Compose — bricht keinen Dev-Flow, muss aber bewusst durchgewunken werden. -6. **`curl|bash`-Vertrauen:** One-Liner als Primärpfad akzeptiert, oder gepinnte Checksum + dokumentierter „clone, lesen, dann ausführen"-Pfad als Default für die Security-Bewussten? \ No newline at end of file diff --git a/docs/release-pipeline.md b/docs/release-pipeline.md deleted file mode 100644 index a1efd2b..0000000 --- a/docs/release-pipeline.md +++ /dev/null @@ -1,46 +0,0 @@ -# Release pipeline (maintainers — INTERNAL) - -> This file is `export-ignore` (see `.gitattributes`) — it never reaches the public repo. It may -> name the private repositories. - -Clusev is promoted across three repositories, dev → staging → public: - -| Repo | URL | Role | -|---|---|---| -| Gitea (dev) | `https://git.bave.dev/boban/clusev` | Sole development source. Commit + tag here. | -| GitHub private (staging) | `https://github.com/boksbc/clusev-staging` | Push-mirror of Gitea. Runs CI; betas tested on staging. | -| GitHub public (release) | `https://github.com/clusev/clusev` | Receives only released versions — one clean commit per release, beta + stable tags. Public users install/update from here. | - -Channels follow semver tags: **beta** = `vX.Y.Z-betaN`, **stable** = `vX.Y.Z`. - -No private repo URL is committed: `config/clusev.php` defaults `repository` to the public URL, -`install.sh` derives `CLUSEV_REPOSITORY` from the clone origin into the (gitignored) `.env`, so dev -points at Gitea, staging at GitHub-private, and public users at GitHub-public — automatically. - -## One-time wiring - -1. **Gitea push-mirror** (Gitea → repo → Settings → Mirror → Push Mirror): - - Remote: `https://github.com/boksbc/clusev-staging.git` - - Auth: GitHub username + a GitHub PAT (scope `repo`) as the password. - - Enable "sync when new commits are pushed". -2. **`boksbc/clusev-staging` → Settings:** - - Variable `PUBLIC_REPO_SLUG = clusev/clusev` (the only variable the promotion workflows read). - - Secret `PUBLIC_REPO_TOKEN` = a PAT/deploy token with push to `clusev/clusev`. - - Environment `production` with a required reviewer (gates the promotion workflows). - - (Optional staging deploy) Variable `STAGING_ENABLED=true`, `STAGING_PROJECT_DIR`, and a - self-hosted runner labelled `clusev-staging` on the internal network — GitHub-hosted runners - cannot reach the internal staging host (`.165`). Until then, update staging from its Versions page. - -## Promoting - -- **To staging:** push a beta tag `vX.Y.Z-betaN` to Gitea → mirror → CI tests it; staging runs the beta. -- **To public (beta):** on `boksbc/clusev-staging` → Actions → run **Promote to public (beta channel)** - with the beta tag → publishes it to `clusev/clusev` (beta channel). -- **To stable:** run **Promote beta to stable** with the beta tag + the stable tag → publishes - `vX.Y.Z` to `clusev/clusev` (stable channel). - -There are no custom webhooks: Gitea's push-mirror + GitHub Actions (`on: push tags` / `workflow_dispatch`) -replace them. `promote.sh` copies the release tree with `git archive`, which honours `export-ignore`, so -internal docs (`CLAUDE.md`, `rules.md`, `handoff.md`, `docs/`) never reach the public repo. Phase B will -drive the `workflow_dispatch` promotions from the dev dashboard via the GitHub API with a deploy token in -the encrypted vault — see `docs/superpowers/specs/2026-06-22-3-repo-release-promotion-design.md`. diff --git a/docs/session-handoff.md b/docs/session-handoff.md deleted file mode 100644 index e77e5ce..0000000 --- a/docs/session-handoff.md +++ /dev/null @@ -1,170 +0,0 @@ -# Clusev — Session Handoff (2026-06-14) - -Self-contained ramp-up for the next session. Read `rules.md` (R1–R17) + `CLAUDE.md` first; -this file = current state, open items, and the exact work recipes. `handoff.md` is the -original foundational decisions doc (do not overwrite it). - ---- - -## 1. Current state - -- **Branch:** `feat/v1-foundation` (never commit to `main`). Remote: Gitea - `git.bave.dev/boban/clusev.git`. -- **Shipped tags** (all pushed): - - `v0.3.0` — full bilingual UI (DE default + EN), user-selectable; rule **R16**. - - `v0.4.0` — change the panel domain from the dashboard (DB snapshot → applied on restart; - Caddy on-demand TLS; request-derived Reverb endpoint; anti-lockout middleware). - - `v0.4.1` — fixed a garbled `
` on every signed-in page (Blade raw-block bug → rule - **R17**); made broadcast channels **private** (metrics no longer public). - - `v0.4.2` — Server-Details UX + hardening polish (see §2b): uniform `danger-soft` buttons, - installed-only firewall/fail2ban grid, auto-updates as a neutral preference, SSH-verified - server creation + `pending` status, SSH key-only hint, and the fail2ban/UFW detection fix. - - `v0.4.3` — responsive audit (R7, all views @ 375/768): hardened mobile overflow from long - panel domains (`break-all`/`min-w-0`) and gebannte IPv6 in der fail2ban-Liste. Live pages were - already overflow-free; only data-dependent edge cases fixed. - - `v0.4.4` — stop iOS zoom-on-focus: one unlayered `@media (pointer: coarse)` rule in `app.css` - forces 16px on `input`/`textarea`/`select` on touch devices (phone + tablet any size), desktop - sizing unchanged. - - `v0.6.0` — **WebAuthn / YubiKey** as an optional login 2nd factor (`web-auth/webauthn-lib`), - gated on domain+HTTPS (rpId = active domain; hidden on bare-IP). TOTP + backup codes stay the - required 2FA. Register/list/remove keys in Settings → Security; use a key at the login - challenge. **DEFERRED MANUAL CHECK:** real YubiKey register + login is browser-verifiable only - on a domain+HTTPS host (the IP rpId is invalid here) — the ceremony code is unit-tested at the - option/storage/gating level + Codex-clean, but the end-to-end crypto is unverified until tested - on a domain. - - `v0.5.0` — **account recovery** (spec/plan in `docs/superpowers/`): 2FA backup codes - (encrypted, shown once + downloadable + regenerable, usable at the login challenge), - forgot-password via 2FA/backup code (email-link path gated on a configured mailer), and a - `clusev:reset-admin` CLI lockout fallback. Resets rotate `remember_token`; codes consumed - atomically. **Phase 2 still open: WebAuthn/YubiKey** as a login factor (needs `laragear/webauthn`, - JS ceremonies, HTTPS/domain) — own spec. - - `v0.4.6` — bilingual input validation: added `lang/{de,en}/validation.php` (full Laravel 13 - key set + localized `attributes`); previously all field-validation errors were English-only. - - `v0.4.5` — branding: full favicon/PWA set (`public/favicon.*`, `icon-192/512`, - `site.webmanifest`) from the orange server brand-mark, wired via `partials/head-icons`; custom - bilingual error pages (`resources/views/errors/*` + `lang/errors.php`) replacing Laravel's - defaults, prod `APP_DEBUG=false` pinned, no raw error text. NOTE: unmatched-404 + 503 render in - the DE default (no session pre-StartSession) — deliberate per R16, custom message still shows. -- **Dev stack** runs on this VM via `docker compose` (app container IP **172.18.0.6**, host - `10.10.90.136:80`). Managed fleet host = **10.10.90.162** (Debian / apt / ufw; the only - live-verified target — dnf/zypper/firewalld command strings are built but not live-tested). - ---- - -## 2. RESOLVED — fail2ban & UFW falsely shown "nicht installiert" (fixed `221e49f`) - -**Was:** the Hardening checklist showed `fail2ban` and `Firewall (UFW)` as **OFFEN · -nicht installiert**, while the fail2ban-Status panel below showed `aktiv · 14 gesperrt` with -real SSH-scanner bans. Contradiction. - -**Root cause — two different detection methods (same host):** -- `HardeningService::state` (checklist): installed = `dpkg -l | grep -q '^ii'` / - `rpm -q`; active = `systemctl is-active`. → asked "is the **apt/rpm package** recorded?" -- `Fail2banService::status` + `FirewallTool` (panels): installed = `command -v `; - active = `systemctl is-active`. → asked "is the **binary present + service running**?" -- The host is Debian/apt and fail2ban/ufw is genuinely running, but dpkg does not list it as - `^ii` (source install, or dpkg `rc` config-only state) → checklist false-negative. - -**Fix (committed on `feat/v1-foundation`, NOT yet tagged/pushed):** the checklist's installed -probe in `app/Services/HardeningService.php::state` now ORs three conditions — -package-manager test (`dpkg ^ii` / `rpm -q`) **OR** client binary on PATH (`command -v`) -**OR** service active (`systemctl is-active`) — via the local `$anyOf()` helper, for **both** -fail2ban and the firewall (ufw/firewalld). Consistent with `Fail2banService::status`. Since a -component can never be active without being installed, this only removes FALSE negatives, -never adds a false-positive. `PackageManager`/`FirewallTool` were left unchanged (the OR is -composed in `HardeningService`, not in their primitives). - -Verified: new unit test `tests/Feature/HardeningServiceTest.php` (Debian/apt source-install -fallback) **passes**; Pint clean; Codex review clean (no actionable regression). - -**Still pending (deferred this session):** (a) live acceptance check against fleet host -10.10.90.162 — load Server-Details and confirm fail2ban/ufw now read "installiert"; (b) no -version bump / CHANGELOG / tag / push — the fix lives on the branch only. The prior session's -"Fix fail2ban/UFW false negative" task chip is now **stale** (work is done). - ---- - -## 2b. DONE — Server-Details UX + hardening polish (2026-06-14) - -Seven reported issues fixed on `feat/v1-foundation`, **released as `v0.4.2`** (tagged + pushed). Spec: -`docs/superpowers/specs/2026-06-14-server-details-hardening-polish-design.md`; plan: -`docs/superpowers/plans/2026-06-14-server-details-hardening-polish.md`. - -1. **Buttons unified** — `x-btn`: retired borderless `ghost`/`ghost-danger`, added `danger-soft` - (red-tinted border+bg, always). Löschen/trash now consistent, no hover-only red. All 10 usages - migrated (servers/settings/files). -2. **Firewall + fail2ban panels** — render only when the tool is installed **or** the read failed - (`readError`); 2-col `lg` grid when both, full width when one, hidden when absent. Removed the - redundant "nicht installiert" boxes. -3. **Auto-updates** — reframed as an operator preference: `secure` always true, muted - aktiv/inaktiv chip (never SICHER/OFFEN). `HardeningService` `neutral` row flag. -4. **Create-server** — verifies the SSH login inside the create transaction via - `FleetService::testConnection` (echoes a marker, requires it in output — proves exec, tolerates - missing exit-status). Failure rolls back + shows the SSH reason. New server starts `pending`. -5. **`pending` status** = "Initialisierung" (cyan, never red) across status-pill/dot, server-item, - fleet index, and the details hero. Promotes on first contact. -6. **SSH key-only hint** on the SSH-Passwort-Login checklist row while password auth is on. - -Verified: 16 tests pass (`ButtonComponentTest`, `StatusComponentTest`, `HardeningServiceTest`, -`FleetTestConnectionTest`, `CreateServerTest`, `ServerShowPanelsTest`, `ServerShowSshHintTest`); -Pint clean; R12 browser DE+EN at 375/768/1280 (200, 0 console, 0 leaked tokens); Codex clean after -3 review rounds. **Pre-existing** stock `tests/Feature/ExampleTest.php` still fails (asserts `/` = -200 but the app redirects to login) — unrelated; a task chip was spawned to delete/fix it. - -## 3. Other open items / follow-ups - -- `resources/js/app.js` `dualChart` is **dead code** — the dashboard chart is a server-rendered - SVG, nothing subscribes via Echo. Consider removing it (its `Echo.private('metrics')` is the - only metrics subscriber and it never runs). -- **Caddy on-demand TLS / real ACME issuance is config-validated only** (no Caddy in dev, no - public DNS). App-side logic (`/_caddy/ask`, snapshot, redirects, Reverb tunnel) is - browser-verified; the actual cert issuance needs a real prod + DNS test. -- "Install-with-domain → clear-to-bare-IP" is the rough edge: re-run `install.sh` for a clean - bare-IP realtime (REVERB_* in `.env` otherwise stays domain-shaped). -- firewalld is read-only (rule add/delete is ufw-only) — needs a RHEL/Fedora/openSUSE host to - finish. - ---- - -## 4. Plugins installed this session (need a SESSION RESTART to load) - -Installed via the server's `claude` CLI (`~/.claude/remote/ccd-cli/`), scope **user**, -into the **local** plugin system (separate from the cloud-synced set): -- `caveman@caveman` — ultra-compressed output (token saver); SessionStart hook auto-activates. -- `superpowers@superpowers-dev` (v5.1.0) — workflow skills (TDD, systematic-debugging, …). -- `ui-ux-pro-max@ui-ux-pro-max-skill` (v2.5.0). - -Marketplaces persisted in `~/.claude/plugins/known_marketplaces.json`. They are **not** in the -current session's skill registry until the session is **restarted** (that's why `/caveman:caveman` -still fails this session). After restart: `/caveman:*`, `/superpowers:*`, `ui-ux-pro-max` work. - ---- - -## 5. Work recipes (how to actually verify & ship) - -- **Everything in containers (R8).** Pint: - `docker compose exec -u "${HOST_UID:-1002}:${HOST_GID:-1002}" -T app ./vendor/bin/pint --dirty`. - Build: same prefix + `npm run build`. -- **R12 — verify in a real browser, BOTH locales:** every touched route → HTTP 200 + 0 console - errors AND **inspect the rendered DOM** for leaked `@` / `{{ }}` / `$var` / `group.key` text - (R17 — this is how the v0.3.0/v0.4.0 `
` bug slipped: 200 + clean console but garbage - markup). Recipe: headless Puppeteer image `ghcr.io/puppeteer/puppeteer:latest` on docker - network `clusev_clusev`, target `http://172.18.0.6`, `NODE_PATH=/home/pptruser/node_modules`. - Auth via a **TEMP** route `Route::get('/__peek', fn (Request $r) => [AuthFacade::loginUsingId(1), - redirect($r->query('to','/'))][1])` — **remove before commit** (route:clear after). Locale is - per-user (`user->locale` wins over session), so to test EN the peek must also persist - `user->locale`. Dashboard route is `/` (not `/dashboard`). -- **R15 — Codex:** `export PATH="$HOME/.npm-global/bin:$PATH"; codex review --uncommitted` until - clean (filter node_modules noise). -- **Localization (R16):** every visible string in `lang/{de,en}/.php`, keys identical in - both. Verify: scan all `__('group.key')` usages resolve via `Lang::has($k,'de'/'en')` and that - each `lang/de/*.php` group has the same keys as `lang/en/*.php`. -- **Push:** token in `/home/nexxo/.env.gitea` (`GIT_ACCESS_TOKEN`) — read ONLY at push time, - never echo/commit; sanitize push output - (`sed -E 's#https://[^@]*@#https://@#g; s/[0-9a-f]{40}//g'`). -- **Fleet host safety:** SSH password is in the encrypted vault (never in code/logs). Never - ban/lock-out the control plane (10.10.90.x). - -**Done = ** Pint clean → npm build → R12 (200 + 0 console + DOM scan, DE+EN) → Codex clean → -version bump + CHANGELOG → commit on `feat/v1-foundation` → tag → push (sanitized) → temp -`/__peek` removed. diff --git a/docs/superpowers/handoffs/2026-06-20-clusev-cli-release-handoff.md b/docs/superpowers/handoffs/2026-06-20-clusev-cli-release-handoff.md deleted file mode 100644 index ba1174b..0000000 --- a/docs/superpowers/handoffs/2026-06-20-clusev-cli-release-handoff.md +++ /dev/null @@ -1,131 +0,0 @@ -# Session Handoff — Clusev v0.9.25 (clusev host CLI + short commands + help tab-URL) - -**Date:** 2026-06-20 -**Branch:** `feat/v1-foundation` -**Status:** Code released (tagged + pushed). **Production deploy is PENDING — user-side.** - ---- - -## TL;DR for the next session - -A feature shipping a host CLI (`clusev`), short operator commands, a help "Commands / CLI" -topic, and help-tab URL syncing is **fully implemented, tested, Codex-reviewed, locally -browser-verified, tagged `v0.9.25`, and pushed** to Gitea. The **only remaining step** is the -production deploy (which I cannot do from this host — no SSH to the prod VM) and the post-deploy -domain R12. Pick up there. - ---- - -## What shipped (v0.9.25) - -Five user requests, all delivered: - -1. **Help tabs in the URL** — `#[Url]` on `App\Livewire\Help\Index::$topic`. Clicking a topic - syncs `?topic=`; reload/bookmark/share keep the active tab. Default `overview` is omitted. - (Settings tabs already had `#[Url]`.) -2. **Short host commands** — new `clusev` wrapper installed by `install.sh` to `/usr/local/bin`. - Subcommands: `update`, `reset-admin`, `restart`, `logs`, `ps`/`status`, `migrate`, `artisan`, - `version`, `help`. Wraps the long `docker compose -f …` invocations. Host command - uses a space (`clusev reset-admin`); the underlying artisan name stays `clusev:reset-admin`. -3. **All operator-facing surfaces switched to short commands + help explains the real command** — - new bilingual help topic **"Befehle / CLI"** (`commands`) documents every command with purpose, - when-to-use, and the real underlying command (shown WITHOUT the long `-f ` filename). - Switched: versions update panel, help recovery/updates/domain-tls, settings/system reset hints, - sentinel-write-failed lang strings, MOTD. -4. **Long compose filename hidden, not renamed** — user chose "wrapper hides it" (renaming collides - with the existing dev `docker-compose.yml` and risks orphaning a live stack). A regression test - (`tests/Feature/CommandShortcutsTest.php`) forbids `docker-compose.prod.yml` on any display - surface; the new CHANGELOG entry was even worded to avoid the literal (the `/versions` page - renders CHANGELOG.md — see gotcha below). -5. **Detailed help descriptions** — done in the commands topic + reworded recovery/updates/versions. - -## Spec / plan / commits - -- Spec: `docs/superpowers/specs/2026-06-19-clusev-cli-and-command-shortcuts-design.md` -- Plan: `docs/superpowers/plans/2026-06-19-clusev-cli-and-command-shortcuts.md` -- Commits on `feat/v1-foundation` (newest first): - - `b619b32` chore: release 0.9.25 (config version bump + CHANGELOG) ← **tag `v0.9.25`** - - `5967b7b` fix: shell-escape the install dir when generating the clusev CLI (R15 Codex) - - `8d2b708` feat: short clusev commands in versions panel, lang strings, MOTD (+ test-string fixups) - - `437a225` feat: help tab-URL + Commands/CLI topic + short command copy - - `580c72a` feat: clusev host CLI wrapper for short stack commands -- Branch + tag **pushed** to `git.bave.dev/boban/clusev.git`. Working tree clean. - -## Key files - -- New: `docker/clusev/clusev` (wrapper template, `__CLUSEV_DIR__` placeholder, **unquoted** - assignment — install.sh injects a `printf %q` shell-escaped path; do NOT re-quote it). -- New: `tests/scripts/test-clusev-cli.sh` (host bash test, 8 checks incl. injection-resistance). -- New: `resources/views/livewire/help/content/{de,en}/commands.blade.php`. -- New: `tests/Feature/CommandShortcutsTest.php` (leak guard + short-command assertions). -- Changed: `install.sh` (phase 9 renders+installs the CLI), `app/Livewire/Help/Index.php`, - `lang/{de,en}/{help,versions,settings,system}.php`, help partials (recovery, updates, domain-tls), - `resources/views/livewire/versions/index.blade.php`, `docker/motd/00-clusev`, - `tests/Feature/{HelpPageTest,ForgotPasswordSmtpAwareTest}.php`, `config/clusev.php`, `CHANGELOG.md`. - -## Verification done - -- `php artisan test` (whole suite): **236 passed**. -- `bash tests/scripts/test-clusev-cli.sh`: green (incl. a real injection-resistance check — - confirmed the old quoted-rendering was exploitable, the new one is not). -- `npm run build`: OK. -- **Codex R15: PASS.** It found ONE security issue — unescaped `$(pwd)` rendered into the generated - CLI, a root-code-injection vector via a hostile checkout path. Fixed (`printf %q` + unquoted - assignment) and re-reviewed PASS, including the embedded-newline edge case. -- **Local R12** (headless Chrome / puppeteer Docker against the dev stack on `http://localhost`): - `/help` 200, URL syncs to `?topic=commands` on click, deep-link reload keeps the topic, commands - content renders, **0 console/page/network errors**; `/versions` 200, shows `sudo clusev update`, - no `docker-compose.prod.yml`. (Used gkonrad id=4, 2FA-off; temp password set + **restored** to the - original hash afterwards.) - ---- - -## NEXT STEP (start here) - -### 1. Deploy v0.9.25 to production (user runs this on the prod VM) - -From this host I have **no SSH access** to the prod VM (`10.10.90.165` — publickey/password denied) -and there is **no local prod stack**, so I cannot deploy. The operator runs, on the prod VM: - -```bash -cd clusev && sudo ./update.sh -``` - -(This is the release that first ships the `clusev` wrapper, so use `./update.sh` this once; the -update runs `install.sh` which installs `/usr/local/bin/clusev`, so from the NEXT release -`sudo clusev update` works. Alternatively click "Jetzt aktualisieren" in the panel.) - -After it finishes, `which clusev` on the VM should resolve. - -### 2. Domain R12 (I CAN do this over HTTP after the deploy) - -The public domain `panel.test.bave.dev` → external proxy `10.10.20.20` (Zoraxy) → prod VM. Only -HTTP is needed (no SSH), via the puppeteer Docker image. **Only meaningful AFTER the deploy** (the -domain serves the old image until then). Verify on the domain: -- `/help` 200, click "Befehle / CLI" → URL `?topic=commands`, reload keeps it, DE/EN switch, no - console errors. -- `/versions` shows `sudo clusev update`. -- On the VM shell (read-only): `clusev help`, `clusev ps`, `clusev version`. Do NOT run - `reset-admin`/`update` against the real admin. - ---- - -## Constraints & gotchas (carry forward) - -- **Respond in German** in chat (code/commits/docs stay English). Caveman-terse mode was active. -- **Gitea token**: read ONLY at push time from `/home/nexxo/.env.gitea` (`GIT_ACCESS_TOKEN`), never - echo/commit; sanitize push output `sed -E 's#https://[^@]*@#https://@#g; s/[0-9a-f]{40,}//g'`. -- **De-Claude history**: commits intentionally carry NO `Co-Authored-By: Claude` trailer (the - project wants Claude-free history; history rewrite + force-push only at beta with explicit OK). -- **Dev DB**: never `Server::create` to inspect; use RefreshDatabase tests / `Blade::render`. -- **View-cache flake**: run `php artisan view:clear` before browser-verifying a `.blade.php` change; - for `artisan test` flakiness isolate with `VIEW_COMPILED_PATH=/tmp/vc-xxx`. -- **`/versions` renders CHANGELOG.md** — whole-page leak scans flag historical changelog prose - (e.g. the v0.4.1 entry literally contains `@php`); scope leak checks to UI chrome, not changelog. -- Memory index updated: see `clusev-prod-deploy-user-side`, `clusev-versions-page-renders-changelog`. - -## Not done / possible follow-ups - -- Production deploy + domain R12 (above) — the only open item for THIS feature. -- (Backlog) de-Claude history rewrite, deferred to beta. -- (Optional) bash completion for `clusev`; was intentionally left out (YAGNI). diff --git a/docs/superpowers/plans/2026-06-14-account-recovery.md b/docs/superpowers/plans/2026-06-14-account-recovery.md deleted file mode 100644 index 04fdfcf..0000000 --- a/docs/superpowers/plans/2026-06-14-account-recovery.md +++ /dev/null @@ -1,1098 +0,0 @@ -# Account Recovery Implementation Plan (Phase 1) - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Add account recovery to Clusev's custom auth — 2FA backup codes, a forgot-password flow, and a CLI lockout fallback. - -**Architecture:** Recovery codes live encrypted on the `users` row (`encrypted:array`), generated on 2FA confirm, usable at the existing TOTP challenge and as the forgot-password proof. Forgot-password is 2FA-code-gated (email link only when SMTP is set). A `clusev:reset-admin` command is the shell last-resort. - -**Tech Stack:** Laravel 13, Livewire 3, `pragmarx/google2fa-qrcode` (existing TOTP), Pest/PHPUnit. All tooling in the `app` container (R8). UI copy bilingual via `lang/{de,en}/auth.php` (R16). Password policy reused: `Password::min(12)->mixedCase()->numbers()`. - -**Conventions:** -- Tests: `docker compose exec -T app php artisan test --filter=` -- Migrate (test DB auto): tests use `RefreshDatabase`; dev: `docker compose exec -T app php artisan migrate` -- Pint: `docker compose exec -T -u "1002:1002" app ./vendor/bin/pint --dirty` -- Commits: Conventional Commits + the Co-Authored-By trailer. - ---- - -### Task 1: Recovery-code storage + User methods - -**Files:** -- Create: `database/migrations/2026_06_14_000001_add_two_factor_recovery_codes_to_users_table.php` -- Modify: `app/Models/User.php` -- Test: `tests/Feature/UserRecoveryCodesTest.php` - -- [ ] **Step 1: Migration** - -```php -text('two_factor_recovery_codes')->nullable()->after('two_factor_secret'); - }); - } - - public function down(): void - { - Schema::table('users', function (Blueprint $table) { - $table->dropColumn('two_factor_recovery_codes'); - }); - } -}; -``` - -- [ ] **Step 2: Write the failing test** - -```php -create(); - $codes = $user->replaceRecoveryCodes(); - - $this->assertCount(8, $codes); - $this->assertCount(8, array_unique($codes)); - $this->assertSame($codes, $user->fresh()->recoveryCodes()); - $this->assertTrue($user->fresh()->hasRecoveryCodes()); - } - - public function test_use_recovery_code_consumes_once(): void - { - $user = User::factory()->create(); - $codes = $user->replaceRecoveryCodes(); - $one = $codes[0]; - - $this->assertTrue($user->useRecoveryCode(' '.$one.' ')); // trims - $this->assertFalse($user->fresh()->useRecoveryCode($one)); // already consumed - $this->assertFalse($user->fresh()->useRecoveryCode('not-a-code')); - $this->assertCount(7, $user->fresh()->recoveryCodes()); - } -} -``` - -- [ ] **Step 3: Run test to verify it fails** - -Run: `docker compose exec -T app php artisan test --filter=UserRecoveryCodesTest` -Expected: FAIL (`replaceRecoveryCodes` undefined). - -- [ ] **Step 4: Add cast + methods to `app/Models/User.php`** - -Add `'two_factor_recovery_codes' => 'encrypted:array'` to `casts()`. Add `two_factor_recovery_codes` to the `#[Hidden(...)]` attribute list. Add `use Illuminate\Support\Str;` and these methods: - -```php - /** @return array The 8 freshly generated one-time codes (also persisted). */ - public function replaceRecoveryCodes(): array - { - $codes = array_map( - fn () => Str::random(10).'-'.Str::random(10), - range(1, 8), - ); - - $this->forceFill(['two_factor_recovery_codes' => $codes])->save(); - - return $codes; - } - - /** @return array */ - public function recoveryCodes(): array - { - return $this->two_factor_recovery_codes ?? []; - } - - public function hasRecoveryCodes(): bool - { - return count($this->recoveryCodes()) > 0; - } - - /** Consume one recovery code (one-time). Returns true if it was valid + removed. */ - public function useRecoveryCode(string $code): bool - { - $code = trim($code); - $codes = $this->recoveryCodes(); - - if (! in_array($code, $codes, true)) { - return false; - } - - $this->forceFill([ - 'two_factor_recovery_codes' => array_values(array_filter($codes, fn ($c) => $c !== $code)), - ])->save(); - - return true; - } -``` - -- [ ] **Step 5: Run test to verify it passes** - -Run: `docker compose exec -T app php artisan test --filter=UserRecoveryCodesTest` -Expected: PASS (2 tests). - -- [ ] **Step 6: Migrate dev DB + commit** - -```bash -docker compose exec -T app php artisan migrate -docker compose exec -T -u "1002:1002" app ./vendor/bin/pint --dirty -git add database/migrations/2026_06_14_000001_add_two_factor_recovery_codes_to_users_table.php app/Models/User.php tests/Feature/UserRecoveryCodesTest.php -git commit -m "feat(auth): store 2FA recovery codes (encrypted) on users" -``` - ---- - -### Task 2: Challenge accepts a backup code - -**Files:** -- Modify: `app/Livewire/Auth/TwoFactorChallenge.php` -- Modify: `resources/views/livewire/auth/two-factor-challenge.blade.php` -- Modify: `lang/de/auth.php`, `lang/en/auth.php` -- Test: `tests/Feature/TwoFactorChallengeRecoveryTest.php` - -- [ ] **Step 1: Write the failing test** - -```php -create([ - 'two_factor_secret' => (new \PragmaRX\Google2FAQRCode\Google2FA)->generateSecretKey(), - 'two_factor_confirmed_at' => now(), - ]); - } - - public function test_recovery_code_logs_in_and_is_consumed(): void - { - $user = $this->enrolledUser(); - $code = $user->replaceRecoveryCodes()[0]; - session()->put('2fa.user', $user->id); - - Livewire::test(TwoFactorChallenge::class) - ->set('code', $code) - ->call('verify'); - - $this->assertAuthenticatedAs($user); - $this->assertFalse($user->fresh()->useRecoveryCode($code)); // consumed - } - - public function test_invalid_code_fails(): void - { - $user = $this->enrolledUser(); - $user->replaceRecoveryCodes(); - session()->put('2fa.user', $user->id); - - Livewire::test(TwoFactorChallenge::class) - ->set('code', '000000') - ->call('verify') - ->assertHasErrors('code'); - - $this->assertGuest(); - } -} -``` - -- [ ] **Step 2: Run test to verify it fails** - -Run: `docker compose exec -T app php artisan test --filter=TwoFactorChallengeRecoveryTest` -Expected: FAIL (recovery code rejected → not authenticated). - -- [ ] **Step 3: Accept a recovery code in `verify()`** - -In `app/Livewire/Auth/TwoFactorChallenge.php`, replace the validity check block: - -```php - $valid = (new Google2FA)->verifyKey($user->two_factor_secret, preg_replace('/\s+/', '', $this->code)); - - if (! $valid) { - RateLimiter::hit($key, 60); - throw ValidationException::withMessages(['code' => __('auth.invalid_code')]); - } -``` - -with (TOTP first, then a one-time recovery code): - -```php - $valid = (new Google2FA)->verifyKey($user->two_factor_secret, preg_replace('/\s+/', '', $this->code)) - || $user->useRecoveryCode($this->code); - - if (! $valid) { - RateLimiter::hit($key, 60); - throw ValidationException::withMessages(['code' => __('auth.invalid_code')]); - } -``` - -- [ ] **Step 4: Run test to verify it passes** - -Run: `docker compose exec -T app php artisan test --filter=TwoFactorChallengeRecoveryTest` -Expected: PASS (2 tests). - -- [ ] **Step 5: Add the challenge hint (view + lang)** - -`lang/de/auth.php` add: `'challenge_recovery_hint' => 'Authenticator verloren? Gib einen deiner Backup-Codes ein.',` -`lang/en/auth.php` add: `'challenge_recovery_hint' => 'Lost your authenticator? Enter one of your backup codes.',` - -In `resources/views/livewire/auth/two-factor-challenge.blade.php`, after the code input's `@error(...)` block, add: -```blade -

{{ __('auth.challenge_recovery_hint') }}

-``` - -- [ ] **Step 6: Pint + commit** - -```bash -docker compose exec -T -u "1002:1002" app ./vendor/bin/pint --dirty -git add app/Livewire/Auth/TwoFactorChallenge.php resources/views/livewire/auth/two-factor-challenge.blade.php lang/de/auth.php lang/en/auth.php tests/Feature/TwoFactorChallengeRecoveryTest.php -git commit -m "feat(auth): accept a 2FA backup code at the login challenge" -``` - ---- - -### Task 3: Recovery-codes view (show once + download + regenerate) + setup hook - -**Files:** -- Create: `app/Livewire/Auth/RecoveryCodes.php` -- Create: `resources/views/livewire/auth/recovery-codes.blade.php` -- Modify: `app/Livewire/Auth/TwoFactorSetup.php`, `routes/web.php`, `lang/de/auth.php`, `lang/en/auth.php` -- Modify: `resources/views/livewire/settings/index.blade.php` (add a link — see step 6) -- Test: `tests/Feature/RecoveryCodesViewTest.php` - -- [ ] **Step 1: Write the failing test** - -```php -create(['two_factor_secret' => null, 'two_factor_confirmed_at' => null]); - $this->actingAs($user); - $secret = (new Google2FA)->generateSecretKey(); - $code = (new Google2FA)->getCurrentOtp($secret); - - Livewire::test(TwoFactorSetup::class) - ->set('secret', $secret) - ->set('code', $code) - ->call('confirm') - ->assertRedirect(route('two-factor.recovery')); - - $this->assertCount(8, $user->fresh()->recoveryCodes()); - } - - public function test_regenerate_replaces_codes(): void - { - $user = User::factory()->create(['two_factor_secret' => 'x', 'two_factor_confirmed_at' => now()]); - $old = $user->replaceRecoveryCodes(); - $this->actingAs($user); - - Livewire::test(RecoveryCodes::class)->call('regenerate'); - - $this->assertNotSame($old, $user->fresh()->recoveryCodes()); - $this->assertCount(8, $user->fresh()->recoveryCodes()); - } - - public function test_download_returns_codes_as_text(): void - { - $user = User::factory()->create(); - $user->replaceRecoveryCodes(); - $this->actingAs($user); - - $res = $this->get(route('two-factor.recovery.download')); - $res->assertOk(); - $res->assertHeader('content-type', 'text/plain; charset=UTF-8'); - $this->assertStringContainsString($user->recoveryCodes()[0], $res->streamedContent()); - } -} -``` - -- [ ] **Step 2: Run test to verify it fails** - -Run: `docker compose exec -T app php artisan test --filter=RecoveryCodesViewTest` -Expected: FAIL (route/component missing). - -- [ ] **Step 3: Create `app/Livewire/Auth/RecoveryCodes.php`** - -```php -replaceRecoveryCodes(); - - AuditEvent::create([ - 'user_id' => Auth::id(), - 'actor' => Auth::user()->name ?? 'system', - 'action' => 'two_factor.recovery_regenerate', - 'target' => Auth::user()->email, - 'ip' => request()->ip(), - ]); - - $this->dispatch('notify', message: __('auth.recovery_regenerated')); - } - - public function render() - { - return view('livewire.auth.recovery-codes', [ - 'codes' => Auth::user()->recoveryCodes(), - ])->title(__('auth.title_recovery')); - } -} -``` - -- [ ] **Step 4: View `resources/views/livewire/auth/recovery-codes.blade.php`** - -```blade -
-
-

{{ __('auth.two_factor') }}

-

{{ __('auth.recovery_heading') }}

-

{{ __('auth.recovery_subtitle') }}

-
- -
- -

{{ __('auth.recovery_warning') }}

-
- -
- @foreach ($codes as $c) - {{ $c }} - @endforeach -
- -
- - {{ __('auth.recovery_download') }} - - - {{ __('auth.recovery_regenerate') }} - - {{ __('auth.recovery_done') }} -
-
-``` - -- [ ] **Step 5: Hook setup + routes + download** - -In `app/Livewire/Auth/TwoFactorSetup.php::confirm()`, after the `forceFill([... 'two_factor_confirmed_at' => now()])->save();` add `Auth::user()->replaceRecoveryCodes();` and change the redirect to `return $this->redirect(route('two-factor.recovery'), navigate: true);`. - -In `routes/web.php`, inside the `auth` group (before the `EnsureSecurityOnboarded` group, so a just-enrolled user can see codes before the dashboard gate — place beside `two-factor.setup`): -```php - Route::get('/two-factor/recovery-codes', Auth\RecoveryCodes::class)->name('two-factor.recovery'); - Route::get('/two-factor/recovery-codes/download', function () { - $codes = \Illuminate\Support\Facades\Auth::user()->recoveryCodes(); - - return response()->streamDownload( - fn () => print(implode("\n", $codes)."\n"), - 'clusev-recovery-codes.txt', - ['Content-Type' => 'text/plain; charset=UTF-8'], - ); - })->name('two-factor.recovery.download'); -``` -(`use App\Livewire\Auth;` already imported.) - -- [ ] **Step 6: Settings link + lang** - -In `resources/views/livewire/settings/index.blade.php`, in the security/2FA section add (near the disable-2FA control): -```blade -{{ __('auth.recovery_manage') }} -``` - -`lang/de/auth.php` add: -```php - 'title_recovery' => 'Backup-Codes — Clusev', - 'recovery_heading' => 'Backup-Codes', - 'recovery_subtitle' => 'Mit diesen Einmal-Codes meldest du dich an, wenn du keinen Authenticator hast.', - 'recovery_warning' => 'Speichere diese Codes jetzt sicher ab — jeder Code funktioniert genau einmal und sie werden nicht erneut im Klartext gezeigt, wenn du sie nicht herunterlädst.', - 'recovery_download' => 'Als Datei herunterladen', - 'recovery_regenerate' => 'Neu erzeugen', - 'recovery_regenerate_confirm' => 'Neue Codes erzeugen? Die alten werden ungültig.', - 'recovery_regenerated' => 'Neue Backup-Codes erzeugt.', - 'recovery_done' => 'Gespeichert — weiter', - 'recovery_manage' => 'Backup-Codes verwalten', -``` -`lang/en/auth.php` add: -```php - 'title_recovery' => 'Backup codes — Clusev', - 'recovery_heading' => 'Backup codes', - 'recovery_subtitle' => 'Use these one-time codes to sign in when you do not have your authenticator.', - 'recovery_warning' => 'Save these codes somewhere safe now — each works exactly once and they are not shown again in plain text unless you download them.', - 'recovery_download' => 'Download as file', - 'recovery_regenerate' => 'Regenerate', - 'recovery_regenerate_confirm' => 'Generate new codes? The old ones stop working.', - 'recovery_regenerated' => 'New backup codes generated.', - 'recovery_done' => 'Saved — continue', - 'recovery_manage' => 'Manage backup codes', -``` - -- [ ] **Step 7: Run tests, Pint, build, commit** - -Run: `docker compose exec -T app php artisan test --filter=RecoveryCodesViewTest` → PASS (3). -```bash -docker compose exec -T -u "1002:1002" app ./vendor/bin/pint --dirty -docker compose exec -T -u "1002:1002" app npm run build -git add app/Livewire/Auth/RecoveryCodes.php resources/views/livewire/auth/recovery-codes.blade.php app/Livewire/Auth/TwoFactorSetup.php routes/web.php resources/views/livewire/settings/index.blade.php lang/de/auth.php lang/en/auth.php tests/Feature/RecoveryCodesViewTest.php -git commit -m "feat(auth): show + download + regenerate 2FA backup codes" -``` - ---- - -### Task 4: Forgot-password (2FA-code proof) - -**Files:** -- Create: `app/Livewire/Auth/ForgotPassword.php`, `resources/views/livewire/auth/forgot-password.blade.php` -- Modify: `routes/web.php`, `resources/views/livewire/auth/login.blade.php`, `lang/de/auth.php`, `lang/en/auth.php` -- Test: `tests/Feature/ForgotPasswordTest.php` - -- [ ] **Step 1: Write the failing test** - -```php -create([ - 'email' => 'admin@clusev.local', - 'two_factor_secret' => (new Google2FA)->generateSecretKey(), - 'two_factor_confirmed_at' => now(), - ]); - } - - public function test_totp_resets_password(): void - { - $user = $this->user(); - $otp = (new Google2FA)->getCurrentOtp($user->two_factor_secret); - - Livewire::test(ForgotPassword::class) - ->set('email', $user->email)->set('code', $otp) - ->set('password', 'NewPass1234')->set('password_confirmation', 'NewPass1234') - ->call('reset')->assertHasNoErrors(); - - $this->assertTrue(Hash::check('NewPass1234', $user->fresh()->password)); - } - - public function test_backup_code_resets_and_is_consumed(): void - { - $user = $this->user(); - $code = $user->replaceRecoveryCodes()[0]; - - Livewire::test(ForgotPassword::class) - ->set('email', $user->email)->set('code', $code) - ->set('password', 'NewPass1234')->set('password_confirmation', 'NewPass1234') - ->call('reset')->assertHasNoErrors(); - - $this->assertTrue(Hash::check('NewPass1234', $user->fresh()->password)); - $this->assertFalse($user->fresh()->useRecoveryCode($code)); - } - - public function test_wrong_code_does_not_reset(): void - { - $user = $this->user(); - $old = $user->password; - - Livewire::test(ForgotPassword::class) - ->set('email', $user->email)->set('code', '000000') - ->set('password', 'NewPass1234')->set('password_confirmation', 'NewPass1234') - ->call('reset')->assertHasErrors('code'); - - $this->assertSame($old, $user->fresh()->password); - } - - public function test_weak_password_rejected(): void - { - $user = $this->user(); - $otp = (new Google2FA)->getCurrentOtp($user->two_factor_secret); - - Livewire::test(ForgotPassword::class) - ->set('email', $user->email)->set('code', $otp) - ->set('password', 'short')->set('password_confirmation', 'short') - ->call('reset')->assertHasErrors('password'); - } -} -``` - -- [ ] **Step 2: Run test to verify it fails** - -Run: `docker compose exec -T app php artisan test --filter=ForgotPasswordTest` -Expected: FAIL (component missing). - -- [ ] **Step 3: Create `app/Livewire/Auth/ForgotPassword.php`** - -```php -validate([ - 'email' => ['required', 'email'], - 'code' => ['required', 'string'], - 'password' => ['required', 'confirmed', Password::min(12)->mixedCase()->numbers()], - ]); - - $key = 'forgot:'.md5(strtolower($this->email).'|'.request()->ip()); - if (RateLimiter::tooManyAttempts($key, 5)) { - throw ValidationException::withMessages([ - 'code' => __('auth.too_many_attempts', ['seconds' => RateLimiter::availableIn($key)]), - ]); - } - - $user = User::where('email', $this->email)->first(); - $clean = preg_replace('/\s+/', '', $this->code); - - $ok = $user - && $user->hasTwoFactorEnabled() - && ((new Google2FA)->verifyKey($user->two_factor_secret, $clean) || $user->useRecoveryCode($this->code)); - - if (! $ok) { - RateLimiter::hit($key, 60); - // Generic message — never reveal whether the email exists. - throw ValidationException::withMessages(['code' => __('auth.reset_invalid')]); - } - - RateLimiter::clear($key); - $user->forceFill(['password' => Hash::make($this->password), 'must_change_password' => false])->save(); - - AuditEvent::create([ - 'user_id' => $user->id, - 'actor' => $user->name ?? 'system', - 'action' => 'password.reset', - 'target' => $user->email, - 'ip' => request()->ip(), - ]); - - session()->flash('status', __('auth.reset_done')); - - return $this->redirect(route('login'), navigate: true); - } - - public function render() - { - return view('livewire.auth.forgot-password')->title(__('auth.title_forgot')); - } -} -``` - -- [ ] **Step 4: View `resources/views/livewire/auth/forgot-password.blade.php`** - -Mirror the login view's field classes ($label/$field/$err can be redefined inline as in login). Minimal structure: -```blade -@php - $label = 'mb-1.5 block font-mono text-[11px] uppercase tracking-wider text-ink-3'; - $field = 'h-11 w-full rounded-md border border-line bg-inset px-3 text-ink placeholder:text-ink-4 focus:border-accent/40 focus:outline-none'; - $err = 'mt-1.5 flex items-center gap-1.5 font-mono text-[11px] text-offline'; -@endphp -
-
-

{{ __('auth.security') }}

-

{{ __('auth.forgot_heading') }}

-

{{ __('auth.forgot_subtitle') }}

-
- -
-
- - - @error('email')

{{ $message }}

@enderror -
-
- - - @error('code')

{{ $message }}

@enderror -

{{ __('auth.forgot_code_hint') }}

-
-
- - - @error('password')

{{ $message }}

@enderror -
-
- - -
- {{ __('auth.forgot_submit') }} -
- - @if ($this->mailEnabled()) -

{{ __('auth.forgot_email_alt') }}

- @endif - - {{ __('auth.back_to_login') }} -
-``` - -- [ ] **Step 5: Route + login link** - -`routes/web.php` guest group, add: -```php - Route::get('/forgot-password', Auth\ForgotPassword::class)->name('password.request'); -``` -`resources/views/livewire/auth/login.blade.php` — after the password field's `@error` block (before the remember-me label), add: -```blade -{{ __('auth.forgot_link') }} -``` - -- [ ] **Step 6: Lang keys** - -`lang/de/auth.php` add: `'title_forgot' => 'Passwort vergessen — Clusev'`, `'forgot_link' => 'Passwort vergessen?'`, `'forgot_heading' => 'Passwort zurücksetzen'`, `'forgot_subtitle' => 'Setze dein Passwort mit deinem 2FA-Code oder einem Backup-Code zurück.'`, `'forgot_code' => '2FA-Code oder Backup-Code'`, `'forgot_code_hint' => '6-stelliger Authenticator-Code oder einer deiner Backup-Codes.'`, `'forgot_submit' => 'Passwort zurücksetzen'`, `'forgot_email_alt' => 'E-Mail-Reset ist konfiguriert — Link auf Anfrage verfügbar.'`, `'reset_invalid' => 'E-Mail oder Code ungültig.'`, `'reset_done' => 'Passwort geändert. Bitte neu anmelden.'` -`lang/en/auth.php` add: `'title_forgot' => 'Forgot password — Clusev'`, `'forgot_link' => 'Forgot password?'`, `'forgot_heading' => 'Reset password'`, `'forgot_subtitle' => 'Reset your password with your 2FA code or a backup code.'`, `'forgot_code' => '2FA code or backup code'`, `'forgot_code_hint' => 'Your 6-digit authenticator code or one of your backup codes.'`, `'forgot_submit' => 'Reset password'`, `'forgot_email_alt' => 'Email reset is configured — link available on request.'`, `'reset_invalid' => 'Invalid email or code.'`, `'reset_done' => 'Password changed. Please sign in again.'` - -- [ ] **Step 7: Run tests, Pint, build, commit** - -Run: `docker compose exec -T app php artisan test --filter=ForgotPasswordTest` → PASS (4). -```bash -docker compose exec -T -u "1002:1002" app ./vendor/bin/pint --dirty -docker compose exec -T -u "1002:1002" app npm run build -git add app/Livewire/Auth/ForgotPassword.php resources/views/livewire/auth/forgot-password.blade.php routes/web.php resources/views/livewire/auth/login.blade.php lang/de/auth.php lang/en/auth.php tests/Feature/ForgotPasswordTest.php -git commit -m "feat(auth): forgot-password reset via 2FA or backup code" -``` - ---- - -### Task 4b: Email reset link (gated on SMTP) - -Builds the secondary email-link path so it activates the moment a real mailer is configured. With `MAIL_MAILER=log` it stays hidden in the UI; tested with `Mail::fake` + a `mail.default` override. `User` already uses Laravel's `CanResetPassword` trait, and `password_reset_tokens` exists. - -**Files:** -- Create: `app/Livewire/Auth/ResetPassword.php`, `resources/views/livewire/auth/reset-password.blade.php` -- Modify: `app/Livewire/Auth/ForgotPassword.php` (add `sendResetLink()`), `resources/views/livewire/auth/forgot-password.blade.php` (conditional button), `routes/web.php`, `lang/de/auth.php`, `lang/en/auth.php` -- Test: `tests/Feature/EmailResetTest.php` - -- [ ] **Step 1: Write the failing test** - -```php - 'log']); - Livewire::test(ForgotPassword::class)->assertReturned(fn () => true); // smoke - $this->assertFalse((new ForgotPassword)->mailEnabled()); - } - - public function test_send_link_dispatches_notification_when_mail_configured(): void - { - config(['mail.default' => 'smtp']); - Notification::fake(); - $user = User::factory()->create(['email' => 'admin@clusev.local']); - - Livewire::test(ForgotPassword::class) - ->set('email', 'admin@clusev.local') - ->call('sendResetLink') - ->assertHasNoErrors(); - - Notification::assertSentTo($user, ResetPasswordNotification::class); - } - - public function test_reset_password_with_token_sets_new_password(): void - { - $user = User::factory()->create(); - $token = Password::createToken($user); - - Livewire::test(ResetPassword::class, ['token' => $token, 'email' => $user->email]) - ->set('password', 'NewPass1234')->set('password_confirmation', 'NewPass1234') - ->call('reset')->assertHasNoErrors(); - - $this->assertTrue(Hash::check('NewPass1234', $user->fresh()->password)); - } -} -``` - -- [ ] **Step 2: Run test to verify it fails** - -Run: `docker compose exec -T app php artisan test --filter=EmailResetTest` -Expected: FAIL (`sendResetLink`/`ResetPassword` missing). - -- [ ] **Step 3: Add `sendResetLink()` to `ForgotPassword`** - -```php - /** Secondary path: email a reset link (only meaningful when a real mailer is set). */ - public function sendResetLink(): void - { - if (! $this->mailEnabled()) { - return; - } - - $this->validate(['email' => ['required', 'email']]); - \Illuminate\Support\Facades\Password::sendResetLink(['email' => $this->email]); - - // Generic — never reveal whether the email exists. - $this->dispatch('notify', message: __('auth.reset_link_sent')); - } -``` - -- [ ] **Step 4: Create `app/Livewire/Auth/ResetPassword.php`** - -```php -token = $token; - $this->email = (string) request()->query('email', ''); - } - - public function reset() - { - $this->validate([ - 'email' => ['required', 'email'], - 'password' => ['required', 'confirmed', PasswordRule::min(12)->mixedCase()->numbers()], - ]); - - $status = Password::reset( - ['email' => $this->email, 'password' => $this->password, 'password_confirmation' => $this->password_confirmation, 'token' => $this->token], - function ($user) { - $user->forceFill([ - 'password' => Hash::make($this->password), - 'must_change_password' => false, - 'remember_token' => Str::random(60), - ])->save(); - event(new PasswordReset($user)); - }, - ); - - if ($status !== Password::PasswordReset) { - throw ValidationException::withMessages(['email' => __('auth.reset_token_invalid')]); - } - - session()->flash('status', __('auth.reset_done')); - - return $this->redirect(route('login'), navigate: true); - } - - public function render() - { - return view('livewire.auth.reset-password')->title(__('auth.title_forgot')); - } -} -``` - -- [ ] **Step 5: View + route + conditional button + lang** - -`resources/views/livewire/auth/reset-password.blade.php`: same field classes as forgot-password; fields email (readonly-ish), password, password_confirmation; `wire:submit="reset"`; submit `auth.forgot_submit`; back-to-login link. - -`routes/web.php` guest group: `Route::get('/reset-password/{token}', Auth\ResetPassword::class)->name('password.reset');` - -In `forgot-password.blade.php`, replace the `@if ($this->mailEnabled())` notice with an actionable button: -```blade -@if ($this->mailEnabled()) - {{ __('auth.forgot_send_email') }} -@endif -``` - -Lang — `lang/de/auth.php`: `'forgot_send_email' => 'Reset-Link per E-Mail senden'`, `'reset_link_sent' => 'Falls die E-Mail existiert, wurde ein Reset-Link versendet.'`, `'reset_token_invalid' => 'Reset-Link ungültig oder abgelaufen.'`. `lang/en/auth.php`: `'forgot_send_email' => 'Email me a reset link'`, `'reset_link_sent' => 'If the email exists, a reset link has been sent.'`, `'reset_token_invalid' => 'Reset link is invalid or expired.'`. (Remove the now-unused `forgot_email_alt` from Task 4 if present, or leave it unused.) - -- [ ] **Step 6: Run tests, Pint, build, commit** - -Run: `docker compose exec -T app php artisan test --filter=EmailResetTest` → PASS (3). -```bash -docker compose exec -T -u "1002:1002" app ./vendor/bin/pint --dirty -docker compose exec -T -u "1002:1002" app npm run build -git add app/Livewire/Auth/ResetPassword.php resources/views/livewire/auth/reset-password.blade.php app/Livewire/Auth/ForgotPassword.php resources/views/livewire/auth/forgot-password.blade.php routes/web.php lang/de/auth.php lang/en/auth.php tests/Feature/EmailResetTest.php -git commit -m "feat(auth): email reset-link path (gated on a configured mailer)" -``` - ---- - -### Task 5: CLI lockout fallback `clusev:reset-admin` - -**Files:** -- Create: `app/Console/Commands/ResetAdmin.php` -- Test: `tests/Feature/ResetAdminCommandTest.php` - -- [ ] **Step 1: Write the failing test** - -```php -create([ - 'email' => 'admin@clusev.local', - 'two_factor_secret' => 'x', 'two_factor_confirmed_at' => now(), - ]); - $user->replaceRecoveryCodes(); - - $this->artisan('clusev:reset-admin', ['--email' => 'admin@clusev.local', '--password' => 'BrandNew1234', '--disable-2fa' => true]) - ->assertExitCode(0); - - $user->refresh(); - $this->assertTrue(Hash::check('BrandNew1234', $user->password)); - $this->assertNull($user->two_factor_secret); - $this->assertNull($user->two_factor_confirmed_at); - $this->assertFalse($user->hasRecoveryCodes()); - } - - public function test_unknown_email_fails(): void - { - $this->artisan('clusev:reset-admin', ['--email' => 'nope@x.test'])->assertExitCode(1); - } -} -``` - -- [ ] **Step 2: Run test to verify it fails** - -Run: `docker compose exec -T app php artisan test --filter=ResetAdminCommandTest` -Expected: FAIL (command missing). - -- [ ] **Step 3: Create `app/Console/Commands/ResetAdmin.php`** - -```php -option('email'); - $user = $email !== '' - ? User::where('email', $email)->first() - : (User::count() === 1 ? User::first() : null); - - if (! $user) { - $this->error($email !== '' ? "Kein Benutzer mit E-Mail {$email}." : 'Kein eindeutiger Benutzer — bitte --email angeben.'); - - return self::FAILURE; - } - - $password = (string) ($this->option('password') ?: Str::password(20)); - $user->forceFill(['password' => Hash::make($password), 'must_change_password' => false]); - - if ($this->option('disable-2fa')) { - $user->forceFill([ - 'two_factor_secret' => null, - 'two_factor_confirmed_at' => null, - 'two_factor_recovery_codes' => null, - ]); - } - - $user->save(); - - $this->info("Passwort für {$user->email} zurückgesetzt."); - if (! $this->option('password')) { - $this->warn("Einmal-Passwort (jetzt notieren): {$password}"); - } - if ($this->option('disable-2fa')) { - $this->info('2FA + Backup-Codes entfernt — beim nächsten Login neu einrichten.'); - } - - return self::SUCCESS; - } -} -``` - -- [ ] **Step 4: Run test to verify it passes** - -Run: `docker compose exec -T app php artisan test --filter=ResetAdminCommandTest` -Expected: PASS (2). - -- [ ] **Step 5: Pint + commit** - -```bash -docker compose exec -T -u "1002:1002" app ./vendor/bin/pint --dirty -git add app/Console/Commands/ResetAdmin.php tests/Feature/ResetAdminCommandTest.php -git commit -m "feat(auth): clusev:reset-admin CLI lockout fallback" -``` - ---- - -### Task 6: Full verification (R12 + Codex) - -- [ ] **Step 1: Full suite + Pint** - -Run: `docker compose exec -T app php artisan test` (only the pre-existing ExampleTest may fail). -Run: `docker compose exec -T -u "1002:1002" app ./vendor/bin/pint --test` - -- [ ] **Step 2: Build** - -Run: `docker compose exec -T -u "1002:1002" app npm run build` - -- [ ] **Step 3: R12 browser — DE + EN, 375 / 768 / 1280** - -Temp `/__peek` recipe (remove before commit). Flows: login shows "Passwort vergessen?" → forgot-password page renders + a wrong code shows the German error; 2FA setup → recovery-codes page shows 8 codes + download returns a .txt; settings → "Backup-Codes verwalten" link works. HTTP 200, 0 console, DOM scan (R17). - -- [ ] **Step 4: Codex review (R15)** - -Run: `export PATH="$HOME/.npm-global/bin:$PATH"; codex review --base ` — fix + re-run until clean. - -- [ ] **Step 5: Update handoff + release** - -Note the feature in `docs/session-handoff.md`. Release the version bump per the session cadence (separate commit + tag + sanitized push) when the user approves. - ---- - -## Self-Review - -**Spec coverage:** -- Data model (column + cast + 4 methods) → Task 1. ✓ -- 2FA backup codes: generation (Task 3), challenge use (Task 2), show/download/regenerate (Task 3), settings link (Task 3). ✓ -- Forgot-password 2FA-code primary → Task 4. ✓ -- Email-link secondary (gated on `mail.default`) → Task 4b (`sendResetLink` + `Auth\ResetPassword` + `/reset-password/{token}`), hidden in the UI while `MAIL_MAILER=log`, tested via `Notification::fake` + a `mail.default` override. ✓ -- CLI fallback → Task 5. ✓ -- i18n DE+EN → keys in Tasks 2-5. ✓ -- Testing + R12 + Codex → Tasks 1-6. ✓ - -**Placeholder scan:** none — every step has concrete code. - -**Type consistency:** `replaceRecoveryCodes()/recoveryCodes()/useRecoveryCode()/hasRecoveryCodes()` defined in Task 1, used in Tasks 2-5. Routes `two-factor.recovery`, `two-factor.recovery.download`, `password.request` consistent across tasks. `mailEnabled()` defined + used in Task 4. - -**Execution order:** Task 1 → 2 → 3 → 4 → 4b → 5 → 6. Tasks 4 and 4b both touch `ForgotPassword`/its view/`routes`/`auth.php`; finish + commit 4 before starting 4b. diff --git a/docs/superpowers/plans/2026-06-14-optional-pluggable-2fa.md b/docs/superpowers/plans/2026-06-14-optional-pluggable-2fa.md deleted file mode 100644 index f3f1cfc..0000000 --- a/docs/superpowers/plans/2026-06-14-optional-pluggable-2fa.md +++ /dev/null @@ -1,1224 +0,0 @@ -# Optional, Pluggable 2FA Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Make 2FA optional and pluggable — the operator secures the account with TOTP, a security key (WebAuthn), both, or nothing — replacing forced-TOTP onboarding and the dedicated recovery-codes page (now a modal). - -**Architecture:** `User` gains a per-factor predicate (`hasTotp()`) while `hasTwoFactorEnabled()` becomes "either factor". Onboarding stops forcing 2FA (only the password rotation stays forced). Recovery codes are generated on the first factor, cleared on the last, shown in a `wire-elements/modal`. The login challenge and Settings adapt to whichever factor(s) exist. - -**Tech Stack:** Laravel 13, Livewire 3 (class-based), wire-elements/modal, pragmarx/google2fa-qrcode (TOTP), web-auth/webauthn-lib (WebAuthn), Tailwind v4, bilingual `lang/{de,en}` (R16). - -**Spec:** `docs/superpowers/specs/2026-06-14-optional-pluggable-2fa-design.md` - ---- - -## Conventions for every task - -- Run all tooling in the container: `docker compose exec -T app php artisan test --filter `, `docker compose exec -T app ./vendor/bin/pint `. -- UI strings are localized in **both** `lang/de/*` and `lang/en/*` with identical keys (R16); no emoji (R9). -- Destructive confirmations use `wire-elements/modal` (R5), never `confirm()`/`wire:confirm`. -- After the last task: rebuild assets, R12 browser verify (bare-IP, DE+EN), Codex review (R15) clean, then release. - -## File Structure - -| File | Responsibility | Change | -|---|---|---| -| `app/Models/User.php` | Factor predicates + recovery-code lifecycle | add `hasTotp()`, redefine `hasTwoFactorEnabled()`/`securityOnboarded()`, add `resetIfNoFactor()` | -| `app/Http/Middleware/EnsureSecurityOnboarded.php` | Onboarding gate | drop the 2FA redirect | -| `app/Livewire/Auth/PasswordChange.php` | Forced password rotation | redirect to dashboard | -| `app/Livewire/Auth/TwoFactorSetup.php` | TOTP enrollment | app layout, guard on `hasTotp()`, generate codes + open modal | -| `app/Livewire/Auth/TwoFactorChallenge.php` | Login second step | TOTP check only when `hasTotp()`; field gated | -| `app/Livewire/Auth/ForgotPassword.php` | Password recovery proof | TOTP check only when `hasTotp()` | -| `app/Livewire/Settings/Index.php` | Manage TOTP factor | remove-TOTP always allowed + `resetIfNoFactor()`; hint flag | -| `app/Livewire/Settings/WebauthnKeys.php` | Manage keys | gate on `available()` only; first-key codes; last-key reset | -| `app/Livewire/Modals/RecoveryCodes.php` (NEW) | Recovery-codes modal | show/download/regenerate | -| `resources/views/livewire/modals/recovery-codes.blade.php` (NEW) | Modal view | — | -| `app/Livewire/Auth/RecoveryCodes.php` (DELETE) | Old full-page recovery | removed | -| `resources/views/livewire/auth/recovery-codes.blade.php` (DELETE) | Old view | removed | -| `routes/web.php` | Routes | remove `two-factor.recovery`, keep download | -| `resources/views/livewire/auth/two-factor-challenge.blade.php` | Challenge UI | gate TOTP field | -| `resources/views/livewire/settings/index.blade.php` | Security tab | modal trigger, hint, TOTP remove | -| `resources/views/livewire/auth/forgot-password.blade.php` | Forgot copy | no-2FA note | -| `resources/views/livewire/auth/two-factor-setup.blade.php` | Setup page | (works under app layout) | -| `lang/{de,en}/auth.php`, `lang/{de,en}/settings.php` | Strings | new keys | - ---- - -### Task 1: `User` factor semantics + recovery lifecycle - -**Files:** -- Modify: `app/Models/User.php:85-98` -- Test: `tests/Feature/UserFactorSemanticsTest.php` (Create) - -- [ ] **Step 1: Write the failing test** - -Create `tests/Feature/UserFactorSemanticsTest.php`: - -```php -create(['two_factor_secret' => 'S', 'two_factor_confirmed_at' => now()]); - } - - private function keyUser(): User - { - $u = User::factory()->create(); - WebauthnCredential::create(['user_id' => $u->id, 'name' => 'k', 'credential_id' => 'cid', 'public_key' => '{}', 'sign_count' => 0]); - - return $u->fresh(); - } - - public function test_has_totp_only_reflects_totp(): void - { - $this->assertTrue($this->totpUser()->hasTotp()); - $this->assertFalse($this->keyUser()->hasTotp()); - } - - public function test_has_two_factor_enabled_is_either_factor(): void - { - $this->assertTrue($this->totpUser()->hasTwoFactorEnabled()); - $this->assertTrue($this->keyUser()->hasTwoFactorEnabled()); - $this->assertFalse(User::factory()->create()->hasTwoFactorEnabled()); - } - - public function test_security_onboarded_is_password_rotation_only(): void - { - $this->assertTrue(User::factory()->create(['must_change_password' => false])->securityOnboarded()); - $this->assertFalse(User::factory()->create(['must_change_password' => true])->securityOnboarded()); - } - - public function test_reset_if_no_factor_clears_codes_only_when_no_factor_remains(): void - { - $totp = $this->totpUser(); - $totp->replaceRecoveryCodes(); - $totp->resetIfNoFactor(); - $this->assertTrue($totp->fresh()->hasRecoveryCodes(), 'codes kept while a factor remains'); - - $none = User::factory()->create(); - $none->replaceRecoveryCodes(); - $none->resetIfNoFactor(); - $this->assertFalse($none->fresh()->hasRecoveryCodes(), 'codes cleared when no factor remains'); - } -} -``` - -- [ ] **Step 2: Run it to confirm it fails** - -Run: `docker compose exec -T app php artisan test --filter UserFactorSemanticsTest` -Expected: FAIL — `hasTotp()` / `resetIfNoFactor()` undefined. - -- [ ] **Step 3: Implement the semantics** - -In `app/Models/User.php`, replace the `hasTwoFactorEnabled()` + `securityOnboarded()` block (lines 85-98) with: - -```php - /** TOTP factor only: an authenticator secret was confirmed. */ - public function hasTotp(): bool - { - return ! is_null($this->two_factor_confirmed_at) && ! is_null($this->two_factor_secret); - } - - /** 2FA is satisfied by EITHER factor — TOTP or a registered security key. */ - public function hasTwoFactorEnabled(): bool - { - return $this->hasTotp() || $this->hasWebauthnCredentials(); - } - - /** - * Completed the security onboarding. 2FA is now OPTIONAL, so this is just the forced - * password rotation — the gate EnsureSecurityOnboarded and the broadcast channels use. - */ - public function securityOnboarded(): bool - { - return ! $this->must_change_password; - } - - /** - * When neither factor remains (no TOTP, no keys), 2FA is fully off — so drop the backup - * codes (nothing left to recover into). Call after removing any factor. - */ - public function resetIfNoFactor(): void - { - if (! $this->hasTotp() && ! $this->hasWebauthnCredentials()) { - $this->forceFill(['two_factor_recovery_codes' => null])->save(); - } - } -``` - -- [ ] **Step 4: Run the test to confirm it passes** - -Run: `docker compose exec -T app php artisan test --filter UserFactorSemanticsTest` -Expected: PASS (4 tests). - -- [ ] **Step 5: Commit** - -```bash -docker compose exec -T app ./vendor/bin/pint app/Models/User.php tests/Feature/UserFactorSemanticsTest.php -git add app/Models/User.php tests/Feature/UserFactorSemanticsTest.php -git commit -m "feat(2fa): pluggable factor semantics on User (hasTotp, either-factor, resetIfNoFactor)" -``` - ---- - -### Task 2: Onboarding stops forcing 2FA - -**Files:** -- Modify: `app/Http/Middleware/EnsureSecurityOnboarded.php:20-28` -- Modify: `app/Livewire/Auth/PasswordChange.php:35-36` -- Test: `tests/Feature/OptionalOnboardingTest.php` (Create) - -- [ ] **Step 1: Write the failing test** - -Create `tests/Feature/OptionalOnboardingTest.php`: - -```php -create(['must_change_password' => false]); - - $this->actingAs($user)->get('/')->assertOk(); - } - - public function test_user_that_must_change_password_is_still_redirected(): void - { - $user = User::factory()->create(['must_change_password' => true]); - - $this->actingAs($user)->get('/')->assertRedirect(route('password.change')); - } - - public function test_password_change_lands_on_dashboard_not_2fa_setup(): void - { - $user = User::factory()->create(['must_change_password' => true, 'password' => bcrypt('old-Password-1')]); - - Livewire::actingAs($user)->test(PasswordChange::class) - ->set('current', 'old-Password-1') - ->set('password', 'new-Password-123') - ->set('password_confirmation', 'new-Password-123') - ->call('update') - ->assertRedirect(route('dashboard')); - } - - public function test_broadcast_gate_passes_on_password_rotation_alone(): void - { - $user = User::factory()->create(['must_change_password' => false]); - - $this->assertTrue($user->securityOnboarded()); - } -} -``` - -- [ ] **Step 2: Run it to confirm it fails** - -Run: `docker compose exec -T app php artisan test --filter OptionalOnboardingTest` -Expected: FAIL — `/` redirects an un-enrolled user to `two-factor.setup`; PasswordChange redirects to `two-factor.setup`. - -- [ ] **Step 3: Drop the 2FA redirect in the middleware** - -In `app/Http/Middleware/EnsureSecurityOnboarded.php`, replace the body of `handle()` (lines 17-31) with: - -```php - $user = $request->user(); - - if ($user && $user->must_change_password && ! $request->routeIs('password.change', 'logout')) { - return redirect()->route('password.change'); - } - - return $next($request); -``` - -Also update the class docblock (lines 9-12) to: - -```php -/** - * After login, force the seeded-password rotation before the panel is reachable. - * 2FA is optional (offered in Settings), so it is NOT forced here. - */ -``` - -- [ ] **Step 4: Redirect PasswordChange to the dashboard** - -In `app/Livewire/Auth/PasswordChange.php`, replace lines 35-36: - -```php - // 2FA is optional now — go straight to the panel; 2FA is offered in Settings. - return $this->redirect(route('dashboard'), navigate: true); -``` - -- [ ] **Step 5: Run the test to confirm it passes** - -Run: `docker compose exec -T app php artisan test --filter OptionalOnboardingTest` -Expected: PASS (4 tests). - -- [ ] **Step 6: Commit** - -```bash -docker compose exec -T app ./vendor/bin/pint app/Http/Middleware/EnsureSecurityOnboarded.php app/Livewire/Auth/PasswordChange.php tests/Feature/OptionalOnboardingTest.php -git add app/Http/Middleware/EnsureSecurityOnboarded.php app/Livewire/Auth/PasswordChange.php tests/Feature/OptionalOnboardingTest.php -git commit -m "feat(2fa): onboarding no longer forces 2FA (only password rotation)" -``` - ---- - -### Task 3: Recovery-codes MODAL (replace the full-page view) - -**Files:** -- Create: `app/Livewire/Modals/RecoveryCodes.php` -- Create: `resources/views/livewire/modals/recovery-codes.blade.php` -- Delete: `app/Livewire/Auth/RecoveryCodes.php`, `resources/views/livewire/auth/recovery-codes.blade.php` -- Modify: `routes/web.php:58` (remove `two-factor.recovery` route; keep download at 59-67) -- Modify: `lang/de/auth.php`, `lang/en/auth.php` (modal title key) -- Test: `tests/Feature/RecoveryCodesModalTest.php` (Create) -- Delete test: `tests/Feature/RecoveryCodesViewTest.php` - -- [ ] **Step 1: Write the failing test** - -Create `tests/Feature/RecoveryCodesModalTest.php`: - -```php -create(); - $codes = $user->replaceRecoveryCodes(); - - Livewire::actingAs($user)->test(RecoveryCodes::class) - ->assertSee($codes[0]) - ->assertSee($codes[7]); - } - - public function test_regenerate_replaces_the_codes(): void - { - $user = User::factory()->create(); - $old = $user->replaceRecoveryCodes(); - - Livewire::actingAs($user)->test(RecoveryCodes::class) - ->call('regenerate') - ->assertDontSee($old[0]); - - $this->assertNotEquals($old, $user->fresh()->recoveryCodes()); - } - - public function test_old_recovery_route_is_gone(): void - { - $this->assertFalse(\Illuminate\Support\Facades\Route::has('two-factor.recovery')); - $this->assertTrue(\Illuminate\Support\Facades\Route::has('two-factor.recovery.download')); - } -} -``` - -- [ ] **Step 2: Run it to confirm it fails** - -Run: `docker compose exec -T app php artisan test --filter RecoveryCodesModalTest` -Expected: FAIL — `App\Livewire\Modals\RecoveryCodes` does not exist; `two-factor.recovery` still registered. - -- [ ] **Step 3: Create the modal component** - -Create `app/Livewire/Modals/RecoveryCodes.php`: - -```php - */ - public function codes(): array - { - return Auth::user()->recoveryCodes(); - } - - /** Regenerate the current user's backup codes (invalidates the old set). */ - public function regenerate(): void - { - Auth::user()->replaceRecoveryCodes(); - - AuditEvent::create([ - 'user_id' => Auth::id(), - 'actor' => Auth::user()->name ?? 'system', - 'action' => 'two_factor.recovery_regenerate', - 'target' => Auth::user()->email, - 'ip' => request()->ip(), - ]); - - $this->dispatch('notify', message: __('auth.recovery_regenerated')); - } - - public function render() - { - return view('livewire.modals.recovery-codes', ['codes' => $this->codes()]); - } -} -``` - -- [ ] **Step 4: Create the modal view** - -Create `resources/views/livewire/modals/recovery-codes.blade.php`: - -```blade -
-
- - - -
-

{{ __('auth.recovery_heading') }}

-

{{ __('auth.recovery_subtitle') }}

-
-
- -
- -

{{ __('auth.recovery_warning') }}

-
- -
- @foreach ($codes as $c) - {{ $c }} - @endforeach -
- -
- - {{ __('auth.recovery_download') }} - - - {{ __('auth.recovery_regenerate') }} - - {{ __('auth.recovery_done') }} -
-
-``` - -- [ ] **Step 5: Remove the old full-page route** - -In `routes/web.php`, delete line 58 (the `two-factor.recovery` GET route) and adjust the comment block (lines 56-57) to: - -```php - // 2FA backup-codes download — the codes themselves are shown in a modal (Modals\RecoveryCodes). -``` - -Keep the download route (lines 59-67) intact. - -- [ ] **Step 6: Delete the old full-page component, view, and its test** - -```bash -git rm app/Livewire/Auth/RecoveryCodes.php resources/views/livewire/auth/recovery-codes.blade.php tests/Feature/RecoveryCodesViewTest.php -``` - -- [ ] **Step 7: Add the modal title key (kept for parity; reuse existing recovery_* keys)** - -No new key is strictly required — the modal reuses `auth.recovery_heading/subtitle/warning/download/regenerate/done`. Verify `lang/de/auth.php` and `lang/en/auth.php` still contain them (they do, lines ~78-85). Remove the now-unused `auth.title_recovery` key from both files (it titled the deleted page): - -In `lang/de/auth.php` and `lang/en/auth.php`, delete the `'title_recovery' => …` line. - -- [ ] **Step 8: Run the test to confirm it passes** - -Run: `docker compose exec -T app php artisan test --filter RecoveryCodesModalTest` -Expected: PASS (3 tests). - -- [ ] **Step 9: Commit** - -```bash -docker compose exec -T app ./vendor/bin/pint app/Livewire/Modals/RecoveryCodes.php tests/Feature/RecoveryCodesModalTest.php -git add -A -git commit -m "feat(2fa): recovery codes become a modal; drop the dedicated page + route" -``` - ---- - -### Task 4: First-factor code generation + open the modal - -**Files:** -- Modify: `app/Livewire/Auth/TwoFactorSetup.php` (layout, mount guard, confirm flow) -- Modify: `app/Livewire/Settings/WebauthnKeys.php:28-47` (first-key codes) -- Modify: `app/Livewire/Settings/Index.php` (mount reads the flash to open the modal) -- Modify: `resources/views/livewire/settings/index.blade.php` (x-init opens modal on flash) -- Test: `tests/Feature/FirstFactorCodesTest.php` (Create) - -- [ ] **Step 1: Write the failing test** - -Create `tests/Feature/FirstFactorCodesTest.php`: - -```php -create(); - $g = new Google2FA; - $secret = $g->generateSecretKey(); - - $component = Livewire::actingAs($user)->test(TwoFactorSetup::class); - $component->set('secret', $secret) - ->set('code', $g->getCurrentOtp($secret)) - ->call('confirm') - ->assertRedirect(route('settings')); - - $this->assertTrue($user->fresh()->hasRecoveryCodes()); - $this->assertTrue(session('open_recovery_modal')); - } - - public function test_first_key_register_generates_codes_and_opens_modal(): void - { - $user = User::factory()->create(); - - $svc = Mockery::mock(WebauthnService::class); - $svc->shouldReceive('available')->andReturnTrue(); - $svc->shouldReceive('verifyRegistration')->andReturnUsing(fn ($u, $a, $name) => WebauthnCredential::create([ - 'user_id' => $u->id, 'name' => $name, 'credential_id' => 'cid', 'public_key' => '{}', 'sign_count' => 0, - ])); - app()->instance(WebauthnService::class, $svc); - - Livewire::actingAs($user)->test(WebauthnKeys::class) - ->set('newName', 'YubiKey 5C') - ->call('register', ['dummy' => true], $svc) - ->assertDispatched('openModal'); - - $this->assertTrue($user->fresh()->hasRecoveryCodes()); - } -} -``` - -- [ ] **Step 2: Run it to confirm it fails** - -Run: `docker compose exec -T app php artisan test --filter FirstFactorCodesTest` -Expected: FAIL — TOTP confirm redirects to the removed `two-factor.recovery` route; key register does not generate codes nor dispatch `openModal`. - -- [ ] **Step 3: Rework TwoFactorSetup** - -In `app/Livewire/Auth/TwoFactorSetup.php`: - -Change the layout attribute (line 12) to: - -```php -#[Layout('layouts.app')] -``` - -Change `mount()` (lines 20-27) — guard on `hasTotp()` so a key-only user can still add TOTP: - -```php - public function mount() - { - if (Auth::user()->hasTotp()) { - return $this->redirect(route('settings'), navigate: true); - } - - $this->secret = (new Google2FA)->generateSecretKey(); - } -``` - -Change `confirm()` (lines 29-46) — generate codes only if none exist, flash the modal flag, redirect to Settings: - -```php - public function confirm() - { - $this->validate(); - - if (! (new Google2FA)->verifyKey($this->secret, preg_replace('/\s+/', '', $this->code))) { - throw ValidationException::withMessages(['code' => __('auth.code_mismatch')]); - } - - Auth::user()->forceFill([ - 'two_factor_secret' => $this->secret, - 'two_factor_confirmed_at' => now(), - ])->save(); - - // First factor enrolled and no codes yet → generate the single recovery set and - // ask Settings to pop the modal (the app layout hosts wire-elements/modal). - if (! Auth::user()->hasRecoveryCodes()) { - Auth::user()->replaceRecoveryCodes(); - session()->flash('open_recovery_modal', true); - } - - return $this->redirect(route('settings'), navigate: true); - } -``` - -- [ ] **Step 4: Generate codes on the first key in WebauthnKeys::register()** - -In `app/Livewire/Settings/WebauthnKeys.php`, inside `register()` after the credential is created (after line 35 `$this->reset('newName');`), insert: - -```php - // First factor enrolled and no codes yet → generate + show the recovery modal. - if (! Auth::user()->hasRecoveryCodes()) { - Auth::user()->replaceRecoveryCodes(); - $this->dispatch('openModal', component: 'modals.recovery-codes'); - } -``` - -- [ ] **Step 5: Settings opens the modal when the flash is set** - -In `app/Livewire/Settings/Index.php`, extend `mount()` (lines 28-32) to land on the security tab and expose the flash: - -```php - public bool $openRecoveryModal = false; - - public function mount(): void - { - $this->name = Auth::user()->name; - $this->email = Auth::user()->email; - - if (session('open_recovery_modal')) { - $this->tab = 'security'; - $this->openRecoveryModal = true; - } - } -``` - -(Add the `public bool $openRecoveryModal = false;` property near the other public props, above `mount()`.) - -- [ ] **Step 6: The security tab dispatches openModal via x-init when flagged** - -In `resources/views/livewire/settings/index.blade.php`, immediately after the opening `
` (line 13), add: - -```blade - @if ($openRecoveryModal) -
- @endif -``` - -- [ ] **Step 7: Run the test to confirm it passes** - -Run: `docker compose exec -T app php artisan test --filter FirstFactorCodesTest` -Expected: PASS (2 tests). - -- [ ] **Step 8: Commit** - -```bash -docker compose exec -T app ./vendor/bin/pint app/Livewire/Auth/TwoFactorSetup.php app/Livewire/Settings/WebauthnKeys.php app/Livewire/Settings/Index.php tests/Feature/FirstFactorCodesTest.php -git add -A -git commit -m "feat(2fa): generate recovery codes on the first factor + open the modal" -``` - ---- - -### Task 5: Login challenge adapts to whichever factor exists - -**Files:** -- Modify: `app/Livewire/Auth/TwoFactorChallenge.php:47-49` (verify) + add `hasTotp()` helper for the view -- Modify: `resources/views/livewire/auth/two-factor-challenge.blade.php` (gate the TOTP field) -- Test: `tests/Feature/ChallengeFactorAdaptTest.php` (Create) - -- [ ] **Step 1: Write the failing test** - -Create `tests/Feature/ChallengeFactorAdaptTest.php`: - -```php -create(); - WebauthnCredential::create(['user_id' => $u->id, 'name' => 'k', 'credential_id' => 'cid', 'public_key' => '{}', 'sign_count' => 0]); - - return $u->fresh(); - } - - public function test_key_only_user_has_no_totp_field_but_a_backup_code_works(): void - { - $user = $this->keyOnlyUser(); - $codes = $user->replaceRecoveryCodes(); - session(['2fa.user' => $user->id, '2fa.remember' => false]); - - Livewire::test(TwoFactorChallenge::class) - ->assertSet('pendingHasTotp', false) - ->set('code', $codes[0]) - ->call('verify') - ->assertRedirect(route('dashboard')); - - $this->assertAuthenticatedAs($user); - } - - public function test_totp_user_sees_the_field(): void - { - $user = User::factory()->create(['two_factor_secret' => 'S', 'two_factor_confirmed_at' => now()]); - session(['2fa.user' => $user->id, '2fa.remember' => false]); - - Livewire::test(TwoFactorChallenge::class)->assertSet('pendingHasTotp', true); - } -} -``` - -- [ ] **Step 2: Run it to confirm it fails** - -Run: `docker compose exec -T app php artisan test --filter ChallengeFactorAdaptTest` -Expected: FAIL — `pendingHasTotp` is not defined; `verify()` calls `Google2FA::verifyKey()` with a null secret for the key-only user (error/false before the backup-code fallback is reached safely). - -- [ ] **Step 3: Guard the TOTP check + expose the predicate** - -In `app/Livewire/Auth/TwoFactorChallenge.php`: - -Add a public computed property for the view. After `mount()` (line 26), add: - -```php - /** Whether the PENDING user has TOTP — the code field renders only then. */ - public function getPendingHasTotpProperty(): bool - { - return (bool) User::find(session('2fa.user'))?->hasTotp(); - } -``` - -Replace the TOTP-or-backup check (lines 47-49) with a guarded version: - -```php - // Accept the TOTP code only when the pending user actually has TOTP, otherwise a - // null secret would break verifyKey; always allow a one-time backup (recovery) code. - $valid = ($user->hasTotp() && (new Google2FA)->verifyKey($user->two_factor_secret, preg_replace('/\s+/', '', $this->code))) - || $user->useRecoveryCode($this->code); -``` - -> Note: Livewire exposes `getPendingHasTotpProperty()` to Blade as `$pendingHasTotp` and to `assertSet('pendingHasTotp', …)`. - -- [ ] **Step 4: Gate the TOTP field in the view** - -In `resources/views/livewire/auth/two-factor-challenge.blade.php`, wrap the code ``/label block in `@if ($this->pendingHasTotp) … @endif`, and make the backup-code hint always available. Read the file first; the field is the `wire:model="code"` input group. Replace its surrounding block with: - -```blade - @if ($this->pendingHasTotp) - {{-- existing TOTP code label + input group, unchanged --}} - @endif -``` - -Keeping: the "Mit Security-Key anmelden" button (already gated by `$this->webauthnAvailable()`), and the backup-code hint (`auth.challenge_recovery_hint`). For a key-only user the `code` input must still be reachable to type a backup code — if the only input was inside the `@if`, add a single shared code input shown when `! $this->pendingHasTotp` too, labelled with `auth.challenge_recovery_hint`. Concretely, structure the form as: - -```blade -
- - - @error('code')

{{ $message }}

@enderror -
-``` - -> The single `code` input serves TOTP for a TOTP user and a backup code for a key-only user; `verify()` already tries both paths. Match the existing `$label`/`$field`/`$err` locals at the top of the view (read it; they mirror the login view). If those locals are absent, reuse the classes already present on the existing input. - -- [ ] **Step 5: Add the new challenge label keys (de + en)** - -In `lang/de/auth.php` add (near the other `challenge_*` keys): - -```php - 'challenge_code_label' => 'Authenticator-Code', - 'challenge_code_placeholder' => '123456', - 'challenge_backup_label' => 'Backup-Code', - 'challenge_backup_placeholder' => 'xxxxxxxxxx-xxxxxxxxxx', -``` - -In `lang/en/auth.php` add the same keys: - -```php - 'challenge_code_label' => 'Authenticator code', - 'challenge_code_placeholder' => '123456', - 'challenge_backup_label' => 'Backup code', - 'challenge_backup_placeholder' => 'xxxxxxxxxx-xxxxxxxxxx', -``` - -(If `challenge_code_label`/`challenge_code_placeholder` already exist in the file, reuse them and only add the two `challenge_backup_*` keys.) - -- [ ] **Step 6: Run the test to confirm it passes** - -Run: `docker compose exec -T app php artisan test --filter ChallengeFactorAdaptTest` -Expected: PASS (2 tests). - -- [ ] **Step 7: Commit** - -```bash -docker compose exec -T app ./vendor/bin/pint app/Livewire/Auth/TwoFactorChallenge.php tests/Feature/ChallengeFactorAdaptTest.php -git add -A -git commit -m "feat(2fa): challenge adapts to factor — TOTP field gated, key-only uses backup code" -``` - ---- - -### Task 6: Settings — manage factors independently - -**Files:** -- Modify: `app/Livewire/Settings/Index.php:77-90` (disableTwoFactor → remove TOTP + reset) -- Modify: `app/Livewire/Settings/WebauthnKeys.php:20,32,97` (gate on `available()` only; last-key reset) -- Modify: `resources/views/livewire/settings/index.blade.php` (TOTP card always removable, "empfohlen" hint, recovery modal trigger) -- Modify: `lang/{de,en}/settings.php` (hint + TOTP-remove keys) -- Test: `tests/Feature/SettingsFactorManagementTest.php` (Create) - -- [ ] **Step 1: Write the failing test** - -Create `tests/Feature/SettingsFactorManagementTest.php`: - -```php -create(['two_factor_secret' => 'S', 'two_factor_confirmed_at' => now()]); - $user->replaceRecoveryCodes(); - - Livewire::actingAs($user)->test(Index::class)->call('disableTwoFactor'); - - $fresh = $user->fresh(); - $this->assertFalse($fresh->hasTotp()); - $this->assertFalse($fresh->hasRecoveryCodes(), 'last factor removed → codes cleared'); - } - - public function test_removing_totp_keeps_codes_when_a_key_remains(): void - { - $user = User::factory()->create(['two_factor_secret' => 'S', 'two_factor_confirmed_at' => now()]); - WebauthnCredential::create(['user_id' => $user->id, 'name' => 'k', 'credential_id' => 'cid', 'public_key' => '{}', 'sign_count' => 0]); - $user->replaceRecoveryCodes(); - - Livewire::actingAs($user->fresh())->test(Index::class)->call('disableTwoFactor'); - - $fresh = $user->fresh(); - $this->assertFalse($fresh->hasTotp()); - $this->assertTrue($fresh->hasWebauthnCredentials()); - $this->assertTrue($fresh->hasRecoveryCodes(), 'key remains → codes kept'); - } - - public function test_a_key_can_be_the_first_factor_without_existing_2fa(): void - { - $user = User::factory()->create(); // no TOTP, no key - - $svc = Mockery::mock(WebauthnService::class); - $svc->shouldReceive('available')->andReturnTrue(); - $svc->shouldReceive('registrationOptions')->andReturn(['ok' => true]); - app()->instance(WebauthnService::class, $svc); - - // options() must NOT 404 just because the user has no 2FA yet. - Livewire::actingAs($user)->test(WebauthnKeys::class) - ->set('newName', 'YubiKey') - ->call('options', $svc) - ->assertReturned(['ok' => true]); - } - - public function test_removing_the_last_key_clears_codes(): void - { - $user = User::factory()->create(); - $cred = WebauthnCredential::create(['user_id' => $user->id, 'name' => 'k', 'credential_id' => 'cid', 'public_key' => '{}', 'sign_count' => 0]); - $user->replaceRecoveryCodes(); - - $svc = Mockery::mock(WebauthnService::class); - $svc->shouldReceive('available')->andReturnTrue(); - app()->instance(WebauthnService::class, $svc); - - Livewire::actingAs($user->fresh())->test(WebauthnKeys::class)->call('remove', $cred->id, $svc); - - $this->assertFalse($user->fresh()->hasRecoveryCodes()); - } -} -``` - -- [ ] **Step 2: Run it to confirm it fails** - -Run: `docker compose exec -T app php artisan test --filter SettingsFactorManagementTest` -Expected: FAIL — `disableTwoFactor` nukes keys + does not reset codes conditionally; `WebauthnKeys::options()`/`register()` 404 without `hasTwoFactorEnabled()`; `remove()` does not reset codes. - -- [ ] **Step 3: Reframe `disableTwoFactor` to remove only TOTP** - -In `app/Livewire/Settings/Index.php`, replace `disableTwoFactor()` (lines 77-90) with: - -```php - #[On('twoFactorDisabled')] - public function disableTwoFactor(): void - { - // Remove the TOTP factor only — security keys are managed on their own card. If no - // factor remains afterwards, the backup codes are dropped too. - Auth::user()->forceFill([ - 'two_factor_secret' => null, - 'two_factor_confirmed_at' => null, - ])->save(); - - Auth::user()->resetIfNoFactor(); - } -``` - -- [ ] **Step 4: Gate WebauthnKeys on `available()` only + reset on last-key removal** - -In `app/Livewire/Settings/WebauthnKeys.php`: - -`options()` line 20 — drop the 2FA requirement: - -```php - abort_unless($webauthn->available(), 404); -``` - -`register()` line 32 — same: - -```php - abort_unless($webauthn->available(), 404); -``` - -`remove()` — after the credential is deleted and audited (after line 90 `$this->dispatch('notify', …)`), add the reset: - -```php - Auth::user()->resetIfNoFactor(); -``` - -`render()` line 97 — the key card is available purely on the service gate now: - -```php - 'available' => app(WebauthnService::class)->available(), -``` - -- [ ] **Step 5: Settings security tab — TOTP always removable, hint, modal trigger** - -In `resources/views/livewire/settings/index.blade.php`, replace the 2FA panel block (lines 99-119) with: - -```blade - - @unless ($twoFactorEnabled) -
- -

{{ __('settings.twofa_recommended') }}

-
- @endunless - -
-
- -
-

{{ $hasTotp ? __('settings.twofa_status_on') : __('settings.twofa_status_off') }}

-

{{ $hasTotp ? __('settings.twofa_hint_on') : __('settings.twofa_hint_off') }}

-
-
-
- @if ($twoFactorEnabled) - {{ __('auth.recovery_manage') }} - @endif - @if ($hasTotp) - {{ __('settings.twofa_remove_totp') }} - @else - - {{ __('settings.twofa_setup') }} - - @endif -
-
-
- - -``` - -- [ ] **Step 6: Pass `hasTotp` into the Settings view** - -In `app/Livewire/Settings/Index.php`, `render()` — add `hasTotp` alongside `twoFactorEnabled`: - -```php - return view('livewire.settings.index', [ - 'twoFactorEnabled' => Auth::user()->hasTwoFactorEnabled(), - 'hasTotp' => Auth::user()->hasTotp(), - ])->title(__('settings.title')); -``` - -- [ ] **Step 7: Add the new settings keys (de + en)** - -In `lang/de/settings.php` add: - -```php - 'twofa_recommended' => '2FA ist optional, aber empfohlen — sichere dein Konto mit einem Authenticator oder Security-Key.', - 'twofa_remove_totp' => 'Authenticator entfernen', -``` - -In `lang/en/settings.php` add: - -```php - 'twofa_recommended' => '2FA is optional but recommended — secure your account with an authenticator or a security key.', - 'twofa_remove_totp' => 'Remove authenticator', -``` - -- [ ] **Step 8: Run the test to confirm it passes** - -Run: `docker compose exec -T app php artisan test --filter SettingsFactorManagementTest` -Expected: PASS (4 tests). - -- [ ] **Step 9: Update the disable-2FA confirm copy (it no longer nukes keys)** - -In `lang/de/settings.php` and `lang/en/settings.php`, soften `disable_2fa_heading`/`disable_2fa_body`/`disable_2fa_notify` to refer to the **authenticator** only (not "2FA"), e.g. DE `disable_2fa_heading` → `'Authenticator entfernen'`, `disable_2fa_body` → `'Den Authenticator (TOTP) als Faktor entfernen? Security-Keys bleiben bestehen.'`. Mirror in EN. (Read both files; keep keys, change values.) - -- [ ] **Step 10: Commit** - -```bash -docker compose exec -T app ./vendor/bin/pint app/Livewire/Settings/Index.php app/Livewire/Settings/WebauthnKeys.php tests/Feature/SettingsFactorManagementTest.php -git add -A -git commit -m "feat(2fa): Settings manages TOTP + keys independently; key can be the only factor; empfohlen hint" -``` - ---- - -### Task 7: forgot-password — key-only safe + no-2FA copy - -**Files:** -- Modify: `app/Livewire/Auth/ForgotPassword.php:66-68` (guard verifyKey on hasTotp) -- Modify: `resources/views/livewire/auth/forgot-password.blade.php` (no-2FA note) -- Modify: `lang/{de,en}/auth.php` (no-2FA reset note) -- Test: `tests/Feature/ForgotPasswordKeyOnlyTest.php` (Create) - -- [ ] **Step 1: Write the failing test** - -Create `tests/Feature/ForgotPasswordKeyOnlyTest.php`: - -```php -create(['must_change_password' => false]); - WebauthnCredential::create(['user_id' => $user->id, 'name' => 'k', 'credential_id' => 'cid', 'public_key' => '{}', 'sign_count' => 0]); - $codes = $user->fresh()->replaceRecoveryCodes(); - - Livewire::test(ForgotPassword::class) - ->set('email', $user->email) - ->set('code', $codes[0]) - ->set('password', 'brand-New-Pass-9') - ->set('password_confirmation', 'brand-New-Pass-9') - ->call('resetPassword') - ->assertRedirect(route('login')); - - $this->assertTrue(\Illuminate\Support\Facades\Hash::check('brand-New-Pass-9', $user->fresh()->password)); - } -} -``` - -- [ ] **Step 2: Run it to confirm it fails** - -Run: `docker compose exec -T app php artisan test --filter ForgotPasswordKeyOnlyTest` -Expected: FAIL — `Google2FA::verifyKey()` is called with the key-only user's null `two_factor_secret` before the backup-code fallback, throwing or short-circuiting. - -- [ ] **Step 3: Guard the TOTP check** - -In `app/Livewire/Auth/ForgotPassword.php`, replace the `$ok` assignment (lines 66-68) with: - -```php - $ok = $user - && $user->hasTwoFactorEnabled() - && (($user->hasTotp() && (new Google2FA)->verifyKey($user->two_factor_secret, $clean)) - || $user->useRecoveryCode($this->code)); -``` - -- [ ] **Step 4: Add the no-2FA reset note to the view + lang** - -In `resources/views/livewire/auth/forgot-password.blade.php`, add a small hint near the form footer: - -```blade -

{{ __('auth.reset_no_2fa_note') }}

-``` - -In `lang/de/auth.php`: - -```php - 'reset_no_2fa_note' => 'Ohne 2FA ist die Wiederherstellung nur per E-Mail-Link (falls SMTP konfiguriert) oder über den Befehl clusev:reset-admin möglich.', -``` - -In `lang/en/auth.php`: - -```php - 'reset_no_2fa_note' => 'Without 2FA, recovery is only possible via the email link (if SMTP is configured) or the clusev:reset-admin command.', -``` - -- [ ] **Step 5: Run the test to confirm it passes** - -Run: `docker compose exec -T app php artisan test --filter ForgotPasswordKeyOnlyTest` -Expected: PASS. - -- [ ] **Step 6: Commit** - -```bash -docker compose exec -T app ./vendor/bin/pint app/Livewire/Auth/ForgotPassword.php tests/Feature/ForgotPasswordKeyOnlyTest.php -git add -A -git commit -m "feat(2fa): forgot-password is key-only safe + states the no-2FA recovery path" -``` - ---- - -### Task 8: Regression sweep, R12 browser verify, Codex, release - -**Files:** -- Modify (as needed): the existing 2FA tests whose assumptions changed -- Modify: `config/clusev.php:6` (version bump) -- Modify: `CHANGELOG.md` - -- [ ] **Step 1: Run the full suite and fix fallout** - -Run: `docker compose exec -T app php artisan test` -Expected: green. Likely files needing small updates because semantics changed: -- `tests/Feature/WebauthnKeysTest.php`, `tests/Feature/WebauthnOptionsTest.php`, `tests/Feature/WebauthnAvailableTest.php`: any test that set up `two_factor_confirmed_at`/secret purely to satisfy the old `hasTwoFactorEnabled()` gate on `options()`/`register()` can drop that setup (the gate is now `available()` only). Where a test asserted a 404 for a key attempt without TOTP, invert it to expect success. -- `tests/Feature/TwoFactorChallengeRecoveryTest.php`, `tests/Feature/TwoFactorWebauthnTest.php`: should still pass; if any asserts the challenge view contains the TOTP field for a key-only user, update to the new gated behaviour. -- Fix each failure by reading the test and aligning it with the spec (TOTP-only field, either-factor `hasTwoFactorEnabled`, codes lifecycle). Do not weaken security assertions. - -- [ ] **Step 2: Build assets** - -Run: `docker compose run --rm --no-deps -u "${HOST_UID}:${HOST_GID}" app npm run build` -Expected: `✓ built`. - -- [ ] **Step 3: R12 browser verification (bare-IP, DE + EN)** - -With a connected browser at `http://10.10.90.136`, verify at 375/768/1280: -- Login as the admin → after password rotation a no-2FA account reaches the **dashboard** (no forced 2FA). -- `/settings` → Security tab: the **"2FA empfohlen"** hint shows; the **TOTP card** offers *Einrichten*; the **Security-Keys** card shows the domain-unavailable hint on bare IP. -- Enroll TOTP at `/two-factor-setup` → on confirm you land on **Settings** and the **recovery-codes modal** pops with 8 codes + Download + Neu erzeugen + Gespeichert. -- After enrolling, the TOTP card shows *Authenticator entfernen*; **Backup-Codes verwalten** opens the modal. -- Remove the authenticator → codes cleared, hint returns. -- Switch locale (DE↔EN) and re-check the same screens for leaked `group.key`/`@`/`{{ }}` tokens (R17) and HTTP 200 + zero console errors. -- Log out, log in again with TOTP → the **challenge** shows the Authenticator field; entering a backup code also works. - -Use the browser tools to confirm zero console errors on each touched route. - -- [ ] **Step 4: Codex review (R15)** - -Run a Codex review over the full diff (`git diff main...HEAD` or the working branch range). Fix every P1 and security finding; re-run until clean. - -- [ ] **Step 5: Version bump + CHANGELOG** - -In `config/clusev.php` line 6, bump `'version' => '0.7.0'` (new feature). Add a `## v0.7.0` section to `CHANGELOG.md` summarizing: optional/pluggable 2FA (TOTP and/or WebAuthn, or off), recovery codes as a modal, onboarding no longer forces 2FA. - -- [ ] **Step 6: Final suite + commit + finish** - -```bash -docker compose exec -T app php artisan test -docker compose exec -T app ./vendor/bin/pint -git add -A -git commit -m "release: optional pluggable 2FA (v0.7.0)" -``` - -Then invoke `superpowers:finishing-a-development-branch` to complete the work. - ---- - -## Self-Review - -**Spec coverage:** -- §1 Factor semantics → Task 1 (`hasTotp`, either-factor `hasTwoFactorEnabled`, `securityOnboarded` = `!must_change_password`, `resetIfNoFactor`). Callers using `securityOnboarded()`/`hasTwoFactorEnabled()` (`routes/channels.php`, Login) need no edit — they call the methods whose behaviour changed; Login routing to the challenge for a key-only user is correct (Task 5 makes the challenge usable). ✓ -- §2 Onboarding not forced → Task 2 (middleware + PasswordChange → dashboard; broadcast gate). ✓ -- §3 Recovery codes modal → Task 3 (new modal + view, delete page/view/route, keep download) + Task 4 (generate on first factor, open modal) + Task 6 (Settings "verwalten" → modal). ✓ -- §4 Challenge adapts → Task 5 (verify guarded, field gated, key-only backup code). ✓ -- §5 Settings manage factors → Task 6 (TOTP removable always + reset, keys gated on `available()` only + last-key reset, "empfohlen" hint, disable copy reframed). ✓ -- §6 forgot-password → Task 7 (key-only safe, no-2FA note). ✓ -- Files-touched + Testing sections → covered across Tasks 1-8; R12/Codex in Task 8. ✓ - -**Placeholder scan:** No "TBD"/"handle edge cases"; the one structural Blade edit (challenge field) gives the concrete replacement markup. The "existing 2FA tests" updates in Task 8 are inherently read-then-fix but bounded with explicit guidance per file. ✓ - -**Type consistency:** Method names consistent across tasks — `hasTotp()`, `hasTwoFactorEnabled()`, `securityOnboarded()`, `resetIfNoFactor()`, `hasRecoveryCodes()`, `replaceRecoveryCodes()`, `recoveryCodes()`. Modal component `App\Livewire\Modals\RecoveryCodes` ↔ dispatch name `modals.recovery-codes` ↔ view `livewire.modals.recovery-codes`. Flash key `open_recovery_modal` set in Task 4 (TwoFactorSetup) and read in Task 4 (Settings). View locals `hasTotp`/`twoFactorEnabled` defined in Task 6 `render()` and used in the Task 6 blade. ✓ diff --git a/docs/superpowers/plans/2026-06-14-server-details-hardening-polish.md b/docs/superpowers/plans/2026-06-14-server-details-hardening-polish.md deleted file mode 100644 index 80ff5df..0000000 --- a/docs/superpowers/plans/2026-06-14-server-details-hardening-polish.md +++ /dev/null @@ -1,1073 +0,0 @@ -# Server-Details UX + Hardening Polish — Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Fix seven reported Server-Details / button / create-flow issues — uniform bordered buttons, an installed-only firewall+fail2ban grid, auto-updates reframed as a neutral preference, SSH-verified server creation with an "Initialisierung" status, and an SSH key-only checklist hint. - -**Architecture:** Mostly Blade/Livewire on `servers.show` plus a small `HardeningService` semantics change, a new `FleetService::testConnection`, and a `CreateServer` guard. Buttons stay centralized in the `x-btn` component. Status `pending` is a new string value — no migration (the `status` column is free-form). - -**Tech Stack:** Laravel 13, Livewire 3 (class-based), Tailwind v4 (`@theme` tokens), Pest/PHPUnit, phpseclib. All tooling runs in the `app` container (R8). UI copy bilingual DE+EN (R16). - -**Conventions reused:** -- Run tests: `docker compose exec -T app php artisan test --filter=` -- Pint: `docker compose exec -T -u "1002:1002" app ./vendor/bin/pint --dirty` -- Build: `docker compose exec -T -u "1002:1002" app npm run build` -- Commit messages: Conventional Commits, end with the Co-Authored-By trailer. - ---- - -### Task 1: Unified button kit (`x-btn`) - -Persistent border+bg on every variant; retire `ghost`/`ghost-danger`; add `danger-soft`. - -**Files:** -- Modify: `resources/views/components/btn.blade.php` -- Test: `tests/Feature/ButtonComponentTest.php` (create) - -- [ ] **Step 1: Write the failing test** - -```php -x'); - $this->assertStringContainsString('border border-offline/25', $html); - $this->assertStringContainsString('bg-offline/10', $html); - $this->assertStringContainsString('text-offline', $html); - } - - public function test_no_borderless_ghost_variants_remain(): void - { - // Unknown variants fall back to secondary (bordered) — never borderless. - $html = Blade::render('x'); - $this->assertStringContainsString('border border-line', $html); - $this->assertStringContainsString('bg-inset', $html); - } -} -``` - -- [ ] **Step 2: Run test to verify it fails** - -Run: `docker compose exec -T app php artisan test --filter=ButtonComponentTest` -Expected: FAIL (`danger-soft` not in map → falls back to secondary, so `bg-offline/10` missing). - -- [ ] **Step 3: Replace the variant map** - -Full new `resources/views/components/btn.blade.php`: - -```php -@props(['variant' => 'secondary', 'icon' => false, 'href' => null, 'size' => 'sm']) -@php - // One button style for the whole app — EVERY variant carries a persistent - // border + background (R10). No borderless variants: low-emphasis actions use - // `secondary`, destructive ones use `danger-soft` (red-tinted, always — not - // only on hover). Unknown variants fall back to the bordered `secondary`. - $variants = [ - 'primary' => 'bg-accent text-void hover:bg-accent-bright', - 'accent' => 'border border-accent/25 bg-accent/10 text-accent-text hover:bg-accent/15', - 'secondary' => 'border border-line bg-inset text-ink-2 hover:bg-raised hover:text-ink', - 'danger' => 'bg-offline text-void hover:bg-offline/90', - 'danger-soft' => 'border border-offline/25 bg-offline/10 text-offline hover:bg-offline/15', - ]; - // sm = compact toolbar/row buttons; lg = full-height primary CTA (≥44px touch target, R7). - $sizes = [ - 'sm' => ($icon ? 'h-8 w-8' : 'h-8 px-3').' text-xs', - 'lg' => ($icon ? 'h-11 w-11' : 'h-11 px-4').' text-sm', - ]; - $classes = 'inline-flex items-center justify-center gap-1.5 rounded-md font-medium transition-colors ' - .'disabled:opacity-50 disabled:pointer-events-none ' - .($sizes[$size] ?? $sizes['sm']).' '.($variants[$variant] ?? $variants['secondary']); -@endphp -@if ($href) - merge(['class' => $classes]) }}>{{ $slot }} -@else - -@endif -``` - -- [ ] **Step 4: Run test to verify it passes** - -Run: `docker compose exec -T app php artisan test --filter=ButtonComponentTest` -Expected: PASS (2 tests). - -- [ ] **Step 5: Migrate all `ghost` / `ghost-danger` usages** - -Map: `ghost` → `secondary`, `ghost-danger` → `danger-soft`. Edit each: -- `resources/views/livewire/servers/show.blade.php`: line 128 `ghost-danger`→`danger-soft`; 210 `ghost`→`secondary`; 323 `ghost-danger`→`danger-soft`; 354 `ghost`→`secondary`; 398 `ghost`→`secondary`; 507 `ghost-danger`→`danger-soft`. -- `resources/views/livewire/settings/index.blade.php`: line 109 `ghost-danger`→`danger-soft`. -- `resources/views/livewire/files/index.blade.php`: lines 122,123 `ghost`→`secondary`; 125 `ghost-danger`→`danger-soft`. - -Leave the two raw chip-x ` - @endunless - - @endforeach -
-
- - {{ __('common.add') }} -
- - @endif - - @endif - - @endif -```` - -Notes: trash rule button is now `danger-soft` (Task 1 migration applied here too); unban button is `secondary`; the firewall config-gear that lived in the *checklist* (Task 1, line 210→`secondary`) is unaffected by this block. - -- [ ] **Step 4: Run test to verify it passes** - -Run: `docker compose exec -T app php artisan test --filter=ServerShowPanelsTest` -Expected: PASS (2 tests). - -- [ ] **Step 5: Build + Pint + commit** - -```bash -docker compose exec -T -u "1002:1002" app ./vendor/bin/pint --dirty -docker compose exec -T -u "1002:1002" app npm run build -git add resources/views/livewire/servers/show.blade.php tests/Feature/ServerShowPanelsTest.php -git commit -m "$(cat <<'EOF' -feat(servers): show firewall/fail2ban only when installed, in a grid - -Each panel renders only when its tool is actually installed (the Sicherheit -checklist carries the install/Aktivieren path otherwise), removing the redundant -"nicht installiert" boxes. Both shown -> 2-col responsive grid (lg, items-start); -one shown -> full width. Trash uses danger-soft, unban uses secondary. - -Co-Authored-By: Claude Opus 4.8 (1M context) -EOF -)" -``` - ---- - -### Task 7: SSH key-only hint on the checklist - -**Files:** -- Modify: `resources/views/livewire/servers/show.blade.php` (checklist detail area) -- Modify: `lang/de/servers.php`, `lang/en/servers.php` -- Test: `tests/Feature/ServerShowSshHintTest.php` (create) - -- [ ] **Step 1: Write the failing test** - -```php -actingAs(User::factory()->create()); - $server = Server::create(['name' => 's', 'ip' => '10.0.0.9', 'ssh_port' => 22, 'status' => 'online']); - - return Livewire::test(Show::class, ['server' => $server])->set('ready', true)->set('hardening', $hardening); - } - - public function test_key_only_hint_shown_when_password_login_on(): void - { - $this->show([ - ['key' => 'ssh_password', 'label' => 'SSH-Passwort-Login', 'detail' => 'PasswordAuthentication yes', 'featureOn' => true, 'secure' => false, 'supported' => true, 'reason' => null], - ])->assertSee(__('servers.ssh_key_only_hint')); - } - - public function test_key_only_hint_hidden_when_password_login_off(): void - { - $this->show([ - ['key' => 'ssh_password', 'label' => 'SSH-Passwort-Login', 'detail' => 'PasswordAuthentication no', 'featureOn' => false, 'secure' => true, 'supported' => true, 'reason' => null], - ])->assertDontSee(__('servers.ssh_key_only_hint')); - } -} -``` - -- [ ] **Step 2: Run test to verify it fails** - -Run: `docker compose exec -T app php artisan test --filter=ServerShowSshHintTest` -Expected: FAIL (hint key not rendered). - -- [ ] **Step 3: Add the hint after the detail line** - -In `resources/views/livewire/servers/show.blade.php`, in the checklist row (just after the -`

{{ $supported ? $check['detail'] : $check['reason'] }}

` line from Task 3), add: -```blade - @if ($supported && $check['key'] === 'ssh_password' && $check['featureOn']) -

{{ __('servers.ssh_key_only_hint') }}

- @endif -``` -(Scope: only `ssh_password` — disabling it is what forces key-only access; `ssh_root` disable does not, so it carries no such hint.) - -- [ ] **Step 4: Add the lang key** - -Append to `lang/de/servers.php`: -```php - 'ssh_key_only_hint' => 'Nach dem Deaktivieren ist der Zugang nur per SSH-Key möglich — Key zuerst hinterlegen.', -``` -Append to `lang/en/servers.php`: -```php - 'ssh_key_only_hint' => 'After disabling, access is SSH-key only — deposit a key first.', -``` - -- [ ] **Step 5: Run test to verify it passes** - -Run: `docker compose exec -T app php artisan test --filter=ServerShowSshHintTest` -Expected: PASS (2 tests). - -- [ ] **Step 6: Pint + commit** - -```bash -docker compose exec -T -u "1002:1002" app ./vendor/bin/pint --dirty -git add resources/views/livewire/servers/show.blade.php lang/de/servers.php lang/en/servers.php tests/Feature/ServerShowSshHintTest.php -git commit -m "$(cat <<'EOF' -feat(servers): hint that disabling password login leaves key-only access - -Adds a muted one-line hint on the SSH-Passwort-Login checklist row while -password auth is still on, so the operator knows access becomes SSH-key only -(and to deposit a key first) before toggling — mirroring the lockout guard. - -Co-Authored-By: Claude Opus 4.8 (1M context) -EOF -)" -``` - ---- - -### Task 8: Full verification (R12 + Codex) - -No code changes — the acceptance gate. - -- [ ] **Step 1: Full suite + Pint clean** - -Run: `docker compose exec -T app php artisan test` -Run: `docker compose exec -T -u "1002:1002" app ./vendor/bin/pint --test` -Expected: all green, no style issues. - -- [ ] **Step 2: Build** - -Run: `docker compose exec -T -u "1002:1002" app npm run build` -Expected: Vite build OK. - -- [ ] **Step 3: R12 browser — DE + EN, 375 / 768 / 1280** - -Per `docs/session-handoff.md` §5 recipe (Puppeteer image, temp `/__peek` route, remove before commit). Server-Details page (`/`→servers→show) checks: -- buttons uniform (border+bg everywhere; Löschen/trash red-tinted persistent, not hover-only); -- firewall + fail2ban panels appear only when installed, in a 2-col grid (lg), full width when one; -- auto-update row reads neutral (aktiv/inaktiv, no orange OFFEN); -- SSH-Passwort row shows the key-only hint while on; -- create-server modal blocks on a bad password (error shown), no server row created; -- a freshly created (good-cred) server shows "Initialisierung" (cyan) in the fleet list. -- HTTP 200, 0 console errors, DOM scan for leaked `@`/`{{ }}`/`$var`/`group.key` (R17). - -- [ ] **Step 4: Codex review (R15)** - -Run: `export PATH="$HOME/.npm-global/bin:$PATH"; codex review --uncommitted` (run before the final commit if anything is left unstaged; otherwise review the branch range). Fix + re-run until clean. - -- [ ] **Step 5: Update handoff doc** - -Note in `docs/session-handoff.md` that these 7 items are done on `feat/v1-foundation` (not yet tagged/pushed). Commit as `docs:`. - ---- - -## Self-Review - -**Spec coverage:** -- Issue 1 (UFW box hidden when not installed) → Task 6. ✓ -- Issue 2 (auto-updates default neutral) → Task 3. ✓ -- Issue 3 (SSH key-only hint) → Task 7. ✓ -- Issue 4 (create-verify + Initialisierung) → Tasks 4, 5 (+ status maps in Task 2). ✓ -- Issue 5 (grid sizing) → Task 6. ✓ -- Issue 6 (consistent buttons / one definition) → Task 1. ✓ -- Issue 7 (delete button bordered danger, not hover-red) → Task 1 (`danger-soft`) + applied in Task 6. ✓ - -**Type/name consistency:** `danger-soft` used identically in Tasks 1 + 6. `pending` used in Tasks 2 + 5. `neutral` row flag defined in Task 3 (`HardeningService`) and consumed in Task 3 Blade. `testConnection` defined in Task 4, called in Task 5. `servers.status_pending` added in Task 2, reused in Task 2 views. `servers.ssh_key_only_hint` added in Task 7. `modals.create_server.validation_ssh_failed` added in Task 5. No dangling references. - -**Placeholder scan:** none — every code step shows complete content. - -**Ordering note:** Task 1 migrates the trash/unban buttons in `show.blade.php`; Task 6 re-pastes that section with the same `danger-soft`/`secondary` choices, so the two are consistent even though Task 6 overwrites the region. diff --git a/docs/superpowers/plans/2026-06-14-webauthn-yubikey.md b/docs/superpowers/plans/2026-06-14-webauthn-yubikey.md deleted file mode 100644 index 67f8ef7..0000000 --- a/docs/superpowers/plans/2026-06-14-webauthn-yubikey.md +++ /dev/null @@ -1,605 +0,0 @@ -# WebAuthn / YubiKey 2nd-Factor Implementation Plan (Phase 2) - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Add WebAuthn security keys (YubiKey / platform authenticators) as an optional login 2nd factor, gated on a domain + HTTPS, without weakening the existing TOTP + backup-code recovery. - -**Architecture:** A single `WebauthnService` wraps `web-auth/webauthn-lib`; everything else (gating, storage, Livewire wiring, JS) is project code. The key is an *alternative* at the existing TOTP challenge — TOTP + backup codes stay required and remain the only recovery. The feature is hidden + routes abort unless `DeploymentService::domain()` is set and the request is HTTPS (rpId = the domain; an IP is not a valid rpId). - -**Tech Stack:** Laravel 13, Livewire 3, `web-auth/webauthn-lib ^5.3` (verified to resolve on L13/PHP 8.3), Vite, Pest/PHPUnit. All tooling in the `app` container (R8). Copy bilingual (R16). - -**Lib-API note:** `web-auth/webauthn-lib` v5 is NOT in context7 and its v5 serializer/validator API must not be guessed. In the `WebauthnService` task, **read the installed package** (`vendor/web-auth/webauthn-lib/src`) for the exact classes (`WebauthnSerializerFactory`, `PublicKeyCredentialCreationOptions`, `PublicKeyCredentialRequestOptions`, `AuthenticatorAttestationResponseValidator`, `AuthenticatorAssertionResponseValidator`, `PublicKeyCredentialSource`, the `*Repository`/`CeremonyStep` shape) before writing the body. The service's **public interface + behavior + tests** below are fixed; only the internal lib calls are confirmed against the source. - -**Conventions:** tests `docker compose exec -T app php artisan test --filter=`; migrate `… php artisan migrate`; Pint `… -u "1002:1002" app ./vendor/bin/pint --dirty`; build `… npm run build`. Conventional Commits + Co-Authored-By trailer. - -**Testability:** the cryptographic ceremony (real key register/login) CANNOT run on this bare-IP host (no domain+HTTPS, IP rpId invalid). It is browser-verified later on a domain. Here: unit/feature tests (gating, option shape, storage, login-on-verified with a **mocked** `WebauthnService`) + R12 that the option is correctly hidden on bare-IP. - ---- - -### Task 1: Install lib + credential storage - -**Files:** -- Modify: `composer.json`/`composer.lock` (require) -- Create: `database/migrations/2026_06_14_000002_create_webauthn_credentials_table.php`, `app/Models/WebauthnCredential.php` -- Modify: `app/Models/User.php` -- Test: `tests/Feature/WebauthnCredentialTest.php` - -- [ ] **Step 1: Require the library** - -```bash -docker compose exec -T -u "1002:1002" app composer require web-auth/webauthn-lib:^5.3 --no-interaction -``` -Expected: installs `web-auth/webauthn-lib` 5.3.x + deps (symfony/serializer, web-auth/cose-lib, spomky-labs/cbor-php). - -- [ ] **Step 2: Migration** - -```php -id(); - $table->foreignId('user_id')->constrained()->cascadeOnDelete(); - $table->string('name'); - $table->text('credential_id'); // base64url of the raw credential id - $table->unique(['user_id', 'credential_id'], 'webauthn_user_credential_unique'); - $table->text('public_key'); // serialized PublicKeyCredentialSource (JSON) - $table->string('aaguid')->nullable(); - $table->json('transports')->nullable(); - $table->unsignedBigInteger('sign_count')->default(0); - $table->timestamp('last_used_at')->nullable(); - $table->timestamps(); - }); - } - - public function down(): void - { - Schema::dropIfExists('webauthn_credentials'); - } -}; -``` - -- [ ] **Step 3: Write the failing test** - -```php -create(); - $this->assertFalse($user->hasWebauthnCredentials()); - - WebauthnCredential::create([ - 'user_id' => $user->id, 'name' => 'YubiKey 5C', 'credential_id' => 'abc', - 'public_key' => '{}', 'sign_count' => 0, - ]); - - $this->assertTrue($user->fresh()->hasWebauthnCredentials()); - $this->assertCount(1, $user->fresh()->webauthnCredentials); - } -} -``` - -- [ ] **Step 4: Run test (fails — model/relation missing)** - -Run: `docker compose exec -T app php artisan test --filter=WebauthnCredentialTest` → FAIL. - -- [ ] **Step 5: Model + relation** - -Create `app/Models/WebauthnCredential.php`: -```php - 'array', - 'sign_count' => 'integer', - 'last_used_at' => 'datetime', - ]; - - public function user(): BelongsTo - { - return $this->belongsTo(User::class); - } -} -``` - -In `app/Models/User.php` add `use Illuminate\Database\Eloquent\Relations\HasMany;` and: -```php - public function webauthnCredentials(): HasMany - { - return $this->hasMany(WebauthnCredential::class); - } - - public function hasWebauthnCredentials(): bool - { - return $this->webauthnCredentials()->exists(); - } -``` - -- [ ] **Step 6: Run test (pass), migrate, commit** - -Run: `docker compose exec -T app php artisan test --filter=WebauthnCredentialTest` → PASS. -```bash -docker compose exec -T app php artisan migrate -docker compose exec -T -u "1002:1002" app ./vendor/bin/pint --dirty -git add composer.json composer.lock database/migrations/2026_06_14_000002_create_webauthn_credentials_table.php app/Models/WebauthnCredential.php app/Models/User.php tests/Feature/WebauthnCredentialTest.php -git commit -m "feat(webauthn): install web-auth/webauthn-lib + credential storage" -``` - ---- - -### Task 2: `WebauthnService::available()` gate - -**Files:** -- Create: `app/Services/WebauthnService.php` (initial — just the gate + ctor) -- Test: `tests/Feature/WebauthnAvailableTest.php` - -- [ ] **Step 1: Write the failing test** - -```php -mock(DeploymentService::class); - $deployment->shouldReceive('domain')->andReturn($domain); - - return app(WebauthnService::class); - } - - public function test_available_only_with_domain_and_https(): void - { - // domain set + secure request - $this->app['request']->server->set('HTTPS', 'on'); - $this->assertTrue($this->service('panel.example.com')->available()); - } - - public function test_unavailable_without_domain(): void - { - $this->app['request']->server->set('HTTPS', 'on'); - $this->assertFalse($this->service(null)->available()); - } - - public function test_unavailable_without_https(): void - { - $this->app['request']->server->remove('HTTPS'); - $this->assertFalse($this->service('panel.example.com')->available()); - } -} -``` - -- [ ] **Step 2: Run test (fails — service missing)** - -Run: `docker compose exec -T app php artisan test --filter=WebauthnAvailableTest` → FAIL. - -- [ ] **Step 3: Initial service** - -```php -deployment->domain() !== null && request()->isSecure(); - } - - /** The Relying-Party ID = the active domain (asserted present by available()). */ - public function rpId(): string - { - return (string) $this->deployment->domain(); - } -} -``` - -- [ ] **Step 4: Run test (pass) + commit** - -Run: `docker compose exec -T app php artisan test --filter=WebauthnAvailableTest` → PASS (3). -```bash -docker compose exec -T -u "1002:1002" app ./vendor/bin/pint --dirty -git add app/Services/WebauthnService.php tests/Feature/WebauthnAvailableTest.php -git commit -m "feat(webauthn): WebauthnService::available gate (domain + https)" -``` - ---- - -### Task 3: Ceremony options + verification (against the installed lib) - -**Read `vendor/web-auth/webauthn-lib/src` first** to confirm the v5 API, then implement. - -**Files:** -- Modify: `app/Services/WebauthnService.php` -- Test: `tests/Feature/WebauthnOptionsTest.php` - -**Public interface to implement (fixed; internals use the lib):** -```php -public function registrationOptions(User $user): array; // JSON-ready PublicKeyCredentialCreationOptions; stores session('webauthn.register') -public function verifyRegistration(User $user, array $response, string $name): WebauthnCredential; // validates + stores; throws RuntimeException on mismatch -public function assertionOptions(User $user): array; // JSON-ready PublicKeyCredentialRequestOptions; stores session('webauthn.login') -public function verifyAssertion(User $user, array $response): bool; // validates against stored credential + session challenge; bumps sign_count/last_used_at -``` -Behavior: -- `registrationOptions`: rpId = `rpId()`, rpName "Clusev", user entity id = a stable per-user handle (e.g. `hash('sha256', $user->id)` raw-> the user's id bytes), `excludeCredentials` = the user's stored credential ids, `userVerification = preferred`, fresh random challenge persisted in the session. Serialize with the lib's `WebauthnSerializerFactory` serializer to an array. -- `verifyRegistration`: deserialize the client response, run `AuthenticatorAttestationResponseValidator` against the session challenge + an rp host/id check, map the resulting `PublicKeyCredentialSource` into a `WebauthnCredential` row (credential_id base64url, serialized source in `public_key`, aaguid, transports, sign_count). Reject duplicates (unique index). -- `assertionOptions`: rpId, `allowCredentials` = the user's credential ids, fresh challenge in session. -- `verifyAssertion`: find the matching stored credential by raw id, run `AuthenticatorAssertionResponseValidator`; on success persist the new sign count + `last_used_at` and return true; any failure returns false (never throws into the login path). - -- [ ] **Step 1: Write the failing test (option shape — no real ceremony needed)** - -```php -mock(DeploymentService::class); - $d->shouldReceive('domain')->andReturn($domain); - - return app(WebauthnService::class); - } - - public function test_registration_options_use_domain_rpid_and_store_challenge(): void - { - $user = User::factory()->create(); - $opts = $this->service()->registrationOptions($user); - - $this->assertSame('panel.example.com', data_get($opts, 'rp.id')); - $this->assertNotEmpty(data_get($opts, 'challenge')); - $this->assertNotNull(session('webauthn.register')); - } - - public function test_assertion_options_list_user_credentials(): void - { - $user = User::factory()->create(); - WebauthnCredential::create(['user_id' => $user->id, 'name' => 'k', 'credential_id' => base64_encode('rawid'), 'public_key' => '{}', 'sign_count' => 0]); - - $opts = $this->service()->assertionOptions($user); - - $this->assertSame('panel.example.com', data_get($opts, 'rpId')); - $this->assertNotEmpty(data_get($opts, 'allowCredentials')); - $this->assertNotNull(session('webauthn.login')); - } -} -``` -(If the lib's serialized key names differ from `rp.id`/`rpId`/`allowCredentials`, adjust these asserts to the real JSON shape after reading the lib — keep the intent: rpId = domain, challenge stored, allow-list present.) - -- [ ] **Step 2: Run test (fails)** → `--filter=WebauthnOptionsTest`. - -- [ ] **Step 3: Implement the four methods** against the installed lib (read vendor first). Map `PublicKeyCredentialSource` ↔ `WebauthnCredential` in a private helper. `verifyAssertion`/`verifyRegistration` wrap the lib validators in try/catch (verify returns false / throws localized). - -- [ ] **Step 4: Run test (pass) + commit** - -```bash -docker compose exec -T -u "1002:1002" app ./vendor/bin/pint --dirty -git add app/Services/WebauthnService.php tests/Feature/WebauthnOptionsTest.php -git commit -m "feat(webauthn): registration/assertion options + verification" -``` - ---- - -### Task 4: Login challenge integration - -**Files:** -- Modify: `app/Livewire/Auth/TwoFactorChallenge.php` -- Modify: `resources/views/livewire/auth/two-factor-challenge.blade.php` -- Test: `tests/Feature/TwoFactorWebauthnTest.php` - -- [ ] **Step 1: Write the failing test (mock the service's verifyAssertion)** - -```php -create(['two_factor_secret' => 'x', 'two_factor_confirmed_at' => now()]); - session()->put('2fa.user', $user->id); - - $svc = $this->mock(WebauthnService::class); - $svc->shouldReceive('available')->andReturn(true); - $svc->shouldReceive('verifyAssertion')->andReturn(true); - - Livewire::test(TwoFactorChallenge::class) - ->call('verifyWebauthn', ['fake' => 'assertion']); - - $this->assertAuthenticatedAs($user); - } - - public function test_failed_assertion_does_not_log_in(): void - { - $user = User::factory()->create(['two_factor_secret' => 'x', 'two_factor_confirmed_at' => now()]); - session()->put('2fa.user', $user->id); - - $svc = $this->mock(WebauthnService::class); - $svc->shouldReceive('available')->andReturn(true); - $svc->shouldReceive('verifyAssertion')->andReturn(false); - - Livewire::test(TwoFactorChallenge::class) - ->call('verifyWebauthn', ['fake' => 'assertion']) - ->assertHasErrors('code'); - - $this->assertGuest(); - } -} -``` - -- [ ] **Step 2: Run test (fails — verifyWebauthn missing)** → `--filter=TwoFactorWebauthnTest`. - -- [ ] **Step 3: Add `verifyWebauthn` + `webauthnOptions` to `TwoFactorChallenge`** - -Inject `WebauthnService`. Add a computed `webauthnAvailable()` (= service `available()` AND the pending user has credentials), an `assertionOptions()` action returning JSON for the JS, and: -```php - public function verifyWebauthn(array $assertion, WebauthnService $webauthn): mixed - { - $key = 'two-factor:'.md5(session('2fa.user').'|'.request()->ip()); - if (RateLimiter::tooManyAttempts($key, 5)) { - throw ValidationException::withMessages(['code' => __('auth.too_many_attempts', ['seconds' => RateLimiter::availableIn($key)])]); - } - - $user = User::find(session('2fa.user')); - if (! $user || ! $webauthn->available() || ! $webauthn->verifyAssertion($user, $assertion)) { - RateLimiter::hit($key, 60); - throw ValidationException::withMessages(['code' => __('auth.webauthn_failed')]); - } - - RateLimiter::clear($key); - $remember = (bool) session('2fa.remember'); - session()->forget(['2fa.user', '2fa.remember']); - Auth::login($user, $remember); - session()->regenerate(); - - return $this->redirectIntended(route('dashboard'), navigate: true); - } -``` -(Reuses the same session gate + rate-limit as `verify()`.) - -- [ ] **Step 4: View — the Security-Key button (only when available)** - -In the challenge view, below the code form, add (gated): -```blade -@if ($this->webauthnAvailable()) -
{{ __('common.or') }}
- - {{ __('auth.webauthn_login') }} - -@endif -``` -The JS (`window.clusevWebauthn.login`, Task 5) calls `$wire.assertionOptions()`, runs `navigator.credentials.get`, then `$wire.verifyWebauthn()`. - -- [ ] **Step 5: Run test (pass) + lang + commit** - -Add `auth.webauthn_failed`, `auth.webauthn_login`, `common.or` (DE+EN). Run `--filter=TwoFactorWebauthnTest` → PASS. -```bash -docker compose exec -T -u "1002:1002" app ./vendor/bin/pint --dirty -git add app/Livewire/Auth/TwoFactorChallenge.php resources/views/livewire/auth/two-factor-challenge.blade.php lang/de/auth.php lang/en/auth.php lang/de/common.php lang/en/common.php tests/Feature/TwoFactorWebauthnTest.php -git commit -m "feat(webauthn): use a security key at the login challenge" -``` - ---- - -### Task 5: Settings registration UI + JS module - -**Files:** -- Create: `app/Livewire/Settings/WebauthnKeys.php`, `resources/views/livewire/settings/webauthn-keys.blade.php`, `resources/js/webauthn.js` -- Modify: `resources/js/app.js` (import the module), `resources/views/livewire/settings/index.blade.php` (embed the keys panel in the security tab), `lang/{de,en}/auth.php` -- Test: `tests/Feature/WebauthnKeysTest.php` - -- [ ] **Step 1: Write the failing test (register via mocked verify, list, remove)** - -```php -create(); - $this->actingAs($user); - $svc = $this->mock(WebauthnService::class); - $svc->shouldReceive('available')->andReturn(true); - $svc->shouldReceive('verifyRegistration')->andReturnUsing(fn ($u, $r, $name) => WebauthnCredential::create([ - 'user_id' => $u->id, 'name' => $name, 'credential_id' => 'cid', 'public_key' => '{}', 'sign_count' => 0, - ])); - - Livewire::test(WebauthnKeys::class) - ->set('newName', 'YubiKey 5C') - ->call('register', ['fake' => 'attestation']) - ->assertHasNoErrors(); - - $this->assertDatabaseHas('webauthn_credentials', ['user_id' => $user->id, 'name' => 'YubiKey 5C']); - } - - public function test_remove_deletes_credential(): void - { - $user = User::factory()->create(); - $this->actingAs($user); - $cred = WebauthnCredential::create(['user_id' => $user->id, 'name' => 'k', 'credential_id' => 'cid', 'public_key' => '{}', 'sign_count' => 0]); - $this->mock(WebauthnService::class)->shouldReceive('available')->andReturn(true); - - Livewire::test(WebauthnKeys::class)->call('remove', $cred->id); - - $this->assertDatabaseMissing('webauthn_credentials', ['id' => $cred->id]); - } -} -``` - -- [ ] **Step 2: Run test (fails)** → `--filter=WebauthnKeysTest`. - -- [ ] **Step 3: `app/Livewire/Settings/WebauthnKeys.php`** - -```php -available(), 404); - - return $webauthn->registrationOptions(Auth::user()); - } - - public function register(array $attestation, WebauthnService $webauthn): void - { - abort_unless($webauthn->available(), 404); - $this->validate(); - - $cred = $webauthn->verifyRegistration(Auth::user(), $attestation, trim($this->newName)); - $this->reset('newName'); - - AuditEvent::create(['user_id' => Auth::id(), 'actor' => Auth::user()->name ?? 'system', 'action' => 'webauthn.register', 'target' => $cred->name, 'ip' => request()->ip()]); - $this->dispatch('notify', message: __('auth.webauthn_added')); - } - - public function remove(int $id, WebauthnService $webauthn): void - { - abort_unless($webauthn->available(), 404); - $cred = Auth::user()->webauthnCredentials()->whereKey($id)->first(); - if ($cred) { - $cred->delete(); - AuditEvent::create(['user_id' => Auth::id(), 'actor' => Auth::user()->name ?? 'system', 'action' => 'webauthn.remove', 'target' => $cred->name, 'ip' => request()->ip()]); - $this->dispatch('notify', message: __('auth.webauthn_removed')); - } - } - - public function render() - { - return view('livewire.settings.webauthn-keys', [ - 'available' => app(WebauthnService::class)->available(), - 'keys' => Auth::user()->webauthnCredentials()->latest()->get(), - ]); - } -} -``` - -- [ ] **Step 4: View `resources/views/livewire/settings/webauthn-keys.blade.php`** - -Panel titled "Security-Keys". When `! $available`: a muted hint (`auth.webauthn_unavailable`). When available: a name input + "Hinzufügen" (`x-on:click="window.clusevWebauthn.register($wire)"`) and a list of `$keys` (name, `created_at`, `last_used_at`) each with a `danger-soft` remove button (`wire:click="remove(id)"` via the confirm modal, R5). Reuse existing field/btn classes. - -- [ ] **Step 5: JS `resources/js/webauthn.js`** — base64url helpers + `register($wire)` (calls `$wire.options()`, `navigator.credentials.create`, `$wire.register(encoded)`) and `login()` (locates the challenge component, `$wire.assertionOptions()`, `navigator.credentials.get`, `$wire.verifyWebauthn(encoded)`); expose `window.clusevWebauthn`. Import it in `resources/js/app.js`. - -- [ ] **Step 6: Embed in Settings security tab** - -In `resources/views/livewire/settings/index.blade.php` security tab, after the 2FA panel, add ``. - -- [ ] **Step 7: Run test (pass), lang, build, commit** - -Add `auth.webauthn_added/removed/unavailable/manage_title` + the keys-panel copy (DE+EN). Run `--filter=WebauthnKeysTest` → PASS. -```bash -docker compose exec -T -u "1002:1002" app ./vendor/bin/pint --dirty -docker compose exec -T -u "1002:1002" app npm run build -git add app/Livewire/Settings/WebauthnKeys.php resources/views/livewire/settings/webauthn-keys.blade.php resources/js/webauthn.js resources/js/app.js resources/views/livewire/settings/index.blade.php lang/de/auth.php lang/en/auth.php tests/Feature/WebauthnKeysTest.php -git commit -m "feat(webauthn): register/list/remove security keys in settings" -``` - ---- - -### Task 6: Verification (R12 gating + Codex + release) - -- [ ] **Step 1: Full suite + Pint** — `php artisan test` (only the pre-existing ExampleTest may fail); `./vendor/bin/pint --test`. -- [ ] **Step 2: Build** — `npm run build`. -- [ ] **Step 3: R12 (bare-IP, DE+EN, 375/768/1280):** Settings security tab shows the "Security-Keys" panel with the **unavailable hint** (no register button) since `domain()` is null; the login challenge shows **no** Security-Key button. HTTP 200, 0 console, DOM scan (R17). (Real key register/login is NOT testable here — see deferred note.) -- [ ] **Step 4: Codex review (R15)** — `codex review --base `; fix + re-run until clean. -- [ ] **Step 5: Handoff + release** — record in `docs/session-handoff.md` incl. the **deferred manual check** (real YubiKey on a domain+HTTPS host); version bump + CHANGELOG + tag + sanitized push per the session cadence. - ---- - -## Self-Review - -**Spec coverage:** library (Task 1), gating `available()` (Task 2), data model (Task 1), `WebauthnService` four methods (Tasks 2-3), challenge integration (Task 4), Settings register/list/remove + JS + gating hint (Task 5), security (rpId=domain via gate, single-use challenge, sign-count via lib, audited, recovery preserved — Tasks 2-5), testing strategy + R12 + deferred E2E (Task 6). ✓ - -**Placeholder scan:** the only intentional non-literal is Task 3's lib-internal body, explicitly pinned to reading `vendor/web-auth/webauthn-lib/src` (the v5 API is not guessable / not in context7); its public interface + tests are concrete. All other steps have full code. - -**Type consistency:** `WebauthnService` methods (`available`, `rpId`, `registrationOptions`, `verifyRegistration`, `assertionOptions`, `verifyAssertion`) defined in Tasks 2-3, used in Tasks 4-5. `WebauthnCredential` fields consistent across tasks. `verifyWebauthn`/`options`/`register`/`remove` Livewire actions consistent with their tests. `window.clusevWebauthn.{login,register}` consistent between Tasks 4 and 5. - -**Note (lib API):** Task 3 test asserts JSON keys `rp.id`/`rpId`/`allowCredentials`; confirm exact serialized names against the installed lib and adjust the asserts (intent fixed: rpId=domain, challenge stored, allow-list present). diff --git a/docs/superpowers/plans/2026-06-15-recovery-codes-airtight-reveal.md b/docs/superpowers/plans/2026-06-15-recovery-codes-airtight-reveal.md deleted file mode 100644 index 6dd4b68..0000000 --- a/docs/superpowers/plans/2026-06-15-recovery-codes-airtight-reveal.md +++ /dev/null @@ -1,471 +0,0 @@ -# Airtight Show-Once Recovery Codes — Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Remove the snapshot-replay vector from the recovery-codes reveal so the plaintext codes and the "revealed" state never enter any persisted Livewire property or snapshot. - -**Architecture:** The `RecoveryCodes` modal keeps zero secret state. On a fresh reveal (one-time `2fa.codes_fresh` flag in `mount()`, or `regenerate()`) it dispatches a transient `reveal-codes` browser event carrying the codes; an Alpine `x-data` holds them in JS memory only and renders them with `x-for`. Because `mount()` never runs on hydrate and dispatched events live in the one-time response `effects.dispatches` (never the snapshot), a replayed/stale snapshot has nothing to re-render and fires no event. The download route gains a Redis-backed session lock (`->block(5, 5)`) to close the concurrent-download grant race. - -**Tech Stack:** Laravel 13, Livewire 3 (class-based, not Volt), Alpine.js, wire-elements/modal, Tailwind v4, Redis (cache/session locks). All tooling runs in-container (R8). - -**Spec:** `docs/superpowers/specs/2026-06-15-recovery-codes-airtight-reveal-design.md` - ---- - -## File Structure - -- `tests/Feature/RecoveryCodesModalTest.php` — rewritten test suite (the executable spec). -- `app/Livewire/Modals/RecoveryCodes.php` — remove `$revealed`, add `dispatchCodes()`, adjust `mount()`/`regenerate()`/`render()`. -- `resources/views/livewire/modals/recovery-codes.blade.php` — Alpine `x-data` + `x-for` + `x-show` panels. -- `routes/web.php` — `->block(5, 5)` on `two-factor.recovery.download` + comment. - -**Not touched (verified during design):** -- `resources/css/app.css` — `[x-cloak] { display: none }` already present (line ~107). No change. -- `app/Livewire/Auth/TwoFactorSetup.php`, `app/Livewire/Settings/WebauthnKeys.php`, `app/Livewire/Settings/Index.php` — still set `2fa.codes_fresh` / `2fa.download_grant` as today. No change. -- No i18n changes — no new visible strings. - -**Commit strategy:** The component, view, and route changes are coupled (the suite cannot go green until all three land), so there is a single feature commit after the suite passes (Task 5). This is intentional given the coupling. - ---- - -### Task 1: Rewrite the test suite (the failing spec) - -**Files:** -- Modify (replace whole file): `tests/Feature/RecoveryCodesModalTest.php` - -- [ ] **Step 1: Replace the test file with the new spec** - -Replace the entire contents of `tests/Feature/RecoveryCodesModalTest.php` with: - -```php -create(); - $codes = $user->replaceRecoveryCodes(); - - // Opened from "Backup-Codes verwalten" with no fresh-generation flag: never reveal. - // No reveal event fires, and the codes never appear in the server-rendered HTML. - Livewire::actingAs($user)->test(RecoveryCodes::class) - ->assertNotDispatched('reveal-codes') - ->assertDontSee($codes[0]) - ->assertDontSee($codes[7]) - ->assertSee(__('auth.recovery_hidden_notice')); - } - - public function test_fresh_flag_dispatches_codes_once_and_keeps_them_out_of_the_snapshot(): void - { - $user = User::factory()->create(); - $codes = $user->replaceRecoveryCodes(); - session(['2fa.codes_fresh' => true]); - - // The fresh flag delivers the codes via a one-time transient event — never as a - // property and never in the server-rendered HTML, so they are absent from the snapshot. - Livewire::actingAs($user)->test(RecoveryCodes::class) - ->assertDispatched('reveal-codes', codes: $codes) - ->assertDontSee($codes[0]) - ->assertDontSee($codes[7]); - - // pull() forgets the flag — it is single-use. - $this->assertFalse(session()->has('2fa.codes_fresh')); - } - - public function test_replayed_or_stale_snapshot_renders_no_codes(): void - { - $user = User::factory()->create(); - $codes = $user->replaceRecoveryCodes(); - session(['2fa.codes_fresh' => true]); - - // Legitimate one-time reveal: consumes the flag, fires the event once, renders no codes. - // A re-render of the SAME component (hydrate does not re-run mount()) renders no codes — - // this models replaying a captured snapshot to /livewire/update. - Livewire::actingAs($user)->test(RecoveryCodes::class) - ->assertDispatched('reveal-codes', codes: $codes) - ->assertDontSee($codes[0]) - ->call('$refresh') - ->assertDontSee($codes[0]); - - // A fresh mount from the now-consumed flag (the state a replayed snapshot starts from) - // dispatches nothing and renders nothing — codes cannot be re-revealed. - Livewire::actingAs($user)->test(RecoveryCodes::class) - ->assertNotDispatched('reveal-codes') - ->assertDontSee($codes[0]); - } - - public function test_regenerate_dispatches_the_new_codes_without_rendering_them(): void - { - $user = User::factory()->create(); - $old = $user->replaceRecoveryCodes(); - - // Manage view (no flag) → nothing dispatched → regenerate → the NEW set is dispatched once. - $component = Livewire::actingAs($user)->test(RecoveryCodes::class) - ->assertNotDispatched('reveal-codes') - ->call('regenerate'); - - $new = $user->fresh()->recoveryCodes(); - $this->assertNotEquals($old, $new); - - // New set delivered via the transient event; neither the old nor the new set is in the HTML. - $component->assertDispatched('reveal-codes', codes: $new) - ->assertDontSee($old[0]) - ->assertDontSee($new[0]); - - // Regenerate also grants exactly one download of the fresh set. - $this->assertTrue(session()->has('2fa.download_grant')); - } - - public function test_download_requires_a_fresh_grant(): void - { - $user = User::factory()->create(); - $user->replaceRecoveryCodes(); - - // Without a fresh-generation grant, a direct hit of the download route is forbidden, - // so stored codes can never be re-downloaded by an authed user at will. - $this->actingAs($user) - ->get(route('two-factor.recovery.download')) - ->assertForbidden(); - - // With the one-time grant (set when codes are freshly generated/regenerated) the - // download succeeds exactly once. - session(['2fa.download_grant' => true]); - $this->actingAs($user) - ->get(route('two-factor.recovery.download')) - ->assertOk() - ->assertDownload('clusev-recovery-codes.txt'); - - // The grant is single-use: pull() consumed it, so an immediate re-download 403s. - $this->actingAs($user) - ->get(route('two-factor.recovery.download')) - ->assertForbidden(); - } - - public function test_old_recovery_route_is_gone(): void - { - $this->assertFalse(Route::has('two-factor.recovery')); - $this->assertTrue(Route::has('two-factor.recovery.download')); - } -} -``` - -Notes for the implementer: -- The `CannotUpdateLockedPropertyException` import and the old `test_revealed_property_is_locked_against_client_tampering` test are intentionally gone — the `$revealed` prop no longer exists, so there is nothing to lock. -- `assertDispatched('reveal-codes', codes: $codes)` matches the named event param against the dispatched array. `replaceRecoveryCodes()` returns the same array `recoveryCodes()` later reads, so they compare equal. -- The authoritative replay proof is the second block of `test_replayed_or_stale_snapshot_renders_no_codes` (a brand-new mount with the flag already consumed). If your Livewire version accumulates dispatch effects across roundtrips on a single testable, only keep `assertDontSee` on the `$refresh` chain (already the case here) and rely on the fresh-mount block for `assertNotDispatched`. - -- [ ] **Step 2: Run the suite to verify it fails (red)** - -Run: `docker compose exec app php artisan test --filter=RecoveryCodesModalTest` -Expected: FAIL. The current component dispatches no `reveal-codes` event and renders the codes into the HTML when `revealed`, so `test_fresh_flag_...` (expects a dispatch + `assertDontSee`), `test_replayed_...`, `test_regenerate_...`, and `test_manage_view_...` (expects `assertNotDispatched`) fail. The download tests still pass. - -Do not commit yet — the suite is red until the implementation tasks land. - ---- - -### Task 2: Strip secret state from the component, dispatch codes transiently - -**Files:** -- Modify (replace whole file): `app/Livewire/Modals/RecoveryCodes.php` - -- [ ] **Step 1: Replace the component with the stateless version** - -Replace the entire contents of `app/Livewire/Modals/RecoveryCodes.php` with: - -```php -pull('2fa.codes_fresh', false)) { - $this->dispatchCodes(); - } - } - - /** Regenerate the current user's backup codes (invalidates the old set). */ - public function regenerate(): void - { - Auth::user()->replaceRecoveryCodes(); - - AuditEvent::create([ - 'user_id' => Auth::id(), - 'actor' => Auth::user()->name ?? 'system', - 'action' => 'two_factor.recovery_regenerate', - 'target' => Auth::user()->email, - 'ip' => request()->ip(), - ]); - - // One-time download grant for the freshly regenerated set (consumed by the - // download route). Without it a later direct hit of the route 403s. - session()->put('2fa.download_grant', true); - - // Reveal the freshly generated set once, via the transient event (never a property). - $this->dispatchCodes(); - - $this->dispatch('notify', message: __('auth.recovery_regenerated')); - } - - public function render() - { - return view('livewire.modals.recovery-codes'); - } - - /** - * Deliver the current user's recovery codes to the browser via a one-time event. The - * codes never enter a Livewire property or the snapshot — Alpine holds them in JS memory - * only, so a replayed snapshot cannot re-render them. - */ - protected function dispatchCodes(): void - { - $this->dispatch('reveal-codes', codes: Auth::user()->recoveryCodes()); - } -} -``` - -What changed vs. the old file: removed the `Livewire\Attributes\Locked` import and the `#[Locked] public bool $revealed` property; `mount()` now dispatches instead of setting `$revealed`; `regenerate()` drops `$this->revealed = true` and dispatches; `render()` no longer passes a `codes` array; added `dispatchCodes()`. - ---- - -### Task 3: Render codes client-side via Alpine (never server-rendered) - -**Files:** -- Modify (replace whole file): `resources/views/livewire/modals/recovery-codes.blade.php` - -- [ ] **Step 1: Replace the view with the Alpine version** - -Replace the entire contents of `resources/views/livewire/modals/recovery-codes.blade.php` with: - -```blade -
-
- - - -
-

{{ __('auth.recovery_heading') }}

-

{{ __('auth.recovery_subtitle') }}

-
-
- - {{-- Revealed panel: shown only once codes arrive via the transient `reveal-codes` event. - The codes live in Alpine JS memory (x-for) and are never in the server HTML/snapshot. --}} -
-
- -

{{ __('auth.recovery_warning') }}

-
- -
- -
- -
- - {{ __('auth.recovery_download') }} - - {{ __('common.close') }} -
-
- - {{-- Hidden notice: the default state (no codes in memory) — manage view, or any replayed - snapshot, which fires no event and therefore never populates `codes`. --}} -
-
- -

{{ __('auth.recovery_hidden_notice') }}

-
- -
- - {{ __('auth.recovery_regenerate') }} - - {{ __('common.close') }} -
-
-
-``` - -Notes: -- `[x-cloak] { display: none }` already exists in `resources/css/app.css` — no CSS change needed. It hides the modal body until Alpine initializes, preventing a raw-template flash. -- Both panels exist in the server HTML; `x-show` toggles visibility client-side. The codes themselves come only from `x-for` over the JS `codes` array, so the server HTML/snapshot contains no codes. The `assertDontSee($codes[0])` assertions therefore hold in every state. - ---- - -### Task 4: Add the session lock to the download route - -**Files:** -- Modify: `routes/web.php` (the `two-factor.recovery.download` route, around line 57-70) - -- [ ] **Step 1: Append `->block(5, 5)` to the route** - -Find this line (end of the download route definition): - -```php - })->name('two-factor.recovery.download'); -``` - -Replace it with: - -```php - })->name('two-factor.recovery.download') - // Session blocking (Redis-backed atomic lock) serializes concurrent same-session - // requests so the one-time `2fa.download_grant` cannot be double-read by a racing - // request. True concurrency cannot be unit-tested; lock-capable stores (redis in - // prod, array/file in tests) keep the happy path green. - ->block(5, 5); -``` - -Leave the route body (the `abort_unless(session()->pull('2fa.download_grant', false), 403)` and the streamed download) unchanged. - ---- - -### Task 5: Green suite, browser verification, Codex review, commit - -**Files:** none (verification + commit) - -- [ ] **Step 1: Run the full recovery-codes suite (expect green)** - -Run: `docker compose exec app php artisan test --filter=RecoveryCodesModalTest` -Expected: PASS — all of `test_manage_view_...`, `test_fresh_flag_...`, `test_replayed_...`, `test_regenerate_...`, `test_download_requires_a_fresh_grant`, `test_old_recovery_route_is_gone`. - -If `test_download_requires_a_fresh_grant` errors on the lock (rather than failing an assertion), confirm the test cache store supports atomic locks (`array`/`file`/`redis` do). If your test env uses an unsupported store, set `CACHE_STORE=array` for the test run; do not remove `->block()`. - -- [ ] **Step 2: Run the broader suite to catch regressions** - -Run: `docker compose exec app php artisan test --filter=Recovery` -Expected: PASS (includes `UserRecoveryCodesTest`, `FirstFactorCodesTest`-style coverage that touches `codes_fresh`). Then run the full `docker compose exec app php artisan test` if time allows. - -- [ ] **Step 3: Browser verification (R12)** - -With the dev stack up (`docker compose up -d`), load both states and check HTTP 200 + zero console/network errors at 375 / 768 / 1280: -- Fresh reveal: trigger first-factor enrollment (or `regenerate` from the modal) → confirm the 8 codes render and the download button appears. -- Manage view: open the modal from Settings → Security ("Backup-Codes verwalten") with no fresh flag → confirm the hidden notice + regenerate button, no codes. -- In DevTools Network, open the `openModal` (Livewire `/livewire/update`) response for the fresh reveal and confirm the codes appear ONLY under `effects.dispatches` (the `reveal-codes` event) and are ABSENT from `serverMemo`/`snapshot` and the rendered `html`. -- Inspect the rendered DOM for leaked `@`/`{{ }}`/`$var`/`group.key` text (R17). - -- [ ] **Step 4: Codex review (R15)** - -Run `/codex:review` over the change. Fix and re-run until it reports no errors and no security issues. The task is not done until Codex passes. - -- [ ] **Step 5: Commit** - -```bash -cd /home/nexxo/clusev -git checkout -b feature/recovery-codes-airtight-reveal -git add app/Livewire/Modals/RecoveryCodes.php \ - resources/views/livewire/modals/recovery-codes.blade.php \ - routes/web.php \ - tests/Feature/RecoveryCodesModalTest.php \ - docs/superpowers/specs/2026-06-15-recovery-codes-airtight-reveal-design.md \ - docs/superpowers/plans/2026-06-15-recovery-codes-airtight-reveal.md -git commit -m "feat(2fa): make recovery-codes reveal airtight against snapshot replay - -Codes and reveal-state no longer enter any Livewire property/snapshot — they -are delivered via a transient reveal-codes event and rendered client-side by -Alpine, so a replayed/stale snapshot re-renders nothing. Add ->block(5,5) -session lock to the download route to close the grant race." -``` - -(Run `git status` first to confirm no stray secrets are staged. Do not push unless asked.) - ---- - -## Self-Review - -**Spec coverage:** -- Threat model / structural show-once → Task 2 (no `$revealed`, no codes prop; mount/regenerate dispatch only). ✓ -- Transient dispatch + Alpine render → Task 2 (`dispatchCodes`) + Task 3 (`x-data`/`x-for`/`x-show`). ✓ -- Download-race lock → Task 4 (`->block(5, 5)`). ✓ -- Replay-proof tests → Task 1 (`test_replayed_or_stale_snapshot_renders_no_codes`). ✓ -- Removed locked-prop test → Task 1 (dropped + import removed). ✓ -- No i18n / no CSS change → confirmed in File Structure (x-cloak already present). ✓ -- Verification R12/R15 → Task 5. ✓ - -**Placeholder scan:** No TBD/TODO; every code step shows full file or exact edit. ✓ - -**Type/name consistency:** `dispatchCodes()` defined in Task 2 and referenced only there; event name `reveal-codes` and detail key `codes` match across component dispatch (Task 2), Alpine listener `$event.detail.codes` (Task 3), and tests `assertDispatched('reveal-codes', codes: ...)` (Task 1). Route name `two-factor.recovery.download` unchanged. ✓ - ---- - -## Post-review additions (Codex review, 2026-06-15) - -Codex raised three findings; resolutions: - -- **F1 (Alpine reveal-state persists across morphs):** Not an exploitable vector — the - snapshot-replay threat needs the codes *in the snapshot*, and they are not. The persisting - state is client-side JS memory in the same tab that already saw the codes legitimately. No - code change; proven by the snapshot assertions below. -- **F2 (download grant had no expiry):** Implemented a 10-minute TTL. The grant is now stored - as `now()->timestamp` by all three setters (`Modals/RecoveryCodes::regenerate()`, - `Auth/TwoFactorSetup.php`, `Settings/WebauthnKeys.php`), and `routes/web.php` rejects any - non-integer or >600s-old grant: - `abort_unless(is_int($grantedAt) && now()->timestamp - $grantedAt <= 600, 403);` - New test `test_a_stale_download_grant_is_rejected` covers the stale, legacy-bool, and fresh - cases. The existing `test_download_requires_a_fresh_grant` now seeds `now()->timestamp`. -- **F3 (snapshot leak not directly tested):** Added explicit snapshot assertions via a - `assertSnapshotHidesCodes(array $codes, $component)` helper that loops over EVERY code and - asserts `assertStringNotContainsString($code, json_encode($component->snapshot))` in the - fresh-flag, replay, and regenerate tests, plus `assertNotDispatched('reveal-codes')` after - `$refresh` (hydration). -- **F4 (state-changing GET is CSRF-exposable — a cross-site request could burn the victim's - one-time grant):** Changed the download route `GET`→`POST` (CSRF-protected) and made the - view's download control a `
` + `@csrf`; download tests use `->post(...)`. - No code disclosure was possible (cross-origin response is unreadable); the fix prevents the - grant-consumption nuisance. - -- **F5 (`2fa.codes_fresh` reveal flag had no expiry — symmetric to F2):** The flag is now a - timestamp set by `Auth/TwoFactorSetup.php` and `Settings/WebauthnKeys.php`, and - `RecoveryCodes::mount()` reveals only within a 10-minute window. New test - `test_a_stale_codes_fresh_flag_reveals_nothing`. `tests/Feature/FirstFactorCodesTest.php` - updated to assert the flag is an int (`assertIsInt`) rather than `true`. -- **F6 (timestamp comparison accepted a future value):** Both checks (download route and - `mount()`) now validate `$age >= 0 && $age <= 600`, rejecting future-dated timestamps. New - case added to `test_a_stale_download_grant_is_rejected`. - -Touched beyond the original plan: `app/Livewire/Auth/TwoFactorSetup.php`, -`app/Livewire/Settings/WebauthnKeys.php` (both flag types → timestamps), and -`tests/Feature/FirstFactorCodesTest.php` (flag type assertion). - -Final suite: `RecoveryCodesModalTest` 8 tests / 65 assertions, all green; recovery/2FA -regression set green. diff --git a/docs/superpowers/plans/2026-06-19-clusev-cli-and-command-shortcuts.md b/docs/superpowers/plans/2026-06-19-clusev-cli-and-command-shortcuts.md deleted file mode 100644 index 690204b..0000000 --- a/docs/superpowers/plans/2026-06-19-clusev-cli-and-command-shortcuts.md +++ /dev/null @@ -1,740 +0,0 @@ -# `clusev` Host-CLI + kurze Befehle + Tab-URL — Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Operatoren rufen kurze Host-Befehle auf (`sudo clusev update`, `clusev reset-admin`, …) statt langer `docker compose -f docker-compose.prod.yml …`; alle user-sichtbaren Stellen zeigen die Kurzform, die Hilfe erklärt sie ausführlich, und der Help-Tab landet (wie Settings) in der URL. - -**Architecture:** Ein vom Installer generiertes Host-Skript `/usr/local/bin/clusev` kapselt den Prod-Stack (Install-Verzeichnis eingebacken). UI/Hilfe/Lang-Strings/MOTD zeigen nur noch `clusev ` — `docker-compose.prod.yml` verschwindet aus jeder Anzeige. Der Help-Komponente bekommt `#[Url]` auf `$topic` und ein neues Thema „Befehle / CLI". - -**Tech Stack:** Bash (Wrapper + Skript-Test), Laravel 13 / Livewire 3 (`#[Url]`), Blade-Hilfe-Partials (DE/EN), PHPUnit (Feature-Tests), `install.sh` (Template-Rendering wie beim MOTD). - -**Conventions (Projekt):** UI-Strings DE+EN (`__('group.key')`, R9/R16); keine Volt; Tests laufen im Container — bevorzugt isoliert gegen den Live-Dev-Stack: -`docker compose exec -T app php artisan test --filter=` (oder mit eigenem `VIEW_COMPILED_PATH`, siehe `[[clusev-test-view-cache-race]]`). Vor Browser-Verify `view:clear` (siehe `[[clusev-dev-view-cache-gotcha]]`). - ---- - -## File Structure - -- **Neu** `docker/clusev/clusev` — Bash-Wrapper-Template (Platzhalter `__CLUSEV_DIR__`). Eine Verantwortung: kurze Unterbefehle → Prod-Stack-Kommandos. -- **Neu** `tests/scripts/test-clusev-cli.sh` — Host-Bash-Test für den Wrapper (Syntax, help, unknown, version, install-Verdrahtung). Kein Docker nötig. -- **Neu** `resources/views/livewire/help/content/de/commands.blade.php` + `…/en/commands.blade.php` — Hilfe-Thema „Befehle / CLI". -- **Neu** `tests/Feature/CommandShortcutsTest.php` — Regressions-Guard: keine Anzeige-Fläche leakt `docker-compose.prod.yml`; Kurzformen vorhanden. -- **Ändern** `install.sh` — Wrapper rendern + nach `/usr/local/bin/clusev` installieren (Phase 9 → „MOTD + CLI"). -- **Ändern** `app/Livewire/Help/Index.php` — `#[Url]` auf `$topic`; `commands` in `TOPICS` + Label. -- **Ändern** `lang/de/help.php` + `lang/en/help.php` — `topic_commands`. -- **Ändern** `resources/views/livewire/help/content/{de,en}/recovery.blade.php` — `
` → `clusev reset-admin`.
-- **Ändern** `resources/views/livewire/help/content/{de,en}/updates.blade.php` — `sudo ./update.sh` → `sudo clusev update`.
-- **Ändern** `resources/views/livewire/versions/index.blade.php` — 3-Zeilen-`
` → `sudo clusev update`.
-- **Ändern** `lang/de/versions.php` + `lang/en/versions.php` — `update_hint`.
-- **Ändern** `lang/de/settings.php` + `lang/en/settings.php` — `recovery_note` (`clusev:reset-admin` → `clusev reset-admin`).
-- **Ändern** `lang/de/system.php` + `lang/en/system.php` — `ssh_reset_hint` (dito).
-- **Ändern** `docker/motd/00-clusev` — Verwalten/Update/Reset-Zeilen → Kurzform.
-- **Ändern** `tests/Feature/HelpPageTest.php` — Marker `updates`/`recovery`, `commands`-Thema, `#[Url]`-Test, „kein docker-compose.prod.yml".
-- **Ändern** `config/clusev.php`, `CHANGELOG.md` — Release.
-
----
-
-## Task 1: `clusev` Host-CLI-Wrapper
-
-**Files:**
-- Create: `docker/clusev/clusev`
-- Create: `tests/scripts/test-clusev-cli.sh`
-- Modify: `install.sh` (Phase 9-Block, ~Zeilen 315–342)
-
-- [ ] **Step 1: Write the failing test (host bash test)**
-
-Create `tests/scripts/test-clusev-cli.sh`:
-
-```bash
-#!/usr/bin/env bash
-# Verifies the clusev host CLI template renders and behaves. No docker needed — only the
-# help / version / unknown-command paths are exercised. Run from the repo root:
-#   bash tests/scripts/test-clusev-cli.sh
-set -euo pipefail
-cd "$(dirname "$0")/../.."
-REPO="$(pwd)"
-TMP="$(mktemp -d)"
-trap 'rm -rf "$TMP"' EXIT
-
-fail() { printf 'FAIL: %s\n' "$1" >&2; exit 1; }
-
-# Render the template the way install.sh does.
-sed "s|__CLUSEV_DIR__|${REPO}|g" docker/clusev/clusev > "${TMP}/clusev"
-chmod +x "${TMP}/clusev"
-CLI="${TMP}/clusev"
-
-# 1. Valid bash syntax.
-bash -n "$CLI" || fail "syntax error in rendered clusev"
-
-# 2. help lists the key subcommands.
-out="$("$CLI" help)"
-for needle in "clusev update" "clusev reset-admin" "clusev logs" "clusev ps" "clusev migrate"; do
-  printf '%s' "$out" | grep -qF "$needle" || fail "help missing: $needle"
-done
-
-# 3. bare invocation and --help also show usage.
-"$CLI" | grep -qF "clusev reset-admin" || fail "bare invocation did not show usage"
-"$CLI" --help | grep -qF "clusev reset-admin" || fail "--help did not show usage"
-
-# 4. unknown command exits 64 and warns on stderr.
-set +e
-"$CLI" definitely-not-a-command >/dev/null 2>"${TMP}/err"; rc=$?
-set -e
-[ "$rc" -eq 64 ] || fail "unknown command exit = $rc (want 64)"
-grep -qF "Unbekannter Befehl" "${TMP}/err" || fail "unknown command did not warn"
-
-# 5. version reads config/clusev.php (no docker).
-want="$(grep -oE "'version' => '[^']+'" config/clusev.php | grep -oE '[0-9]+\.[0-9]+\.[0-9]+' | head -n1)"
-got="$("$CLI" version)"
-[ "$got" = "$want" ] || fail "version = '$got' (want '$want')"
-
-# 6. install.sh wires the CLI into PATH.
-grep -qF "/usr/local/bin/clusev" install.sh || fail "install.sh does not install /usr/local/bin/clusev"
-
-# 7. usage text must NOT leak the long compose filename.
-printf '%s' "$out" | grep -qF "docker-compose.prod.yml" && fail "usage leaks docker-compose.prod.yml"
-
-printf 'ok — clusev CLI checks passed\n'
-```
-
-- [ ] **Step 2: Run it to confirm it fails**
-
-Run: `bash tests/scripts/test-clusev-cli.sh`
-Expected: FAIL — `sed: can't read docker/clusev/clusev: No such file or directory` (template missing).
-
-- [ ] **Step 3: Create the wrapper template**
-
-Create `docker/clusev/clusev`:
-
-```bash
-#!/usr/bin/env bash
-# Clusev host CLI — short wrappers around the production stack.
-# Generated by install.sh (it substitutes __CLUSEV_DIR__ with the install directory) and
-# installed to /usr/local/bin/clusev. Operators run `clusev ` from anywhere instead
-# of long `docker compose -f docker-compose.prod.yml ...` invocations. The compose filename is
-# referenced internally only — it is never printed to the operator.
-set -euo pipefail
-
-CLUSEV_DIR="__CLUSEV_DIR__"
-COMPOSE_FILE="${CLUSEV_DIR}/docker-compose.prod.yml"
-
-compose() { docker compose -f "$COMPOSE_FILE" "$@"; }
-
-usage() {
-  cat <<'EOF'
-clusev — Fleet-Control Verwaltung (Host)
-
-  sudo clusev update     Update holen, Image neu bauen, Migrationen anwenden (root)
-  clusev reset-admin     Admin-Zugang zuruecksetzen (2FA entfernen, Passwort neu setzen)
-  clusev restart         Stack neu starten
-  clusev logs [dienst]   Logs folgen (Strg-C beendet)
-  clusev ps              Dienst-Status (Alias: status)
-  clusev migrate         Datenbank-Migrationen anwenden
-  clusev artisan <...>   beliebiges artisan-Kommando im app-Container
-  clusev version         installierte Version anzeigen
-  clusev help            diese Uebersicht
-
-Was im Hintergrund laeuft:
-  update      -> update.sh (git pull + Image-Rebuild + migrate)
-  reset-admin -> docker compose exec app php artisan clusev:reset-admin
-  migrate     -> docker compose exec app php artisan migrate --force
-EOF
-}
-
-cmd="${1:-help}"; shift 2>/dev/null || true
-case "$cmd" in
-  update)         exec "${CLUSEV_DIR}/update.sh" "$@" ;;
-  reset-admin)    compose exec app php artisan clusev:reset-admin "$@" ;;
-  restart)        compose up -d "$@" ;;
-  logs)           compose logs -f "$@" ;;
-  ps|status)      compose ps "$@" ;;
-  migrate)        compose exec app php artisan migrate --force "$@" ;;
-  artisan)        compose exec app php artisan "$@" ;;
-  version)        grep -oE "'version' => '[^']+'" "${CLUSEV_DIR}/config/clusev.php" \
-                    | grep -oE '[0-9]+\.[0-9]+\.[0-9]+' | head -n1 || true ;;
-  help|-h|--help) usage ;;
-  *)              printf 'Unbekannter Befehl: %s\n\n' "$cmd" >&2; usage; exit 64 ;;
-esac
-```
-
-- [ ] **Step 4: Run the test to confirm it passes**
-
-Run: `bash tests/scripts/test-clusev-cli.sh`
-Expected: PASS — `ok — clusev CLI checks passed`.
-
-- [ ] **Step 5: Wire the wrapper into install.sh**
-
-In `install.sh`, change the phase 9 header (line ~316) and add a render+install block **before** the `motd_render`/MOTD block. Replace:
-
-```bash
-# ── [9/9] MOTD (themed, shown at host login) ─────────────────────────
-phase 9/9 "MOTD installieren"
-```
-
-with:
-
-```bash
-# ── [9/9] Host-CLI + MOTD ────────────────────────────────────────────
-phase 9/9 "Host-CLI + MOTD installieren"
-
-# clusev host command: render the template with the install dir baked in, drop it in PATH.
-# Best-effort — a read-only /usr/local/bin must never fail the installer.
-if sed "s|__CLUSEV_DIR__|$(pwd)|g" docker/clusev/clusev > /usr/local/bin/clusev 2>/dev/null \
-   && chmod 0755 /usr/local/bin/clusev 2>/dev/null; then
-  info "Host-Befehl installiert: clusev (z. B. 'clusev ps', 'sudo clusev update')"
-else
-  info "Host-Befehl uebersprungen (/usr/local/bin nicht beschreibbar)"
-fi
-```
-
-(Leave the rest of the MOTD block unchanged.)
-
-- [ ] **Step 6: Verify install.sh still parses + test still green**
-
-Run: `bash -n install.sh && bash tests/scripts/test-clusev-cli.sh`
-Expected: no syntax error; `ok — clusev CLI checks passed`.
-
-- [ ] **Step 7: Commit**
-
-```bash
-git add docker/clusev/clusev tests/scripts/test-clusev-cli.sh install.sh
-git commit -m "feat: clusev host CLI wrapper for short stack commands"
-```
-
----
-
-## Task 2: Hilfe-Seite — Tab-URL + Thema „Befehle"
-
-**Files:**
-- Modify: `app/Livewire/Help/Index.php`
-- Modify: `lang/de/help.php`, `lang/en/help.php`
-- Create: `resources/views/livewire/help/content/de/commands.blade.php`, `…/en/commands.blade.php`
-- Modify: `resources/views/livewire/help/content/de/recovery.blade.php`, `…/en/recovery.blade.php`
-- Modify: `resources/views/livewire/help/content/de/updates.blade.php`, `…/en/updates.blade.php`
-- Test: `tests/Feature/HelpPageTest.php`
-
-- [ ] **Step 1: Update HelpPageTest — url binding, new topic, short commands**
-
-In `tests/Feature/HelpPageTest.php`, add the `Url` import and three checks, and update the data provider. Add at the top with the other `use` lines:
-
-```php
-use Livewire\Attributes\Url;
-```
-
-Add these test methods (after `test_security_topic_explains_the_2fa_access_paths`):
-
-```php
-    public function test_topic_property_is_url_bound(): void
-    {
-        $attrs = (new \ReflectionProperty(Index::class, 'topic'))->getAttributes(Url::class);
-        $this->assertNotEmpty($attrs, 'Help topic must be #[Url]-bound for deep links / reload');
-    }
-
-    public function test_recovery_shows_the_short_host_command_not_the_long_compose_one(): void
-    {
-        $this->actAsAdmin();
-        Livewire::test(Index::class)
-            ->set('topic', 'recovery')
-            ->assertSee('clusev reset-admin')
-            ->assertDontSee('docker-compose.prod.yml');
-    }
-
-    public function test_commands_topic_lists_the_cli(): void
-    {
-        $this->actAsAdmin();
-        Livewire::test(Index::class)
-            ->set('topic', 'commands')
-            ->assertSee('clusev reset-admin')
-            ->assertSee('sudo clusev update')
-            ->assertDontSee('docker-compose.prod.yml');
-    }
-```
-
-Replace the `topicProvider` body:
-
-```php
-    public static function topicProvider(): array
-    {
-        return [
-            ['updates', 'clusev update'],
-            ['servers', 'SSH'],
-            ['sessions', 'Sitzung'],
-            ['email', 'SMTP'],
-            ['audit', 'Audit'],
-            ['recovery', 'clusev reset-admin'],
-            ['commands', 'clusev ps'],
-        ];
-    }
-```
-
-- [ ] **Step 2: Run the tests to confirm they fail**
-
-Run: `docker compose exec -T app php artisan test --filter=HelpPageTest`
-Expected: FAIL — `test_topic_property_is_url_bound` (no `#[Url]`), `commands` topic renders overview (no marker), recovery still shows `docker-compose.prod.yml`.
-
-- [ ] **Step 3: Add `#[Url]` + `commands` topic to the component**
-
-In `app/Livewire/Help/Index.php`:
-
-Change the imports (lines 5–6):
-
-```php
-use Livewire\Attributes\Layout;
-use Livewire\Attributes\Url;
-use Livewire\Component;
-```
-
-Change the `TOPICS` constant (insert `commands` after `updates`):
-
-```php
-    private const TOPICS = [
-        'overview', 'domain-tls', 'security', 'updates', 'commands',
-        'servers', 'sessions', 'email', 'audit', 'recovery',
-    ];
-```
-
-Add `#[Url]` to the property (line ~22):
-
-```php
-    #[Url]
-    public string $topic = 'overview';
-```
-
-Add the `commands` label to the `$labels` array in `render()` (after the `updates` entry):
-
-```php
-            'updates' => __('help.topic_updates'),
-            'commands' => __('help.topic_commands'),
-```
-
-- [ ] **Step 4: Add the `topic_commands` lang key (DE + EN)**
-
-In `lang/de/help.php`, after the `topic_updates` line:
-
-```php
-    'topic_commands' => 'Befehle / CLI',
-```
-
-In `lang/en/help.php`, after the `topic_updates` line:
-
-```php
-    'topic_commands' => 'Commands / CLI',
-```
-
-- [ ] **Step 5: Create the DE „Befehle" partial**
-
-Create `resources/views/livewire/help/content/de/commands.blade.php`:
-
-```blade
-@php
-    $h = 'font-display text-base font-semibold text-ink';
-    $p = 'text-sm leading-relaxed text-ink-2';
-    $code = 'rounded bg-inset px-1.5 py-0.5 font-mono text-[12px] text-accent-text';
-    $cmd = 'block w-full overflow-x-auto rounded-md border border-line bg-void px-3 py-2 font-mono text-[12px] text-accent-text';
-    $real = 'mt-1 font-mono text-[11px] leading-relaxed text-ink-3';
-@endphp
-
-
-

Befehle / CLI

-

Auf dem Host richtet der Installer den Befehl clusev ein. Er kapselt die langen Docker-Kommandos — du musst dir weder Compose-Datei noch Container-Namen merken. Per SSH einloggen und von überall clusev <befehl> tippen. clusev help zeigt die Übersicht.

-
- -
-
- sudo clusev update -

Holt die neueste Version, baut das Image neu (inkl. CSS/JS) und wendet Datenbank-Migrationen an. Braucht root (sudo). Secrets und die konfigurierte Domain bleiben erhalten; das Panel ist ein bis zwei Minuten kurz nicht erreichbar.

-

→ führt update.sh aus (git pull + Image-Rebuild + migrate)

-
- -
- clusev reset-admin -

Setzt den Admin-Zugang zurück: entfernt den zweiten Faktor (2FA), sodass beim nächsten Login ein neues Passwort gesetzt werden kann. Letzter Ausweg, wenn Passwort und 2FA verloren sind (siehe Konto-Wiederherstellung).

-

docker compose exec app php artisan clusev:reset-admin

-
- -
- clusev restart -

Startet den Stack neu (alle Container) und wendet Konfigurationsänderungen an, ohne neu zu bauen.

-

docker compose up -d

-
- -
- clusev logs -

Folgt den Live-Logs des Stacks (z. B. zur Fehlersuche). Mit einem Dienstnamen dahinter — etwa clusev logs app — nur dessen Logs. Strg-C beendet.

-

docker compose logs -f

-
- -
- clusev ps -

Zeigt, welche Dienste laufen (Alias: clusev status). Schneller Gesundheits-Check des Stacks.

-

docker compose ps

-
- -
- clusev migrate -

Wendet ausstehende Datenbank-Migrationen an. Normalerweise erledigt das clusev update automatisch — dieser Befehl ist für den seltenen manuellen Fall.

-

docker compose exec app php artisan migrate --force

-
- -
- clusev artisan <...> -

Für Fortgeschrittene: reicht ein beliebiges artisan-Kommando in den app-Container durch, etwa clusev artisan about.

-

docker compose exec app php artisan <...>

-
-
-``` - -- [ ] **Step 6: Create the EN „Commands" partial** - -Create `resources/views/livewire/help/content/en/commands.blade.php`: - -```blade -@php - $h = 'font-display text-base font-semibold text-ink'; - $p = 'text-sm leading-relaxed text-ink-2'; - $code = 'rounded bg-inset px-1.5 py-0.5 font-mono text-[12px] text-accent-text'; - $cmd = 'block w-full overflow-x-auto rounded-md border border-line bg-void px-3 py-2 font-mono text-[12px] text-accent-text'; - $real = 'mt-1 font-mono text-[11px] leading-relaxed text-ink-3'; -@endphp - -
-

Commands / CLI

-

The installer sets up the clusev command on the host. It wraps the long Docker invocations — you never need to remember the compose file or container names. SSH in and run clusev <command> from anywhere. clusev help prints the overview.

-
- -
-
- sudo clusev update -

Pulls the latest release, rebuilds the image (including CSS/JS) and applies database migrations. Requires root (sudo). Secrets and the configured domain are preserved; the panel is briefly unreachable for a minute or two.

-

→ runs update.sh (git pull + image rebuild + migrate)

-
- -
- clusev reset-admin -

Resets dashboard access: clears the second factor (2FA) so you can set a new password on the next login. Last resort when password and 2FA are both lost (see Account recovery).

-

docker compose exec app php artisan clusev:reset-admin

-
- -
- clusev restart -

Restarts the stack (all containers) and applies configuration changes without rebuilding.

-

docker compose up -d

-
- -
- clusev logs -

Follows the stack's live logs (handy for troubleshooting). Add a service name — e.g. clusev logs app — for just that one. Ctrl-C stops.

-

docker compose logs -f

-
- -
- clusev ps -

Shows which services are running (alias: clusev status). A quick stack health check.

-

docker compose ps

-
- -
- clusev migrate -

Applies pending database migrations. Normally clusev update does this for you — this is for the rare manual case.

-

docker compose exec app php artisan migrate --force

-
- -
- clusev artisan <...> -

For advanced use: passes any artisan command through to the app container, e.g. clusev artisan about.

-

docker compose exec app php artisan <...>

-
-
-``` - -- [ ] **Step 7: Shorten the recovery `
` (DE + EN)**
-
-In `resources/views/livewire/help/content/de/recovery.blade.php`, replace the `
` block (lines 21–22) and the following paragraph (line 23):
-
-```blade
-    
clusev reset-admin
-

clusev reset-admin entfernt den zweiten Faktor, sodass du beim nächsten Login ein neues Passwort setzen kannst. (Dahinter läuft docker compose exec app php artisan clusev:reset-admin — siehe „Befehle / CLI".)

-``` - -In `resources/views/livewire/help/content/en/recovery.blade.php`, replace the `
` block (lines 21–22) and the following paragraph (line 23):
-
-```blade
-    
clusev reset-admin
-

clusev reset-admin clears the second factor so you can set a new password on the next login. (Under the hood it runs docker compose exec app php artisan clusev:reset-admin — see "Commands / CLI".)

-``` - -- [ ] **Step 8: Switch the updates partial to `clusev update` (DE + EN)** - -In `resources/views/livewire/help/content/de/updates.blade.php`, replace line 13: - -```blade -
  • Per SSH: auf dem Host sudo clusev update ausführen (von überall). Mehr unter „Befehle / CLI".
  • -``` - -In `resources/views/livewire/help/content/en/updates.blade.php`, replace line 13: - -```blade -
  • Over SSH: on the host run sudo clusev update (from anywhere). More under "Commands / CLI".
  • -``` - -- [ ] **Step 9: Run the Help tests to confirm they pass** - -Run: `docker compose exec -T app php artisan test --filter=HelpPageTest` -Expected: PASS — all topics render, url-binding present, recovery/commands show short forms, no `docker-compose.prod.yml`. - -- [ ] **Step 10: Commit** - -```bash -git add app/Livewire/Help/Index.php lang/de/help.php lang/en/help.php \ - resources/views/livewire/help/content/de/commands.blade.php \ - resources/views/livewire/help/content/en/commands.blade.php \ - resources/views/livewire/help/content/de/recovery.blade.php \ - resources/views/livewire/help/content/en/recovery.blade.php \ - resources/views/livewire/help/content/de/updates.blade.php \ - resources/views/livewire/help/content/en/updates.blade.php \ - tests/Feature/HelpPageTest.php -git commit -m "feat: help tab-URL + Commands/CLI topic + short command copy" -``` - ---- - -## Task 3: Übrige Anzeige-Flächen + Regressions-Guard - -**Files:** -- Modify: `resources/views/livewire/versions/index.blade.php` (lines 162–164) -- Modify: `lang/de/versions.php`, `lang/en/versions.php` (`update_hint`) -- Modify: `lang/de/settings.php`, `lang/en/settings.php` (`recovery_note`) -- Modify: `lang/de/system.php`, `lang/en/system.php` (`ssh_reset_hint`) -- Modify: `docker/motd/00-clusev` (lines 43–45) -- Test: `tests/Feature/CommandShortcutsTest.php` - -- [ ] **Step 1: Write the failing regression test** - -Create `tests/Feature/CommandShortcutsTest.php`: - -```php -assertStringNotContainsString('docker-compose.prod.yml', $contents, "{$rel} still shows docker-compose.prod.yml"); - } - } - - public function test_versions_panel_shows_the_short_update_command(): void - { - $blade = file_get_contents(base_path('resources/views/livewire/versions/index.blade.php')); - $this->assertStringContainsString('sudo clusev update', $blade); - $this->assertStringNotContainsString('docker compose -f', $blade); - } - - public function test_reset_hints_use_the_short_host_command(): void - { - foreach (['de', 'en'] as $locale) { - $settings = require base_path("lang/{$locale}/settings.php"); - $system = require base_path("lang/{$locale}/system.php"); - $this->assertStringContainsString('clusev reset-admin', $settings['recovery_note']); - $this->assertStringContainsString('clusev reset-admin', $system['ssh_reset_hint']); - } - } - - public function test_motd_uses_short_commands(): void - { - $motd = file_get_contents(base_path('docker/motd/00-clusev')); - $this->assertStringContainsString('clusev ps | logs | restart', $motd); - $this->assertStringContainsString('sudo clusev update', $motd); - $this->assertStringContainsString('clusev reset-admin', $motd); - } -} -``` - -- [ ] **Step 2: Run it to confirm it fails** - -Run: `docker compose exec -T app php artisan test --filter=CommandShortcutsTest` -Expected: FAIL — versions blade + MOTD still carry `docker-compose.prod.yml`; lang strings still say `clusev:reset-admin`. - -- [ ] **Step 3: Shorten the versions update panel** - -In `resources/views/livewire/versions/index.blade.php`, replace the `
    ` block (lines 162–164):
    -
    -```blade
    -                    
    sudo clusev update
    -``` - -- [ ] **Step 4: Reword the `update_hint` lang strings** - -In `lang/de/versions.php` (line 47): - -```php - 'update_hint' => 'Updates kommen über getaggte Releases. Auf dem Host genügt ein Befehl — er baut das Image neu und wendet Migrationen an:', -``` - -In `lang/en/versions.php` (line 47): - -```php - 'update_hint' => 'Updates arrive via tagged releases. On the host a single command rebuilds the image and applies migrations:', -``` - -- [ ] **Step 5: Short host command in the reset hints** - -In `lang/de/settings.php` (line 44): - -```php - 'recovery_note' => 'Konto-Wiederherstellung (letzter Ausweg): per SSH auf den Host einloggen und `clusev reset-admin` ausführen.', -``` - -In `lang/en/settings.php` (line 44): - -```php - 'recovery_note' => 'Account recovery (last resort): sign in to the host over SSH and run `clusev reset-admin`.', -``` - -In `lang/de/system.php` (line 34): - -```php - 'ssh_reset_hint' => 'Komplett ausgesperrt? Per SSH auf den Host einloggen und `clusev reset-admin` ausführen, um den Zugang zurückzusetzen.', -``` - -In `lang/en/system.php` (line 34): - -```php - 'ssh_reset_hint' => 'Fully locked out? SSH into the host and run `clusev reset-admin` to reset dashboard access.', -``` - -- [ ] **Step 6: Short commands in the MOTD** - -In `docker/motd/00-clusev`, replace lines 43–45: - -```bash -printf ' %sLogin%s %sStandard-Passwort: clusev — beim ersten Login aendern · Reset: clusev reset-admin%s\n' "$D" "$R" "$D" "$R" -printf ' %sVerwalten%s %sclusev ps | logs | restart%s\n' "$D" "$R" "$D" "$R" -printf ' %sUpdate%s %ssudo clusev update%s\n' "$D" "$R" "$D" "$R" -``` - -(`stack_status()` keeps using `$COMPOSE` internally — that is script logic, not displayed text, and uses the `__CLUSEV_COMPOSE__` placeholder, not the literal filename.) - -- [ ] **Step 7: Run the regression test to confirm it passes** - -Run: `docker compose exec -T app php artisan test --filter=CommandShortcutsTest` -Expected: PASS — all four assertions green. - -- [ ] **Step 8: Commit** - -```bash -git add resources/views/livewire/versions/index.blade.php \ - lang/de/versions.php lang/en/versions.php \ - lang/de/settings.php lang/en/settings.php \ - lang/de/system.php lang/en/system.php \ - docker/motd/00-clusev tests/Feature/CommandShortcutsTest.php -git commit -m "feat: short clusev commands in versions panel, lang strings, MOTD" -``` - ---- - -## Task 4: Integration, Verifizierung & Release - -**Files:** -- Modify: `config/clusev.php` (version bump) -- Modify: `CHANGELOG.md` - -- [ ] **Step 1: Full test suite green** - -Run: `docker compose exec -T app php artisan test` -Expected: PASS — no regressions (note `[[clusev-test-view-cache-race]]`: if a Blade-touch flake appears, re-run the failing file with an isolated `VIEW_COMPILED_PATH`). - -- [ ] **Step 2: Wrapper host test green** - -Run: `bash tests/scripts/test-clusev-cli.sh` -Expected: `ok — clusev CLI checks passed`. - -- [ ] **Step 3: Build assets (prod parity)** - -Run: `docker compose exec -T app npm run build` -Expected: Vite build succeeds, no errors. - -- [ ] **Step 4: Bump version** - -In `config/clusev.php`, set the `'version'` to the next patch (current is `0.9.24` → `0.9.25`): - -```php - 'version' => '0.9.25', -``` - -- [ ] **Step 5: Update CHANGELOG** - -Add a top entry to `CHANGELOG.md` (match the existing format/heading style already in the file): - -```markdown -## [0.9.25] — 2026-06-19 - -### Added -- Host CLI `clusev` (installed to `/usr/local/bin`): short wrappers for `update`, `reset-admin`, `restart`, `logs`, `ps`, `migrate`, `artisan`, `version`. Operators no longer type the long `docker compose -f docker-compose.prod.yml …` invocations. -- Help topic „Befehle / CLI" (DE/EN) documenting every `clusev` command and what it runs underneath. - -### Changed -- Help tab now reflects in the URL (`#[Url]` on the topic) — reload / bookmark / share keep the active tab, matching Settings. -- All operator-facing surfaces (version update panel, help recovery/updates, settings/system reset hints, MOTD) now show the short `clusev …` commands; the `docker-compose.prod.yml` filename no longer appears in the UI. -``` - -- [ ] **Step 6: Commit the release** - -```bash -git add config/clusev.php CHANGELOG.md -git commit -m "chore: release 0.9.25 — clusev host CLI + short commands + help tab-URL" -``` - -- [ ] **Step 7: R12 browser verify on the live domain** - -Deploy to the VM, then load the panel in headless Chrome (per `[[clusev-prod-browser-verify]]`): -- `/help` → HTTP 200; click „Befehle / CLI" → the new topic renders; reload `…/help?topic=commands` → still on the topic; switch DE/EN; zero console/network errors; no leaked `@`/`{{ }}`/`group.key`. -- `/versions` (Version & Releases) → the update panel shows `sudo clusev update`, not the long command. -- On the host shell run `clusev help`, `clusev ps`, `clusev version` (read-only — NOT `reset-admin`/`update`); confirm sane output. - -Expected: every check passes; throwaway admin (if created) removed afterwards. - -- [ ] **Step 8: Codex review (R15)** - -Run `/codex:review` over the branch diff. Fix anything it flags as an error or security issue; re-run until clean. - -- [ ] **Step 9: Tag, push, deploy** - -Tag `v0.9.25`, push branch + tag to the Gitea remote (token read only at push time from `/home/nexxo/.env.gitea`, never echoed/committed; sanitize push output `sed -E 's#https://[^@]*@#https://@#g; s/[0-9a-f]{40,}//g'`), then deploy to the VM and re-confirm `clusev` is on PATH (`which clusev`). - ---- - -## Self-Review - -**1. Spec coverage:** -- Teil A (clusev wrapper) → Task 1 (template, test, install.sh). ✓ -- Teil B (Help `#[Url]`) → Task 2 Step 3 + url-binding test. ✓ -- Teil C (alle Anzeige-Flächen) → versions+lang+motd Task 3; help recovery/updates Task 2. ✓ -- Teil D (Thema „Befehle" + ausführliche Erklärung + echtes Kommando) → Task 2 Steps 4–6. ✓ -- Teil E (Dateistruktur) → File Structure section + per-task file lists. ✓ -- Teil F (Test/Verify) → bash test (Task 1), HelpPageTest (Task 2), CommandShortcutsTest (Task 3), suite+build+R12+Codex (Task 4). ✓ -- Teil G (kein Rename, keine internen Skripte) → respected; only display surfaces changed; `docker-compose.prod.yml` stays the real filename, referenced internally in the wrapper + MOTD `stack_status`. ✓ - -**2. Placeholder scan:** No TBD/TODO; every code step shows full content. ✓ - -**3. Type/name consistency:** Topic key `commands` used identically in `TOPICS`, `$labels`, lang key `topic_commands`, partial filenames `commands.blade.php`, and test markers. Short command spelled `clusev reset-admin` / `sudo clusev update` consistently across wrapper usage, help, lang, MOTD, tests. The wrapper's internal `COMPOSE_FILE` is the only place the long filename appears (not a display surface). ✓ - -**Note on the regression guard:** `CommandShortcutsTest` deliberately excludes `docker/clusev/clusev` (the wrapper *must* reference the compose file internally) — only operator-visible files are scanned. diff --git a/docs/superpowers/plans/2026-06-19-help-page.md b/docs/superpowers/plans/2026-06-19-help-page.md deleted file mode 100644 index 173c060..0000000 --- a/docs/superpowers/plans/2026-06-19-help-page.md +++ /dev/null @@ -1,459 +0,0 @@ -# Hilfe-Seite Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Eine bilinguale In-Panel-Hilfe-Seite unter `/help` mit linker Themen-Navigation, die alle Einstellungen erklärt — inkl. generischer Reverse-Proxy-Anleitung und dem 2FA-Zugangspfad-Hinweis. - -**Architecture:** Full-Page-Livewire-Komponente `App\Livewire\Help\Index` (App-Layout, hinter Auth + Onboarding). Kurze Strings in `lang/{de,en}/help.php`; lange Texte als Blade-Partials je Sprache unter `resources/views/livewire/help/content/{de,en}/.blade.php`, nach `app()->getLocale()` eingebunden (Fallback `de`). Kein State außer `$topic`. - -**Tech Stack:** Laravel 13, Livewire 3 (class-based), Tailwind v4 `@theme`-Tokens, bestehende Blade-Komponenten (`x-nav-item`, `x-panel`, `x-icon`). - -**Konventionen (hart):** Route-Pfad englisch (R13); sichtbare Texte DE+EN, kein Emoji (R9/R16); nur `@theme`-Token-Utilities, keine Inline-Styles (R4/R3); Container-Tooling (R8); Browser-Verify (R12) + Codex (R15) vor „fertig". - -Spec: `docs/superpowers/specs/2026-06-19-help-page-design.md` (Abschnitt 4 = verbindliche Inhalte). - ---- - -## Task 1: Gerüst + Thema „overview" + Tests (TDD) - -**Files:** -- Create: `app/Livewire/Help/Index.php` -- Create: `resources/views/livewire/help/index.blade.php` -- Create: `resources/views/livewire/help/content/de/overview.blade.php` -- Create: `resources/views/livewire/help/content/en/overview.blade.php` -- Create: `lang/de/help.php`, `lang/en/help.php` -- Modify: `routes/web.php` (Route + Import) -- Modify: `resources/views/components/sidebar.blade.php` (Nav-Eintrag nach Zeile 36) -- Modify: `lang/de/shell.php`, `lang/en/shell.php` (`nav_help`) -- Modify: `resources/views/components/command-palette.blade.php` (chords + `$nav`) -- Modify: `resources/js/app.js` (`CMDK_GO` + `h`) -- Test: `tests/Feature/HelpPageTest.php` - -- [ ] **Step 1: Failing test** - -`tests/Feature/HelpPageTest.php`: -```php -actingAs(User::factory()->create(['must_change_password' => false])); - } - - public function test_help_route_loads_for_an_authenticated_admin(): void - { - $this->actAsAdmin(); - $this->get('/help')->assertOk(); - } - - public function test_default_topic_is_overview(): void - { - $this->actAsAdmin(); - Livewire::test(Index::class) - ->assertSet('topic', 'overview') - ->assertSee(__('help.topic_overview')); - } - - public function test_unknown_topic_falls_back_to_overview(): void - { - $this->actAsAdmin(); - Livewire::test(Index::class, ['topic' => 'bogus'])->assertSet('topic', 'overview'); - } -} -``` - -- [ ] **Step 2: Run — expect FAIL** - -Run: `docker compose exec -T -u app app php artisan test --filter=HelpPageTest` -Expected: FAIL (class `App\Livewire\Help\Index` not found). - -- [ ] **Step 3: Component** - -`app/Livewire/Help/Index.php`: -```php -.blade.php. - */ -#[Layout('layouts.app')] -class Index extends Component -{ - /** Help topics in display order. Keys are also the content-partial filenames. */ - private const TOPICS = [ - 'overview', 'domain-tls', 'security', 'updates', - 'servers', 'sessions', 'email', 'audit', 'recovery', - ]; - - public string $topic = 'overview'; - - public function mount(?string $topic = null): void - { - if ($topic !== null && in_array($topic, self::TOPICS, true)) { - $this->topic = $topic; - } - } - - public function render() - { - $labels = [ - 'overview' => __('help.topic_overview'), - 'domain-tls' => __('help.topic_domain_tls'), - 'security' => __('help.topic_security'), - 'updates' => __('help.topic_updates'), - 'servers' => __('help.topic_servers'), - 'sessions' => __('help.topic_sessions'), - 'email' => __('help.topic_email'), - 'audit' => __('help.topic_audit'), - 'recovery' => __('help.topic_recovery'), - ]; - - $topics = array_map(fn (string $key): array => ['key' => $key, 'label' => $labels[$key]], self::TOPICS); - - return view('livewire.help.index', ['topics' => $topics])->title(__('help.title')); - } -} -``` - -- [ ] **Step 4: Chrome lang files** - -`lang/de/help.php`: -```php - 'Hilfe — Clusev', - 'eyebrow' => 'Hilfe', - 'heading' => 'Hilfe & Dokumentation', - 'subtitle' => 'Einstellungen und Abläufe erklärt.', - - 'topic_overview' => 'Überblick', - 'topic_domain_tls' => 'Domain, TLS & Reverse-Proxy', - 'topic_security' => 'Sicherheit & 2FA', - 'topic_updates' => 'Updates & Versionen', - 'topic_servers' => 'Server & SSH', - 'topic_sessions' => 'Sitzungen & Benutzer', - 'topic_email' => 'E-Mail (SMTP)', - 'topic_audit' => 'Audit-Log', - 'topic_recovery' => 'Konto-Wiederherstellung', -]; -``` - -`lang/en/help.php` (same keys, English values): -```php - 'Help — Clusev', - 'eyebrow' => 'Help', - 'heading' => 'Help & documentation', - 'subtitle' => 'Settings and procedures explained.', - - 'topic_overview' => 'Overview', - 'topic_domain_tls' => 'Domain, TLS & reverse proxy', - 'topic_security' => 'Security & 2FA', - 'topic_updates' => 'Updates & versions', - 'topic_servers' => 'Servers & SSH', - 'topic_sessions' => 'Sessions & users', - 'topic_email' => 'Email (SMTP)', - 'topic_audit' => 'Audit log', - 'topic_recovery' => 'Account recovery', -]; -``` - -- [ ] **Step 5: Index view** - -`resources/views/livewire/help/index.blade.php`: -```blade -@php - $locale = in_array(app()->getLocale(), ['de', 'en'], true) ? app()->getLocale() : 'de'; - $partial = 'livewire.help.content.'.$locale.'.'.$topic; - if (! view()->exists($partial)) { - $partial = 'livewire.help.content.de.'.$topic; - } -@endphp - -
    -
    -

    {{ __('help.eyebrow') }}

    -

    {{ __('help.heading') }}

    -

    {{ __('help.subtitle') }}

    -
    - -
    - {{-- Topic nav --}} - - - {{-- Content --}} -
    - @include($partial) -
    -
    -
    -``` - -- [ ] **Step 6: overview partials (de + en)** - -`resources/views/livewire/help/content/de/overview.blade.php` — short, real prose. Cover: was Clusev ist (Fleet-Control über SSH); Erst-Login `admin@clusev.local` / `clusev` (sofort ändern, Zwangswechsel); Bare-IP vs. Domain; wo man was findet (Server, Dienste, Dateien, Audit, Einstellungen, System, Version, Hilfe). Use `

    `, `

    `, ``. NO emoji, NO inline styles. - -`resources/views/livewire/help/content/en/overview.blade.php` — same content, English. - -- [ ] **Step 7: Route + import** - -`routes/web.php`: add import near the other `use App\Livewire\...;` lines: -```php -use App\Livewire\Help; -``` -Inside the `EnsureSecurityOnboarded` group, after the `/versions` route: -```php -Route::get('/help', Help\Index::class)->name('help'); -``` - -- [ ] **Step 8: Sidebar nav** - -`resources/views/components/sidebar.blade.php` — after the `/versions` `x-nav-item` (line 36), add: -```blade -{{ __('shell.nav_help') }} -``` -If `x-icon` has no `help-circle` case, add it to `resources/views/components/icon.blade.php` with the Lucide path: -```blade -@case('help-circle') - - @break -``` -(Match the existing `@case`/`@break` structure + the component's stroke attributes.) - -- [ ] **Step 9: shell lang + cmdk** - -`lang/de/shell.php`: add `'nav_help' => 'Hilfe',`. `lang/en/shell.php`: add `'nav_help' => 'Help',`. - -`resources/views/components/command-palette.blade.php`: -- In `$chords` (after `['g v', __('shell.nav_versions')],`): add `['g h', __('shell.nav_help')],` -- In `$nav` (after the versions entry): add `['label' => __('shell.nav_help'), 'href' => '/help', 'hint' => 'g h'],` - -`resources/js/app.js` line ~256 `CMDK_GO`: add `h: '/help'` to the map: -```js -const CMDK_GO = { d: '/', s: '/servers', i: '/services', f: '/files', l: '/audit', e: '/settings', y: '/system', v: '/versions', h: '/help' }; -``` - -- [ ] **Step 10: Run — expect PASS** - -Run: `docker compose exec -T -u app app php artisan test --filter=HelpPageTest` -Expected: PASS (3 tests). - -- [ ] **Step 11: Pint + commit** - -```bash -docker compose exec -T -u app app ./vendor/bin/pint app/Livewire/Help/Index.php tests/Feature/HelpPageTest.php -git add app/Livewire/Help routes/web.php resources/views/livewire/help resources/views/components/sidebar.blade.php resources/views/components/command-palette.blade.php resources/views/components/icon.blade.php resources/js/app.js lang/de/help.php lang/en/help.php lang/de/shell.php lang/en/shell.php tests/Feature/HelpPageTest.php -git commit -m "feat(help): in-panel help page scaffold + overview topic" -``` - ---- - -## Task 2: Thema „domain-tls" (Reverse-Proxy-Anleitung) - -**Files:** -- Create: `resources/views/livewire/help/content/de/domain-tls.blade.php` -- Create: `resources/views/livewire/help/content/en/domain-tls.blade.php` -- Test: `tests/Feature/HelpPageTest.php` (add a test) - -- [ ] **Step 1: Failing test** — add to `HelpPageTest`: -```php - public function test_domain_tls_topic_has_the_reverse_proxy_guide(): void - { - $this->actAsAdmin(); - Livewire::test(Index::class) - ->set('topic', 'domain-tls') - ->assertSee('X-Forwarded-Proto') - ->assertSee('TRUSTED_PROXY_CIDR') - ->assertDontSee('Zoraxy'); // generic — no product names - } -``` - -- [ ] **Step 2: Run — expect FAIL** (`view [livewire.help.content.de.domain-tls] not found`). -Run: `docker compose exec -T -u app app php artisan test --filter=HelpPageTest` - -- [ ] **Step 3: Write the partials (de + en)** - -Content per spec §4a — three modes (Bare-IP / eingebautes TLS / externer Proxy) then the **generic step-by-step** proxy guide. MUST contain the literal tokens `X-Forwarded-Proto` and `TRUSTED_PROXY_CIDR`; MUST NOT contain `Zoraxy` (use „Proxy Manager"). Steps verbatim: -1. Im Panel: System → Domain → TLS „Externer Reverse-Proxy" → speichern → Stack neu starten. -2. Im Proxy Manager: Domain = `panel.`; Ziel = `http://:80` (HTTP); Host-Header + `X-Forwarded-Proto: https` weiterreichen; WebSocket aktivieren (`/app/*`, `/apps/*`). -3. `.env`: `TRUSTED_PROXY_CIDR` = Proxy-Adresse → Secure-Cookie + echte IP im Audit; danach `sudo ./update.sh`. -4. Firewall: HTTP-Port nur vom Proxy erreichbar. -5. In diesem Modus stellt Clusev kein Zertifikat aus. -Use `

    `, `

    `, `

      `/`
        `, and a `
        ` for the proxy fields/CLI. @theme tokens only.
        -
        -- [ ] **Step 4: Run — expect PASS.** `docker compose exec -T -u app app php artisan test --filter=HelpPageTest`
        -
        -- [ ] **Step 5: Commit**
        -```bash
        -git add resources/views/livewire/help/content tests/Feature/HelpPageTest.php
        -git commit -m "feat(help): domain-tls topic with the reverse-proxy setup guide"
        -```
        -
        ----
        -
        -## Task 3: Thema „security" (2FA-Zugangspfade + Extensions)
        -
        -**Files:**
        -- Create: `resources/views/livewire/help/content/de/security.blade.php`
        -- Create: `resources/views/livewire/help/content/en/security.blade.php`
        -- Test: `tests/Feature/HelpPageTest.php`
        -
        -- [ ] **Step 1: Failing test** — add:
        -```php
        -    public function test_security_topic_explains_the_2fa_access_paths(): void
        -    {
        -        $this->actAsAdmin();
        -        Livewire::test(Index::class)
        -            ->set('topic', 'security')
        -            ->assertSee('Backup-Code')
        -            ->assertSee('TOTP');
        -    }
        -```
        -
        -- [ ] **Step 2: Run — expect FAIL.**
        -
        -- [ ] **Step 3: Write the partials (de + en)** — content per spec §4b + §4c:
        -  - TOTP / Security-Keys (YubiKey) / Backup-Codes erklären.
        -  - **Security-Key nur über die HTTPS-Domain** (WebAuthn braucht sicheren Kontext + Domain als rpId); über **Bare-IP/HTTP** kein Security-Key → **Backup-Code** nötig.
        -  - **TOTP funktioniert überall** (auch Bare-IP/HTTP), braucht keinen Backup-Code für den IP-Pfad.
        -  - Empfehlung: reiner Security-Key-Nutzer → Backup-Codes aufbewahren.
        -  - Passwort-Manager-Extensions (Bitwarden/Kaspersky) können die Registrierung abfangen → in der Extension Passkey-Übernahme deaktivieren oder ohne Extension registrieren (privates Fenster).
        -  Must contain literal `Backup-Code` and `TOTP`.
        -
        -- [ ] **Step 4: Run — expect PASS.**
        -
        -- [ ] **Step 5: Commit**
        -```bash
        -git add resources/views/livewire/help/content tests/Feature/HelpPageTest.php
        -git commit -m "feat(help): security topic — 2FA access paths + extension note"
        -```
        -
        ----
        -
        -## Task 4: Restliche Themen (updates, servers, sessions, email, audit, recovery)
        -
        -**Files:**
        -- Create: `resources/views/livewire/help/content/{de,en}/{updates,servers,sessions,email,audit,recovery}.blade.php` (12 Partials)
        -- Test: `tests/Feature/HelpPageTest.php`
        -
        -- [ ] **Step 1: Failing test** — assert every topic renders without falling back (a marker string per topic). Add:
        -```php
        -    /** @dataProvider topicProvider */
        -    public function test_each_topic_renders_its_own_partial(string $topic, string $marker): void
        -    {
        -        $this->actAsAdmin();
        -        Livewire::test(Index::class)->set('topic', $topic)->assertSee($marker);
        -    }
        -
        -    public static function topicProvider(): array
        -    {
        -        return [
        -            ['updates', 'update.sh'],
        -            ['servers', 'SSH'],
        -            ['sessions', 'Sitzung'],
        -            ['email', 'SMTP'],
        -            ['audit', 'Audit'],
        -            ['recovery', 'clusev:reset-admin'],
        -        ];
        -    }
        -```
        -
        -- [ ] **Step 2: Run — expect FAIL** (missing partials).
        -
        -- [ ] **Step 3: Write the 12 partials (de + en)** — each short + accurate:
        -  - `updates`: Auto-Check, „Jetzt aktualisieren"-Knopf, CLI `sudo ./update.sh`, Build+Migrate beim Update. (marker `update.sh`)
        -  - `servers`: Server hinzufügen, SSH-Credential-Vault, Hardening, SSH-Key-Provisioning. (marker `SSH`)
        -  - `sessions`: aktive Sitzungen sehen/widerrufen, weitere Admins, Audit-Attribution. (marker `Sitzung`/`Session` — DE uses `Sitzung`)
        -  - `email`: SMTP konfigurieren (Settings → E-Mail) + Testmail. (marker `SMTP`)
        -  - `audit`: was protokolliert wird + Retention. (marker `Audit`)
        -  - `recovery`: Forgot-Password (E-Mail-Link / 2FA-Proof), Bare-IP-Recovery, `clusev:reset-admin`. (marker `clusev:reset-admin`)
        -  Same markup conventions (h3/p/ul/pre), @theme tokens, no emoji. EN partials mirror DE (the marker strings are language-neutral tokens, so the EN test passes too).
        -
        -- [ ] **Step 4: Run — expect PASS** (full HelpPageTest green).
        -
        -- [ ] **Step 5: Run full suite + Pint**
        -```bash
        -docker compose exec -T -u app app ./vendor/bin/pint
        -docker compose exec -T -u app app php artisan test
        -```
        -Expected: all green.
        -
        -- [ ] **Step 6: Commit**
        -```bash
        -git add resources/views/livewire/help/content tests/Feature/HelpPageTest.php
        -git commit -m "feat(help): remaining topics (updates, servers, sessions, email, audit, recovery)"
        -```
        -
        ----
        -
        -## Task 5: Browser-Verify (R12), Codex (R15), Release
        -
        -**Files:** Modify `config/clusev.php` (version), `CHANGELOG.md`.
        -
        -- [ ] **Step 1: R12 browser-verify on the domain** — temp admin (create + delete after, do not touch the real account), headless Chrome via the puppeteer docker image against `https://panel.test.bave.dev/help`:
        -  - HTTP 200, no console errors, no leaked `{{ }}`/`help.`/`shell.` tokens.
        -  - Click two topic-nav buttons (`domain-tls`, `security`) → content swaps.
        -  - Toggle DE/EN → content language changes (the partial locale switches).
        -  - Check at 375 / 768 / 1280 widths.
        -
        -- [ ] **Step 2: Codex review** — run `/codex:review` over the diff; fix until no errors / no security issues.
        -
        -- [ ] **Step 3: Version + CHANGELOG** — bump `config/clusev.php` version; add a `### Hinzugefügt` entry: „In-Panel-Hilfe-Seite (`/help`) mit Themen-Navigation; erklärt alle Einstellungen, die Reverse-Proxy-Einrichtung und die 2FA-Zugangspfade (Security-Key nur über HTTPS-Domain, Backup-Code über Bare-IP, TOTP überall). Zoraxy-Produktname aus dem TLS-Hinweis entfernt."
        -
        -- [ ] **Step 4: Commit, tag, push** (token from `/home/nexxo/.env.gitea`, output sanitized):
        -```bash
        -git add config/clusev.php CHANGELOG.md
        -git commit -m "chore(release): vX.Y.Z — in-panel help page"
        -git tag -a vX.Y.Z -m "Clusev vX.Y.Z — Hilfe-Seite"
        -TOKEN="$(grep -E '^GIT_ACCESS_TOKEN=' /home/nexxo/.env.gitea | cut -d= -f2- | tr -d '\r\n')"
        -git push "https://boban:${TOKEN}@git.bave.dev/boban/clusev.git" feat/v1-foundation --follow-tags 2>&1 | sed -E 's#https://[^@]*@#https://@#g; s/[0-9a-f]{40,}//g'
        -```
        -
        -- [ ] **Step 5: Deploy to VM + final verify** — `sudo ./update.sh` on the VM; confirm `/help` renders over the domain (R12 spot-check).
        -
        ----
        -
        -## Self-Review (Plan vs. Spec)
        -
        -- **Spec §1 Platzierung** → Task 1 (route, sidebar, cmdk, layout). ✓
        -- **Spec §2 Aufbau** → Task 1 (component `$topic`, index view, topic nav). ✓
        -- **Spec §3 zweisprachig** → Task 1 (lang chrome + per-locale partials + fallback). ✓
        -- **Spec §4a Proxy-Anleitung** → Task 2 (must-have tokens, no product name). ✓
        -- **Spec §4b 2FA-Pfade** → Task 3 (Backup-Code/TOTP markers). ✓
        -- **Spec §4c Extensions** → Task 3. ✓
        -- **Spec §5 Logik klein** → Task 1 component (pure display). ✓
        -- **Spec §6 Dateistruktur** → all 18 partials across Tasks 1–4. ✓
        -- **Spec §7 Test/Verify** → Tasks 1–4 (Livewire tests) + Task 5 (R12/Codex/Release). ✓
        -- **Spec §8 YAGNI** → no search/markdown engine; static partials only. ✓
        -
        -Type/name consistency: topic keys identical in `TOPICS`, labels map, partial filenames, and tests (`overview`, `domain-tls`, `security`, `updates`, `servers`, `sessions`, `email`, `audit`, `recovery`). `$set('topic', …)` matches the public `$topic` prop.
        diff --git a/docs/superpowers/plans/2026-06-20-2fa-challenge-backup-view.md b/docs/superpowers/plans/2026-06-20-2fa-challenge-backup-view.md
        deleted file mode 100644
        index 123076d..0000000
        --- a/docs/superpowers/plans/2026-06-20-2fa-challenge-backup-view.md
        +++ /dev/null
        @@ -1,921 +0,0 @@
        -# 2FA Challenge — Backup Code as its own button + view — Implementation Plan
        -
        -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
        -
        -**Goal:** Move the backup (recovery) code off the combined 2FA challenge screen into its own button + dedicated route/view, sharing all rate-limit and login logic via a trait.
        -
        -**Architecture:** Extract the pending-user resolver, the brute-force buckets and the login success-tail from `TwoFactorChallenge` into a `CompletesTwoFactorChallenge` trait. A new `TwoFactorBackup` full-page Livewire component (guest route `/two-factor-challenge/backup`) reuses the trait and accepts only recovery codes. The main challenge view shows only the primary factor (TOTP field *or* security-key button) plus a subordinate "Backup-Code verwenden" link; a key-only user with no secure context (http + bare IP) is redirected straight to the backup view from `mount()`. IP-vs-domain factor routing (`WebauthnService::available()`) is unchanged.
        -
        -**Tech Stack:** Laravel 13, Livewire v3 (class-based, full-page components as routes), Tailwind v4, PHPUnit + `livewire/livewire` testing, Pint. Spec: `docs/superpowers/specs/2026-06-20-2fa-challenge-backup-view-design.md`.
        -
        ----
        -
        -## Pre-flight
        -
        -- Dev stack up: `docker compose up -d` (app on :80). **Run ALL tooling inside the container (R8).**
        -- Branch: `feat/v1-foundation` (already checked out).
        -- Known flake (memory: test view-cache race): if `php artisan test` shows Blade-compile races against the live dev stack, re-run the affected filter with an isolated compiled-view dir, e.g. `docker compose exec -e VIEW_COMPILED_PATH=/tmp/views-test app php artisan test --filter=...`.
        -
        -## File structure
        -
        -| File | Responsibility |
        -|---|---|
        -| `app/Livewire/Concerns/CompletesTwoFactorChallenge.php` | **New.** Shared trait: `pendingUser()`, `getPendingHasTotpProperty()`, `webauthnAvailable()`, rate-limit buckets/helpers, `completeLogin()`. |
        -| `app/Livewire/Auth/TwoFactorChallenge.php` | **Modify.** Use the trait, drop the extracted methods, add the mount→backup redirect. WebAuthn methods stay here. |
        -| `app/Livewire/Auth/TwoFactorBackup.php` | **New.** Backup-only component: session guard + recovery-code `verify()`. |
        -| `resources/views/livewire/auth/two-factor-challenge.blade.php` | **Modify.** Gate the form to TOTP users, remove the backup field/hints, add the backup button. |
        -| `resources/views/livewire/auth/two-factor-backup.blade.php` | **New.** Backup-code field + return links. |
        -| `routes/web.php` | **Modify.** New `two-factor.challenge.backup` route in the `guest` group. |
        -| `lang/de/auth.php`, `lang/en/auth.php` | **Modify.** Add 4 keys; later remove 2 orphaned hint keys. |
        -| `tests/Feature/TwoFactorBackupTest.php` | **New.** Backup component coverage. |
        -| `tests/Feature/ChallengeFactorAdaptTest.php` | **Modify.** Flip the key-only assertion; add main-view content tests. |
        -
        ----
        -
        -### Task 1: i18n keys
        -
        -**Files:**
        -- Modify: `lang/de/auth.php`, `lang/en/auth.php`
        -
        -- [ ] **Step 1: Add the 4 new German keys**
        -
        -In `lang/de/auth.php`, inside the `// ── 2FA challenge ──` block (right after the `'code' => 'Code',` line, before `'back_to_login'`), add:
        -
        -```php
        -    'challenge_use_backup' => 'Backup-Code verwenden',
        -    'backup_heading' => 'Backup-Codes',
        -    'backup_subtitle' => 'Gib einen Backup-Code ein, um fortzufahren.',
        -    'back_to_options' => 'Zurück zu Anmelde-Optionen',
        -```
        -
        -- [ ] **Step 2: Add the same 4 keys in English**
        -
        -In `lang/en/auth.php`, in the matching `// ── 2FA challenge ──` block after `'code' => 'Code',`:
        -
        -```php
        -    'challenge_use_backup' => 'Use backup code',
        -    'backup_heading' => 'Backup codes',
        -    'backup_subtitle' => 'Enter a backup code to continue.',
        -    'back_to_options' => 'Back to sign-in options',
        -```
        -
        -- [ ] **Step 3: Verify both files parse and the keys are present + identical**
        -
        -Run:
        -```bash
        -docker compose exec app php -r "\$de=require 'lang/de/auth.php'; \$en=require 'lang/en/auth.php'; \$k=['challenge_use_backup','backup_heading','backup_subtitle','back_to_options']; foreach(\$k as \$x){ echo \$x.': '.(isset(\$de[\$x])&&isset(\$en[\$x])?'OK':'MISSING').PHP_EOL; }"
        -```
        -Expected: four `OK` lines.
        -
        -- [ ] **Step 4: Commit**
        -
        -```bash
        -git add lang/de/auth.php lang/en/auth.php
        -git commit -m "i18n(auth): add 2FA backup-view strings"
        -```
        -
        ----
        -
        -### Task 2: Extract the shared trait
        -
        -Pure refactor — no behaviour change. The existing 2FA test suite is the safety net.
        -
        -**Files:**
        -- Create: `app/Livewire/Concerns/CompletesTwoFactorChallenge.php`
        -- Modify: `app/Livewire/Auth/TwoFactorChallenge.php`
        -
        -- [ ] **Step 1: Create the trait**
        -
        -Create `app/Livewire/Concerns/CompletesTwoFactorChallenge.php`:
        -
        -```php
        -pendingResolved) {
        -            $this->pendingUser = User::find(session('2fa.user'));
        -            $this->pendingResolved = true;
        -        }
        -
        -        return $this->pendingUser;
        -    }
        -
        -    /** Whether the PENDING user has TOTP — the authenticator code field renders only then. */
        -    public function getPendingHasTotpProperty(): bool
        -    {
        -        return (bool) $this->pendingUser()?->hasTotp();
        -    }
        -
        -    /** Whether to offer the security-key option for the pending user (domain+HTTPS + has a key). */
        -    public function webauthnAvailable(): bool
        -    {
        -        $user = $this->pendingUser();
        -
        -        return $user !== null
        -            && app(WebauthnService::class)->available()
        -            && $user->hasWebauthnCredentials();
        -    }
        -
        -    /**
        -     * Two auto-expiring 2FA brute-force buckets, keyed on the SERVER-side pending user id
        -     * (not client-controlled) — a tight per-(user+IP) one plus an IP-independent per-user
        -     * backstop, so a distributed (multi-IP) brute-force of the code is still capped. Both
        -     * decay in minutes (never a permanent lockout; clusev:reset-admin stays open).
        -     *
        -     * @return array key => [maxAttempts, decaySeconds]
        -     */
        -    protected function rateLimitBuckets(): array
        -    {
        -        $uid = (string) session('2fa.user');
        -
        -        return [
        -            'two-factor:'.md5($uid.'|'.request()->ip()) => [5, 60],
        -            'two-factor-acct:'.md5($uid) => [20, 900],
        -        ];
        -    }
        -
        -    protected function assertNotRateLimited(): void
        -    {
        -        foreach ($this->rateLimitBuckets() as $key => [$max]) {
        -            if (RateLimiter::tooManyAttempts($key, $max)) {
        -                throw ValidationException::withMessages([
        -                    'code' => __('auth.too_many_attempts', ['seconds' => RateLimiter::availableIn($key)]),
        -                ]);
        -            }
        -        }
        -    }
        -
        -    protected function hitRateLimit(): void
        -    {
        -        foreach ($this->rateLimitBuckets() as $key => [, $decay]) {
        -            RateLimiter::hit($key, $decay);
        -        }
        -    }
        -
        -    protected function clearRateLimit(): void
        -    {
        -        foreach (array_keys($this->rateLimitBuckets()) as $key) {
        -            RateLimiter::clear($key);
        -        }
        -    }
        -
        -    /** Log the verified pending user in and finish the challenge (shared success tail). */
        -    protected function completeLogin(User $user)
        -    {
        -        $remember = (bool) session('2fa.remember');
        -        session()->forget(['2fa.user', '2fa.remember']);
        -
        -        Auth::login($user, $remember);
        -        session()->regenerate();
        -
        -        return $this->redirectIntended(route('dashboard'), navigate: true);
        -    }
        -}
        -```
        -
        -- [ ] **Step 2: Rewrite `TwoFactorChallenge` to use the trait**
        -
        -Replace the entire contents of `app/Livewire/Auth/TwoFactorChallenge.php` with:
        -
        -```php
        -has('2fa.user')) {
        -            return $this->redirect(route('login'), navigate: true);
        -        }
        -    }
        -
        -    public function verify()
        -    {
        -        $this->validate();
        -
        -        $this->assertNotRateLimited();
        -
        -        $user = User::find(session('2fa.user'));
        -
        -        if (! $user || ! $user->hasTwoFactorEnabled()) {
        -            session()->forget(['2fa.user', '2fa.remember']);
        -            throw ValidationException::withMessages(['code' => __('auth.session_expired')]);
        -        }
        -
        -        // Accept the TOTP code only when the pending user actually has TOTP (otherwise a null
        -        // secret would break verifyKey); always allow a one-time backup (recovery) code.
        -        $valid = $user->verifyTotp($this->code) || $user->useRecoveryCode($this->code);
        -
        -        if (! $valid) {
        -            $this->hitRateLimit();
        -            throw ValidationException::withMessages(['code' => __('auth.invalid_code')]);
        -        }
        -
        -        $this->clearRateLimit();
        -
        -        return $this->completeLogin($user);
        -    }
        -
        -    /** JSON request options for navigator.credentials.get — the JS posts the result to verifyWebauthn. */
        -    public function assertionOptions(WebauthnService $webauthn): array
        -    {
        -        $user = User::find(session('2fa.user'));
        -        abort_unless($user && $webauthn->available() && $user->hasWebauthnCredentials(), 404);
        -
        -        return $webauthn->assertionOptions($user);
        -    }
        -
        -    /** Complete the login with a verified security-key assertion (alternative to the TOTP/backup code). */
        -    public function verifyWebauthn(array $assertion, WebauthnService $webauthn)
        -    {
        -        $this->assertNotRateLimited();
        -
        -        $user = User::find(session('2fa.user'));
        -
        -        if (! $user || ! $user->hasTwoFactorEnabled() || ! $webauthn->available() || ! $webauthn->verifyAssertion($user, $assertion)) {
        -            $this->hitRateLimit();
        -            throw ValidationException::withMessages(['code' => __('auth.webauthn_failed')]);
        -        }
        -
        -        $this->clearRateLimit();
        -
        -        return $this->completeLogin($user);
        -    }
        -
        -    public function render()
        -    {
        -        return view('livewire.auth.two-factor-challenge')->title(__('auth.title_challenge'));
        -    }
        -}
        -```
        -
        -- [ ] **Step 3: Run the existing 2FA suite — must stay green**
        -
        -Run:
        -```bash
        -docker compose exec app php artisan test --filter='TwoFactorChallengeRecoveryTest|TwoFactorWebauthnTest|ChallengeFactorAdaptTest|BruteForceHardeningTest'
        -```
        -Expected: PASS (behaviour unchanged — the trait just relocates the same code).
        -
        -- [ ] **Step 4: Pint + commit**
        -
        -```bash
        -docker compose exec app ./vendor/bin/pint app/Livewire/Concerns/CompletesTwoFactorChallenge.php app/Livewire/Auth/TwoFactorChallenge.php
        -git add app/Livewire/Concerns/CompletesTwoFactorChallenge.php app/Livewire/Auth/TwoFactorChallenge.php
        -git commit -m "refactor(auth): extract CompletesTwoFactorChallenge trait"
        -```
        -
        ----
        -
        -### Task 3: Backup route + component + view (TDD)
        -
        -**Files:**
        -- Create: `tests/Feature/TwoFactorBackupTest.php`
        -- Modify: `routes/web.php`
        -- Create: `app/Livewire/Auth/TwoFactorBackup.php`
        -- Create: `resources/views/livewire/auth/two-factor-backup.blade.php`
        -
        -- [ ] **Step 1: Write the failing test file**
        -
        -Create `tests/Feature/TwoFactorBackupTest.php`:
        -
        -```php
        -create([
        -            'two_factor_secret' => (new Google2FA)->generateSecretKey(),
        -            'two_factor_confirmed_at' => now(),
        -        ]);
        -    }
        -
        -    private function keyOnlyUser(): User
        -    {
        -        $u = User::factory()->create();
        -        WebauthnCredential::create(['user_id' => $u->id, 'name' => 'k', 'credential_id' => 'cid', 'public_key' => '{}', 'sign_count' => 0]);
        -
        -        return $u->fresh();
        -    }
        -
        -    public function test_backup_code_logs_in_and_is_consumed(): void
        -    {
        -        $user = $this->enrolledUser();
        -        $code = $user->replaceRecoveryCodes()[0];
        -        session()->put('2fa.user', $user->id);
        -
        -        Livewire::test(TwoFactorBackup::class)
        -            ->set('code', $code)
        -            ->call('verify')
        -            ->assertRedirect(route('dashboard'));
        -
        -        $this->assertAuthenticatedAs($user);
        -        $this->assertFalse($user->fresh()->useRecoveryCode($code)); // consumed
        -    }
        -
        -    public function test_invalid_backup_code_fails(): void
        -    {
        -        $user = $this->enrolledUser();
        -        $user->replaceRecoveryCodes();
        -        session()->put('2fa.user', $user->id);
        -
        -        Livewire::test(TwoFactorBackup::class)
        -            ->set('code', 'nope-nope')
        -            ->call('verify')
        -            ->assertHasErrors('code');
        -
        -        $this->assertGuest();
        -    }
        -
        -    public function test_a_totp_code_is_not_accepted_on_the_backup_view(): void
        -    {
        -        $user = $this->enrolledUser();
        -        $user->replaceRecoveryCodes();
        -        $totp = (new Google2FA)->getCurrentOtp($user->two_factor_secret);
        -        session()->put('2fa.user', $user->id);
        -
        -        Livewire::test(TwoFactorBackup::class)
        -            ->set('code', $totp)
        -            ->call('verify')
        -            ->assertHasErrors('code');
        -
        -        $this->assertGuest();
        -    }
        -
        -    public function test_requires_a_pending_2fa_session(): void
        -    {
        -        Livewire::test(TwoFactorBackup::class)
        -            ->assertRedirect(route('login'));
        -    }
        -
        -    public function test_field_and_login_link_render(): void
        -    {
        -        $user = $this->enrolledUser();
        -        $user->replaceRecoveryCodes();
        -        session()->put('2fa.user', $user->id);
        -
        -        Livewire::test(TwoFactorBackup::class)
        -            ->assertSee(__('auth.backup_heading'))
        -            ->assertSee(__('auth.challenge_backup_placeholder')) // the backup field is present
        -            ->assertSee(__('auth.back_to_login'));
        -    }
        -
        -    public function test_back_to_options_shown_for_a_totp_user(): void
        -    {
        -        $user = $this->enrolledUser();
        -        $user->replaceRecoveryCodes();
        -        session()->put('2fa.user', $user->id);
        -
        -        Livewire::test(TwoFactorBackup::class)
        -            ->assertSee(__('auth.back_to_options'));
        -    }
        -
        -    public function test_back_to_options_hidden_for_key_only_without_secure_context(): void
        -    {
        -        // No domain configured → webauthnAvailable() false, pendingHasTotp false: backup is the
        -        // only path, so the "back to options" link would loop straight back here and is hidden.
        -        $user = $this->keyOnlyUser();
        -        $user->replaceRecoveryCodes();
        -        session()->put('2fa.user', $user->id);
        -
        -        Livewire::test(TwoFactorBackup::class)
        -            ->assertDontSee(__('auth.back_to_options'))
        -            ->assertSee(__('auth.back_to_login'));
        -    }
        -
        -    public function test_failed_backup_attempts_share_the_rate_limit_with_the_main_challenge(): void
        -    {
        -        $user = $this->enrolledUser();
        -        $codes = $user->replaceRecoveryCodes();
        -        session()->put('2fa.user', $user->id);
        -
        -        // 5 wrong backup attempts trip the per-(user+IP) bucket (5/60s).
        -        for ($i = 0; $i < 5; $i++) {
        -            Livewire::test(TwoFactorBackup::class)->set('code', 'wrong-'.$i)->call('verify');
        -        }
        -
        -        // A genuinely valid, unused backup code is now refused on the MAIN challenge by the
        -        // SAME shared bucket — proving one counter spans both components.
        -        Livewire::test(TwoFactorChallenge::class)
        -            ->set('code', $codes[0])
        -            ->call('verify')
        -            ->assertHasErrors('code');
        -
        -        $this->assertGuest();
        -    }
        -}
        -```
        -
        -- [ ] **Step 2: Run it to confirm it fails**
        -
        -Run:
        -```bash
        -docker compose exec app php artisan test --filter=TwoFactorBackupTest
        -```
        -Expected: FAIL — `Class "App\Livewire\Auth\TwoFactorBackup" not found` (and the route does not exist yet).
        -
        -- [ ] **Step 3: Add the route**
        -
        -In `routes/web.php`, in the `guest` middleware group, immediately after the existing `two-factor.challenge` line, add the backup sibling:
        -
        -```php
        -        Route::get('/two-factor-challenge', Auth\TwoFactorChallenge::class)->name('two-factor.challenge');
        -        Route::get('/two-factor-challenge/backup', Auth\TwoFactorBackup::class)->name('two-factor.challenge.backup');
        -```
        -
        -- [ ] **Step 4: Create the `TwoFactorBackup` component**
        -
        -Create `app/Livewire/Auth/TwoFactorBackup.php`:
        -
        -```php
        -has('2fa.user')) {
        -            return $this->redirect(route('login'), navigate: true);
        -        }
        -    }
        -
        -    public function verify()
        -    {
        -        $this->validate();
        -
        -        $this->assertNotRateLimited();
        -
        -        $user = User::find(session('2fa.user'));
        -
        -        if (! $user || ! $user->hasTwoFactorEnabled()) {
        -            session()->forget(['2fa.user', '2fa.remember']);
        -            throw ValidationException::withMessages(['code' => __('auth.session_expired')]);
        -        }
        -
        -        // Backup-only view: a one-time recovery code is the sole accepted credential (no TOTP).
        -        if (! $user->useRecoveryCode($this->code)) {
        -            $this->hitRateLimit();
        -            throw ValidationException::withMessages(['code' => __('auth.invalid_code')]);
        -        }
        -
        -        $this->clearRateLimit();
        -
        -        return $this->completeLogin($user);
        -    }
        -
        -    public function render()
        -    {
        -        return view('livewire.auth.two-factor-backup')->title(__('auth.title_challenge'));
        -    }
        -}
        -```
        -
        -- [ ] **Step 5: Create the backup view**
        -
        -Create `resources/views/livewire/auth/two-factor-backup.blade.php`:
        -
        -```blade
        -@php
        -    $fieldText = 'h-12 w-full rounded-md border border-line bg-inset px-3 text-center font-mono text-sm text-ink placeholder:text-ink-4 focus:border-accent/40 focus:outline-none';
        -    $label = 'mb-1.5 block font-mono text-[11px] uppercase tracking-wider text-ink-3';
        -    $err = 'mt-1.5 flex items-center gap-1.5 font-mono text-[11px] text-offline';
        -@endphp
        -
        -
        -
        -

        {{ __('auth.two_factor') }}

        -

        {{ __('auth.backup_heading') }}

        -

        {{ __('auth.backup_subtitle') }}

        -
        - - -
        - - - @error('code')

        {{ $message }}

        @enderror -
        - - - - {{ __('common.confirm') }} - {{ __('auth.checking') }} - - - -
        - @if ($this->pendingHasTotp || $this->webauthnAvailable()) - - {{ __('auth.back_to_options') }} - - @endif - - {{ __('auth.back_to_login') }} - -
        -
        -``` - -- [ ] **Step 6: Run the test — must pass** - -Run: -```bash -docker compose exec app php artisan test --filter=TwoFactorBackupTest -``` -Expected: PASS (all 8 tests). - -- [ ] **Step 7: Pint + commit** - -```bash -docker compose exec app ./vendor/bin/pint routes/web.php app/Livewire/Auth/TwoFactorBackup.php tests/Feature/TwoFactorBackupTest.php -git add routes/web.php app/Livewire/Auth/TwoFactorBackup.php resources/views/livewire/auth/two-factor-backup.blade.php tests/Feature/TwoFactorBackupTest.php -git commit -m "feat(auth): dedicated backup-code 2FA view + route" -``` - ---- - -### Task 4: Main view redesign + mount redirect (TDD) - -The backup route now exists, so `TwoFactorChallenge::mount()` can safely redirect to it. - -**Files:** -- Modify: `tests/Feature/ChallengeFactorAdaptTest.php` -- Modify: `app/Livewire/Auth/TwoFactorChallenge.php` -- Modify: `resources/views/livewire/auth/two-factor-challenge.blade.php` - -- [ ] **Step 1: Update `ChallengeFactorAdaptTest` to the new behaviour (failing)** - -Replace the entire contents of `tests/Feature/ChallengeFactorAdaptTest.php` with: - -```php -create(); - WebauthnCredential::create(['user_id' => $u->id, 'name' => 'k', 'credential_id' => 'cid', 'public_key' => '{}', 'sign_count' => 0]); - - return $u->fresh(); - } - - public function test_totp_user_sees_the_field(): void - { - $user = User::factory()->create(['two_factor_secret' => 'S', 'two_factor_confirmed_at' => now()]); - session(['2fa.user' => $user->id, '2fa.remember' => false]); - - Livewire::test(TwoFactorChallenge::class)->assertSet('pendingHasTotp', true); - } - - public function test_totp_user_sees_the_backup_link_but_not_the_backup_field(): void - { - $user = User::factory()->create(['two_factor_secret' => 'S', 'two_factor_confirmed_at' => now()]); - session(['2fa.user' => $user->id, '2fa.remember' => false]); - - Livewire::test(TwoFactorChallenge::class) - ->assertSee('000000') // TOTP field present - ->assertSee(__('auth.challenge_use_backup')) // backup link present - ->assertDontSee(__('auth.challenge_backup_placeholder')); // no backup field here - } - - public function test_key_only_user_without_secure_context_is_redirected_to_the_backup_view(): void - { - // No domain configured → webauthnAvailable() false, pendingHasTotp false: the backup code - // is the only usable path, so mount() sends the user straight to the dedicated view. - $user = $this->keyOnlyUser(); - session(['2fa.user' => $user->id, '2fa.remember' => false]); - - Livewire::test(TwoFactorChallenge::class) - ->assertRedirect(route('two-factor.challenge.backup')); - } - - public function test_key_only_user_with_secure_context_stays_and_shows_key_plus_backup(): void - { - $svc = $this->mock(WebauthnService::class); - $svc->shouldReceive('available')->andReturn(true); - - $user = $this->keyOnlyUser(); - session(['2fa.user' => $user->id, '2fa.remember' => false]); - - Livewire::test(TwoFactorChallenge::class) - ->assertNoRedirect() - ->assertSee(__('auth.webauthn_login')) // security-key button - ->assertSee(__('auth.challenge_use_backup')) // backup link - ->assertDontSee('000000') // no TOTP field - ->assertDontSee(__('auth.challenge_backup_placeholder')); // no inline backup field - } -} -``` - -- [ ] **Step 2: Run it — confirm the new assertions fail** - -Run: -```bash -docker compose exec app php artisan test --filter=ChallengeFactorAdaptTest -``` -Expected: FAIL — the redirect test fails (no redirect yet) and the "no backup field" assertions fail (the field is still rendered). - -- [ ] **Step 3: Add the mount redirect to `TwoFactorChallenge`** - -In `app/Livewire/Auth/TwoFactorChallenge.php`, replace the `mount()` method with: - -```php - public function mount() - { - if (! session()->has('2fa.user')) { - return $this->redirect(route('login'), navigate: true); - } - - // No TOTP and no usable security key (a key-only user over http + bare IP, where WebAuthn - // has no secure context) → the backup code is the only path: go straight to its own view. - if (! $this->pendingHasTotp && ! $this->webauthnAvailable()) { - return $this->redirect(route('two-factor.challenge.backup'), navigate: true); - } - } -``` - -- [ ] **Step 4: Redesign the main challenge view** - -Replace the entire contents of `resources/views/livewire/auth/two-factor-challenge.blade.php` with: - -```blade -@php - $field = 'h-14 w-full rounded-md border border-line bg-inset text-center font-mono text-2xl font-semibold tracking-[0.5em] text-ink caret-accent placeholder:text-ink-4 focus:border-accent/40 focus:outline-none'; - $label = 'mb-1.5 block font-mono text-[11px] uppercase tracking-wider text-ink-3'; - $err = 'mt-1.5 flex items-center gap-1.5 font-mono text-[11px] text-offline'; -@endphp - -
        -
        -

        {{ __('auth.two_factor') }}

        -

        {{ __('auth.challenge_heading') }}

        -

        {{ $this->pendingHasTotp ? __('auth.challenge_subtitle') : __('auth.challenge_key_subtitle') }}

        -
        - - @if (! $this->pendingHasTotp && $this->webauthnAvailable()) - {{-- Key-only: the security key is the primary path. --}} - - {{ __('auth.webauthn_login') }} - - @endif - - @if ($this->pendingHasTotp) -
        -
        - - - @error('code')

        {{ $message }}

        @enderror -
        - - - - {{ __('common.confirm') }} - {{ __('auth.checking') }} - -
        - - @if ($this->webauthnAvailable()) - {{-- TOTP + key: the security key is the alternate path. --}} -
        - - {{ __('common.or') }} - -
        - - {{ __('auth.webauthn_login') }} - - @endif - @endif - - {{-- Backup code lives on its own view — a deliberate, subordinate step. --}} - - {{ __('auth.challenge_use_backup') }} - - - -
        -``` - -(Compared to the old view: `$fieldText` removed from the `@php` block; the always-on backup field, the `challenge_recovery_hint` and the `challenge_backup_only_hint` paragraphs are gone; the form is wrapped in `@if ($this->pendingHasTotp)`; the "Backup-Code verwenden" button is added before the back-to-login link.) - -- [ ] **Step 5: Run the adapt test — must pass** - -Run: -```bash -docker compose exec app php artisan test --filter=ChallengeFactorAdaptTest -``` -Expected: PASS (4 tests). - -- [ ] **Step 6: Run the whole 2FA + auth suite — guard against regressions** - -Run: -```bash -docker compose exec app php artisan test --filter='TwoFactor|Challenge|Webauthn|BruteForce|Login|Factor' -``` -Expected: PASS. (If `BruteForceHardeningTest` or another suite asserted the old combined-view backup field, fix that assertion to key on `challenge_backup_placeholder` per the substring caveat below, then re-run.) - -> **Substring caveat:** never assert `assertSee/DontSee(__('auth.challenge_backup_label'))` to detect the backup field — `challenge_backup_label` ("Backup-Code") is a substring of `challenge_use_backup` ("Backup-Code verwenden"). Use `__('auth.challenge_backup_placeholder')` (`xxxxxxxxxx-xxxxxxxxxx`), which is unique to the field. - -- [ ] **Step 7: Pint + commit** - -```bash -docker compose exec app ./vendor/bin/pint app/Livewire/Auth/TwoFactorChallenge.php tests/Feature/ChallengeFactorAdaptTest.php -git add app/Livewire/Auth/TwoFactorChallenge.php resources/views/livewire/auth/two-factor-challenge.blade.php tests/Feature/ChallengeFactorAdaptTest.php -git commit -m "feat(auth): split backup code out of the main 2FA challenge view" -``` - ---- - -### Task 5: Remove the orphaned hint keys - -`challenge_recovery_hint` and `challenge_backup_only_hint` are no longer rendered by any view. - -**Files:** -- Modify: `lang/de/auth.php`, `lang/en/auth.php` - -- [ ] **Step 1: Confirm they are unreferenced** - -Run: -```bash -docker compose exec app grep -rn "challenge_recovery_hint\|challenge_backup_only_hint" resources/ app/ || echo "NO REFERENCES" -``` -Expected: `NO REFERENCES`. (If any line prints, stop — that view still needs the key; do not delete it.) - -- [ ] **Step 2: Delete the two keys from both language files** - -Remove these lines from `lang/de/auth.php`: -```php - 'challenge_recovery_hint' => 'Authenticator verloren? Gib einen deiner Backup-Codes ein.', - 'challenge_backup_only_hint' => 'Kein Authenticator nötig — Security-Key oder ein Backup-Code.', -``` -And from `lang/en/auth.php`: -```php - 'challenge_recovery_hint' => 'Lost your authenticator? Enter one of your backup codes.', - 'challenge_backup_only_hint' => 'No authenticator needed — use a security key or a backup code.', -``` - -- [ ] **Step 3: Verify both files still parse** - -Run: -```bash -docker compose exec app php -r "require 'lang/de/auth.php'; require 'lang/en/auth.php'; echo 'OK'.PHP_EOL;" -``` -Expected: `OK`. - -- [ ] **Step 4: Commit** - -```bash -git add lang/de/auth.php lang/en/auth.php -git commit -m "i18n(auth): drop orphaned 2FA recovery hint strings" -``` - ---- - -### Task 6: Full verification (R12 browser + R15 Codex) - -**Files:** none (verification only). - -- [ ] **Step 1: Full test suite green** - -Run: -```bash -docker compose exec app php artisan test -``` -Expected: PASS, 0 failures. - -- [ ] **Step 2: Build assets** - -Run: -```bash -docker compose exec app npm run build -``` -Expected: build completes, no errors. - -- [ ] **Step 3: R12 browser verify — `/two-factor-challenge` (DE + EN)** - -With a pending-2FA session for a TOTP user, load `/two-factor-challenge` headless. Confirm: -- HTTP 200, zero console/network errors. -- Rendered DOM shows the TOTP field + the "Backup-Code verwenden" button + "Zurück zur Anmeldung"; **no** inline backup field (`xxxxxxxxxx-xxxxxxxxxx` placeholder absent). -- No leaked `@`/`{{ }}`/`$var`/`group.key` text (R17). -- 3 breakpoints (375 / 768 / 1280), touch targets ≥44px. - -(Bare-IP setup: a key-only pending session should 302/redirect to `/two-factor-challenge/backup` — verify the redirect lands.) - -- [ ] **Step 4: R12 browser verify — `/two-factor-challenge/backup` (DE + EN)** - -Load `/two-factor-challenge/backup` with a pending-2FA session. Confirm HTTP 200, zero console/network errors, the backup field + "Bestätigen" + the correct return links (for a TOTP user both "Zurück zu Anmelde-Optionen" and "Zurück zur Anmeldung"), no leaked template tokens, 3 breakpoints. - -- [ ] **Step 5: R15 — Codex review clean** - -Run `/codex:review` over the branch diff. Fix anything it flags (errors or security) and re-run until it reports no errors and no security issues. - -- [ ] **Step 6: Final Pint** - -Run: -```bash -docker compose exec app ./vendor/bin/pint --dirty -``` -Expected: no changes / all clean. Commit if Pint reformatted anything. - ---- - -## Self-review notes (already reconciled with the spec) - -- **Shared rate-limit bucket** — Task 2 keeps the exact bucket key format; Task 3's cross-component test proves the counter spans both views. -- **No property→method churn** — the computed property `getPendingHasTotpProperty()` moves into the trait, so every `$this->pendingHasTotp` blade/test usage is unchanged. -- **Redirect-loop guard** — `back_to_options` is hidden exactly when `! pendingHasTotp && ! webauthnAvailable()` (covered by `TwoFactorBackupTest::test_back_to_options_hidden_for_key_only_without_secure_context`). -- **Substring trap** — backup-field assertions use `challenge_backup_placeholder`, never `challenge_backup_label`. -- **Out of scope (Spec 2):** auth-failure → fail2ban hard IP ban. Not in this plan. diff --git a/docs/superpowers/plans/2026-06-20-control-plane-bruteforce-ban.md b/docs/superpowers/plans/2026-06-20-control-plane-bruteforce-ban.md deleted file mode 100644 index 8f4f671..0000000 --- a/docs/superpowers/plans/2026-06-20-control-plane-bruteforce-ban.md +++ /dev/null @@ -1,1388 +0,0 @@ -# Control-plane brute-force IP ban (Anmeldeschutz) — Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Add an application-level persistent IP ban that blocks unauthenticated requests from IPs with too many failed Clusev logins, configurable from a new "Anmeldeschutz" settings tab. - -**Architecture:** A `BannedIp` table + `BruteforceGuard` service (cache-counter → DB ban, inet_pton CIDR exemptions, cached ban check) feeds an **appended** `BlockBannedIp` web-group middleware that 403s **guest** requests from banned IPs (authenticated operators pass, so they're never self-locked-out and can unban from the tab). Login + 2FA failure hooks drive the guard and write audit events. A `clusev:unban` CLI is the host escape hatch. - -**Tech Stack:** Laravel 13, Livewire v3 (class-based), Tailwind v4, PHPUnit, Pint, wire-elements/modal (R5 confirm via `ConfirmToken`). Spec: `docs/superpowers/specs/2026-06-20-control-plane-bruteforce-ban-design.md`. - -## Pre-flight & corrections - -- Dev stack up (`docker compose up -d`); **run all tooling in the container (R8)**. Branch `feat/v1-foundation`. -- **Spec erratum (this plan supersedes it):** the middleware is **appended** to the web group, not prepended. Guests-only enforcement needs `Auth::guest()`, which requires the session middleware (`StartSession`) to have already run — a *prepended* middleware runs before it and would see every request as a guest. Appended (after `AuthenticateSession`), the auth guard resolves correctly. `request()->ip()` is still correct because `trustProxies` is a global middleware that runs before the whole web group. -- Test isolation (memory: view-cache race): if Blade-compile flakiness appears, re-run with `-e VIEW_COMPILED_PATH=/tmp/views-test`. - -## File structure - -| File | Responsibility | -|---|---| -| `app/Models/BannedIp.php` + migration | active-ban storage | -| `app/Services/BruteforceGuard.php` | record/ban/isBanned/isExempt/unban + CIDR + config casts | -| `app/Rules/ValidIpOrCidr.php` | whitelist field validation | -| `app/Http/Middleware/BlockBannedIp.php` | guests-only 403 enforcement | -| `resources/views/errors/blocked.blade.php` + `lang/{de,en}/errors.php` | the 403 page | -| `app/Console/Commands/UnbanIp.php` + `docker/clusev/clusev` | host escape | -| `app/Livewire/Settings/LoginProtection.php` + view | the settings tab | -| `app/Support/Confirm/ConfirmToken.php` (modify) | allowlist the unban confirm events | -| `bootstrap/app.php`, `Login.php`, `CompletesTwoFactorChallenge.php`, `settings/index.blade.php`, `lang/{de,en}/settings.php` (modify) | wiring + hooks + tab + i18n | - ---- - -### Task 1: `BannedIp` model + migration - -**Files:** Create `app/Models/BannedIp.php`, `database/migrations/2026_06_20_000001_create_banned_ips_table.php`; Test `tests/Feature/BruteforceBannedIpTest.php` - -- [ ] **Step 1: Failing test** - -```php - '1.2.3.4', 'banned_until' => now()->addHour(), 'attempts' => 10]); - BannedIp::create(['ip' => '5.6.7.8', 'banned_until' => now()->subHour(), 'attempts' => 10]); - - $active = BannedIp::active()->pluck('ip')->all(); - - $this->assertSame(['1.2.3.4'], $active); - } -} -``` - -- [ ] **Step 2: Run → fails** (`docker compose exec -T app php artisan test --filter=BruteforceBannedIpTest`) — "Class BannedIp not found". - -- [ ] **Step 3: Migration** - -```php -id(); - $table->string('ip', 45)->unique(); - $table->timestamp('banned_until'); - $table->string('reason')->nullable(); - $table->unsignedInteger('attempts')->default(0); - $table->timestamps(); - }); - } - - public function down(): void - { - Schema::dropIfExists('banned_ips'); - } -}; -``` - -- [ ] **Step 4: Model** (`app/Models/BannedIp.php`) - -```php - 'datetime']; - - /** Non-expired bans only. */ - public function scopeActive(Builder $query): Builder - { - return $query->where('banned_until', '>', now()); - } -} -``` - -- [ ] **Step 5: Migrate + run → passes** - -```bash -docker compose exec -T app php artisan migrate -docker compose exec -T app php artisan test --filter=BruteforceBannedIpTest -``` - -- [ ] **Step 6: Pint + commit** - -```bash -docker compose exec -T app ./vendor/bin/pint app/Models/BannedIp.php database/migrations tests/Feature/BruteforceBannedIpTest.php -git add app/Models/BannedIp.php database/migrations/*_create_banned_ips_table.php tests/Feature/BruteforceBannedIpTest.php -git commit -m "feat(auth): BannedIp model + migration" -``` - ---- - -### Task 2: `BruteforceGuard` — CIDR exemptions - -The safety core: which IPs are never counted/banned. `matchesCidr` mirrors `Fail2banService::sameIp()` (inet_pton). - -**Files:** Create `app/Services/BruteforceGuard.php`; Test `tests/Feature/BruteforceGuardExemptTest.php` - -- [ ] **Step 1: Failing test** - -```php -guard(); - $this->assertTrue($g->isExempt('127.0.0.1')); - $this->assertTrue($g->isExempt('::1')); - $this->assertTrue($g->isExempt('0:0:0:0:0:0:0:1')); // alternate spelling - } - - public function test_default_private_whitelist_is_exempt_public_is_not(): void - { - $g = $this->guard(); - $this->assertTrue($g->isExempt('192.168.4.20')); // default whitelist - $this->assertTrue($g->isExempt('10.9.9.9')); - $this->assertFalse($g->isExempt('203.0.113.7')); // public - } - - public function test_invalid_ip_is_treated_as_exempt(): void - { - $this->assertTrue($this->guard()->isExempt('not-an-ip')); - } - - public function test_custom_whitelist_cidr_matches(): void - { - Setting::put('bruteforce_whitelist', "203.0.113.0/24"); - $this->assertTrue($this->guard()->isExempt('203.0.113.50')); - $this->assertFalse($this->guard()->isExempt('203.0.114.50')); - } -} -``` - -- [ ] **Step 2: Run → fails.** - -- [ ] **Step 3: Implement** (`app/Services/BruteforceGuard.php`) - -```php -whitelist()) as $cidr) { - if ($this->matchesCidr($ip, $cidr)) { - return true; - } - } - - return false; - } - - public function record(?string $ip, string $reason): void - { - if (! $this->enabled() || ! $ip || filter_var($ip, FILTER_VALIDATE_IP) === false || $this->isExempt($ip)) { - return; - } - - $canonical = $this->canonical($ip); - $key = 'bruteforce:'.md5($canonical); - RateLimiter::hit($key, $this->findtime() * 60); // minutes → seconds - $hits = RateLimiter::attempts($key); - - if ($hits < $this->maxretry()) { - return; - } - - // Atomic upsert: two concurrent requests at the threshold must not collide on the - // unique ip index. Eloquent upsert does not auto-manage timestamps for bulk ops. - BannedIp::upsert( - [[ - 'ip' => $canonical, - 'banned_until' => now()->addMinutes($this->bantime()), - 'reason' => $reason, - 'attempts' => $hits, - 'created_at' => now(), - 'updated_at' => now(), - ]], - ['ip'], - ['banned_until', 'reason', 'attempts', 'updated_at'], - ); - RateLimiter::clear($key); - Cache::forget('bruteforce:banned:'.$canonical); - - AuditEvent::create([ - 'user_id' => null, - 'actor' => 'system', - 'action' => 'auth.ip_banned', - 'target' => $canonical, - 'ip' => $canonical, - 'meta' => ['reason' => $reason, 'attempts' => $hits], - ]); - } - - public function isBanned(string $ip): bool - { - if ($this->isExempt($ip)) { - return false; // exempt short-circuits — whitelisting unblocks immediately - } - - $canonical = $this->canonical($ip); - - return Cache::remember('bruteforce:banned:'.$canonical, self::CHECK_TTL, fn () => BannedIp::query() - ->where('ip', $canonical) - ->where('banned_until', '>', now()) - ->exists()); - } - - public function unban(string $ip): void - { - $canonical = $this->canonical($ip); - BannedIp::query()->where('ip', $canonical)->delete(); - Cache::forget('bruteforce:banned:'.$canonical); - } - - /** inet_pton-based CIDR / single-IP match (IPv4, IPv6, IPv4-mapped). */ - public function matchesCidr(string $ip, string $cidr): bool - { - if (! str_contains($cidr, '/')) { - $a = @inet_pton($ip); - $b = @inet_pton($cidr); - - return $a !== false && $b !== false && $a === $b; - } - - [$subnet, $maskStr] = explode('/', $cidr, 2); - $pip = @inet_pton($ip); - $psub = @inet_pton($subnet); - if ($pip === false || $psub === false || strlen($pip) !== strlen($psub) || ! ctype_digit($maskStr)) { - return false; // v4-vs-v6 mismatch or junk mask - } - - $mask = (int) $maskStr; - $bytes = intdiv($mask, 8); - $rem = $mask % 8; - if ($bytes > 0 && substr($pip, 0, $bytes) !== substr($psub, 0, $bytes)) { - return false; - } - if ($rem === 0) { - return true; - } - $b = 0xFF & (0xFF << (8 - $rem)); - - return (ord($pip[$bytes]) & $b) === (ord($psub[$bytes]) & $b); - } - - private function canonical(string $ip): string - { - $packed = @inet_pton($ip); - - return $packed === false ? $ip : (string) inet_ntop($packed); - } -} -``` - -- [ ] **Step 4: Run → passes.** - -- [ ] **Step 5: Pint + commit** - -```bash -docker compose exec -T app ./vendor/bin/pint app/Services/BruteforceGuard.php tests/Feature/BruteforceGuardExemptTest.php -git add app/Services/BruteforceGuard.php tests/Feature/BruteforceGuardExemptTest.php -git commit -m "feat(auth): BruteforceGuard with inet_pton CIDR exemptions" -``` - ---- - -### Task 3: `BruteforceGuard` — record, ban, isBanned, cache - -**Files:** Test `tests/Feature/BruteforceGuardBanTest.php` (the service already exists from Task 2) - -- [ ] **Step 1: Failing test** - -```php -guard(); - Setting::put('bruteforce_maxretry', '3'); - - $g->record('203.0.113.5', 'login'); - $g->record('203.0.113.5', 'login'); - $this->assertFalse($g->isBanned('203.0.113.5')); - - $g->record('203.0.113.5', 'login'); // 3rd → ban - $this->assertTrue($g->isBanned('203.0.113.5')); - $this->assertDatabaseHas('banned_ips', ['ip' => '203.0.113.5', 'reason' => 'login']); - $this->assertDatabaseHas('audit_events', ['action' => 'auth.ip_banned', 'ip' => '203.0.113.5']); - } - - public function test_exempt_ip_is_never_banned(): void - { - $g = $this->guard(); - Setting::put('bruteforce_maxretry', '1'); - for ($i = 0; $i < 5; $i++) { - $g->record('192.168.1.50', 'login'); // private = default whitelist - } - $this->assertFalse($g->isBanned('192.168.1.50')); - $this->assertDatabaseCount('banned_ips', 0); - } - - public function test_disabled_records_nothing(): void - { - Setting::put('bruteforce_enabled', '0'); - Setting::put('bruteforce_maxretry', '1'); - $this->guard()->record('203.0.113.9', 'login'); - $this->assertDatabaseCount('banned_ips', 0); - } - - public function test_unban_clears_row_and_cache(): void - { - $g = $this->guard(); - BannedIp::create(['ip' => '203.0.113.5', 'banned_until' => now()->addHour(), 'attempts' => 10]); - $this->assertTrue($g->isBanned('203.0.113.5')); // populates cache - $g->unban('203.0.113.5'); - $this->assertFalse($g->isBanned('203.0.113.5')); // no stale positive - $this->assertDatabaseCount('banned_ips', 0); - } - - public function test_expired_ban_is_not_active(): void - { - BannedIp::create(['ip' => '203.0.113.5', 'banned_until' => now()->subMinute(), 'attempts' => 10]); - $this->assertFalse($this->guard()->isBanned('203.0.113.5')); - } -} -``` - -- [ ] **Step 2: Run → passes** (the Task 2 implementation already covers this; if any assertion fails, fix `BruteforceGuard` and re-run — do not commit red). - -- [ ] **Step 3: Commit** - -```bash -git add tests/Feature/BruteforceGuardBanTest.php -git commit -m "test(auth): BruteforceGuard record/ban/unban/cache" -``` - ---- - -### Task 4: `ValidIpOrCidr` rule - -**Files:** Create `app/Rules/ValidIpOrCidr.php`; Test `tests/Feature/ValidIpOrCidrTest.php` - -- [ ] **Step 1: Failing test** - -```php - $value], ['v' => [new ValidIpOrCidr]])->passes(); - } - - public function test_accepts_ips_and_cidrs(): void - { - $this->assertTrue($this->passes('203.0.113.4')); - $this->assertTrue($this->passes('10.0.0.0/8')); - $this->assertTrue($this->passes('::1')); - $this->assertTrue($this->passes('2001:db8::/32')); - } - - public function test_rejects_junk(): void - { - $this->assertFalse($this->passes('nope')); - $this->assertFalse($this->passes('10.0.0.0/99')); - $this->assertFalse($this->passes('1.2.3.4/')); - } -} -``` - -- [ ] **Step 2: Run → fails.** - -- [ ] **Step 3: Implement** (`app/Rules/ValidIpOrCidr.php`) - -```php - $v])); - - return; - } - - [$ip, $mask] = explode('/', $v, 2); - $max = str_contains($ip, ':') ? 128 : 32; - if (filter_var($ip, FILTER_VALIDATE_IP) === false || ! ctype_digit($mask) || (int) $mask < 0 || (int) $mask > $max) { - $fail(__('settings.lp_whitelist_invalid', ['value' => $v])); - } - } -} -``` - -- [ ] **Step 4: Add the `lp_whitelist_invalid` key** to both `lang/de/settings.php` and `lang/en/settings.php` (the test resolves `__()` — a missing key returns the key string, which still "passes" the boolean test, but add it now so the message is real): - - de: `'lp_whitelist_invalid' => ':value ist keine gültige IP/CIDR.',` - - en: `'lp_whitelist_invalid' => ':value is not a valid IP/CIDR.',` - -- [ ] **Step 5: Run → passes. Pint + commit** - -```bash -docker compose exec -T app ./vendor/bin/pint app/Rules/ValidIpOrCidr.php tests/Feature/ValidIpOrCidrTest.php -git add app/Rules/ValidIpOrCidr.php tests/Feature/ValidIpOrCidrTest.php lang/de/settings.php lang/en/settings.php -git commit -m "feat(auth): ValidIpOrCidr validation rule" -``` - ---- - -### Task 5: `BlockBannedIp` middleware + 403 page - -**Files:** Create `app/Http/Middleware/BlockBannedIp.php`, `resources/views/errors/blocked.blade.php`; Modify `bootstrap/app.php`, `lang/de/errors.php`, `lang/en/errors.php`; Test `tests/Feature/BlockBannedIpMiddlewareTest.php` - -- [ ] **Step 1: Failing test** - -```php - $ip, 'banned_until' => now()->addHour(), 'attempts' => 10]); - } - - public function test_guest_from_banned_ip_is_blocked_with_403(): void - { - $this->banned(); - $this->get('/login', ['REMOTE_ADDR' => '203.0.113.5']) - ->assertStatus(403) - ->assertSee(__('errors.blocked_title')); - } - - public function test_guest_from_clean_ip_passes(): void - { - $this->banned(); - $this->get('/login', ['REMOTE_ADDR' => '198.51.100.9'])->assertStatus(200); - } - - public function test_authenticated_user_from_banned_ip_passes(): void - { - $this->banned(); - $user = User::factory()->create(['must_change_password' => false]); - $this->actingAs($user) - ->withServerVariables(['REMOTE_ADDR' => '203.0.113.5']) - ->get('/') - ->assertSuccessful(); - } - - public function test_feature_disabled_lets_banned_ip_through(): void - { - Setting::put('bruteforce_enabled', '0'); - $this->banned(); - $this->get('/login', ['REMOTE_ADDR' => '203.0.113.5'])->assertStatus(200); - } -} -``` - -> Note: `$this->get($uri, $headers)` sets request headers; for the client IP in tests use `withServerVariables(['REMOTE_ADDR' => ...])` (shown in the authed test) — adjust the guest tests to the same form if `REMOTE_ADDR` via the headers arg does not drive `request()->ip()` in this Laravel version. Verify at Step 4 and use whichever reaches `request()->ip()`. - -- [ ] **Step 2: Run → fails** (middleware not registered). - -- [ ] **Step 3: Middleware** (`app/Http/Middleware/BlockBannedIp.php`) - -```php -ip() is correct via the global trustProxies (prod). - */ -class BlockBannedIp -{ - public function __construct(private BruteforceGuard $guard) {} - - public function handle(Request $request, Closure $next): Response - { - if ($this->guard->enabled() && Auth::guest() && $this->guard->isBanned((string) $request->ip())) { - return response()->view('errors.blocked', [], 403); - } - - return $next($request); - } -} -``` - -- [ ] **Step 4: Register** — in `bootstrap/app.php`, add `use App\Http\Middleware\BlockBannedIp;` and append it to the web group (after `AuthenticateSession`): - -```php - $middleware->web( - prepend: [PanelScheme::class], - append: [SetLocale::class, SecurityHeaders::class, AuthenticateSession::class, BlockBannedIp::class], - ); -``` - -- [ ] **Step 5: 403 view** (`resources/views/errors/blocked.blade.php`) - -```blade -@extends('errors.layout', ['title' => __('errors.blocked_title')]) -@section('code', '403') -@section('heading', __('errors.blocked_title')) -@section('message', __('errors.blocked_body')) -``` - -- [ ] **Step 6: errors i18n** — add to `lang/de/errors.php` and `lang/en/errors.php`: - - de: `'blocked_title' => 'Zugriff vorübergehend gesperrt',` / `'blocked_body' => 'Zu viele fehlgeschlagene Anmeldeversuche aus deinem Netzwerk. Bitte später erneut versuchen.',` - - en: `'blocked_title' => 'Access temporarily blocked',` / `'blocked_body' => 'Too many failed sign-in attempts from your network. Please try again later.',` - -- [ ] **Step 7: Run → passes** (fix the IP-injection form per the Step 1 note if needed). Pint + commit - -```bash -docker compose exec -T app ./vendor/bin/pint app/Http/Middleware/BlockBannedIp.php bootstrap/app.php tests/Feature/BlockBannedIpMiddlewareTest.php -git add app/Http/Middleware/BlockBannedIp.php bootstrap/app.php resources/views/errors/blocked.blade.php lang/de/errors.php lang/en/errors.php tests/Feature/BlockBannedIpMiddlewareTest.php -git commit -m "feat(auth): guests-only BlockBannedIp middleware + 403 page" -``` - ---- - -### Task 6: Failure hooks — Login + 2FA - -**Files:** Modify `app/Livewire/Auth/Login.php`, `app/Livewire/Concerns/CompletesTwoFactorChallenge.php`, `app/Livewire/Auth/TwoFactorChallenge.php`, `app/Livewire/Auth/TwoFactorBackup.php`; Test `tests/Feature/BruteforceHooksTest.php` - -- [ ] **Step 1: Failing test** - -```php -create(['email' => 'admin@clusev.local', 'password' => Hash::make('correct-horse')]); - - for ($i = 0; $i < 3; $i++) { - Livewire::withServerVariables(['REMOTE_ADDR' => '203.0.113.5']) - ->test(Login::class)->set('email', 'admin@clusev.local')->set('password', 'wrong')->call('authenticate'); - } - - $this->assertDatabaseHas('banned_ips', ['ip' => '203.0.113.5', 'reason' => 'login']); - $this->assertDatabaseHas('audit_events', ['action' => 'auth.login_failed']); - } - - public function test_failed_2fa_codes_ban_the_ip(): void - { - $user = User::factory()->create(['two_factor_secret' => (new Google2FA)->generateSecretKey(), 'two_factor_confirmed_at' => now()]); - - for ($i = 0; $i < 3; $i++) { - session()->put('2fa.user', $user->id); - Livewire::withServerVariables(['REMOTE_ADDR' => '203.0.113.6']) - ->test(TwoFactorChallenge::class)->set('code', '000000')->call('verify'); - } - - $this->assertDatabaseHas('banned_ips', ['ip' => '203.0.113.6', 'reason' => '2fa']); - } -} -``` - -> If `Livewire::withServerVariables` is unavailable, set the IP via the test request the same way Task 5 settled on; the 2FA per-(user+IP) rate-limit (5/60s) is above 3, so it won't trip first. - -- [ ] **Step 2: Run → fails** (no ban created — hooks absent). - -- [ ] **Step 3: Login hook** — in `app/Livewire/Auth/Login.php`, add imports `use App\Models\AuditEvent;` and `use App\Services\BruteforceGuard;`, then inside the `if (! $user || ! $validPassword) {` block, **before** the `throw`, add (after the three `RateLimiter::hit` lines): - -```php - app(BruteforceGuard::class)->record(request()->ip(), 'login'); - AuditEvent::create([ - 'user_id' => null, - 'actor' => 'system', - 'action' => 'auth.login_failed', - 'target' => $this->email, - 'ip' => request()->ip(), - ]); -``` - -- [ ] **Step 4: 2FA hook** — in `app/Livewire/Concerns/CompletesTwoFactorChallenge.php`, add imports `use App\Models\AuditEvent;` and `use App\Services\BruteforceGuard;`, then add a method: - -```php - /** A failed 2FA attempt: throttle (Layer 1) + feed the persistent ban (Layer 2) + audit. */ - protected function recordFailedAttempt(string $reason): void - { - $this->hitRateLimit(); - $ip = request()->ip(); - app(BruteforceGuard::class)->record($ip, $reason); - AuditEvent::create([ - 'user_id' => null, - 'actor' => 'system', - 'action' => 'auth.2fa_failed', - 'target' => (string) session('2fa.user'), - 'ip' => $ip, - ]); - } -``` - - Then replace the three `$this->hitRateLimit();` call sites with `$this->recordFailedAttempt('2fa');`: - - `app/Livewire/Auth/TwoFactorChallenge.php` `verify()` (the line inside `if (! $valid)`) - - `app/Livewire/Auth/TwoFactorChallenge.php` `verifyWebauthn()` (the line inside the failure `if`) - - `app/Livewire/Auth/TwoFactorBackup.php` `verify()` (the line inside `if (! $user->useRecoveryCode(...))`) - -- [ ] **Step 5: Run → passes**, then the full auth suite stays green: - -```bash -docker compose exec -T app php artisan test --filter='Bruteforce|TwoFactor|Challenge|Webauthn|BruteForce|Login' -``` - -- [ ] **Step 6: Pint + commit** - -```bash -docker compose exec -T app ./vendor/bin/pint app/Livewire/Auth/Login.php app/Livewire/Concerns/CompletesTwoFactorChallenge.php app/Livewire/Auth/TwoFactorChallenge.php app/Livewire/Auth/TwoFactorBackup.php tests/Feature/BruteforceHooksTest.php -git add app/Livewire/Auth/Login.php app/Livewire/Concerns/CompletesTwoFactorChallenge.php app/Livewire/Auth/TwoFactorChallenge.php app/Livewire/Auth/TwoFactorBackup.php tests/Feature/BruteforceHooksTest.php -git commit -m "feat(auth): feed BruteforceGuard from login + 2FA failures, audit them" -``` - ---- - -### Task 7: `clusev:unban` CLI + host wrapper - -**Files:** Create `app/Console/Commands/UnbanIp.php`; Modify `docker/clusev/clusev`; Test `tests/Feature/UnbanCommandTest.php` - -- [ ] **Step 1: Failing test** - -```php - '203.0.113.5', 'banned_until' => now()->addHour(), 'attempts' => 10]); - $this->artisan('clusev:unban', ['ip' => '203.0.113.5'])->assertSuccessful(); - $this->assertDatabaseCount('banned_ips', 0); - } - - public function test_unban_all(): void - { - BannedIp::create(['ip' => '203.0.113.5', 'banned_until' => now()->addHour(), 'attempts' => 10]); - BannedIp::create(['ip' => '203.0.113.6', 'banned_until' => now()->addHour(), 'attempts' => 10]); - $this->artisan('clusev:unban', ['--all' => true])->assertSuccessful(); - $this->assertDatabaseCount('banned_ips', 0); - } - - public function test_unban_without_args_fails(): void - { - $this->artisan('clusev:unban')->assertFailed(); - } -} -``` - -- [ ] **Step 2: Run → fails.** - -- [ ] **Step 3: Command** (`app/Console/Commands/UnbanIp.php`) - -```php -option('all')) { - $count = BannedIp::query()->count(); - foreach (BannedIp::query()->pluck('ip') as $ip) { - Cache::forget('bruteforce:banned:'.$ip); - } - BannedIp::query()->delete(); - $this->info("{$count} Bann/Bänne entfernt."); - - return self::SUCCESS; - } - - $ip = (string) $this->argument('ip'); - if ($ip === '') { - $this->error('Bitte eine IP angeben oder --all verwenden.'); - - return self::FAILURE; - } - - $guard->unban($ip); - $this->info("Bann für {$ip} entfernt (falls vorhanden)."); - - return self::SUCCESS; - } -} -``` - -- [ ] **Step 4: Host wrapper** — in `docker/clusev/clusev`, add a usage line under the `reset-admin` line (around line 21): ` clusev unban Gebannte IP entsperren (--all für alle)` and add a case after the `reset-admin)` case: - -```bash - unban) compose exec app php artisan clusev:unban "$@" ;; -``` - -- [ ] **Step 5: Run → passes. Pint + commit** - -```bash -docker compose exec -T app ./vendor/bin/pint app/Console/Commands/UnbanIp.php tests/Feature/UnbanCommandTest.php -git add app/Console/Commands/UnbanIp.php docker/clusev/clusev tests/Feature/UnbanCommandTest.php -git commit -m "feat(auth): clusev:unban CLI + host wrapper escape hatch" -``` - ---- - -### Task 8: Settings tab "Anmeldeschutz" - -**Files:** Modify `app/Support/Confirm/ConfirmToken.php`, `resources/views/livewire/settings/index.blade.php`, `lang/de/settings.php`, `lang/en/settings.php`; Create `app/Livewire/Settings/LoginProtection.php`, `resources/views/livewire/settings/login-protection.blade.php`; Test `tests/Feature/Settings/LoginProtectionTabTest.php` - -- [ ] **Step 1: Allowlist the confirm events** — in `app/Support/Confirm/ConfirmToken.php`, add two entries to the `ACTIONS` const array: - -```php - 'banCleared', - 'bansClearedAll', -``` - -- [ ] **Step 2: Failing test** - -```php -create(); - Livewire::actingAs($user)->test(LoginProtection::class) - ->set('enabled', false)->set('maxretry', 7)->set('findtime', 15)->set('bantime', 120) - ->set('whitelist', "203.0.113.0/24") - ->call('save'); - - $this->assertSame('0', Setting::get('bruteforce_enabled')); - $this->assertSame('7', Setting::get('bruteforce_maxretry')); - $this->assertSame('203.0.113.0/24', Setting::get('bruteforce_whitelist')); - } - - public function test_invalid_whitelist_is_rejected(): void - { - $user = User::factory()->create(); - Livewire::actingAs($user)->test(LoginProtection::class) - ->set('whitelist', "junk-not-a-cidr")->call('save') - ->assertHasErrors('whitelist'); - $this->assertNull(Setting::get('bruteforce_maxretry')); - } - - public function test_table_lists_active_bans_and_unban_removes_one(): void - { - $user = User::factory()->create(); - BannedIp::create(['ip' => '203.0.113.5', 'banned_until' => now()->addHour(), 'attempts' => 10]); - - $token = ConfirmToken::issue('banCleared', ['ip' => '203.0.113.5']); - ConfirmToken::confirm($token); - Livewire::actingAs($user)->test(LoginProtection::class) - ->assertSee('203.0.113.5') - ->call('unban', $token); - - $this->assertDatabaseCount('banned_ips', 0); - $this->assertDatabaseHas('audit_events', ['action' => 'auth.ip_unbanned', 'target' => '203.0.113.5']); - } - - public function test_widening_whitelist_clears_now_exempt_bans(): void - { - $user = User::factory()->create(); - BannedIp::create(['ip' => '203.0.113.5', 'banned_until' => now()->addHour(), 'attempts' => 10]); - - Livewire::actingAs($user)->test(LoginProtection::class) - ->set('whitelist', "203.0.113.0/24")->call('save'); - - $this->assertDatabaseCount('banned_ips', 0); // 203.0.113.5 now exempt → purged - } - - public function test_whitelist_my_ip_adds_current_ip(): void - { - $user = User::factory()->create(); - Livewire::actingAs($user)->withServerVariables(['REMOTE_ADDR' => '198.51.100.7']) - ->test(LoginProtection::class)->call('whitelistMyIp') - ->assertSet('whitelist', fn ($w) => str_contains($w, '198.51.100.7')); - } -} -``` - -- [ ] **Step 3: Run → fails.** - -- [ ] **Step 4: Component** (`app/Livewire/Settings/LoginProtection.php`) - -```php -enabled = $guard->enabled(); - $this->maxretry = $guard->maxretry(); - $this->findtime = $guard->findtime(); - $this->bantime = $guard->bantime(); - $this->whitelist = implode("\n", $guard->whitelist()); - } - - public function save(BruteforceGuard $guard): void - { - $this->validate([ - 'maxretry' => ['required', 'integer', 'min:1', 'max:1000'], - 'findtime' => ['required', 'integer', 'min:1', 'max:1440'], - 'bantime' => ['required', 'integer', 'min:1', 'max:43200'], - 'whitelist' => ['nullable', 'string'], - ]); - - $lines = array_values(array_filter(array_map('trim', preg_split('/[\r\n,]+/', $this->whitelist) ?: []))); - foreach ($lines as $line) { - if (Validator::make(['v' => $line], ['v' => [new ValidIpOrCidr]])->fails()) { - $this->addError('whitelist', __('settings.lp_whitelist_invalid', ['value' => $line])); - - return; - } - } - - Setting::put('bruteforce_enabled', $this->enabled ? '1' : '0'); - Setting::put('bruteforce_maxretry', (string) $this->maxretry); - Setting::put('bruteforce_findtime', (string) $this->findtime); - Setting::put('bruteforce_bantime', (string) $this->bantime); - Setting::put('bruteforce_whitelist', implode("\n", $lines)); - - // Widening the whitelist immediately clears any now-exempt bans. - foreach (BannedIp::all() as $ban) { - if ($guard->isExempt($ban->ip)) { - $guard->unban($ban->ip); - } - } - - $this->audit('auth.protection_updated', null); - $this->dispatch('notify', message: __('settings.lp_saved')); - } - - public function whitelistMyIp(): void - { - $ip = (string) request()->ip(); - $lines = array_values(array_filter(array_map('trim', preg_split('/[\r\n,]+/', $this->whitelist) ?: []))); - if (! in_array($ip, $lines, true)) { - $lines[] = $ip; - } - $this->whitelist = implode("\n", $lines); - } - - public function confirmUnban(string $ip): void - { - $this->dispatch('openModal', - component: 'modals.confirm-action', - arguments: [ - 'heading' => __('settings.lp_unban_heading'), - 'body' => __('settings.lp_unban_body', ['ip' => $ip]), - 'confirmLabel' => __('settings.lp_unban'), - 'danger' => false, - 'icon' => 'shield', - 'notify' => __('settings.lp_unban_notify'), - 'token' => ConfirmToken::issue('banCleared', ['ip' => $ip], auditTarget: $ip), - ], - ); - } - - #[On('banCleared')] - public function unban(string $confirmToken, BruteforceGuard $guard): void - { - try { - $payload = ConfirmToken::consume($confirmToken, 'banCleared'); - } catch (InvalidConfirmToken) { - return; - } - $ip = (string) $payload['params']['ip']; - $guard->unban($ip); - $this->audit('auth.ip_unbanned', $ip); - } - - public function confirmUnbanAll(): void - { - $this->dispatch('openModal', - component: 'modals.confirm-action', - arguments: [ - 'heading' => __('settings.lp_unban_all_heading'), - 'body' => __('settings.lp_unban_all_body'), - 'confirmLabel' => __('settings.lp_unban_all'), - 'danger' => true, - 'icon' => 'shield', - 'notify' => __('settings.lp_unban_all_notify'), - 'token' => ConfirmToken::issue('bansClearedAll'), - ], - ); - } - - #[On('bansClearedAll')] - public function unbanAll(string $confirmToken): void - { - try { - ConfirmToken::consume($confirmToken, 'bansClearedAll'); - } catch (InvalidConfirmToken) { - return; // forged / replayed / direct-bypass attempt — no-op - } - foreach (BannedIp::query()->pluck('ip') as $ip) { - Cache::forget('bruteforce:banned:'.$ip); - } - BannedIp::query()->delete(); - $this->audit('auth.ip_unbanned', 'all'); - } - - private function audit(string $action, ?string $target): void - { - AuditEvent::create([ - 'user_id' => Auth::id(), - 'actor' => Auth::user()?->name ?? 'system', - 'action' => $action, - 'target' => $target, - 'ip' => request()->ip(), - ]); - } - - public function render(BruteforceGuard $guard) - { - return view('livewire.settings.login-protection', [ - 'bans' => BannedIp::query()->where('banned_until', '>', now())->orderByDesc('banned_until')->get(), - 'currentIp' => (string) request()->ip(), - 'currentIpExempt' => $guard->isExempt((string) request()->ip()), - ]); - } -} -``` - -- [ ] **Step 5: View** (`resources/views/livewire/settings/login-protection.blade.php`) — mirror the profile form + sessions table patterns: - -```blade -@php - $field = 'h-9 w-full rounded-md border border-line bg-inset px-3 font-sans text-sm text-ink placeholder:text-ink-4 focus:border-accent/40 focus:outline-none'; - $label = 'mb-1 block font-mono text-[11px] uppercase tracking-wider text-ink-3'; - $err = 'mt-1 flex items-center gap-1.5 font-mono text-[11px] text-offline'; -@endphp - -
        - -
        - - -
        -
        - - - @error('maxretry')

        {{ $message }}

        @enderror -
        -
        - - - @error('findtime')

        {{ $message }}

        @enderror -
        -
        - - - @error('bantime')

        {{ $message }}

        @enderror -
        -
        - -
        - - -

        {{ __('settings.lp_whitelist_hint') }}

        - @error('whitelist')

        {{ $message }}

        @enderror -
        - -
        -

        - {{ __('settings.lp_current_ip') }}: {{ $currentIp }} - @if ($currentIpExempt) · {{ __('settings.lp_current_ip_exempt') }} @endif - -

        - {{ __('settings.lp_save') }} -
        -
        -
        - - -
        - @forelse ($bans as $ban) -
        -
        -

        {{ $ban->ip }}

        -

        - {{ __('settings.lp_ban_reason') }}: {{ $ban->reason ?? '—' }} - · {{ __('settings.lp_ban_attempts') }}: {{ $ban->attempts }} - · {{ __('settings.lp_ban_until', ['time' => $ban->banned_until->diffForHumans()]) }} -

        -
        - - {{ __('settings.lp_unban') }} - -
        - @empty -

        {{ __('settings.lp_no_bans') }}

        - @endforelse -
        - @if ($bans->isNotEmpty()) -
        - {{ __('settings.lp_unban_all') }} -
        - @endif -
        - -

        - - {{ __('settings.lp_lockout_note') }} -

        -
        -``` - -- [ ] **Step 6: Register the tab** — in `resources/views/livewire/settings/index.blade.php`, add to the `$tabs` array right after the `security` entry: - -```php - ['key' => 'login-protection', 'label' => __('settings.tab_login_protection'), 'icon' => 'shield'], -``` - - and in the content switch, after the `security` arm and before the closing `@endif`, add the arm + a safe default: - -```blade - @elseif ($tab === 'login-protection') - @elseif ($tab === 'users') - @elseif ($tab === 'sessions') - @elseif ($tab === 'email') - @else - @endif -``` - -- [ ] **Step 7: settings i18n** — add to BOTH `lang/de/settings.php` and `lang/en/settings.php` (identical keys; DE values shown — provide the EN equivalents in the same terse register): - -```php - 'tab_login_protection' => 'Anmeldeschutz', - 'lp_title' => 'Anmeldeschutz', - 'lp_subtitle' => 'Sperrt IP-Adressen nach zu vielen fehlgeschlagenen Anmeldeversuchen.', - 'lp_enabled' => 'Aktiv', - 'lp_maxretry' => 'Max. Versuche', - 'lp_findtime' => 'Zeitfenster (Min.)', - 'lp_bantime' => 'Bann-Dauer (Min.)', - 'lp_whitelist' => 'Whitelist (IP/CIDR, eine pro Zeile)', - 'lp_whitelist_hint' => 'Diese Adressen werden nie gezählt oder gesperrt. Loopback ist immer ausgenommen.', - 'lp_current_ip' => 'Deine aktuelle IP', - 'lp_current_ip_exempt' => 'ausgenommen', - 'lp_whitelist_my_ip' => 'Meine IP zur Whitelist hinzufügen', - 'lp_save' => 'Speichern', - 'lp_saved' => 'Anmeldeschutz gespeichert.', - 'lp_bans_title' => 'Aktive Sperren', - 'lp_ban_reason' => 'Grund', - 'lp_ban_attempts' => 'Versuche', - 'lp_ban_until' => 'gesperrt bis :time', - 'lp_no_bans' => 'Keine aktiven Sperren.', - 'lp_unban' => 'Entsperren', - 'lp_unban_heading' => 'IP entsperren?', - 'lp_unban_body' => ':ip wird wieder zugelassen.', - 'lp_unban_notify' => 'IP entsperrt.', - 'lp_unban_all' => 'Alle entsperren', - 'lp_unban_all_heading' => 'Alle Sperren aufheben?', - 'lp_unban_all_body' => 'Alle aktuell gesperrten IP-Adressen werden wieder zugelassen.', - 'lp_unban_all_notify' => 'Alle Sperren aufgehoben.', - 'lp_lockout_note' => 'Eingeloggte Operatoren werden nie geblockt und können hier ihre eigene IP entsperren. Notfall: clusev unban auf der Host-Shell.', -``` - - (`lp_whitelist_invalid` was already added in Task 4.) - -- [ ] **Step 8: Run → passes** - -```bash -docker compose exec -T app php artisan test --filter=LoginProtectionTabTest -``` - -- [ ] **Step 9: Pint + commit** - -```bash -docker compose exec -T app ./vendor/bin/pint app/Support/Confirm/ConfirmToken.php app/Livewire/Settings/LoginProtection.php tests/Feature/Settings/LoginProtectionTabTest.php -git add app/Support/Confirm/ConfirmToken.php app/Livewire/Settings/LoginProtection.php resources/views/livewire/settings/login-protection.blade.php resources/views/livewire/settings/index.blade.php lang/de/settings.php lang/en/settings.php tests/Feature/Settings/LoginProtectionTabTest.php -git commit -m "feat(auth): Anmeldeschutz settings tab + unban confirm flow" -``` - ---- - -### Task 9: Full verification (R12 + R15) - -- [ ] **Step 1: Full suite** - -```bash -docker compose exec -T app php artisan test -``` -Expected: PASS, 0 failures. - -- [ ] **Step 2: Build + view:clear** (Blade changed) - -```bash -docker compose exec -T app php artisan view:clear -docker compose exec -T app npm run build -``` - -- [ ] **Step 3: R12 browser-verify** (DE+EN), via the temp-password puppeteer flow (admin@clusev.local): log in, open Settings → **Anmeldeschutz** tab — HTTP 200, zero console/network errors, DOM leak scan scoped to the tab (no `{{ }}`/`@`/`settings.lp_*` literals), 3 breakpoints (375/768/1280), touch targets ≥44px. Confirm the config form, the empty/active bans table, and the current-IP/self-whitelist row render. The 403 `errors.blocked` page is covered by the feature test (it returns 403, not 200) — not via the R12 200-path. - -- [ ] **Step 4: R15 Codex** — run `/codex:review` over the branch; fix until no errors / no security issues. - -- [ ] **Step 5: Final Pint** - -```bash -docker compose exec -T app ./vendor/bin/pint --dirty -``` - ---- - -## Self-review notes - -- **Spec coverage:** §2 BannedIp → Task 1; §3 Guard (config/record/ban/exempt/CIDR) → Tasks 2–3; §4 middleware (guests-only, appended — correcting the spec's "prepend") + 403 → Task 5; §5 hooks + audit → Task 6; §6 tab (+ ConfirmToken allowlist, self-whitelist, whitelist-purge, R5 modals) → Task 8; §7 CLI escape → Task 7; §8 i18n split across Tasks 4/5/8. §0 trust model = no code (documented). -- **Casting:** every `Setting::get` is cast (`=== '1'` / `(int)`); every `Setting::put` stringifies. **Units:** findtime/bantime are minutes; `record()` multiplies findtime ×60 for the RateLimiter decay. -- **Cache:** `bruteforce:banned:{canonical}` (30s), busted on ban + unban; test covers the no-stale-positive case. -- **R5:** both unban actions use `modals.confirm-action` + `ConfirmToken` (allowlisted `banCleared` / `bansClearedAll`). -- **Type consistency:** service methods (`enabled/maxretry/findtime/bantime/whitelist/isExempt/record/isBanned/unban/matchesCidr`) are referenced identically across middleware, hooks, command, and tab. -- **Out of scope (per spec):** host-fail2ban logfile; remote Fail2banService; trustProxies changes. diff --git a/docs/superpowers/plans/2026-06-20-wireguard-dashboard-p1-status.md b/docs/superpowers/plans/2026-06-20-wireguard-dashboard-p1-status.md deleted file mode 100644 index 4cc3c8d..0000000 --- a/docs/superpowers/plans/2026-06-20-wireguard-dashboard-p1-status.md +++ /dev/null @@ -1,752 +0,0 @@ -# WireGuard dashboard — Phase 1 (live status) Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** A read-only `/wireguard` dashboard page showing live WireGuard status — gate state, server info, and connected peers (handshake, endpoint, rx/tx) — fed by a periodic host collector through the `./run` bridge. - -**Architecture:** A new `clusev-wg.sh collect` subcommand (run by a systemd `.timer` every 5 s) writes `run/wg-status.json`. A PHP `WgStatus` reader parses + validates that file; a full-page Livewire component renders it and `wire:poll`s every 5 s. No writes, no DB, no chart (those are P2–P4). Container never touches the host — same constraint as SP1. - -**Tech Stack:** bash + `wg`/`iptables`/`systemctl` (host), systemd `.timer`/`.service`, Laravel 13 + Livewire 3, Tailwind v4 tokens, Pint, shellcheck. Spec: `docs/superpowers/specs/2026-06-20-wireguard-dashboard-sp2-design.md` (§0 Bridge 1, §1, §2). - -**Run tooling (R8):** tests `docker compose --project-directory /home/nexxo/clusev exec -T -u 1002:1002 app sh -lc 'mkdir -p /tmp/views-test && VIEW_COMPILED_PATH=/tmp/views-test php artisan test --filter='`; Pint `… vendor/bin/pint `; shellcheck `docker run --rm -v "$PWD:/mnt" -w /mnt koalaman/shellcheck:stable