Files
CapaKraken/plan.md
T
Hartmut 745be7ee8b fix(dashboard): scope localStorage key by userId to prevent cross-user layout bleed (#27)
New users on a shared device were picking up a previous user's stale
(potentially empty) dashboard layout from localStorage because the key
"capakraken_dashboard_v1" was not user-scoped.

- useDashboardLayout: key is now capakraken_dashboard_v1_{userId};
  userId is resolved via trpc.user.me before touching localStorage
- Initial state falls back to createDefaultDashboardLayout() until
  userId resolves, then hydrates from the user-scoped key
- DB layout still wins over localStorage when it has data (unchanged)
- E2E test suite covers: new-user flow, modal widget list, add widget
  persists after reload, cross-user localStorage isolation
- plan.md: added ticket #27 implementation plan

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-04-01 22:44:41 +02:00

382 lines
18 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# CapaKraken — Umsetzungsplan
Gitea-Repo: `https://gitea.hartmut-noerenberg.com/Hartmut/plANARCHY`
Stand: 2026-04-01 | Issues: #19#27
---
## Plan: Ticket #27 — Dashboard Widget Bug (new users see empty modal)
### Anforderungsanalyse
Neue Nutzer können keine Widgets zum Dashboard hinzufügen. Root cause: `localStorage`-Key
`"capakraken_dashboard_v1"` ist **nicht user-scoped**. Wenn User A sich ausloggt und User B
auf demselben Gerät anmeldet, liest `useDashboardLayout` das Layout von User A aus
`localStorage`. Hat User A ein leeres Dashboard gespeichert, sieht User B 0 Widgets —
und nach Klick auf "Add Widget" im Modal erscheint der neue Widget **nicht** (da der
Hydrationszustand nicht sauber initialisiert wird).
**Fix:** localStorage-Key auf `capakraken_dashboard_v1_{userId}` umstellen. Vor dem Zugriff
auf `localStorage` wartet der Hook auf `trpc.user.me`, um die User-ID zu kennen.
### Betroffene Pakete & Dateien
| Paket | Datei | Art |
|-------|-------|-----|
| `apps/web` | `src/hooks/useDashboardLayout.ts` | edit — user-scoped storage key |
| `apps/web` | `e2e/dev-system/dashboard-widgets.spec.ts` | create — E2E tests |
### Task-Liste
- [x] **T-1:** `useDashboardLayout` auf user-scoped localStorage umstellen → `useDashboardLayout.ts`
- [x] **T-2:** E2E-Test für den kompletten Widget-Flow (new user, add, persist, reload) → `dashboard-widgets.spec.ts`
- [ ] **T-3:** `pnpm --filter @capakraken/web exec tsc --noEmit` — keine neuen TS-Fehler
- [ ] **T-4:** E2E-Tests gegen laufenden Dev-Server ausführen (manuell oder CI)
- [ ] **T-5:** Commit + Ticket #27 auf Gitea kommentieren + `in-review` setzen
### Akzeptanzkriterien
- [ ] Neuer Admin-User sieht nach Login das Default-Dashboard (stat-cards)
- [ ] Modal zeigt alle 11 Widgets an
- [ ] Gewählter Widget erscheint sofort auf dem Dashboard
- [ ] Nach Page-Reload ist der Widget weiterhin sichtbar
- [ ] Cross-User-Bleed: User B erbt nicht das Layout von User A
---
## Plan: In-Review-Tickets verifizieren und in main integrieren
### Anforderungsanalyse
8 Tickets sind `in-review`. Ziel: jeden Ticket-Scope im Code verifizieren, Gitea-Kommentar
mit Bewertung (✅ akzeptiert / ⚠️ Nachbesserung nötig) hinterlassen, dann alle offenen
Changes in einem sauberen Commit auf `main` integrieren.
**Umfang der Verifikation pro Ticket:**
- Code-Artefakte im Repo prüfen (Dateien, Tests)
- Quality-Gates (tsc, test:unit) müssen grün sein
- Akzeptanzkriterien aus dem Ticket-Body abgleichen
- Gitea-Kommentar: Strukturiertes Urteil mit Befunden
**Integration-Strategie:**
- Alle ungestagten Änderungen (`git status`) gehören zu den Tickets #20, #22, #25, #26
- Ein einziger Commit auf `main` mit klarer Commit-Message
- plan.md und docs/ mitcommiten
### Betroffene Pakete & Dateien
| Paket | Dateien | Art der Änderung |
|-------|---------|-----------------|
| `apps/web` | `next.config.ts`, `src/app/layout.tsx`, `src/middleware.ts`, `src/middleware.test.ts`, `src/app/api/perf/route.ts`, `src/app/api/perf/route.test.ts`, `src/components/security/MfaSetup.test.ts`, `e2e/dev-system/rbac-data-access.spec.ts`, `e2e/dev-system/helpers.ts` | Verifikation + commit |
| `packages/api` | `src/__tests__/ssrf-guard.test.ts`, `src/__tests__/webhook-procedure-support.test.ts` | Verifikation + commit |
| root | `package.json`, `docker-compose.yml`, `docs/developer-runbook.md`, `plan.md` | Verifikation + commit |
### Task-Liste (atomare Schritte in Reihenfolge)
**Phase 1 — Verifikation & Kommentierung (sequenziell, ein Ticket nach dem anderen)**
- [ ] **V-1:** Ticket #19 (MFA-QR) — Code-Check: `MfaSetup.tsx` + `MfaSetup.test.ts`; Urteil auf Gitea posten
- [ ] **V-2:** Ticket #20 (Webhook SSRF) — Code-Check: `ssrf-guard.ts` + `ssrf-guard.test.ts` + `webhook-procedure-support.test.ts`; Urteil posten
- [ ] **V-3:** Ticket #21 (/api/perf) — Code-Check: `route.ts` + `route.test.ts`; Urteil posten
- [ ] **V-4:** Ticket #22 (CSP) — Code-Check: `middleware.ts` + `middleware.test.ts` + `next.config.ts` + `layout.tsx`; Urteil posten
- [ ] **V-5:** Ticket #23 (Active-Session-Registry) — Code-Check: Session-Guard-Middleware, Auth-Flow; Urteil posten
- [ ] **V-6:** Ticket #24 (Docker reproducibility) — Code-Check: `docker-compose.yml`, Dockerfile.dev; Urteil posten
- [ ] **V-7:** Ticket #25 (Env/Migration-Strategie) — Code-Check: `docker-compose.yml`, `docs/developer-runbook.md`; Urteil posten
- [ ] **V-8:** Ticket #26 (RBAC E2E-Tests) — Code-Check: `rbac-data-access.spec.ts`; Urteil posten
**Phase 2 — Quality Gates**
- [ ] **Q-1:** `pnpm --filter @capakraken/web exec tsc --noEmit` — keine Fehler
- [ ] **Q-2:** `pnpm --filter @capakraken/api test:unit` — alle Tests grün
- [ ] **Q-3:** `pnpm --filter @capakraken/web test:unit` — alle Tests grün
**Phase 3 — Git-Integration**
- [ ] **G-1:** `git add` aller ungestagten Änderungen (alle Ticket-Artefakte)
- [ ] **G-2:** Commit mit Message: `security/platform: close audit findings #19#26 (tests, CSP nonce, SSRF guard, runbook)`
- [ ] **G-3:** `git push origin main`
### Abhängigkeiten
- V-1 bis V-8 sind unabhängig voneinander, aber sequenziell (ein Kommentar pro Ticket)
- Q-1 bis Q-3 können nach allen V-Tasks parallel laufen
- G-1/G-2/G-3 müssen nach Q-1..3 erfolgen (kein Commit bei roten Tests)
### Akzeptanzkriterien
- [ ] Alle 8 in-review-Tickets haben einen Bewertungskommentar
- [ ] `pnpm test:unit` läuft grün (beide Pakete)
- [ ] `tsc --noEmit` ohne Fehler
- [ ] Alle neuen Dateien in einem sauberen Commit auf `main`
- [ ] `git status` danach: working tree clean
### Risiken & offene Fragen
- **Ticket #23 (Active-Session):** Die Implementierung in früheren Sessions könnte durch spätere Auth-Fixes (jti→sid) überholt worden sein — genau prüfen
- **Ticket #24 vs. #25:** Überlappen in `docker-compose.yml` — beide Tickets betreffen dieselbe Datei; ein Commit deckt beide ab
- **Push auf main:** Direkt auf `main` — kein PR da kein Remote-Review-Prozess konfiguriert. Sicherstellen dass alle Tests grün sind
---
---
## Ticket #25 — Docker/Env/Migration-Strategie
### Anforderungsanalyse
Ziel: Docker-Container-Lifecycle ohne manuelle Eingriffe.
Konkrete Mängel:
1. `REDIS_URL` in `docker-compose.yml` nutzt `${REDIS_URL:-redis://redis:6379}` — Host-Env-Var kann Docker-internen Wert überschreiben (gleiche Klasse wie das behobene `DATABASE_URL`-Problem)
2. Migration-Strategy undokumentiert: DB per `db push` aufgebaut, dann Migration hinzugefügt → `migrate deploy` scheitert mit P3005, erforderte `migrate resolve --applied`
3. Kein Developer-Runbook (Setup, Restart, DB-Ops fehlen)
### Betroffene Dateien
| Datei | Änderung |
|---|---|
| `docker-compose.yml` | `REDIS_URL` hardcoden |
| `docs/developer-runbook.md` | create |
### Task-Liste
- [ ] **#25-T1:** `docker-compose.yml``REDIS_URL: redis://redis:6379` (Literal, kein `${}`)
- [ ] **#25-T2:** `docs/developer-runbook.md` erstellen mit: Erstmaligem Setup, DB-Migration-Strategie inkl. P3005-Recovery, E2E_TEST_MODE-Erklärung, Container-Neustart-Checkliste
---
## Ticket #26 — RBAC Datenzugriffs-Matrix E2E-Tests
### Anforderungsanalyse
Neue Testdatei `apps/web/e2e/dev-system/rbac-data-access.spec.ts` mit **Netzwerk-Ebene** tRPC-Response-Assertions (nicht nur UI-Sichtbarkeit).
Grundlage `docs/route-access-matrix.md`:
| tRPC-Prozedur | Audience | Admin | Manager | Viewer |
|---|---|---|---|---|
| `user.list` | `admin-only` | ✓ 200 | FORBIDDEN | FORBIDDEN |
| `allocation.listView` | `planning-read` | ✓ 200 | ✓ 200 | FORBIDDEN |
| `resource.listSummaries` | `resource-overview` | ✓ 200 | ✓ 200 | FORBIDDEN |
| `user.listAssignable` | `manager-write` | ✓ 200 | ✓ 200 | FORBIDDEN |
Technik: `page.evaluate()` mit `fetch()` gegen `/api/trpc/<proc>?batch=1&input=...` — läuft im Browser-Kontext der gespeicherten Session.
tRPC GET-Format:
```
GET /api/trpc/<proc>?batch=1&input={"0":{"json":null}}
Erfolg: [{"result":{"data":{"json":[...]}}}]
Fehler: [{"error":{"json":{"data":{"code":"FORBIDDEN","httpStatus":403}}}}]
```
### Betroffene Dateien
| Datei | Änderung |
|---|---|
| `apps/web/e2e/dev-system/rbac-data-access.spec.ts` | create |
### Task-Liste
- [ ] **#26-T1:** Datei erstellen mit `trpcQuery(page, procedure, input?)` Helper-Funktion
- [ ] **#26-T2:** Admin-Describe-Block (4 Tests: alle 4 Prozeduren → Erfolg erwartet)
- [ ] **#26-T3:** Manager-Describe-Block (4 Tests: `user.list` → FORBIDDEN, Rest → Erfolg)
- [ ] **#26-T4:** Viewer-Describe-Block (4 Tests: alle → FORBIDDEN)
- [ ] **#26-T5:** Smoke-Run: `pnpm exec playwright test e2e/dev-system/rbac-data-access.spec.ts --config=playwright.dev.config.ts`
### Risiken
- `allocation.listView` könnte ein Pflicht-Input-Objekt erfordern → Falls `BAD_REQUEST` statt FORBIDDEN, Schema im Router prüfen und minimalen Input übergeben
- Viewer-Permissions aus DB-Seed (SystemRoleConfig) — prüfen ob VIEWER tatsächlich kein `VIEW_PLANNING` hat
---
---
## Anforderungsanalyse
Alle 6 Issues stammen aus einem Security-Audit und einem Plattform-Review.
5 davon sind Security-Findings (OWASP A02/A05/A07/A09), 1 ist ein Plattform-Thema (Docker-Reproduzierbarkeit).
Die Security-Issues sind weitgehend unabhängig voneinander; lediglich #23 greift in den tRPC-Request-Pfad ein und sollte zuletzt umgesetzt werden, da es den Haupt-Auth-Pfad berührt.
---
## Betroffene Pakete & Dateien
| Issue | Paket/Pfad | Datei | Art |
|-------|-----------|-------|-----|
| #21 | `apps/web` | `src/app/api/perf/route.ts` | edit |
| #19 | `apps/web` | `src/components/security/MfaSetup.tsx` | edit |
| #19 | `apps/web` | `package.json` | edit (neue Dep: `qrcode` + `@types/qrcode`) |
| #20 | `packages/api` | `src/lib/webhook-dispatcher.ts` | edit |
| #20 | `packages/api` | `src/router/webhook-support.ts` | edit |
| #20 | `packages/api` | `src/lib/ssrf-guard.ts` | create |
| #22 | `apps/web` | `next.config.ts` | edit |
| #23 | `apps/web` | `src/app/api/trpc/[trpc]/route.ts` | edit |
| #23 | `apps/web` | `src/server/auth.ts` | edit (Doku/Cleanup) |
| #24 | root | `Dockerfile.dev`, `Dockerfile.prod`, `docker-compose.yml`, `docker-compose.prod.yml`, `tooling/docker/app-dev-start.sh` | edit |
---
## Task-Liste (in empfohlener Reihenfolge)
### Issue #21 — /api/perf fail-closed + Query-Token entfernen
- [ ] **Task 1:** `GET`-Handler in `apps/web/src/app/api/perf/route.ts` ändern:
- `if (cronSecret)``if (!cronSecret) return 401/403` (fail-closed)
- `queryToken`-Zweig vollständig entfernen
- Nur noch `Authorization: Bearer <secret>` prüfen
- → Datei: `apps/web/src/app/api/perf/route.ts`
- [ ] **Task 2:** Unit-Tests für `/api/perf`:
- Test: autorisiert per Header → 200
- Test: kein Secret → 401
- Test: Query-Param-Token → 401 (nicht mehr akzeptiert)
- Test: fehlende `CRON_SECRET`-Env → fail-closed (kein Metrics-Leak)
- → Datei: `apps/web/src/app/api/perf/route.test.ts` (neu)
---
### Issue #19 — MFA QR lokal rendern
- [ ] **Task 3:** `qrcode`-Paket und `@types/qrcode` zu `apps/web/package.json` hinzufügen, `pnpm install` ausführen.
- [ ] **Task 4:** `MfaSetup.tsx` umschreiben:
- `<img src="https://api.qrserver.com/...">` durch lokale QR-Generierung ersetzen
- `qrcode.toDataURL(uri)` im Client-Effekt aufrufen und als `<img src={dataUrl}>` rendern
- Sicherstellen: der `otpauth://`-URI verlässt den Browser nicht mehr
- → Datei: `apps/web/src/components/security/MfaSetup.tsx`
- [ ] **Task 5:** Test sicherstellen, dass kein Rendering-Request an externe QR-URL geht:
- Unit-Test oder Playwright-Test der prüft, dass kein `<img src>` mit `qrserver.com` oder `chart.googleapis.com` gerendert wird
- → Datei: `apps/web/src/components/security/MfaSetup.test.tsx` (neu oder bestehend ergänzen)
---
### Issue #20 — Webhook SSRF-Schutz
- [ ] **Task 6:** `ssrf-guard.ts` erstellen mit einer `assertWebhookUrlAllowed(url: string): void`-Funktion:
- Parst die URL, löst Hostname auf (DNS-Check via Node `dns.lookup`)
- Blockt: Loopback (`127.0.0.0/8`, `::1`), RFC1918 (`10.x`, `172.1631.x`, `192.168.x`), Link-Local (`169.254.x`), Cloud-Metadata (`169.254.169.254`)
- Blockt: alle Schemes außer `https` (und `http` nur wenn expliziter Dev-Override gesetzt)
- Wirft `TRPCError({ code: "BAD_REQUEST" })` mit allgemeiner Fehlermeldung (ohne IP preiszugeben)
- → Datei: `packages/api/src/lib/ssrf-guard.ts`
- [ ] **Task 7:** `ssrf-guard` in `webhook-support.ts` und `webhook-dispatcher.ts` integrieren:
- Vor Speicherung + vor Dispatch `assertWebhookUrlAllowed(url)` aufrufen
- → Dateien: `packages/api/src/router/webhook-support.ts`, `packages/api/src/lib/webhook-dispatcher.ts`
- [ ] **Task 8:** Unit-Tests für `ssrf-guard.ts`:
- Erlaubt: `https://example.com/hook`
- Blockt: `http://localhost/…`, `http://127.0.0.1/…`, `http://10.0.0.1/…`, `http://192.168.1.1/…`, `http://169.254.169.254/…`, `ftp://…`
- → Datei: `packages/api/src/__tests__/ssrf-guard.test.ts` (neu)
---
### Issue #22 — CSP härten
- [ ] **Task 9:** CSP in `apps/web/next.config.ts` überarbeiten:
- `unsafe-eval` entfernen oder nur für `NODE_ENV === "development"` erlauben
- `unsafe-inline` aus `script-src` entfernen
- Nonce-basierte Inline-Scripts prüfen: Next.js 15 unterstützt CSP-Nonces via `nonce`-Prop auf `<Script>` — recherchieren ob tatsächliche Inline-Scripts existieren, die Nonces brauchen
- Unnötige `connect-src`-Origins bereinigen
- → Datei: `apps/web/next.config.ts`
- [ ] **Task 10:** Smoke-Test: App unter gehärteter CSP starten, Browser-DevTools auf CSP-Violations prüfen (mindestens Login → Dashboard → Timeline → Allocations).
Gefundene Violations entweder beheben (Move to external file / Nonce) oder als Known Exception dokumentieren.
---
### Issue #23 — Active-Session-Registry bei jedem Request prüfen
- [ ] **Task 11:** Im tRPC-Route-Handler (`apps/web/src/app/api/trpc/[trpc]/route.ts`) nach dem `auth()`-Call die `jti` aus dem Session-Token lesen und gegen `prisma.activeSession` validieren:
```ts
const jti = session?.user?.jti as string | undefined;
if (jti) {
const active = await prisma.activeSession.findUnique({ where: { jti } });
if (!active) {
return NextResponse.json({ error: "Session revoked" }, { status: 401 });
}
}
```
- Gilt für alle authentisierten tRPC-Requests
- Gilt auch für nicht-tRPC Auth-Pfade wenn vorhanden (Route-Handler prüfen)
- → Datei: `apps/web/src/app/api/trpc/[trpc]/route.ts`
- [ ] **Task 12:** Unit-/Integrations-Tests:
- gültige aktive Session → Request durch
- ausgeloggte (gelöschte) Session → 401
- durch Concurrent-Session-Limit verdrängte Session → 401
- → Datei: `apps/web/src/app/api/trpc/[trpc]/route.test.ts` (neu oder ergänzen)
---
### Issue #24 — Docker-Setup host-unabhängig
- [ ] **Task 13:** `Dockerfile.dev` prüfen und absichern:
- `pnpm install` muss im Container ablaufen (kein Volume-Mount der Host-`node_modules`)
- `prisma generate` muss Teil des Starts sein (nicht als Host-Voraussetzung)
- Fehlende Systempakete (z. B. OpenSSL für Prisma) explizit installieren
- → Datei: `Dockerfile.dev`
- [ ] **Task 14:** `Dockerfile.prod` prüfen:
- Multi-Stage-Build: Build-Stage hat pnpm + alle Dev-Deps; Runtime-Stage nur Prod-Artefakte
- Generierte Prisma-Artefakte (`node_modules/.prisma`) korrekt aus Build-Stage kopiert
- → Datei: `Dockerfile.prod`
- [ ] **Task 15:** `docker-compose.yml` absichern:
- `node_modules`-Volume-Override korrekt gesetzt damit Host-Modules nicht reinmappen
- `app-dev-start.sh` ausführbar und alle Schritte (generate, migrate, start) enthalten
- → Dateien: `docker-compose.yml`, `tooling/docker/app-dev-start.sh`
- [ ] **Task 16:** Frischer-Checkout-Smoke-Test dokumentieren:
```bash
git clone … && cd capakraken
docker compose up --build
# → App erreichbar, Login funktioniert, keine Host-Abhängigkeiten
```
→ Schritt in `docs/` oder `README.md` festhalten
---
## Abhängigkeiten
```
Task 1 → Task 2 (Tests setzen fertige Impl voraus)
Task 3 → Task 4 → Task 5 (Dep-Install → Impl → Test)
Task 6 → Task 7 → Task 8 (Guard-Lib → Integration → Tests)
Task 9 → Task 10 (CSP-Änderung → Smoke-Test)
Task 11 → Task 12 (Session-Impl → Tests)
Task 1316 unabhängig von allen anderen (Docker-only)
Parallel möglich (nach Task-Gruppe):
- #21 (Tasks 12) || #19 (Tasks 35) || #24 (Tasks 1316)
- #20 (Tasks 68) || #22 (Tasks 910) — erst nachdem #21/#19 fertig sind (separater Branch)
- #23 (Tasks 1112) — zuletzt, da Auth-Pfad berührt
```
---
## Akzeptanzkriterien
- [ ] `pnpm test:unit` läuft grün
- [ ] `pnpm --filter @capakraken/web exec tsc --noEmit` — keine neuen Errors
- [ ] `pnpm lint` — sauber
- [ ] **#21:** `/api/perf` ohne `CRON_SECRET` gibt 401/403, Query-Token wird nicht mehr akzeptiert
- [ ] **#19:** `MfaSetup` macht keinen Request an `qrserver.com` oder `chart.googleapis.com`
- [ ] **#20:** Webhook-Save und Dispatch für `http://127.0.0.1/…` gibt `BAD_REQUEST`
- [ ] **#22:** Browser-DevTools zeigt keine CSP-Violations für Haupt-User-Flows in Production-Mode
- [ ] **#23:** Request mit gelöschter `ActiveSession`-`jti` gibt 401
- [ ] **#24:** `docker compose up --build` auf sauberem Checkout bootet die App ohne Host-Abhängigkeiten
---
## Risiken & offene Fragen
| Risiko | Einschätzung | Maßnahme |
|--------|-------------|----------|
| CSP-Nonces in Next.js 15: `<Script>` und Tailwind CSS benötigen ggf. Nonces | Mittel | Vor Task 9 in Next.js-15-Doku recherchieren; ggf. nur `unsafe-eval` entfernen als erster Schritt |
| Session-Registry-Check (#23) erhöht DB-Load: jeder tRPC-Request = 1 DB-Read | Mittel | Redis-Cache mit kurzer TTL (30s) als Opt-in; erst messen ob nötig |
| SSRF-Guard DNS-Lookup: async, könnte Race-Condition durch DNS-Rebinding haben | Niedrig | Nach DNS-Lookup Socket-Verbindung ebenfalls gegen IP prüfen (defense-in-depth) |
| Docker #24: `node_modules`-Volume-Semantik bei pnpm-Workspaces komplex | Mittel | Symlink-Struktur von pnpm in Container testen; ggf. `--shamefully-hoist` Flag |
| `jti` im Session-Token: Auth.js-Version muss `jti` ins JWT schreiben | Offen | In `auth.ts` prüfen ob `token.jti` tatsächlich im JWT-Callback persistiert wird |