6.1 KiB
PRD Addendum — Technical Depth (Team-Release v1.0)
Technical-how that does not belong in the PRD but feeds Architecture / Epics / Dev. Authoritative source: docs/superpowers/specs/2026-07-07-team-multiuser-design.md + approved plan.
Decision rationale (options considered)
- Passwortlos statt Passwort-Login: kein Passwort-Speicher/-Reset, geringere Angriffsfläche, weniger Support. Trade-off: E-Mail-Zustellung wird kritischer Pfad (→ SPF/DKIM).
- Server-Sessions statt JWT: widerrufbar (Logout, Nutzer-Löschung), kein Secret-Rotation-Aufwand. Trade-off: DB-Lookup pro Request (vernachlässigbar bei better-sqlite3 synchron).
- Token- UND Session-Hashing (SHA-256): aus externem Review übernommen — bei DB-Leak wertlos. (Rohwert nur im Link/Cookie.)
- Kein
cookie-parser:res.cookie()ist nativ in Express; Lesen via kleiner Helfer → keine Dependency. - Verworfen:
db.run()-Stil (node-sqlite3-API, existiert nicht in better-sqlite3); CORS (same-origin).
Schema-Deltas (server/db.js)
- Pragma:
db.pragma('foreign_keys = ON')(sonst wirkt CASCADE nicht). users(id PK, email UNIQUE lowercase, is_admin, created_at).login_tokens(token_hash PK, user_id → users ON DELETE CASCADE, expires_at, used_at).sessions(id_hash PK, user_id → users ON DELETE CASCADE, expires_at, created_at).documents: neuer zusammengesetzter PK(user_id, key)→ Table-Rebuild (SQLite kann PK nicht per ALTER ändern):documents_v2anlegen →INSERT … SELECT <adminId>, …→DROP→RENAME. Guard: nur wenndocumentsnoch keineuser_id-Spalte hat.history:ADD COLUMN user_id+UPDATE … WHERE user_id IS NULL.getDoc(userId,key)/putDoc(userId,key,value,now)— strikt nachuser_idgefiltert.
Startup-Reihenfolge (kritisch)
seedAdmin(ADMIN_EMAIL) zuerst (liefert adminUserId) → dann migrateToMultiUser(adminUserId). Sonst kann die Migration Alt-Zeilen keinem Admin zuordnen. Beides idempotent, läuft vor listen.
Neue Backend-Dateien
server/auth.js—hashToken,createLoginToken/consumeLoginToken,createSession/validateSession/deleteSession/deleteUserSessions,seedAdmin,migrateToMultiUser,authMiddleware,adminMiddleware. better-sqlite3-synchron, keinasync.server/mailer.js—sendMagicLink(email,url)vianodemailer; ohne SMTP-Config →console.log(link)(Dev/Test), kein Wurf.server/ratelimit.js— In-MemoryMap<key,{count,resetAt}>; per-Prozess (resettet bei Neustart — für einen Container ok).
Endpunkte
POST /api/auth/request (neutral, rate-limited) · GET /auth?token= (einlösen → Cookie → 302) · POST /api/auth/logout · GET /api/auth/me · GET/PUT /api/state (hinter authMiddleware, nutzen req.user.id) · GET/POST/DELETE /api/admin/users (hinter authMiddleware+adminMiddleware).
Cookie setzen: res.cookie('session', raw, {httpOnly:true, secure:true, sameSite:'lax', maxAge}). Lesen: Helfer readCookie(req,'session').
Frontend-Integrationspunkte
index.html: Login-Overlay am Anfang<body>(initial sichtbar); App-Container initial versteckt; in#tab-settingsLogout-Button + Admin-Sektion (versteckt).app.jsBootstrap:GET /api/auth/mevor App-Start; 401 → Overlay; sonst Nutzerwechsel-Check →DataSync.boot()→ Admin-Sektion beiisAdmin.sync.js: beidefetchmitcredentials:'include'; 401 → Overlay + Sync stopp.storage.js:clearUserData()— nur Datenschlüssel +pendingleeren (OpenRouter-Key/Modell bleiben).dienstplan_current_userhält den eingeloggten Nutzer.sw.js: Cache-Version bumpen.
Env-Variablen
ADMIN_EMAIL, APP_BASE_URL, SESSION_TTL_DAYS=30, RATE_LIMIT_EMAIL=5, RATE_LIMIT_WINDOW_MIN=15, SMTP_HOST/PORT/SECURE/USER/PASS/FROM. Secrets nur als Env.
Deployment-Notizen
- Neue Dependency
nodemailer. Dockernode:20-slim, Volumedienstplan-data:/data, hinter Caddy. - SPF + DKIM beim Mail-Provider einrichten (sonst Magic-Links im Spam).
- Server zieht von GitHub
Kenearos/Dienstplan-Pro; Deploy per SSH-Keyid_ed25519_hetzner; Container-Env um obige Variablen erweitern.
Verifikation (Kurz)
Node-Unit-Tests (Token-Hash/Consume, Session validate/delete, Migration idempotent, Rate-Limit, Datentrennung 2 Nutzer, 401/403). Browser-E2E: Server ohne SMTP → Magic-Link in Konsole → kompletter Login-/Trennungs-/Logout-/Nutzerwechsel-Durchlauf. Migration gegen Prod-DB-Kopie.
Review-Nachträge (Opus-4-Linsen + qwen)
- Migrations-Atomarität ist bei SQLite real gegeben (echte Transaktion inkl. DDL) — kein Datei-Rollback nötig; Guard über
documents.user_id-Existenz. Migration + history-Update in einer Transaktion, sonst Teilabbruch-Risiko (verwaiste history). - Session-Invalidierung bei Nutzer-Löschung funktioniert per
sessions.user_id-FK + CASCADE (kein Reverse-Lookup vom Hash nötig — user_id ist indiziert). Logout löscht per Hash des präsentierten Cookies. user_idnur aus Session: InauthMiddlewaregesetzt;/api/stateliestreq.user.id, ignoriert jeden client-seitigenuser_id. Eigener Test: manipulierteuser_idim Body/Query hat keinen Effekt.- E-Mail-Normalisierung = eine Funktion
normalizeEmail(lowercase+trim), identisch beiseedAdmin, Allowlist-Insert (FR-9) und Login-Request-Abgleich (FR-1). - Audit-Log: append-only, Felder
ts, event, user_id (pseudonym), ip_hash?— keine E-Mail/Namen. Events: login_ok, logout, admin_add/remove, emergency_link, auth_fail. Macht SM-1 (distinct user_id) und SM-3 (auth_fail-Häufung) auswertbar. - Deploy-Guard: Pipeline/Startup prüft
ADMIN_EMAILgesetzt+valide vor Live (Fail-Fast inseedAdmin, zusätzlich Deploy-Checkliste). - OpenRouter-Key beim Nutzerwechsel: wird geleert (Kosten-/Secret-Schutz auf geteiltem PC). Bewusste Abwägung gegen Komfort; auf Einzelgeräten neu einzugeben.
- Foto-Import-Ambiguität (zwei ähnliche Namen): bestehende Levenshtein-≤2-Heuristik; bei Mehrdeutigkeit besser als „unbekannt" behandeln (Nutzer entscheidet in der Vorschau) — bestehende Funktion, keine v1-Änderung erzwungen.