PullMD Teil 6: Multi-User-Pivot
Am Ende von Teil 5 stand Issue #5 oben in der Liste: „Architecture: Phase 1 — Multi-user system with sessions + API keys.“ Ich hatte das nicht geplant — nicht für diese Woche, nicht für diesen Monat. Dann saß ich am 29. April abends vor dem Issue-Tracker und merkte, dass ich gar keine andere vernünftige Wahl hatte.
Warum Multi-User Phase 1 wurde, nicht Phase 2
Die eigentliche Erkenntnis kam nicht aus dem Issue selbst, sondern aus den zwei Issues danach.
Issue #6 wollte OAuth für den claude.ai-Web-Connector. Issue #10 fragte, wie man Claude Desktop mit einem Self-Hosted PullMD authentifiziert — mit dem Hinweis, dass Claude Desktops UI nur OAuth unterstützt. Beide Issues wären ohne ein User-Modell nicht lösbar. Kein User-Modell heißt kein OAuth. Kein OAuth heißt kein web-connector-fähiger Self-Host.
Was ich für „nice to have“ gehalten hatte, war eigentlich der gemeinsame Vorläufer von zwei anderen Issues — und das war mir vorher nicht klar.
Die Reihenfolge war also nicht eine Entscheidung zwischen Prioritäten, sondern eine Frage der Abhängigkeiten. Ich konnte #6 und #10 nicht angehen, ohne #5 zu lösen. Also fing ich am 29. April abends an.
Plan: drei Auth-Modi
Der wichtigste Designpunkt war, dass das Update nicht breaking sein durfte. Jemand, der PullMD seit dem Reddit-Post am 28. April deployed hatte — und laut GitHub Insights waren das 320 Unique Cloner in zwei Wochen — sollte nach einem docker compose pull nicht plötzlich ausgesperrt sein.
Drei Modi:
disabled(Default) — verhält sich identisch zu v1.x. Kein Login, keine Sessions, keine neue Konfiguration notwendig.single-admin— ein User, kein Self-Signup. Für Hobby-Self-Hoster, die ihr eigenes PullMD betreiben.multi-user— mehrere Konten mit selbst verwalteten Passwörtern, für shared Instanzen.
Wer nichts in der .env ändert, bekommt disabled. Die Migration ist additiv: neue Tabellen, eine neue Spalte in conversions, aber nichts geht kaputt. Wer v2 mit Auth haben will, setzt drei Zeilen in der .env und startet neu.
Implementation: 30 Commits zwischen v1.2.0 und v2.0.0
Der git-log zwischen den beiden Tags sieht lang aus, ist es aber nicht — 30 Commits, davon ein großer Schwung Tests und Dokumentation. Das eigentliche Auth-System sind ein Middleware-Layer und fünf neue Tabellen:
Schema-Additionen: users, sessions (mit flash_data-Spalte für Login-Messages), api_keys, user_fetches, plus user_id-Spalte in conversions.
Passwort-Hashing: Argon2id mit Produktions-Parametern über OWASP-Empfehlung (t=3, m=64 MiB, p=4). Timing-Attack-Schutz durch Dummy-Verify wenn der Account nicht existiert.
Sessions: Cookie, HttpOnly, SameSite=Lax, Sliding-Expiry 7 Tage. Token als randomBytes(32).toString('hex') — 256 Bit Entropie.
Per-User API-Keys: pmd_<32-base62>-Format, SHA-256-gespeichert. Prefix sichtbar für Identifikation, Hash nie ausgelesen. Erzeugt via PRAGMA foreign_keys = ON, CASCADE auf User-Delete, SET NULL auf conversions.user_id damit der Cache-Eintrag selbst erhalten bleibt, wenn ein User gelöscht wird.
Admin-CLI: node scripts/admin.js reset-password, list-users, make-admin — für den Fall, dass jemand das Admin-Passwort verliert.
Der req.user-Layer abstrahiert über Session, API-Key und Legacy-Token: Jede Route liest nur req.user, egal über welchen Pfad die Anfrage reingekommen ist. Das bedeutet, Phase 2 (OAuth für Issue #6) kann als vierter Auth-Pfad additiv eingehängt werden — die bestehenden API- und MCP-Routen müssen dafür nicht angefasst werden.
REVIEW-FINDINGS.md öffentlich mitveröffentlicht
Vor dem Tag habe ich einen eigenen Security-Review-Pass gemacht — Subagent-driven, mit Claude Code als security-reviewer in der Kritiker-Rolle. Das Ergebnis: 1 CRITICAL und 2 SHOULD-FIX, die vor dem Tag gefixt wurden.
Das CRITICAL war ein echter Bug: ein neu erzeugter API-Key wurde per Redirect-URL übergeben — POST /api/keys leitete auf /settings?new_key=pmd_... um. Der Key landete damit in der Browser-History, in Server-Logs und potenziell in Referer-Headern. Fix: Flash-Message via flash_data-Spalte in der Session, Redirect ohne Query-Parameter.
Die beiden SHOULD-FIX waren ein Open-Redirect über ?next=/\evil.com im Login-Flow (Browser normalisieren \ zu /) und ein fehlender Admin-Check auf DELETE /api/cache* im Multi-User-Modus.
Nach dem Fix blieben 6 NICE-TO-HAVE offen: Modulo-Bias bei der Key-Generation, fehlende FK-Constraint auf user_fetches.cache_id, Share-Link-IDs mit 32-Bit-Entropie (vorhandenes v1-Problem), kein Rate-Limit auf Login/Signup, X-Forwarded-Proto-Trust ohne Proxy-Allowlist, Legacy-Token ohne Mindest-Entropie-Empfehlung.
Alle sechs blieben dokumentiert — in REVIEW-FINDINGS.md, die ich bewusst öffentlich ins Repo gelegt habe. Die Datei ist maschinenlesbar und greppbar: Wer self-hosten will und kein Bauchgefühl für das eigene Threat-Model haben mag, kann sie in fünf Minuten gegen das eigene Setup abgleichen.
Statt die Punkte still zu beheben oder zu verstecken: Wer PullMD self-hostet, soll selbst entscheiden können, ob sie für das eigene Threat-Model relevant sind. Lieber transparent als glattpoliert.
Privacy-Slip in den Release-Notes
Beim Schreiben der v2.0-Release-Notes ist mir ein interner Staging-Hostname versehentlich rausgerutscht — eine Test-Adresse aus meinem Heimnetz, die in einem public Release-Text nichts zu suchen hatte.
gh release edit war der Hotfix, innerhalb von Minuten nach dem Publish.
Ich hatte keinen automatischen Privacy-Lint im Release-Workflow. Das wäre einen Pre-Push-Hook wert — steht als Backlog-Item.
Kein großer Schaden, aber ein sauberes Beispiel dafür, dass Release-Texte derselben Pre-Flight-Aufmerksamkeit brauchen wie Code-Commits.
:latest bleibt auf v1.2.x — für zwei Wochen
Die Entscheidung, den :latest-Docker-Tag nicht automatisch auf v2.0 zu schieben, war bewusst.
Wer image: aeternalabshq/pullmd:latest in der docker-compose.yml hat und docker compose pull macht, bekommt weiterhin v1.2.x. Das neue Auth-System erscheint nicht über Nacht auf einer Instanz, die jemand als Single-URL-Tool ohne Auth betreibt.
Wer v2 explizit will, pinnt auf :2. Ein Scheduled Agent flippt den :latest-Pointer am 16. Mai via PR — review-gated, kein Auto-Merge. Der Commit 2bf9cb0 disablet die entsprechende CI-Zeile; der Agent kommentiert sie wieder ein.
Ich wollte mich selbst daran hindern, die neue Version voreilig zu empfehlen.
Ein Migrations-Bug und drei falsche CSS-Iterationen
Nach dem Tag meldete sich der erste Admin: /api/history war nach der Migration leer, obwohl der Cache voll war. Der Backfill in der Migration hatte cache.user_id korrekt gesetzt, aber user_fetches — die Verknüpfungstabelle zwischen Cache-Einträgen und dem anfragenden User — komplett ausgelassen. Ein klassisches „hat lokal funktioniert, weil mein Test-Datensatz sauber war“-Problem. Fix in Commit 808990a, idempotent via LEFT JOIN ... WHERE uf.id IS NULL, damit der Patch auch mehrfach laufen kann ohne Duplikate zu erzeugen. Der Tag war schon raus, aber der Fix landete als Patch innerhalb der gleichen Stunde.
Kleineres Drama, anderes Register: die Login-Page. Drei falsche Iterationen — Padding-Bump, flex mit flex: 1, dann flex: 1 droppen — bevor sich herausstellte, dass globales button { width: 100% } aus dem alten Stylesheet das gesamte Card-Layout zog. Commit 69ccc60 fixt das mit einem .card-head-Flex-Pattern und explizitem width: auto auf dem Language-Toggle. Force-Push um die Noise-Commits zu entfernen, einer der seltenen Fälle wo das sinnvoll ist, weil der Branch noch local-only war. Drei Iterationen für eine Zeile CSS sind keine Niederlage, aber auch nicht der eleganteste Pfad.
Die eigentliche Lehre
Der Pivot war weniger Code als erwartet. Argon2id plus Sessions plus API-Keys sind kein großer Architektur-Umbau — das sind fünf Tabellen und ein Auth-Middleware-Layer. Vier Tage von v1.2.0 bis v2.0.0, inklusive Security-Review, Tests und Dokumentation.
Das eigentliche Lehrstück ist nicht das Auth-System selbst: Die Security-Review gehört mit ins Release. Wenn sechs NICE-TO-HAVE offen bleiben, sollen die Self-Hoster das wissen, nicht ich es allein.
Cliffhanger
v2.0 ship am 02. Mai abends. Die nächste logische Phase: OAuth für claude.ai-Web, Issue #6. Der Branch lag bereit als feat/oauth. Was dann passiert ist, war eines der frustrierendsten Debugging-Erlebnisse seit langem — und das Frustrierende war nicht ein Bug, den ich gemacht habe.
Teil 6 von 9 der PullMD Serie.
← PullMD Teil 5: Reddit-Tag und die ersten Issues | PullMD Teil 7: Spec-Compliant ist nicht genug →