Compare commits

...

35 Commits

Author SHA1 Message Date
Bernd Steckmeister 725f03abb0 docs: Fable-5-Review (y) – (x)-Hinterlassenschaft geprüft/nachgeholt, kein Pi-bearbeitbarer ROADMAP-Punkt offen
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-05 18:41:20 +02:00
Bernd Steckmeister 15001ae80a docs: Fable-5-Review (x) – Protokoll-Commit der abgebrochenen Vorsession nachgeholt
Der (x)-Eintrag (Review von 66bf54b, kein Befund) lag fertig geschrieben,
aber uncommitted im Arbeitsbaum – die Session wurde vor ihrem
Protokoll-Commit abgebrochen. Inhalt in dieser Session gegengeprüft
(66bf54b-Diff, keine lib/-Änderungen seit 925aafb, A/B-Frage überall
geschlossen) und unverändert übernommen.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-05 18:40:06 +02:00
Bernd Steckmeister 1ef32c0022 chore: CHANGES.md (auto-Hook) 2026-07-05 14:54:38 +02:00
Bernd Steckmeister 66bf54bdff docs: Dashboard bleibt Heimnetz-only (Bernd Nr. 3) – ROADMAP-Punkt geschlossen, Doku nachgezogen, Nr.-4-Vermerk am Secrets-Punkt
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-05 14:53:27 +02:00
Bernd Steckmeister 81ec06398f docs: Fable-5-Review (v)+(w) – PC-Commits 825481e/4477ed9 geprüft, Fix korrekt, 1 Doku-Lücke (Entscheidung 3 nicht nachgezogen)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-05 14:52:09 +02:00
Bernd Steckmeister 4477ed9101 fix: Hauptkonto-Profil ohne CLAUDE_CONFIG_DIR ansprechen (Config-Pfad-Falle)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CPrAGBxBT6GfPXzeWQ4AXb
2026-07-05 14:47:35 +02:00
Bernd Steckmeister 825481e6fa docs: Bernds Richtungsfragen beantwortet (ANTWORTEN_BERND.md) - SQLCipher vor M2, Call-Pilot zuerst, Dashboard Heimnetz-only, History unangetastet 2026-07-05 07:41:36 +02:00
Bernd Steckmeister db4d001ef1 chore: CHANGES.md (auto-Hook)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-04 20:06:53 +02:00
Bernd Steckmeister af7c538e18 docs: Fable-5-Review (u) – (t)-Fix verifiziert, server.py-Review-Kette konvergiert
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-04 19:44:55 +02:00
Bernd Steckmeister 4c6efa2a7a chore: CHANGES.md (auto-Hook)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-04 19:22:29 +02:00
Bernd Steckmeister d3e75c36a1 security: server.py Restbefund aus Review (t) – registration-status antwortet generisch statt str(e)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-04 19:21:19 +02:00
Bernd Steckmeister 326cc598c7 chore: CHANGES.md (auto-Hook)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-04 18:57:29 +02:00
Bernd Steckmeister 13b333cbec security: server.py Restbefunde aus Review (s) – ban/unban-Body vor ADMIN_LOCK, generische 500er auf oeffentlichen Routen
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-04 18:56:42 +02:00
Bernd Steckmeister 4d4044b888 chore: CHANGES.md (auto-Hook)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-04 14:48:07 +02:00
Bernd Steckmeister 5ada0e5038 security: server.py Restbefunde aus Review (r) – GET-Leser-Lock, Content-Length-Haertung, secrets-Token
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-04 14:46:27 +02:00
Bernd Steckmeister c8ff850c98 security: server.py Admin-Routen serialisieren (ADMIN_LOCK, Review-Befund q)
Threading-Umstellung aus (p) hatte eine Nebenlaeufigkeits-Race in den
Admin-Routen erzeugt: conduit.toml Read-Modify-Write + docker restart und
der send_admin-Readback-nach-sleep waren seit multithreaded race-faehig.
ADMIN_LOCK serialisiert NUR die Admin-Mutationen; die oeffentlichen Routen
(livekit-token, e2ee-diagnostics) laufen zuerst und bleiben nebenlaeufig,
der Slow-Loris-Schutz aus (p) bleibt erhalten. Headless verifiziert.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-04 14:20:42 +02:00
Bernd Steckmeister d42beaf7ab chore: CHANGES.md (auto-Hook)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-04 13:52:07 +02:00
Bernd Steckmeister 343545b59e security: server.py multithreaded + Diag-Log-Lock (Review-Befund p, Threading-Haertung vollendet)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-04 13:51:32 +02:00
Bernd Steckmeister 455742d1bf security: Dashboard-Admin-Routen per Interims-Gate auf Heimnetz begrenzt (Befund o)
server.py (auf dem Pi, nicht im Repo): /api/ban, /api/unban,
/api/toggle-registration, /api/create-invite, /api/run-stats nur noch fuer
Heimnetz-/localhost-Socket-IPs ohne Cloudflare-Header; sonst 403 mit
Erklaerung. Headless verifiziert (9 Pruefungen). Bernds A/B-Entscheidung
fuer den Fernzugriff bleibt offen (docs/DASHBOARD_AUTH_HARDENING.md).
Zusatzbefund: Repo privat schalten wuerde In-App-Updater brechen (ROADMAP).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-04 09:17:56 +02:00
Bernd Steckmeister e97a748b35 security: KRITISCH – Dashboard-Admin-Endpoints ohne Auth (Review-Befund o)
Fable-5-Review (n)+(o) beim Gegenlesen von server.py auf dem Pi:

(o) KRITISCH: /api/ban, /api/unban, /api/toggle-registration, /api/create-invite,
/api/run-stats sind über https://dashboard.steggi-matrix.work oeffentlich
erreichbar und pruefen KEINEN Token (per curl belegt: App-eigene 400-Antwort
ueber die oeffentliche URL). Fremde koennten Uta sperren oder offene
Registrierung aktivieren; Hostname ist ueber CT-Logs auffindbar. Nicht blind
gefixt (jede Auth-Ergaenzung legt Bernds Dashboard bis zur Token-Eingabe lahm
-> kein Aussperren). Fertiger Plan mit zwei Wegen (Cloudflare Access / DASH_TOKEN)
in docs/DASHBOARD_AUTH_HARDENING.md, ROADMAP M0 dringend, wartet auf Bernds Wahl.

(n) LiveKit-Token-Route POST /api/livekit-token wurde von einer abgebrochenen
Session bereits live in server.py gebaut (unprotokolliert). Geprueft + headless
verifiziert (401/400/413/200, JWT-Signatur unabhaengig gegen livekit.yaml
gueltig, Identitaet = gepruefte user_id). Server-Seite des M0-LiveKit-Punkts
erledigt; Client-Umstellung + Rotation bleiben PC/Geraet (heiliger Call-Pfad).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-04 08:43:42 +02:00
Bernd Steckmeister ba97f942d6 security: Diagnose-Endpoint gegen Disk-Fill haerten (Review-Befund l)
Fable-5-Review (l)+(m): /api/e2ee-diagnostics auf dem Pi las den Body ohne
Groessenlimit und haengte ihn unbegrenzt ans Log – bei oeffentlich bekanntem
DIAG_TOKEN ein Disk-Fill-DoS-Risiko. server.py: 256-KB-Body-Limit (413) +
20-MB-Log-Kappung, headless verifiziert (200/403/413). LiveKit-Plan (m)
gegen Code + Pi-Konfig gegengeprueft, stimmt.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-04 04:32:38 +02:00
Bernd Steckmeister 88af6c016c chore: CHANGES.md (auto-Hook)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-04 04:07:39 +02:00
Bernd Steckmeister 78e4174658 docs: LiveKit-Token-Minting-Migrationsplan (apiSecret raus aus Client)
Fertiger Umsetzungsplan fuer den offenen M0-Punkt: Token-Endpoint am
Dashboard-Server mintet LiveKit-JWTs server-seitig (Matrix-whoami-Auth,
stdlib-HMAC), Client holt nur das JWT. Braucht echten Call-Test -> nur Plan.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-04 04:06:53 +02:00
Bernd Steckmeister 925aafb2fe security: Admin-Token aus Client entfernen (E2EE-Diagnose entkoppeln)
Der Dashboard-Server benutzte fuer /api/e2ee-diagnostics denselben String
wie den Matrix-Admin-Token, weshalb der Client den vollen Server-Admin-Token
einbetten musste (jeder Nutzer, auch Uta, trug ihn im Geraet).

- server.py (Pi): eigener, eng begrenzter DIAG_TOKEN statt Admin-TOKEN
- settings_encryption.dart: benutzt den neuen Diagnose-Token
- grep J5lax lib/ jetzt leer; Endpoint verifiziert (neu 200, alt 403)

Admin-Token-Rotation bleibt offen (Bernds Sache, ROADMAP M0).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-04 04:04:15 +02:00
Bernd Steckmeister fe6427bfba security: geleakte Secrets aus Repo entfernen (Review-Befund k)
Secret-Scan im Fable-5-Review fand vier aktive Zugangsdaten im
oeffentlich erreichbaren Gitea-Repo. Gefahrlos entfernbare Stellen:
- scripts/release.ps1: Gitea-Token -> Umgebungsvariable PYRAMID_GITEA_TOKEN
- docs/matrix-sdk/13-server-admin: 19x Admin-Token -> <ADMIN_TOKEN>

Client-eingebettete Secrets (LiveKit apiSecret, Admin-Token im
E2EE-Diagnose-Upload, Giphy-Key) brauchen Rotation + Server-Umbau
und stehen als neue M0-ROADMAP-Punkte (nicht blind auf dem Pi fixbar).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-04 03:32:37 +02:00
Bernd Steckmeister a5a5c4baf5 docs: Fable-5-Review um Punkte (h)-(j) ergänzt, Testplan auf 24 Tests
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-03 23:22:15 +02:00
Bernd Steckmeister 7b90f20e36 fix: Lade-Race in BoolPref/StringSetPref (Review-Befund)
StringSetPref.add/remove machten Read-Modify-Write auf möglicherweise noch
ungeladenem State: ein add() direkt nach Erstellung persistierte nur den
neuen Eintrag und LÖSCHTE alle bereits gespeicherten (per neuem Test am
alten Code bewiesen). Jetzt warten add/remove auf _loaded. BoolPref bekommt
einen _userSet-Guard, damit ein spät landender persistierter Wert nie eine
schon getätigte Nutzeraktion zurücküberschreibt (Reihenfolge ist plattform-
abhängig). In der Praxis kaum treffbar (Provider laden beim App-Start),
aber die Fassade wird beim M2-Storage-Umbau Pflicht-Schnittstelle für
neue, ggf. früh laufende Aufrufer. 2 neue Regressionstests, alle 24 grün.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-03 23:20:49 +02:00
Bernd Steckmeister 03b84e0e0e docs: PC-Testplan Schritt 0 auf aktuellen Teststand (22 Tests) gebracht
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-03 22:53:42 +02:00
Bernd Steckmeister 7dfdbf1159 test: Vertragsnetz für die Storage-Fassade settings_prefs.dart
8 Tests mit dem offiziellen SharedPreferences-In-Memory-Mock: BoolPref,
StringSetPref, Theme-Prefs (Roundtrip/Teil-Save), Server-Banner inkl.
korruptes-JSON-Fehlerpfad. Sichert die Schnittstelle ab, die beim
M2-Storage-Umbau zur Pflicht-Fassade wird. Alle 22 Tests grün auf dem Pi.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-03 22:53:26 +02:00
Bernd Steckmeister e0ac5cb9fd test: AuthLog-Regressionstests (wirft nie, 500-Zeilen-Kappung)
AuthLog.write ist der einzige Aufruf im catch-Pfad des Soft-Logout-Guards;
ein Throw dort würde im SDK zu logout()+clear() eskalieren. 4 neue Tests
sichern genau das ab (kaputte Plattform, leeres Log, Zeitstempel, Kappung).
path_provider_platform_interface als dev_dependency (war schon transitiv).
Alle 14 Tests grün auf dem Pi, flutter analyze unverändert.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-03 22:51:35 +02:00
Bernd Steckmeister eebf8ae7dd docs: Fable-5-Review um Nachzügler-Punkte (e-g) ergänzt
Tests 99cde9a, Doku-Commits 16561bb/566938f und Autopilot-Konto-Wechsel
bfe5876 kritisch geprüft: keine Fehler, alle Behauptungen gegen Code/SDK
verifiziert. Damit sind ALLE als erledigt markierten Punkte reviewt.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-03 22:49:00 +02:00
Bernd Steckmeister 566938f004 docs: PC-Testplan um flutter-test-Vorabschritt ergänzt
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-03 22:23:42 +02:00
Bernd Steckmeister 1630d0e389 chore: Commit-Hash im PROGRESS-Eintrag nachgetragen + Hook-Log
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-03 22:23:12 +02:00
Bernd Steckmeister 99cde9a1e2 test: Regressionstests für Soft-Logout-Guard und Power-Levels-Schutz
Erste Testsuite des Projekts, gezielt für die zwei heiligsten Codepfade:
guardedSoftLogoutRefresh (wirft nie -> kein SDK-logout()+clear(), Uta-Bug)
und updatePowerLevelsSafely (Selbst-Aussperr-Schutz, Server-Stand bleibt
erhalten). 10 Tests, alle gruen auf dem Pi (flutter test laeuft headless).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-03 22:22:06 +02:00
Bernd Steckmeister bfe58760ee feat: automatischer Konto-Wechsel (pyramid<->haupt) bei 5h-Limit
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CPrAGBxBT6GfPXzeWQ4AXb
2026-07-03 19:33:13 +02:00
19 changed files with 2168 additions and 37 deletions
+42
View File
@@ -0,0 +1,42 @@
# Bernds Entscheidungen Richtungsfragen aus PROGRESS.md beantwortet
Diese Datei beantwortet die über mehrere Sessions in PROGRESS.md gesammelten
„Fragen an Bernd". Sie ist ab jetzt **maßgeblich** die betroffenen Punkte in
ROADMAP.md/PROGRESS.md gelten als entschieden.
Zuletzt aktualisiert: **2026-07-05** (Bernd + Claude am PC).
## 1. SQLCipher (lokale DB + Access-Token verschlüsseln) → VOR M2 priorisieren
- **Entscheidung:** Ja. Die Verschlüsselung der lokalen `pyramid.sqlite` hat Vorrang
vor dem M2-Refactoring. Betrifft den M1-Punkt „Sichere Speicherung von Access-Token
& Krypto-DB".
- Umsetzung nach `docs/SQLCIPHER_MIGRATION.md`. **Wichtig:** Die Migration bestehender
Klartext-DBs (Utas Gerät!) ist der riskante Teil und braucht einen echten
PC-/Gerätetest. Auf dem Pi (kein GUI) nur so weit vorbereiten, wie es OHNE GUI sicher
testbar ist; alles Verhaltensrelevante klar als UNGETESTET markieren und erst nach
`docs/PC_TESTPLAN.md` als erledigt abhaken. Grundsatz „Kein Datenverlust" im Zweifel
für den PC-Termin liegen lassen.
## 2. M2 erstes Umbau-Modul → Call-Pilot zuerst
- **Entscheidung:** Wenn M2 startet (nach SQLCipher), zuerst die Call-Schicht entwirren
(Call-Pilot / gemeinsame Fassade über `voip_manager.dart` + `livekit_call_manager.dart`),
wie in `docs/M2_MODULE_SCHNITT.md` vorgeschlagen. **Nicht** zuerst `settings_modal.dart`
aufsplitten.
- **Wichtig:** heiliger Call-Pfad → braucht zwingend echten Call-Test auf einem Gerät.
Auf dem Pi nicht blind umbauen; Vorbereitung/Analyse ok, Umsetzung + Test am PC.
## 3. Dashboard-Fernzugriff (stats.html) → Heimnetz-only lassen
- **Entscheidung:** KEIN Fernzugriff. Das Interims-Gate (nur Heimnetz/localhost) bleibt so.
Die offene A/B-Frage (Cloudflare Access vs. DASH_TOKEN) aus
`docs/DASHBOARD_AUTH_HARDENING.md` ist damit **geschlossen** nicht weiter daran
arbeiten. Unterwegs per WireGuard erreichbar.
## 4. Git-History / Admin-Token → vorerst NICHTS tun
- **Entscheidung:** Kein Git-History-Rewrite und aktuell keine Token-Rotation durch den
Autopilot. Der Admin-Token ist bereits aus dem App-Code heraus (eigener `DIAG_TOKEN`).
Eine spätere Rotation bleibt Bernds manuelle Sache nicht eigenmächtig anstoßen.
---
**Kurzfassung fürs Arbeiten:** SQLCipher vor M2, dann Call-Pilot als erstes M2-Modul.
Dashboard bleibt Heimnetz-only. History unangetastet. Riskante/GUI-relevante Teile
warten auf den PC-Termin (`docs/PC_TESTPLAN.md`); auf dem Pi nur sicher Vorbereitbares.
+110
View File
@@ -1199,3 +1199,113 @@
2026-07-03 17:51 [Write] /home/steggi/.claude-pyramid/projects/-home-steggi-pyramid/memory/pyramid_push.md 2026-07-03 17:51 [Write] /home/steggi/.claude-pyramid/projects/-home-steggi-pyramid/memory/pyramid_push.md
2026-07-03 17:52 [Write] /home/steggi/.claude-pyramid/projects/-home-steggi-pyramid/memory/pyramid-arbeitsstand.md 2026-07-03 17:52 [Write] /home/steggi/.claude-pyramid/projects/-home-steggi-pyramid/memory/pyramid-arbeitsstand.md
2026-07-03 17:52 [Write] /home/steggi/.claude-pyramid/projects/-home-steggi-pyramid/memory/MEMORY.md 2026-07-03 17:52 [Write] /home/steggi/.claude-pyramid/projects/-home-steggi-pyramid/memory/MEMORY.md
2026-07-03 18:16 [Write] /home/steggi/pyramid/test/soft_logout_guard_test.dart
2026-07-03 18:17 [Write] /home/steggi/pyramid/test/power_levels_safety_test.dart
2026-07-03 22:21 [Edit] /home/steggi/pyramid/PROGRESS.md
2026-07-03 22:22 [Edit] /home/steggi/pyramid/PROGRESS.md
2026-07-03 22:23 [Write] /home/steggi/.claude-pyramid/projects/-home-steggi-pyramid/memory/pyramid-arbeitsstand.md
2026-07-03 22:23 [Edit] /home/steggi/pyramid/docs/PC_TESTPLAN.md
2026-07-03 22:48 [Edit] /home/steggi/pyramid/PROGRESS.md
2026-07-03 22:50 [Write] /home/steggi/pyramid/test/auth_log_test.dart
2026-07-03 22:50 [Edit] /home/steggi/pyramid/pubspec.yaml
2026-07-03 22:51 [Edit] /home/steggi/pyramid/PROGRESS.md
2026-07-03 22:52 [Write] /home/steggi/pyramid/test/settings_prefs_test.dart
2026-07-03 22:53 [Edit] /home/steggi/pyramid/PROGRESS.md
2026-07-03 22:53 [Edit] /home/steggi/pyramid/docs/PC_TESTPLAN.md
2026-07-03 22:54 [Edit] /home/steggi/.claude-pyramid/projects/-home-steggi-pyramid/memory/pyramid-arbeitsstand.md
2026-07-03 23:18 [Edit] /home/steggi/pyramid/test/settings_prefs_test.dart
2026-07-03 23:18 [Edit] /home/steggi/pyramid/test/settings_prefs_test.dart
2026-07-03 23:19 [Edit] /home/steggi/pyramid/lib/core/settings_prefs.dart
2026-07-03 23:20 [Edit] /home/steggi/pyramid/lib/core/settings_prefs.dart
2026-07-03 23:21 [Edit] /home/steggi/pyramid/PROGRESS.md
2026-07-03 23:21 [Edit] /home/steggi/pyramid/docs/PC_TESTPLAN.md
2026-07-03 23:22 [Edit] /home/steggi/pyramid/PROGRESS.md
2026-07-04 03:28 [Edit] /home/steggi/pyramid/scripts/release.ps1
2026-07-04 03:31 [Edit] /home/steggi/pyramid/PROGRESS.md
2026-07-04 03:32 [Edit] /home/steggi/pyramid/PROGRESS.md
2026-07-04 03:32 [Edit] /home/steggi/pyramid/ROADMAP.md
2026-07-04 04:01 [Edit] /home/steggi/matrix/server.py
2026-07-04 04:02 [Edit] /home/steggi/pyramid/lib/widgets/settings/settings_encryption.dart
2026-07-04 04:03 [Edit] /home/steggi/pyramid/ROADMAP.md
2026-07-04 04:03 [Edit] /home/steggi/pyramid/PROGRESS.md
2026-07-04 04:06 [Write] /home/steggi/pyramid/docs/LIVEKIT_TOKEN_MIGRATION.md
2026-07-04 04:06 [Edit] /home/steggi/pyramid/ROADMAP.md
2026-07-04 04:06 [Edit] /home/steggi/pyramid/PROGRESS.md
2026-07-04 04:07 [Edit] /home/steggi/.claude-pyramid/projects/-home-steggi-pyramid/memory/pyramid-arbeitsstand.md
2026-07-04 04:30 [Edit] /home/steggi/matrix/server.py
2026-07-04 04:30 [Edit] /home/steggi/matrix/server.py
2026-07-04 04:31 [Edit] /home/steggi/pyramid/PROGRESS.md
2026-07-04 04:32 [Edit] /home/steggi/pyramid/PROGRESS.md
2026-07-04 04:33 [Edit] /home/steggi/matrix/server.py
2026-07-04 04:33 [Edit] /home/steggi/matrix/server.py
2026-07-04 04:33 [Edit] /home/steggi/matrix/server.py
2026-07-04 08:42 [Write] /home/steggi/pyramid/docs/DASHBOARD_AUTH_HARDENING.md
2026-07-04 08:42 [Edit] /home/steggi/pyramid/PROGRESS.md
2026-07-04 08:43 [Edit] /home/steggi/pyramid/PROGRESS.md
2026-07-04 08:43 [Edit] /home/steggi/pyramid/ROADMAP.md
2026-07-04 09:14 [Edit] /home/steggi/matrix/server.py
2026-07-04 09:14 [Edit] /home/steggi/matrix/server.py
2026-07-04 09:15 [Edit] /home/steggi/pyramid/docs/DASHBOARD_AUTH_HARDENING.md
2026-07-04 09:15 [Edit] /home/steggi/pyramid/docs/DASHBOARD_AUTH_HARDENING.md
2026-07-04 09:16 [Edit] /home/steggi/pyramid/ROADMAP.md
2026-07-04 09:16 [Edit] /home/steggi/pyramid/ROADMAP.md
2026-07-04 09:17 [Edit] /home/steggi/pyramid/PROGRESS.md
2026-07-04 09:17 [Edit] /home/steggi/pyramid/PROGRESS.md
2026-07-04 09:18 [Edit] /home/steggi/.claude-pyramid/projects/-home-steggi-pyramid/memory/pyramid-arbeitsstand.md
2026-07-04 09:43 [Edit] /home/steggi/matrix/server.py
2026-07-04 09:43 [Edit] /home/steggi/matrix/server.py
2026-07-04 09:43 [Edit] /home/steggi/matrix/server.py
2026-07-04 13:46 [Edit] /home/steggi/matrix/server.py
2026-07-04 13:46 [Edit] /home/steggi/matrix/server.py
2026-07-04 13:50 [Edit] /home/steggi/pyramid/PROGRESS.md
2026-07-04 13:51 [Edit] /home/steggi/pyramid/PROGRESS.md
2026-07-04 13:51 [Edit] /home/steggi/pyramid/ROADMAP.md
2026-07-04 13:51 [Edit] /home/steggi/.claude-pyramid/projects/-home-steggi-pyramid/memory/pyramid-arbeitsstand.md
2026-07-04 14:17 [Write] /home/steggi/matrix/server.py
2026-07-04 14:19 [Edit] /home/steggi/pyramid/PROGRESS.md
2026-07-04 14:20 [Edit] /home/steggi/pyramid/PROGRESS.md
2026-07-04 14:20 [Edit] /home/steggi/pyramid/ROADMAP.md
2026-07-04 14:43 [Edit] /home/steggi/matrix/server.py
2026-07-04 14:44 [Edit] /home/steggi/matrix/server.py
2026-07-04 14:44 [Edit] /home/steggi/matrix/server.py
2026-07-04 14:44 [Edit] /home/steggi/matrix/server.py
2026-07-04 14:44 [Edit] /home/steggi/matrix/server.py
2026-07-04 14:45 [Edit] /home/steggi/pyramid/PROGRESS.md
2026-07-04 14:46 [Edit] /home/steggi/pyramid/PROGRESS.md
2026-07-04 14:46 [Edit] /home/steggi/pyramid/ROADMAP.md
2026-07-04 14:47 [Edit] /home/steggi/.claude-pyramid/projects/-home-steggi-pyramid/memory/pyramid-arbeitsstand.md
2026-07-04 18:54 [Edit] /home/steggi/matrix/server.py
2026-07-04 18:54 [Edit] /home/steggi/matrix/server.py
2026-07-04 18:54 [Edit] /home/steggi/matrix/server.py
2026-07-04 18:54 [Edit] /home/steggi/matrix/server.py
2026-07-04 18:54 [Edit] /home/steggi/matrix/server.py
2026-07-04 18:56 [Edit] /home/steggi/pyramid/PROGRESS.md
2026-07-04 18:56 [Edit] /home/steggi/pyramid/PROGRESS.md
2026-07-04 18:56 [Edit] /home/steggi/pyramid/ROADMAP.md
2026-07-04 18:57 [Edit] /home/steggi/.claude-pyramid/projects/-home-steggi-pyramid/memory/pyramid-arbeitsstand.md
2026-07-04 19:19 [Edit] /home/steggi/matrix/server.py
2026-07-04 19:20 [Edit] /home/steggi/pyramid/PROGRESS.md
2026-07-04 19:21 [Edit] /home/steggi/pyramid/PROGRESS.md
2026-07-04 19:21 [Edit] /home/steggi/pyramid/ROADMAP.md
2026-07-04 19:22 [Edit] /home/steggi/.claude-pyramid/projects/-home-steggi-pyramid/memory/pyramid-arbeitsstand.md
2026-07-04 19:44 [Edit] /home/steggi/pyramid/PROGRESS.md
2026-07-04 19:44 [Edit] /home/steggi/pyramid/PROGRESS.md
2026-07-04 19:44 [Edit] /home/steggi/pyramid/ROADMAP.md
2026-07-04 19:45 [Edit] /home/steggi/.claude-pyramid/projects/-home-steggi-pyramid/memory/pyramid-arbeitsstand.md
2026-07-05 14:51 [Edit] /home/steggi/pyramid/PROGRESS.md
2026-07-05 14:52 [Edit] /home/steggi/pyramid/PROGRESS.md
2026-07-05 14:52 [Edit] /home/steggi/pyramid/ROADMAP.md
2026-07-05 14:52 [Edit] /home/steggi/pyramid/ROADMAP.md
2026-07-05 14:52 [Edit] /home/steggi/pyramid/ROADMAP.md
2026-07-05 14:52 [Edit] /home/steggi/pyramid/docs/DASHBOARD_AUTH_HARDENING.md
2026-07-05 14:52 [Edit] /home/steggi/pyramid/docs/DASHBOARD_AUTH_HARDENING.md
2026-07-05 14:52 [Edit] /home/steggi/pyramid/docs/DASHBOARD_AUTH_HARDENING.md
2026-07-05 14:53 [Edit] /home/steggi/pyramid/docs/DASHBOARD_AUTH_HARDENING.md
2026-07-05 14:53 [Edit] /home/steggi/pyramid/PROGRESS.md
2026-07-05 14:54 [Write] /home/steggi/.claude/projects/-home-steggi-pyramid/memory/pyramid-arbeitsstand-haupt.md
2026-07-05 14:54 [Edit] /home/steggi/.claude-pyramid/projects/-home-steggi-pyramid/memory/pyramid-arbeitsstand.md
2026-07-05 14:54 [Edit] /home/steggi/.claude/projects/-home-steggi-pyramid/memory/MEMORY.md
2026-07-05 15:18 [Edit] /home/steggi/pyramid/PROGRESS.md
2026-07-05 15:18 [Edit] /home/steggi/pyramid/PROGRESS.md
2026-07-05 18:40 [Edit] /home/steggi/pyramid/PROGRESS.md
2026-07-05 18:41 [Edit] /home/steggi/pyramid/PROGRESS.md
+3
View File
@@ -9,6 +9,9 @@ Einsatz (u. a. Uta) Stabilität und Datensicherheit gehen vor Feature-Tempo.
(`C:\Users\nordm\pyramid` und `C:\Users\nordm\MatrixPi\pyramid` sind veraltete (`C:\Users\nordm\pyramid` und `C:\Users\nordm\MatrixPi\pyramid` sind veraltete
April-Schnappschüsse dort NIE arbeiten.) April-Schnappschüsse dort NIE arbeiten.)
> **Bernds Entscheidungen zu den offenen Richtungsfragen stehen in `ANTWORTEN_BERND.md`
> (zuletzt 2026-07-05) vor dem Abarbeiten von ROADMAP-Punkten lesen, sie sind maßgeblich.**
## Infrastruktur ## Infrastruktur
- Homeserver: Continuwuity auf dem Pi5 (`steggi-matrix.work`) - Homeserver: Continuwuity auf dem Pi5 (`steggi-matrix.work`)
+1051
View File
File diff suppressed because it is too large Load Diff
+76
View File
@@ -60,6 +60,82 @@ Punkte abhaken (`[x]`), wenn erledigt UND in `PROGRESS.md` protokolliert.
- [x] Geräte-/Sessionverwaltung in den Einstellungen: bereits vorhanden - [x] Geräte-/Sessionverwaltung in den Einstellungen: bereits vorhanden
(`_SessionsSection` in `settings_modal.dart` Liste, Umbenennen, Verifizieren (`_SessionsSection` in `settings_modal.dart` Liste, Umbenennen, Verifizieren
per SAS/QR, Abmelden, Massen-Abmeldung mit Passwort-Reauth) per SAS/QR, Abmelden, Massen-Abmeldung mit Passwort-Reauth)
- [x] **SICHERHEIT: Dashboard-Admin-Endpoints ERLEDIGT: Loch geschlossen,
Heimnetz-only ist laut Bernd der Endzustand** (Fable-5-Review (o);
Entscheidung `ANTWORTEN_BERND.md` Nr. 3, 2026-07-05).
`server.py` nimmt `/api/ban`, `/api/unban`, `/api/toggle-registration`,
`/api/create-invite`, `/api/run-stats` jetzt NUR noch aus Heimnetz/localhost an
(Socket-IP + Cloudflare-Header-Check); Fremde aus dem Internet bekommen 403.
Headless verifiziert (öffentlich 403, Heimnetz ok, App-Endpoints unverändert),
siehe PROGRESS.md 2026-07-04. **Bernds Dashboard läuft im Heimnetz unverändert:
`http://192.168.178.71:8080/stats.html`.** Offen bleibt: Will Bernd die
Admin-Buttons auch von unterwegs? Dann Weg A (Cloudflare Access, empfohlen
schließt auch die weiterhin öffentliche Nutzerlisten-Ansicht) oder Weg B
(`DASH_TOKEN`), Plan + Restrisiken in `docs/DASHBOARD_AUTH_HARDENING.md`.
Zusatz (Fable-5-Review (p), 2026-07-04): `server.py` läuft jetzt multithreaded
mit 30-s-Socket-Timeout ein einzelner langsamer Client kann den Server
(und damit `/api/livekit-token`) nicht mehr blockieren; headless verifiziert.
Zusatz (Fable-5-Review (q), 2026-07-04): Der durch (p) eingeführte
Nebenläufigkeits-Race in den Admin-Routen (Read-Modify-Write auf `conduit.toml`,
`send_admin`-Readback) ist mit `ADMIN_LOCK` geschlossen, das NUR die
Admin-Mutationen serialisiert die öffentlichen Routen bleiben nebenläufig;
headless verifiziert (2× parallel = 2× Laufzeit, öffentlicher Pfad 11 ms).
Zusatz (Fable-5-Review (r), 2026-07-04): drei Restbefunde gefixt
`registration-status`-Leser jetzt unter ADMIN_LOCK (Truncate-Write-Race),
negative/übergroße `Content-Length` überall abgelehnt (kein Thread-Hänger,
ban/unban 4-KB-Deckel), Einladungs-Token per `secrets` statt `random`.
Zusatz (Fable-5-Review (s), 2026-07-04): ban/unban-Body wird jetzt VOR dem
ADMIN_LOCK gelesen (hängender Body-Read konnte den Lock 30 s festhalten),
und die öffentlichen Routen antworten bei internen Fehlern generisch statt
mit Exception-Text (Info-Leak); Details ins Journal. Headless verifiziert.
Zusatz (Fable-5-Review (t), 2026-07-04): auch der öffentliche GET-Leser
`registration-status` antwortet bei internen Fehlern jetzt generisch
statt mit `str(e)` (hätte den `conduit.toml`-Pfad geleakt); headless
verifiziert inkl. 500-Beweis.
Zusatz (Fable-5-Review (u), 2026-07-04): (t)-Fix gegengelesen korrekt,
kein Befund; die server.py-Härtungskette ist damit KONVERGIERT (alle
verbliebenen `str(e)`-Antworten LAN-gegated, absichtlich).
**Abschluss (2026-07-05, `ANTWORTEN_BERND.md` Nr. 3): KEIN Fernzugriff
gewünscht die A/B-Frage ist GESCHLOSSEN, das Heimnetz-Gate ist der
Endzustand (unterwegs nutzt Bernd WireGuard). Die Restrisiken des Gates
(öffentliche Nutzerlisten-Ansicht, theoretisches LAN-CSRF) sind bewusst
akzeptiert und in `docs/DASHBOARD_AUTH_HARDENING.md` dokumentiert.
Nicht weiter daran arbeiten.**
- [x] **LiveKit-Token-Route server-seitig gebaut + verifiziert** (Fable-5-Review (n),
PROGRESS.md 2026-07-04). `POST /api/livekit-token` läuft live in `server.py`
(whoami-Auth, stdlib-HS256-Minting aus `livekit.yaml`), headless geprüft
(401/400/413/200, JWT-Signatur unabhängig gegen `livekit.yaml` gültig, Identität =
geprüfte user_id). **Offen:** Client-Umstellung (`livekit_token.dart`/
`livekit_call_manager.dart`) + Secret-Rotation beides PC/Gerät (heiliger
Call-Pfad), siehe `docs/LIVEKIT_TOKEN_MIGRATION.md`.
- [ ] **SICHERHEIT: geleakte Secrets rotieren + aus dem Client holen** (Fable-5-Review (k),
PROGRESS.md 2026-07-04). Das Gitea-Repo ist öffentlich (`private:false`), und es lagen
vier aktive Secrets drin teils schon bereinigt (release.ps1, Admin-Doku), aber:
- **Rotieren (Pflicht, sie sind bereits geleakt):** Matrix-Server-Admin-Token
`J5lax…` (noch gültig, voller Admin!), Gitea-Release-Token, LiveKit-`apiKey`/
`apiSecret`, Giphy-Key. Alte jeweils widerrufen.
- **LiveKit-Token server-seitig minten:** kleiner Token-Endpoint auf dem Pi, damit
`apiSecret` aus `lib/core/livekit_token.dart` verschwindet (App holt nur das JWT).
**Umsetzungsplan liegt fertig in `docs/LIVEKIT_TOKEN_MIGRATION.md`** (2026-07-04,
gegen Code + Pi-Konfig geschrieben) braucht echten Call-Test auf einem Gerät.
- **E2EE-Diagnose-Upload: ERLEDIGT (2026-07-04).** Der Dashboard-Server auf dem Pi
(`/home/steggi/matrix/server.py`) benutzte für `/api/e2ee-diagnostics` denselben
String wie den Matrix-Admin-Token deshalb steckte er im Client. Jetzt hat das
Diagnose-Endpoint einen eigenen, eng begrenzten Token (`DIAG_TOKEN`, hängt nur an
den Log an, keine Admin-Rechte); `settings_encryption.dart` benutzt diesen. Der
**Admin-Token ist damit nicht mehr im App-Code** (per `grep` bestätigt). Server +
Client umgestellt und verifiziert (neuer Token 200, alter Admin-Token 403). Der
Admin-Token selbst muss trotzdem noch rotiert werden (steht oben, Bernds Sache).
- Optional: Git-History-Rewrite (Bernds Entscheidung, Repo ist auch Backup).
- **Stand 2026-07-05 (`ANTWORTEN_BERND.md` Nr. 4): Rotation und
History-Rewrite ruhen BEWUSST beides bleibt Bernds manuelle Sache,
NICHT vom Autopilot anstoßen oder nachfragen. Der Punkt bleibt nur als
Merkposten offen; Autopilot-bearbeitbar ist hier nichts mehr.**
- **ACHTUNG bei „Repo einfach privat schalten":** Der In-App-Updater
(`lib/core/update_checker.dart`) holt Releases **anonym** über die öffentliche
Gitea-API (`/api/v1/repos/steggi/pyramid/releases`). Repo privat = Utas App
findet keine Updates mehr (und ein Fix ließe sich nicht mehr per Update
verteilen Henne-Ei). Erst Updater-Strategie klären, dann Sichtbarkeit ändern.
- [ ] Härtetest dokumentieren: Login → Nachrichten → Logout → Login neu → alles noch lesbar - [ ] Härtetest dokumentieren: Login → Nachrichten → Logout → Login neu → alles noch lesbar
(gehört zum ausstehenden PC-Praxistest der beiden Bugfixes oben; (gehört zum ausstehenden PC-Praxistest der beiden Bugfixes oben;
kompletter Ablauf inkl. aller aufgelaufenen UNGETESTET-Punkte steht kompletter Ablauf inkl. aller aufgelaufenen UNGETESTET-Punkte steht
+45 -4
View File
@@ -9,9 +9,32 @@
cd "$(dirname "$0")" cd "$(dirname "$0")"
export PATH="$HOME/.local/bin:$HOME/flutter/bin:$PATH" export PATH="$HOME/.local/bin:$HOME/flutter/bin:$PATH"
# Pyramid laeuft auf eigenem Claude-Konto (Profil ~/.claude-pyramid), # --- Zwei Claude-Konten mit automatischem Wechsel bei 5h-Limit ---
# das Standard-Profil ~/.claude bleibt fuer die Webseiten-Arbeit. # Konto "pyramid" = Profil ~/.claude-pyramid (Standard fuer diese Arbeit),
# Konto "haupt" = Profil ~/.claude (springt ein, wenn pyramid am Limit ist).
ACCOUNT_FILE="$HOME/.claude-accounts/active"
get_account() { cat "$ACCOUNT_FILE" 2>/dev/null || echo pyramid; }
apply_account() {
if [ "$(get_account)" = "haupt" ]; then
# Hauptkonto = Standard-Profil -> Variable NICHT setzen
# (sonst sucht Claude die Config unter ~/.claude/.claude.json statt ~/.claude.json)
unset CLAUDE_CONFIG_DIR
else
export CLAUDE_CONFIG_DIR="$HOME/.claude-pyramid" export CLAUDE_CONFIG_DIR="$HOME/.claude-pyramid"
fi
}
switch_account() {
local cur next
cur=$(get_account)
if [ "$cur" = "pyramid" ]; then next=haupt; else next=pyramid; fi
mkdir -p "$HOME/.claude-accounts"
echo "$next" > "$ACCOUNT_FILE"
notify_matrix "🔺 Autopilot: 5h-Limit auf Konto '$cur' erreicht wechsle auf Konto '$next' und arbeite direkt weiter."
}
JUST_SWITCHED=0
# --- Matrix-Statusberichte (Raum "🔺 Pyramid Autopilot", Token vom Gatus-Bot) --- # --- Matrix-Statusberichte (Raum "🔺 Pyramid Autopilot", Token vom Gatus-Bot) ---
MATRIX_URL="http://127.0.0.1:6167" MATRIX_URL="http://127.0.0.1:6167"
@@ -36,9 +59,11 @@ while true; do
sleep 600 sleep 600
continue continue
fi fi
echo "===== $(date '+%F %T') Neue Claude-Session =====" | tee -a autopilot.log apply_account
echo "===== $(date '+%F %T') Neue Claude-Session (Konto $(get_account)) =====" | tee -a autopilot.log
BEFORE=$(git rev-parse HEAD 2>/dev/null) BEFORE=$(git rev-parse HEAD 2>/dev/null)
claude -p "$PROMPT" --model claude-fable-5 --dangerously-skip-permissions 2>&1 | tee -a autopilot.log SESSION_OUT=$(mktemp)
claude -p "$PROMPT" --model claude-fable-5 --dangerously-skip-permissions 2>&1 | tee -a autopilot.log "$SESSION_OUT"
AFTER=$(git rev-parse HEAD 2>/dev/null) AFTER=$(git rev-parse HEAD 2>/dev/null)
if [ -n "$BEFORE" ] && [ "$BEFORE" != "$AFTER" ]; then if [ -n "$BEFORE" ] && [ "$BEFORE" != "$AFTER" ]; then
COMMITS=$(git log --oneline --no-decorate "$BEFORE..$AFTER" | head -15) COMMITS=$(git log --oneline --no-decorate "$BEFORE..$AFTER" | head -15)
@@ -50,6 +75,22 @@ $COMMITS
$NEXT" $NEXT"
fi fi
# 5h-Limit erkannt? -> sofort aufs andere Konto wechseln statt zu warten
if grep -qiE "hit your.*limit|usage limit|session limit" "$SESSION_OUT"; then
rm -f "$SESSION_OUT"
if [ "$JUST_SWITCHED" != "1" ]; then
switch_account
JUST_SWITCHED=1
continue
fi
JUST_SWITCHED=0
echo "===== $(date '+%F %T') Beide Konten am Limit - naechster Versuch in 20 Minuten =====" | tee -a autopilot.log
sleep 1200
continue
fi
rm -f "$SESSION_OUT"
JUST_SWITCHED=0
echo "===== $(date '+%F %T') Session beendet - Neustart in 20 Minuten (Strg+C zum Stoppen) =====" | tee -a autopilot.log echo "===== $(date '+%F %T') Session beendet - Neustart in 20 Minuten (Strg+C zum Stoppen) =====" | tee -a autopilot.log
sleep 1200 sleep 1200
done done
+177
View File
@@ -0,0 +1,177 @@
# Dashboard-Server absichern: Admin-Endpoints haben KEINE Authentifizierung
**Status (ABGESCHLOSSEN 2026-07-05): Das Loch ist mit dem Heimnetz-Gate
(„Weg C", siehe unten) geschlossen, und Bernd hat entschieden
(`ANTWORTEN_BERND.md` Nr. 3): KEIN Fernzugriff das Gate ist der ENDZUSTAND.**
Die fünf Admin-Endpoints akzeptieren nur noch Anfragen aus dem
Heimnetz/localhost; alles, was über den Cloudflare-Tunnel kommt, bekommt 403 mit
einer Erklärung (die das Dashboard per `alert()` anzeigt). Bernds Dashboard
funktioniert im Heimnetz unverändert unter
`http://192.168.178.71:8080/stats.html`; unterwegs kommt Bernd per WireGuard ins
Heimnetz. Die A/B-Frage (Cloudflare Access vs. `DASH_TOKEN`) ist damit
GESCHLOSSEN die Optionen unten bleiben nur als Archiv stehen, falls sich der
Bedarf je ändert. Die unten dokumentierten Restrisiken des Gates sind bewusst
akzeptiert. **Nicht weiter an diesem Thema arbeiten.**
## Heimnetz-Gate („Weg C", aktiv seit 2026-07-04, seit 2026-07-05 Endzustand)
In `server.py` (Konstante `ADMIN_PATHS` + Helfer `lan_denied_reason`): eine
Admin-POST-Anfrage wird nur angenommen, wenn (1) **kein** `Cf-Connecting-Ip`/
`Cf-Ray`-Header vorhanden ist (= sie kam nicht durch den Cloudflare-Tunnel) und
(2) die **Socket-Absender-IP** (nicht spoofbar) `127.0.0.1`/`::1`/`192.168.*`
ist. Abgelehnte Versuche landen größenbegrenzt (5 MB-Deckel) in
`security_alerts.log` und damit in der Dashboard-Anzeige. Headless verifiziert:
öffentliche URL → 403 (ban/toggle), Heimnetz-IP und localhost → App-Validierung
erreicht (400 bei leerem Nutzer), gespoofter Cf-Header aus dem LAN → 403,
`GET /` öffentlich → 200, `/api/livekit-token` (401 bei ungültigem Token) und
`/api/e2ee-diagnostics` (403 bei falschem Token) unverändert öffentlich
erreichbar (die App braucht sie, eigene Auth vorhanden).
**Bekannte Restrisiken des Gates (Bernd bekannt und bewusst akzeptiert,
Entscheidung 2026-07-05):**
- Die **öffentliche Ansicht bleibt offen** und zeigt Nutzerliste + Präsenz
(Informations-Preisgabe). Weg A würde auch das schließen.
- **CSRF aus dem Heimnetz:** Eine bösartige Webseite in Bernds Browser könnte
theoretisch POSTs an `http://192.168.178.71:8080` schicken (moderne
Chrome-Versionen blocken Public→Private-Requests, Firefox nicht vollständig).
Erst ein Token/Access-Login (Weg A/B) schließt das sauber.
- Wer **im Heimnetz** ist, kann weiterhin ohne Anmeldung administrieren.
## Problem (belegt, nicht vermutet)
Der Dashboard-Server `/home/steggi/matrix/server.py` (läuft als
`matrix-stats.service` auf `0.0.0.0:8080`) ist über Cloudflare unter
`https://dashboard.steggi-matrix.work` **öffentlich aus dem Internet erreichbar**.
Getestet: `GET /` liefert 200 (stats.html), und die **zustandsändernden POST-Endpoints
antworten mit der App-eigenen Validierung ohne jede Authentifizierung**:
```
$ curl -X POST https://dashboard.steggi-matrix.work/api/ban -d '{"user":""}'
{"error": "Kein Nutzer angegeben"} # HTTP 400 App-Antwort, KEIN Login davor
```
Damit kann **jeder Fremde im Internet**, der den Hostnamen kennt, ohne Anmeldung:
- `/api/ban` / `/api/unban` **jeden Matrix-Nutzer sperren/entsperren** (auch Uta!)
→ Aussperrung / Denial-of-Service gegen echte Nutzer.
- `/api/toggle-registration` und `/api/create-invite` **offene Registrierung am
Homeserver aktivieren** (setzt `allow_registration = true` +
`yes_i_am_very_very_sure…`) → der Homeserver wird zur Spam-/Missbrauchsschleuder.
- `/api/run-stats` Stats-Skript auslösen (harmlos, aber ebenfalls ungeschützt).
**Der Hostname ist nicht wirklich geheim:** Cloudflare stellt TLS-Zertifikate aus,
die in den öffentlichen Certificate-Transparency-Logs landen jede Subdomain von
`steggi-matrix.work` ist per CT-Log-Scan auffindbar. Die bisherige „Sicherheit"
beruht also allein auf Verborgenheit eines Namens, der praktisch entdeckbar ist.
Bereits **korrekt abgesichert** (zum Vergleich, nicht betroffen):
- `/api/e2ee-diagnostics` eigener `DIAG_TOKEN` (Bearer).
- `/api/livekit-token` Matrix-Access-Token per `whoami` gegen Continuwuity.
Nur die vier Admin-/Stats-Endpoints oben sind offen.
## Wie das Dashboard heute aufruft
`stats.html` ruft alle Aktionen als **Same-Origin-`fetch` ohne Auth-Header** auf
(`toggleRegistration`, `createInvite`, `refreshStats`, `doUserAction`
`/api/ban` /`/api/unban`). Es gibt keine Session, kein Cookie, keinen Token
deshalb bricht **jede** hinzugefügte Authentifizierung das Dashboard so lange,
bis `stats.html` den Token mitschickt. Genau darum ist das eine Bernd-Entscheidung
und kein stiller Fix.
## Option A (empfohlen, robust): Cloudflare Access vor die Subdomain
Am saubersten wird das Dashboard **komplett** durch Cloudflare Access geschützt
(Zero-Trust-Login vor der Subdomain), statt in `server.py` Auth nachzubauen:
1. Im Cloudflare-Zero-Trust-Dashboard eine **Access-Application** für
`dashboard.steggi-matrix.work` anlegen.
2. Policy: nur Bernds E-Mail (`bernd.steckmeister@gmail.com`) per One-Time-PIN
oder Google-Login zulassen.
3. Fertig `server.py` und `stats.html` bleiben **unverändert**, das Dashboard
funktioniert für Bernd wie bisher (nach dem Access-Login), aber Fremde kommen
gar nicht erst an die Endpoints.
Vorteil: kein Secret im Code, kein Umbau der HTML-Fetches, kein Aussperr-Risiko
durch vergessene Tokens. Nachteil: Bernd muss die Access-App einmal im
Cloudflare-Panel klicken (kein Code, aber sein Konto nötig). **Das ist der
Königsweg Option B nur, falls Access nicht gewünscht ist.**
## Option B (Fallback, rein in server.py): eigener Dashboard-Token
Wenn Access nicht gewünscht ist, ein **neuer, zufälliger `DASH_TOKEN`** (getrennt
vom geleakten Admin-Token `J5lax…` und vom `DIAG_TOKEN`!), den die vier
Admin-/Stats-Endpoints als `Authorization: Bearer <DASH_TOKEN>` verlangen:
```python
# server.py, zu den anderen Token-Konstanten:
DASH_TOKEN = "<neu erzeugen: python3 -c 'import secrets;print(\"dash-\"+secrets.token_urlsafe(24))'>"
def _dash_authed(self):
return self.headers.get("Authorization", "") == "Bearer " + DASH_TOKEN
```
In `do_POST` **vor** der Behandlung von `/api/ban`, `/api/unban`,
`/api/toggle-registration`, `/api/create-invite`, `/api/run-stats` einfügen:
```python
if self.path in ("/api/ban", "/api/unban", "/api/toggle-registration",
"/api/create-invite", "/api/run-stats") and not self._dash_authed():
self.send_error(403)
return
```
`GET /` und `/api/registration-status` bleiben öffentlich (nur Lesen), damit die
Stats-Seite ohne Token sichtbar bleibt.
**`stats.html`** darf den Token **NIEMALS fest eingebettet** ausliefern (die Seite
ist öffentlich der Token läge sonst für jeden offen). Stattdessen einmal im
Browser abfragen und in `localStorage` halten:
```javascript
function dashToken() {
var t = localStorage.getItem('dashToken');
if (!t) { t = prompt('Dashboard-Token:'); if (t) localStorage.setItem('dashToken', t); }
return t || '';
}
// und bei JEDEM Aktions-fetch:
fetch('/api/ban', { method:'POST',
headers: {'Content-Type':'application/json', 'Authorization':'Bearer ' + dashToken()},
body: JSON.stringify({user:u}) })
```
(betrifft `toggleRegistration`, `createInvite`, `refreshStats`, `doUserAction`).
Bei 403 den gecachten Token verwerfen (`localStorage.removeItem('dashToken')`) und
neu fragen. Bernd gibt den Token so genau **einmal pro Browser** ein.
## Rollout / Testplan
**Option A:** Access-App klicken → von einem fremden Netz/Inkognito prüfen, dass
`https://dashboard.steggi-matrix.work/` zum Access-Login umleitet statt die Seite
zu zeigen; dann als Bernd einloggen und alle Buttons testen. Kein Code-Deploy.
**Option B (headless auf dem Pi verifizierbar):**
1. `server.py` + `stats.html` ändern, Dienst-Neustart (Prozess killen,
`Restart=always` lädt neu siehe PROGRESS 2026-07-04 zur polkit-Falle).
2. `curl -X POST .../api/ban -d '{"user":"x"}'` **ohne** Bearer → **403** erwartet.
3. Mit `Authorization: Bearer <DASH_TOKEN>` + leerem Nutzer → 400 (App-Validierung
erreicht) → beweist: Token akzeptiert, Auth greift.
4. Im Browser: Dashboard öffnen, Token eingeben, je ein Button testen
(Registrierung toggeln + sofort zurück, Stats aktualisieren; Ban/Unban an
einem **Test-Nutzer**, nicht an Uta).
## Warum erst nur der Plan kam und dann doch das Interims-Gate
Eine Token-/Access-Absicherung (A/B) macht die Dashboard-Buttons überall
funktionslos, bis Bernd aktiv etwas tut (Token eingeben bzw. Access-App
einrichten) das mitten in einer autonomen Session zu deployen verletzt die
CLAUDE.md-Leitplanke „kein Aussperren". Das *Wie* der Anmeldung bleibt deshalb
Bernds Richtungsentscheidung. Das später ergänzte Interims-Gate umgeht dieses
Dilemma: Es braucht keinerlei Aktion von Bernd (Heimnetz-Nutzung läuft
unverändert weiter, inkl. spontanem Spam-Bann von zu Hause), sperrt aber
Fremde aus dem Internet sofort aus. Einzige Einschränkung: Admin-Buttons über
die öffentliche URL gehen nicht die 403-Meldung im Dashboard erklärt das und
nennt die Heimnetz-URL. Mit Bernds Entscheidung vom 2026-07-05 (Heimnetz-only,
unterwegs WireGuard) ist genau dieser Zustand der gewollte Endzustand. Rückbau
(falls je nötig) = die Gate-Zeilen in `server.py` entfernen
(Backup: `server.py.bak-20260704-dashgate`).
+143
View File
@@ -0,0 +1,143 @@
# LiveKit-Token server-seitig minten (apiSecret aus dem Client entfernen)
**Status:** geplant, NICHT umgesetzt (braucht echten Call-Test auf einem Gerät
Calls sind laut CLAUDE.md „heilig"). Dieser Plan ist gegen den echten Code und die
echte Pi-Konfiguration geschrieben (2026-07-04), damit ein PC-Termin direkt starten
kann. Analog zu `docs/SQLCIPHER_MIGRATION.md`.
## Problem
`lib/core/livekit_token.dart` mintet die LiveKit-JWTs **im Client**. Dafür muss das
LiveKit-`apiSecret` im App-Code liegen (`livekit_token.dart:6`) d. h. jeder
App-Nutzer (auch Uta) trägt das SFU-Secret im Gerät. Wer es hat, kann sich für
**jeden** Raum und **jede** Identität ein Beitritts-Token ausstellen. Das ist ein
Architekturproblem, kein bloßer Repo-Leak: Selbst nach einem History-Rewrite bliebe
das Secret im ausgelieferten Binary.
Aktueller Fluss (`lib/core/livekit_call_manager.dart:154`):
```dart
final token = LiveKitTokenGenerator.generate(
roomName: roomName,
identity: identity,
displayName: identity,
ttlSeconds: 21600, // 6 h
);
```
Erzeugtes JWT (HS256, `livekit_token.dart:14-33`): Grants `video` mit
`roomJoin/room/canPublish/canSubscribe/canPublishData`, `metadata` = Anzeigename,
`iss` = apiKey (`LKMatrixPi`), `sub` = identity, `exp` = jetzt+ttl.
## Ziel
Das `apiSecret` liegt **nur noch auf dem Pi**. Der Client holt sich für einen
konkreten Raum ein fertiges JWT von einem kleinen Token-Endpoint und übergibt es
unverändert an `Room.connect`.
## Server-Seite (Pi)
Es gibt bereits einen schlanken Dashboard-Server ohne Fremd-Abhängigkeiten:
`/home/steggi/matrix/server.py` (stdlib `http.server`, läuft als
`matrix-stats.service` auf `0.0.0.0:8080`, nach außen `dashboard.steggi-matrix.work`).
Der Token-Endpoint kann dort als weitere Route `POST /api/livekit-token` andocken
kein neuer Dienst nötig. Das LiveKit-`apiSecret` steht schon auf dem Pi in
`/home/steggi/matrix/livekit.yaml` (`keys: LKMatrixPi: <secret>`); der Server liest
es von dort, statt es erneut zu hinterlegen (eine Quelle der Wahrheit).
**Wichtig Authentifizierung:** Der Endpoint darf NICHT anonym Tokens ausstellen
(sonst ist nichts gewonnen). Der Client schickt seinen **Matrix-Access-Token** mit;
der Server prüft ihn gegen Continuwuity und leitet daraus die Identität ab:
1. `POST /api/livekit-token`, Body `{ "room": "<roomName>", "matrix_token": "<access_token>" }`.
2. Server ruft `GET http://127.0.0.1:6167/_matrix/client/v3/account/whoami`
mit `Authorization: Bearer <matrix_token>` auf → liefert `user_id`
(401 → Endpoint gibt 401 zurück, kein Token).
3. `identity` = `user_id` (NICHT vom Client wählbar verhindert Identitäts-Spoofing).
4. Optional, aber empfohlen: Raummitgliedschaft prüfen (`/_matrix/client/v3/rooms/
{roomId}/joined_members` oder der Matrix-Raum, zu dem der Voice-Channel gehört)
nur ausstellen, wenn der Nutzer wirklich Mitglied ist. Für Phase 1 reicht die
whoami-Authentifizierung; Mitgliedschaftsprüfung als Härtung nachziehen.
5. Server mintet das LiveKit-JWT **mit denselben Grants wie heute** und gibt
`{ "token": "<jwt>", "url": "wss://livekit.steggi-matrix.work" }` zurück.
**JWT-Minting ohne Fremd-Paket (stdlib genügt):** LiveKit-Tokens sind HS256-JWTs.
Mit `hmac`/`hashlib`/`base64`/`json` aus der stdlib:
```python
import base64, hmac, hashlib, json, time
def _b64url(b): return base64.urlsafe_b64encode(b).rstrip(b"=")
def mint_livekit(api_key, api_secret, identity, room, name, ttl=21600):
now = int(time.time())
header = {"alg": "HS256", "typ": "JWT"}
payload = {
"iss": api_key, "sub": identity, "name": name,
"nbf": now, "exp": now + ttl, "metadata": name,
"video": {"roomJoin": True, "room": room,
"canPublish": True, "canSubscribe": True,
"canPublishData": True},
}
segs = [_b64url(json.dumps(header, separators=(",", ":")).encode()),
_b64url(json.dumps(payload, separators=(",", ":")).encode())]
signing_input = b".".join(segs)
sig = hmac.new(api_secret.encode(), signing_input, hashlib.sha256).digest()
return (signing_input + b"." + _b64url(sig)).decode()
```
Verifikation lokal auf dem Pi (kein Gerät nötig): geminetes Token gegen den
laufenden LiveKit prüfen, z. B. per LiveKit-CLI `lk` oder indem man das JWT auf
jwt.io-Art dekodiert und Claims/Signatur vergleicht. Zusätzlich Gegentest mit dem
**alten** Client-Pfad: dasselbe Token muss `Room.connect` akzeptieren.
## Client-Seite
- `lib/core/livekit_token.dart`: `apiKey`/`apiSecret` **entfernen**. Die Klasse wird
entweder gelöscht oder zu einem dünnen HTTP-Client
`Future<String> fetchLiveKitToken({required String room, required String matrixToken})`,
der `POST https://dashboard.steggi-matrix.work/api/livekit-token` aufruft und das
`token`-Feld zurückgibt. `http`-Paket ist schon Abhängigkeit (siehe
`update_checker.dart`).
- `lib/core/livekit_call_manager.dart:154`: `LiveKitTokenGenerator.generate(...)`
→ `await fetchLiveKitToken(room: roomName, matrixToken: matrixClient.accessToken)`.
Der Manager hat den Matrix-Client bereits (`_matrixClient`, Zeile 149) der
Access-Token ist also verfügbar. `identity`/`displayName` werden nicht mehr vom
Client bestimmt (Server leitet Identität aus whoami ab); den bisherigen
`identity`-Parameter entsprechend zurückbauen.
- Fehlerpfade: Netzfehler / 401 / 403 sauber als Call-Fehler anzeigen
(`error = ...; notifyListeners()`), NICHT still schlucken sonst „Call verbindet
nicht" ohne Hinweis. TTL bleibt 21600 s (6 h), Erneuerung wie bisher beim
Neu-Beitreten.
## Rotation (Pflicht, danach)
Das alte `apiSecret` `rYUT2PRa…` ist geleakt (Client + Git-History). Nach der
Umstellung:
1. Neues `apiSecret` in `/home/steggi/matrix/livekit.yaml` (`keys:`) setzen.
2. LiveKit-Container neu starten
(`docker compose -f /home/steggi/matrix/docker-compose.yml restart livekit`).
3. `server.py` liest das Secret aus `livekit.yaml` kein zweiter Ort zu pflegen.
4. Alte Clients (Utas installierte APK) minten mit dem alten Secret → deren
selbst-gemintete Tokens werden nach der Rotation abgelehnt. Deshalb Rotation
**zusammen mit einem neuen Release** ausrollen, das den neuen Fetch-Pfad nutzt.
## Reihenfolge / Testplan (PC + Gerät)
1. `server.py`-Route bauen, lokal auf dem Pi verifizieren (whoami-Auth greift,
geminetes Token dekodiert korrekt, `Room.connect` akzeptiert es).
2. Client umstellen, `flutter analyze` sauber, `flutter test` grün.
3. **Auf echtem Gerät** (Calls heilig, kein Headless-Test möglich): Voice-Channel
beitreten, Audio/Video/Screenshare, Verlassen/Wieder-Beitreten, zweiter
Teilnehmer. Erst wenn das steht: Secret rotieren + Release.
4. Danach ROADMAP-M0-Punkt „LiveKit-Token server-seitig minten" abhaken und den
Rotations-Teil des Secret-Punkts als erledigt markieren.
## Warum nicht jetzt (auf dem Pi) gemacht
Die Server-Route ließe sich hier bauen und headless testen aber der zugehörige
Client-Umbau tauscht den **heiligen Call-Pfad** aus und ist auf diesem Pi ohne
GUI/Gerät nicht praktisch prüfbar. Einen halben, nicht end-to-end verifizierbaren
Umbau am Call-Pfad zu stapeln verbietet die CLAUDE.md-Leitplanke („keine
Verhaltensänderung ohne Not", Calls heilig). Deshalb hier nur der fertige Plan.
+7
View File
@@ -8,6 +8,13 @@ Punkt hier eintragen (✅/❌ + Datum), Befunde als eigene PROGRESS-Einträge.
Schnellster Weg: `flutter run -d windows`. Schnellster Weg: `flutter run -d windows`.
## 0. Vorab (1 Minute)
- [ ] `flutter test` die Regressionstests unter `test/` (Soft-Logout-Guard,
Power-Levels-Schutz, AuthLog, Storage-Fassade `settings_prefs`
Stand 2026-07-03: 24 Tests) müssen grün sein, bevor irgendetwas
anderes geprüft wird.
## 1. Härtetest Login/Krypto (M1 PFLICHT, zuerst!) ## 1. Härtetest Login/Krypto (M1 PFLICHT, zuerst!)
Deckt die Fixes 63e4919, 9dc83f7, 891f348 ab (Uta-Random-Logout + Guard). Deckt die Fixes 63e4919, 9dc83f7, 891f348 ab (Uta-Random-Logout + Guard).
+19 -19
View File
@@ -70,7 +70,7 @@ Continuwuity hat einen speziellen Admin-Raum. Als Server-Admin kannst du dort Be
# Als curl (Admin-Token erforderlich): # Als curl (Admin-Token erforderlich):
# Admin-Room-ID herausfinden: # Admin-Room-ID herausfinden:
curl -s "https://steggi-matrix.work/_matrix/client/v3/directory/room/%23admins%3Asteggi-matrix.work" \ curl -s "https://steggi-matrix.work/_matrix/client/v3/directory/room/%23admins%3Asteggi-matrix.work" \
-H "Authorization: Bearer J5laxTz5Zte9oRB5x8QsyYRQ6b0hAuXI" -H "Authorization: Bearer <ADMIN_TOKEN>"
# → {"room_id": "!adminRoomId:steggi-matrix.work", ...} # → {"room_id": "!adminRoomId:steggi-matrix.work", ...}
``` ```
@@ -84,22 +84,22 @@ curl -s "https://steggi-matrix.work/_matrix/client/v3/directory/room/%23admins%3
# User zu Server-Admin machen # User zu Server-Admin machen
curl -X POST "https://steggi-matrix.work/_conduwuit/admin/v1/make_user_admin" \ curl -X POST "https://steggi-matrix.work/_conduwuit/admin/v1/make_user_admin" \
-H "Authorization: Bearer J5laxTz5Zte9oRB5x8QsyYRQ6b0hAuXI" \ -H "Authorization: Bearer <ADMIN_TOKEN>" \
-H "Content-Type: application/json" \ -H "Content-Type: application/json" \
-d '{"user_id": "@todesneutron:steggi-matrix.work"}' -d '{"user_id": "@todesneutron:steggi-matrix.work"}'
# Server-Status abfragen # Server-Status abfragen
curl "https://steggi-matrix.work/_conduwuit/server/version" \ curl "https://steggi-matrix.work/_conduwuit/server/version" \
-H "Authorization: Bearer J5laxTz5Zte9oRB5x8QsyYRQ6b0hAuXI" -H "Authorization: Bearer <ADMIN_TOKEN>"
# Alternativ — Standard Matrix-Whoami (prüft ob Token gültig): # Alternativ — Standard Matrix-Whoami (prüft ob Token gültig):
curl "https://steggi-matrix.work/_matrix/client/v3/account/whoami" \ curl "https://steggi-matrix.work/_matrix/client/v3/account/whoami" \
-H "Authorization: Bearer J5laxTz5Zte9oRB5x8QsyYRQ6b0hAuXI" -H "Authorization: Bearer <ADMIN_TOKEN>"
# → {"user_id": "@todesneutron:steggi-matrix.work", "is_guest": false} # → {"user_id": "@todesneutron:steggi-matrix.work", "is_guest": false}
# Prüfen ob User Server-Admin ist (Conduwuit-spezifisch): # Prüfen ob User Server-Admin ist (Conduwuit-spezifisch):
curl "https://steggi-matrix.work/_conduwuit/admin/v1/users/@todesneutron:steggi-matrix.work" \ curl "https://steggi-matrix.work/_conduwuit/admin/v1/users/@todesneutron:steggi-matrix.work" \
-H "Authorization: Bearer J5laxTz5Zte9oRB5x8QsyYRQ6b0hAuXI" -H "Authorization: Bearer <ADMIN_TOKEN>"
``` ```
--- ---
@@ -112,7 +112,7 @@ Als Server-Admin kannst du via Matrix Client API Power Levels in jedem Raum setz
```bash ```bash
# Schritt 1: Aktuelles Power-Level-Event lesen # Schritt 1: Aktuelles Power-Level-Event lesen
ROOM_ID="!raumId:steggi-matrix.work" ROOM_ID="!raumId:steggi-matrix.work"
TOKEN="J5laxTz5Zte9oRB5x8QsyYRQ6b0hAuXI" TOKEN="<ADMIN_TOKEN>"
SERVER="https://steggi-matrix.work" SERVER="https://steggi-matrix.work"
curl "${SERVER}/_matrix/client/v3/rooms/${ROOM_ID}/state/m.room.power_levels/" \ curl "${SERVER}/_matrix/client/v3/rooms/${ROOM_ID}/state/m.room.power_levels/" \
@@ -172,7 +172,7 @@ await client.setRoomStateWithKey(
```bash ```bash
# Testen ob restricted join rules funktionieren: # Testen ob restricted join rules funktionieren:
ROOM_ID="!testRaum:steggi-matrix.work" ROOM_ID="!testRaum:steggi-matrix.work"
TOKEN="J5laxTz5Zte9oRB5x8QsyYRQ6b0hAuXI" TOKEN="<ADMIN_TOKEN>"
SPACE_ID="!spaceId:steggi-matrix.work" SPACE_ID="!spaceId:steggi-matrix.work"
curl -X PUT "https://steggi-matrix.work/_matrix/client/v3/rooms/${ROOM_ID}/state/m.room.join_rules/" \ curl -X PUT "https://steggi-matrix.work/_matrix/client/v3/rooms/${ROOM_ID}/state/m.room.join_rules/" \
@@ -230,7 +230,7 @@ try {
```bash ```bash
# 1. Prüfen: Welchen Power Level hat mein User in diesem Raum? # 1. Prüfen: Welchen Power Level hat mein User in diesem Raum?
ROOM_ID="!raumId:steggi-matrix.work" ROOM_ID="!raumId:steggi-matrix.work"
TOKEN="J5laxTz5Zte9oRB5x8QsyYRQ6b0hAuXI" TOKEN="<ADMIN_TOKEN>"
curl "https://steggi-matrix.work/_matrix/client/v3/rooms/${ROOM_ID}/state/m.room.power_levels/" \ curl "https://steggi-matrix.work/_matrix/client/v3/rooms/${ROOM_ID}/state/m.room.power_levels/" \
-H "Authorization: Bearer ${TOKEN}" -H "Authorization: Bearer ${TOKEN}"
@@ -269,7 +269,7 @@ curl "https://steggi-matrix.work/_matrix/client/v3/capabilities" \
```bash ```bash
# Prüfen ob User Server-Admin ist: # Prüfen ob User Server-Admin ist:
curl "https://steggi-matrix.work/_matrix/client/v3/account/whoami" \ curl "https://steggi-matrix.work/_matrix/client/v3/account/whoami" \
-H "Authorization: Bearer J5laxTz5Zte9oRB5x8QsyYRQ6b0hAuXI" -H "Authorization: Bearer <ADMIN_TOKEN>"
# Auf Pi (SSH): User-Datenbank direkt abfragen (Conduwuit/Continuwuity nutzt RocksDB) # Auf Pi (SSH): User-Datenbank direkt abfragen (Conduwuit/Continuwuity nutzt RocksDB)
# Alternativ: Über Admin-Room-Befehl # Alternativ: Über Admin-Room-Befehl
@@ -278,7 +278,7 @@ curl "https://steggi-matrix.work/_matrix/client/v3/account/whoami" \
# Über HTTP (Conduwuit Admin-Endpoint): # Über HTTP (Conduwuit Admin-Endpoint):
curl -X POST "https://steggi-matrix.work/_conduwuit/admin/v1/make_user_admin" \ curl -X POST "https://steggi-matrix.work/_conduwuit/admin/v1/make_user_admin" \
-H "Authorization: Bearer J5laxTz5Zte9oRB5x8QsyYRQ6b0hAuXI" \ -H "Authorization: Bearer <ADMIN_TOKEN>" \
-H "Content-Type: application/json" \ -H "Content-Type: application/json" \
-d '{"user_id": "@todesneutron:steggi-matrix.work"}' -d '{"user_id": "@todesneutron:steggi-matrix.work"}'
``` ```
@@ -292,19 +292,19 @@ Da kein Synapse-Admin-API existiert, nutze die Standard-Matrix-Client-API mit Ad
```bash ```bash
# Raum beitreten (als Admin, auch wenn invite-only): # Raum beitreten (als Admin, auch wenn invite-only):
curl -X POST "https://steggi-matrix.work/_matrix/client/v3/join/!roomId%3Asteggi-matrix.work" \ curl -X POST "https://steggi-matrix.work/_matrix/client/v3/join/!roomId%3Asteggi-matrix.work" \
-H "Authorization: Bearer J5laxTz5Zte9oRB5x8QsyYRQ6b0hAuXI" \ -H "Authorization: Bearer <ADMIN_TOKEN>" \
-H "Content-Type: application/json" \ -H "Content-Type: application/json" \
-d '{}' -d '{}'
# User in Raum einladen: # User in Raum einladen:
curl -X POST "https://steggi-matrix.work/_matrix/client/v3/rooms/!roomId%3Asteggi-matrix.work/invite" \ curl -X POST "https://steggi-matrix.work/_matrix/client/v3/rooms/!roomId%3Asteggi-matrix.work/invite" \
-H "Authorization: Bearer J5laxTz5Zte9oRB5x8QsyYRQ6b0hAuXI" \ -H "Authorization: Bearer <ADMIN_TOKEN>" \
-H "Content-Type: application/json" \ -H "Content-Type: application/json" \
-d '{"user_id": "@user:steggi-matrix.work"}' -d '{"user_id": "@user:steggi-matrix.work"}'
# Space-Child-Relation setzen (Channel einem Space hinzufügen): # Space-Child-Relation setzen (Channel einem Space hinzufügen):
curl -X PUT "https://steggi-matrix.work/_matrix/client/v3/rooms/!spaceId%3Asteggi-matrix.work/state/m.space.child/!channelId%3Asteggi-matrix.work/" \ curl -X PUT "https://steggi-matrix.work/_matrix/client/v3/rooms/!spaceId%3Asteggi-matrix.work/state/m.space.child/!channelId%3Asteggi-matrix.work/" \
-H "Authorization: Bearer J5laxTz5Zte9oRB5x8QsyYRQ6b0hAuXI" \ -H "Authorization: Bearer <ADMIN_TOKEN>" \
-H "Content-Type: application/json" \ -H "Content-Type: application/json" \
-d '{ -d '{
"via": ["steggi-matrix.work"], "via": ["steggi-matrix.work"],
@@ -313,7 +313,7 @@ curl -X PUT "https://steggi-matrix.work/_matrix/client/v3/rooms/!spaceId%3Astegg
# Space-Parent-Relation setzen (im Channel-Raum): # Space-Parent-Relation setzen (im Channel-Raum):
curl -X PUT "https://steggi-matrix.work/_matrix/client/v3/rooms/!channelId%3Asteggi-matrix.work/state/m.space.parent/!spaceId%3Asteggi-matrix.work/" \ curl -X PUT "https://steggi-matrix.work/_matrix/client/v3/rooms/!channelId%3Asteggi-matrix.work/state/m.space.parent/!spaceId%3Asteggi-matrix.work/" \
-H "Authorization: Bearer J5laxTz5Zte9oRB5x8QsyYRQ6b0hAuXI" \ -H "Authorization: Bearer <ADMIN_TOKEN>" \
-H "Content-Type: application/json" \ -H "Content-Type: application/json" \
-d '{ -d '{
"via": ["steggi-matrix.work"], "via": ["steggi-matrix.work"],
@@ -322,11 +322,11 @@ curl -X PUT "https://steggi-matrix.work/_matrix/client/v3/rooms/!channelId%3Aste
# Alle Mitglieder eines Raums auflisten: # Alle Mitglieder eines Raums auflisten:
curl "https://steggi-matrix.work/_matrix/client/v3/rooms/!roomId%3Asteggi-matrix.work/members" \ curl "https://steggi-matrix.work/_matrix/client/v3/rooms/!roomId%3Asteggi-matrix.work/members" \
-H "Authorization: Bearer J5laxTz5Zte9oRB5x8QsyYRQ6b0hAuXI" -H "Authorization: Bearer <ADMIN_TOKEN>"
# Raum verlassen (als User, nicht löschen): # Raum verlassen (als User, nicht löschen):
curl -X POST "https://steggi-matrix.work/_matrix/client/v3/rooms/!roomId%3Asteggi-matrix.work/leave" \ curl -X POST "https://steggi-matrix.work/_matrix/client/v3/rooms/!roomId%3Asteggi-matrix.work/leave" \
-H "Authorization: Bearer J5laxTz5Zte9oRB5x8QsyYRQ6b0hAuXI" \ -H "Authorization: Bearer <ADMIN_TOKEN>" \
-H "Content-Type: application/json" \ -H "Content-Type: application/json" \
-d '{}' -d '{}'
``` ```
@@ -419,15 +419,15 @@ Wenn du M_FORBIDDEN bekommst:
```bash ```bash
# 1. Token gültig? # 1. Token gültig?
curl "https://steggi-matrix.work/_matrix/client/v3/account/whoami" \ curl "https://steggi-matrix.work/_matrix/client/v3/account/whoami" \
-H "Authorization: Bearer J5laxTz5Zte9oRB5x8QsyYRQ6b0hAuXI" -H "Authorization: Bearer <ADMIN_TOKEN>"
# 2. PL im betroffenen Raum prüfen: # 2. PL im betroffenen Raum prüfen:
curl "https://steggi-matrix.work/_matrix/client/v3/rooms/!ROOM_ID/state/m.room.power_levels/" \ curl "https://steggi-matrix.work/_matrix/client/v3/rooms/!ROOM_ID/state/m.room.power_levels/" \
-H "Authorization: Bearer J5laxTz5Zte9oRB5x8QsyYRQ6b0hAuXI" -H "Authorization: Bearer <ADMIN_TOKEN>"
# 3. Mitglied des Raums? # 3. Mitglied des Raums?
curl "https://steggi-matrix.work/_matrix/client/v3/rooms/!ROOM_ID/state/m.room.member/%40todesneutron%3Asteggi-matrix.work/" \ curl "https://steggi-matrix.work/_matrix/client/v3/rooms/!ROOM_ID/state/m.room.member/%40todesneutron%3Asteggi-matrix.work/" \
-H "Authorization: Bearer J5laxTz5Zte9oRB5x8QsyYRQ6b0hAuXI" -H "Authorization: Bearer <ADMIN_TOKEN>"
# 4. Was genau schlägt fehl? (Antwort-Body lesen!) # 4. Was genau schlägt fehl? (Antwort-Body lesen!)
# M_FORBIDDEN + "Power level too low" → PL erhöhen # M_FORBIDDEN + "Power level too low" → PL erhöhen
+13 -4
View File
@@ -7,6 +7,9 @@ import 'package:shared_preferences/shared_preferences.dart';
class BoolPref extends StateNotifier<bool> { class BoolPref extends StateNotifier<bool> {
final String _key; final String _key;
// Guards against the load race: if set()/toggle() ran before _load's read
// lands, the stale persisted value must not overwrite the user's choice.
bool _userSet = false;
BoolPref(this._key, bool defaultValue) : super(defaultValue) { BoolPref(this._key, bool defaultValue) : super(defaultValue) {
_load(defaultValue); _load(defaultValue);
@@ -14,10 +17,11 @@ class BoolPref extends StateNotifier<bool> {
Future<void> _load(bool def) async { Future<void> _load(bool def) async {
final p = await SharedPreferences.getInstance(); final p = await SharedPreferences.getInstance();
if (mounted) state = p.getBool(_key) ?? def; if (mounted && !_userSet) state = p.getBool(_key) ?? def;
} }
Future<void> set(bool v) async { Future<void> set(bool v) async {
_userSet = true;
state = v; state = v;
final p = await SharedPreferences.getInstance(); final p = await SharedPreferences.getInstance();
await p.setBool(_key, v); await p.setBool(_key, v);
@@ -30,9 +34,10 @@ class BoolPref extends StateNotifier<bool> {
class StringSetPref extends StateNotifier<Set<String>> { class StringSetPref extends StateNotifier<Set<String>> {
final String _key; final String _key;
late final Future<void> _loaded;
StringSetPref(this._key) : super({}) { StringSetPref(this._key) : super({}) {
_load(); _loaded = _load();
} }
Future<void> _load() async { Future<void> _load() async {
@@ -42,14 +47,18 @@ class StringSetPref extends StateNotifier<Set<String>> {
} }
Future<void> add(String v) async { Future<void> add(String v) async {
if (state.contains(v)) return; // Read-modify-write: ohne abgeschlossenes Laden würde hier der halbleere
// Start-State zurückgeschrieben und persistierte Einträge gingen verloren.
await _loaded;
if (!mounted || state.contains(v)) return;
state = {...state, v}; state = {...state, v};
final p = await SharedPreferences.getInstance(); final p = await SharedPreferences.getInstance();
await p.setStringList(_key, state.toList()); await p.setStringList(_key, state.toList());
} }
Future<void> remove(String v) async { Future<void> remove(String v) async {
if (!state.contains(v)) return; await _loaded;
if (!mounted || !state.contains(v)) return;
state = {...state}..remove(v); state = {...state}..remove(v);
final p = await SharedPreferences.getInstance(); final p = await SharedPreferences.getInstance();
await p.setStringList(_key, state.toList()); await p.setStringList(_key, state.toList());
@@ -428,7 +428,11 @@ class _EncryptionSectionState extends ConsumerState<_EncryptionSection> {
final notifier = ref.read(e2eeDiagnosticsProvider.notifier); final notifier = ref.read(e2eeDiagnosticsProvider.notifier);
final msg = await notifier.upload( final msg = await notifier.upload(
serverUrl: 'https://dashboard.steggi-matrix.work', serverUrl: 'https://dashboard.steggi-matrix.work',
token: 'J5laxTz5Zte9oRB5x8QsyYRQ6b0hAuXI', // Eng begrenzter Diagnose-Token (haengt nur an den Diagnose-Log an,
// KEINE Admin-Rechte). Bewusst NICHT der Matrix-Server-Admin-Token
// der darf niemals im Client landen. Passendes Gegenstueck in
// /home/steggi/matrix/server.py (DIAG_TOKEN) auf dem Pi.
token: 'pyr-diag-6AyELgODRv-4rF4dcau3kSa5YbgZqUcn',
); );
if (mounted) setState(() { _diagUploading = false; _diagMsgIsError = false; _diagMsg = msg; }); if (mounted) setState(() { _diagUploading = false; _diagMsgIsError = false; _diagMsg = msg; });
} catch (e) { } catch (e) {
+7 -7
View File
@@ -801,10 +801,10 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: meta name: meta
sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394" sha256: "1741988757a65eb6b36abe716829688cf01910bbf91c34354ff7ec1c3de2b349"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.17.0" version: "1.18.0"
mime: mime:
dependency: "direct main" dependency: "direct main"
description: description:
@@ -918,7 +918,7 @@ packages:
source: hosted source: hosted
version: "2.2.1" version: "2.2.1"
path_provider_platform_interface: path_provider_platform_interface:
dependency: transitive dependency: "direct dev"
description: description:
name: path_provider_platform_interface name: path_provider_platform_interface
sha256: "88f5779f72ba699763fa3a3b06aa4bf6de76c8e5de842cf6f29e2e06476c2334" sha256: "88f5779f72ba699763fa3a3b06aa4bf6de76c8e5de842cf6f29e2e06476c2334"
@@ -1294,18 +1294,18 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: test_api name: test_api
sha256: "8161c84903fd860b26bfdefb7963b3f0b68fee7adea0f59ef805ecca346f0c7a" sha256: "949a932224383300f01be9221c39180316445ecb8e7547f70a41a35bf421fb9e"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "0.7.10" version: "0.7.11"
test_core: test_core:
dependency: transitive dependency: transitive
description: description:
name: test_core name: test_core
sha256: "0381bd1585d1a924763c308100f2138205252fb90c9d4eeaf28489ee65ccde51" sha256: "1991d4cfe85d5043241acac92962c3977c8d2f2add1ee73130c7b286417d1d34"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "0.6.16" version: "0.6.17"
timezone: timezone:
dependency: transitive dependency: transitive
description: description:
+4
View File
@@ -82,6 +82,10 @@ dev_dependencies:
sdk: flutter sdk: flutter
flutter_lints: ^6.0.0 flutter_lints: ^6.0.0
flutter_launcher_icons: ^0.13.1 flutter_launcher_icons: ^0.13.1
# Nur für Tests: offizieller Weg, path_provider ohne echte Plattform zu
# mocken (PathProviderPlatform.instance überschreiben). War schon transitiv
# im Lockfile, kein neues externes Paket.
path_provider_platform_interface: ^2.1.2
dependency_overrides: dependency_overrides:
webcrypto: webcrypto:
+11 -1
View File
@@ -11,7 +11,17 @@ Set-Location (Split-Path $PSScriptRoot -Parent)
# ── Config ──────────────────────────────────────────────────────────────────── # ── Config ────────────────────────────────────────────────────────────────────
$GiteaBase = "http://192.168.178.71:3000" $GiteaBase = "http://192.168.178.71:3000"
$GiteaRepo = "steggi/pyramid" $GiteaRepo = "steggi/pyramid"
$GiteaToken = "bb522f9646c6856c9ab58bef0151ac36a9ce1d57" # Token NIE hier eintragen (CLAUDE.md: keine Secrets ins Repo). Er kommt aus der
# Umgebungsvariable PYRAMID_GITEA_TOKEN einmalig pro PC setzen mit:
# [Environment]::SetEnvironmentVariable("PYRAMID_GITEA_TOKEN", "<token>", "User")
# (neues Terminal öffnen, damit die Variable sichtbar ist)
$GiteaToken = $env:PYRAMID_GITEA_TOKEN
if ([string]::IsNullOrWhiteSpace($GiteaToken)) {
Write-Error ("PYRAMID_GITEA_TOKEN ist nicht gesetzt. Einmalig setzen mit:`n" +
' [Environment]::SetEnvironmentVariable("PYRAMID_GITEA_TOKEN", "<token>", "User")' +
"`nDen Token in Gitea unter Einstellungen > Anwendungen erzeugen (Scope: repository).")
exit 1
}
$Headers = @{ Authorization = "token $GiteaToken"; "Content-Type" = "application/json" } $Headers = @{ Authorization = "token $GiteaToken"; "Content-Type" = "application/json" }
# ── Read and Update version from pubspec.yaml ───────────────────────────────── # ── Read and Update version from pubspec.yaml ─────────────────────────────────
+82
View File
@@ -0,0 +1,82 @@
import 'dart:io';
import 'package:flutter_test/flutter_test.dart';
import 'package:path_provider_platform_interface/path_provider_platform_interface.dart';
import 'package:pyramid/core/auth_log.dart';
/// Plattform kaputt/nicht verfügbar (z. B. sehr früher App-Start, exotischer
/// Fehler): genau der Fall, in dem AuthLog.write NIEMALS werfen darf der
/// Soft-Logout-Guard ruft es in seinem catch-Pfad auf, und ein Throw dort
/// würde im SDK zu logout()+clear() eskalieren (der Uta-Bug).
class _BrokenPathProvider extends PathProviderPlatform {
@override
Future<String?> getApplicationSupportPath() async =>
throw StateError('Plattform nicht verfügbar');
}
class _TempPathProvider extends PathProviderPlatform {
_TempPathProvider(this.path);
final String path;
@override
Future<String?> getApplicationSupportPath() async => path;
}
void main() {
// WICHTIG: Reihenfolge der Tests ist Absicht. AuthLog cached sein File-Handle
// statisch; der Kaputt-Plattform-Test muss laufen, BEVOR ein Test mit
// funktionierender Plattform das Handle in den Cache legt.
late Directory tempDir;
setUpAll(() {
tempDir = Directory.systemTemp.createTempSync('pyramid_auth_log_test');
});
tearDownAll(() {
tempDir.deleteSync(recursive: true);
});
test(
'write/read werfen NIE, auch wenn die Plattform fehlt '
'(CLAUDE.md: Fehlerpfade dürfen nie in einem stillen Logout enden)',
() async {
PathProviderPlatform.instance = _BrokenPathProvider();
// Darf normal zurückkehren, obwohl getApplicationSupportPath wirft.
await AuthLog.write('darf nicht werfen');
expect(await AuthLog.read(), startsWith('Log konnte nicht gelesen werden'));
});
test('read ohne bisherige Einträge liefert Platzhalter statt Fehler',
() async {
PathProviderPlatform.instance = _TempPathProvider(tempDir.path);
expect(await AuthLog.read(), '(noch keine Einträge)');
});
test('write hängt Zeile mit ISO-Zeitstempel an, read liefert sie zurück',
() async {
PathProviderPlatform.instance = _TempPathProvider(tempDir.path);
await AuthLog.write('Hallo Log');
final content = await AuthLog.read();
expect(content, contains('Hallo Log'));
expect(
RegExp(r'^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}').hasMatch(content),
isTrue,
reason: 'jede Zeile beginnt mit einem ISO-8601-Zeitstempel',
);
});
test(
'Log wird auf 500 Zeilen gekappt (älteste fliegen raus), '
'damit es nie unbegrenzt wächst', () async {
PathProviderPlatform.instance = _TempPathProvider(tempDir.path);
for (var i = 0; i < 520; i++) {
await AuthLog.write('Zeile $i');
}
final lines = (await AuthLog.read()).trimRight().split('\n');
expect(lines.length, 500);
expect(lines.last, contains('Zeile 519'),
reason: 'neueste Einträge müssen überleben');
expect((await AuthLog.read()).contains('Hallo Log'), isFalse,
reason: 'ältester Eintrag (aus dem Test davor) muss weggekappt sein');
});
}
+170
View File
@@ -0,0 +1,170 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:matrix/matrix.dart';
import 'package:pyramid/features/spaces/space_admin_dialog.dart';
/// Fake-Client für `updatePowerLevelsSafely`: liefert einen vorgegebenen
/// Server-Stand des power_levels-Events und zeichnet auf, was (wenn
/// überhaupt) zurückgeschrieben wird. Alles andere NoSuchMethodError.
class _FakeClient implements Client {
_FakeClient({required this.serverState, this.getError});
Map<String, Object?> serverState;
MatrixException? getError;
Map<String, Object?>? written;
@override
String? get userID => '@me:pyramid.example';
@override
Future<Map<String, Object?>> getRoomStateWithKey(
String roomId,
String eventType,
String stateKey, {
Format? format,
}) async {
final err = getError;
if (err != null) throw err;
return serverState;
}
@override
Future<String> setRoomStateWithKey(
String roomId,
String eventType,
String stateKey,
Map<String, Object?> body,
) async {
written = body;
return r'$neuesEvent';
}
@override
dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation);
}
class _FakeRoom implements Room {
_FakeRoom(this.client);
@override
final Client client;
@override
String get id => '!raum:pyramid.example';
@override
dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation);
}
void main() {
test('Server-Stand bleibt erhalten: fremde Einträge werden nie überschrieben '
'(genau der Bug, der schon einmal Admins ausgesperrt hat)', () async {
final client = _FakeClient(serverState: {
'users': {'@me:pyramid.example': 100, '@uta:pyramid.example': 50},
'users_default': 0,
'state_default': 50,
});
final room = _FakeRoom(client);
await updatePowerLevelsSafely(room, (content) {
(content['users'] as Map<String, dynamic>)['@neu:pyramid.example'] = 50;
});
final users = client.written!['users'] as Map;
expect(users['@uta:pyramid.example'], 50,
reason: 'bestehender Server-Eintrag muss überleben');
expect(users['@me:pyramid.example'], 100);
expect(users['@neu:pyramid.example'], 50);
});
test('Selbst-Degradierung unter das nötige Level wird abgebrochen, '
'nichts wird geschrieben', () async {
final client = _FakeClient(serverState: {
'users': {'@me:pyramid.example': 100},
'state_default': 50,
});
final room = _FakeRoom(client);
await expectLater(
updatePowerLevelsSafely(room, (content) {
(content['users'] as Map<String, dynamic>)['@me:pyramid.example'] = 0;
}),
throwsException,
);
expect(client.written, isNull);
});
test('eigener Eintrag entfernt → Fallback auf users_default greift und '
'schützt ebenfalls', () async {
final client = _FakeClient(serverState: {
'users': {'@me:pyramid.example': 100},
'users_default': 0,
});
final room = _FakeRoom(client);
await expectLater(
updatePowerLevelsSafely(room, (content) {
(content['users'] as Map<String, dynamic>)
.remove('@me:pyramid.example');
}),
throwsException,
);
expect(client.written, isNull);
});
test('explizites events[m.room.power_levels]-Erfordernis wird respektiert',
() async {
final client = _FakeClient(serverState: {
'users': {'@me:pyramid.example': 60},
'events': {'m.room.power_levels': 75},
'state_default': 50,
});
final room = _FakeRoom(client);
// 60 < 75 muss abbrechen, obwohl state_default (50) erfüllt wäre.
await expectLater(
updatePowerLevelsSafely(room, (_) {}),
throwsException,
);
expect(client.written, isNull);
});
test('fehlendes power_levels-Event (M_NOT_FOUND): Start mit leerem Event, '
'Schreiben nur wenn man sich selbst hoch genug setzt', () async {
final client = _FakeClient(
serverState: {},
getError: MatrixException.fromJson(
{'errcode': 'M_NOT_FOUND', 'error': 'Event not found.'}),
);
final room = _FakeRoom(client);
// Ohne eigenen Eintrag: users_default 0 < state_default-Fallback 50 Abbruch.
await expectLater(
updatePowerLevelsSafely(room, (_) {}),
throwsException,
);
expect(client.written, isNull);
// Setzt man sich selbst auf 100, darf geschrieben werden.
await updatePowerLevelsSafely(room, (content) {
(content['users'] as Map<String, dynamic>)['@me:pyramid.example'] = 100;
});
final users = client.written!['users'] as Map;
expect(users['@me:pyramid.example'], 100);
});
test('andere Serverfehler (z. B. M_FORBIDDEN) werden durchgereicht, '
'nichts wird geschrieben', () async {
final client = _FakeClient(
serverState: {},
getError: MatrixException.fromJson(
{'errcode': 'M_FORBIDDEN', 'error': 'Nope.'}),
);
final room = _FakeRoom(client);
await expectLater(
updatePowerLevelsSafely(room, (_) {}),
throwsA(isA<MatrixException>()),
);
expect(client.written, isNull);
});
}
+135
View File
@@ -0,0 +1,135 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:pyramid/core/settings_prefs.dart';
import 'package:shared_preferences/shared_preferences.dart';
/// Tests für die Storage-Fassade, die laut M2-Modulschnitt zur Pflicht-
/// Schnittstelle für alle Einstellungs-Zugriffe ausgebaut werden soll
/// dieses Vertragsnetz muss bei dem Umbau stehen bleiben.
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
setUp(() {
// Frischer In-Memory-Store pro Test (setzt auch die gecachte
// SharedPreferences-Instanz zurück).
SharedPreferences.setMockInitialValues({});
});
group('BoolPref', () {
test('lädt persistierten Wert und überstimmt damit den Default', () async {
SharedPreferences.setMockInitialValues({'notif_sound': false});
final pref = BoolPref('notif_sound', true);
expect(pref.state, true, reason: 'Default gilt, bis geladen ist');
await pumpEventQueue();
expect(pref.state, false, reason: 'persistierter Wert muss gewinnen');
});
test(
'set() sofort nach Erstellung gewinnt gegen den später ladenden '
'persistierten Wert (Lade-Race)', () async {
SharedPreferences.setMockInitialValues({'notif_sound': false});
final pref = BoolPref('notif_sound', false);
final done = pref.set(true); // bevor _load fertig ist
await pumpEventQueue();
await done;
expect(pref.state, true,
reason: 'die Nutzeraktion darf nicht vom Lade-Ergebnis '
'zurücküberschrieben werden');
final p = await SharedPreferences.getInstance();
expect(p.getBool('notif_sound'), true);
});
test('set/toggle ändern Zustand und persistieren', () async {
final pref = BoolPref('notif_desktop', true);
await pumpEventQueue();
pref.toggle();
await pumpEventQueue();
expect(pref.state, false);
final p = await SharedPreferences.getInstance();
expect(p.getBool('notif_desktop'), false);
});
});
group('StringSetPref', () {
test('lädt persistierte Liste als Set, add/remove persistieren', () async {
SharedPreferences.setMockInitialValues({
'muted_rooms': ['!a:hs', '!b:hs'],
});
final pref = StringSetPref('muted_rooms');
await pumpEventQueue();
expect(pref.state, {'!a:hs', '!b:hs'});
await pref.add('!c:hs');
await pref.remove('!a:hs');
final p = await SharedPreferences.getInstance();
expect(p.getStringList('muted_rooms')!.toSet(), {'!b:hs', '!c:hs'});
});
test(
'add() sofort nach Erstellung verliert keine bereits persistierten '
'Einträge (Lade-Race)', () async {
SharedPreferences.setMockInitialValues({
'muted_rooms': ['!a:hs'],
});
final pref = StringSetPref('muted_rooms');
final done = pref.add('!b:hs'); // bevor _load fertig ist
await pumpEventQueue();
await done;
expect(pref.state, {'!a:hs', '!b:hs'});
final p = await SharedPreferences.getInstance();
expect(p.getStringList('muted_rooms')!.toSet(), {'!a:hs', '!b:hs'},
reason: 'Read-Modify-Write auf ungeladenem State darf keine '
'persistierten Einträge verlieren');
});
test('doppeltes add und remove von Fehlendem sind No-Ops', () async {
final pref = StringSetPref('muted_rooms');
await pumpEventQueue();
await pref.add('!a:hs');
await pref.add('!a:hs');
await pref.remove('!gibtsnicht:hs');
expect(pref.state, {'!a:hs'});
});
});
group('Theme-Prefs', () {
test('leerer Store liefert die dokumentierten Defaults', () async {
final t = await loadThemePrefs();
expect(t.isDark, true);
expect(t.accentIdx, 0);
expect(t.radius, 12.0);
expect(t.motion, 1.0);
});
test('Roundtrip: gespeicherte Werte kommen zurück, Teil-Save lässt '
'den Rest unangetastet', () async {
await saveThemePrefs(isDark: false, accentIdx: 3, radius: 8, motion: 0.5);
var t = await loadThemePrefs();
expect((t.isDark, t.accentIdx, t.radius, t.motion), (false, 3, 8.0, 0.5));
// Nur ein Feld ändern die anderen dürfen nicht zurückfallen.
await saveThemePrefs(accentIdx: 1);
t = await loadThemePrefs();
expect((t.isDark, t.accentIdx, t.radius, t.motion), (false, 1, 8.0, 0.5));
});
});
group('Server-Banner', () {
test('Roundtrip + Entfernen per null/leerem mxcUri', () async {
await saveServerBanner('https://hs1', 'mxc://hs1/abc');
await saveServerBanner('https://hs2', 'mxc://hs2/def');
expect(await loadAllServerBanners(),
{'https://hs1': 'mxc://hs1/abc', 'https://hs2': 'mxc://hs2/def'});
await saveServerBanner('https://hs1', null);
await saveServerBanner('https://hs2', '');
expect(await loadAllServerBanners(), isEmpty);
});
test('korruptes JSON im Store crasht nicht, sondern liefert leere Map',
() async {
SharedPreferences.setMockInitialValues(
{'server_banners': '{kein json'});
expect(await loadAllServerBanners(), isEmpty);
});
});
}
+67
View File
@@ -0,0 +1,67 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:matrix/matrix.dart';
import 'package:pyramid/core/soft_logout_guard.dart';
/// Fake-Client: nur `refreshAccessToken` ist implementiert. Jeder andere
/// SDK-Aufruf (insbesondere `logout()`/`clear()`!) schlägt laut mit
/// NoSuchMethodError fehl so würde der Test es sofort merken, wenn der
/// Guard je etwas anderes am Client aufriefe als den Refresh.
class _FakeClient implements Client {
_FakeClient(this._onRefresh);
final Future<void> Function(int attempt) _onRefresh;
int attempts = 0;
@override
Future<void> refreshAccessToken({Duration? customRefreshTokenLifetime}) {
attempts++;
return _onRefresh(attempts);
}
@override
dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation);
}
void main() {
test('erfolgreicher Refresh beim ersten Versuch: genau 1 Aufruf', () async {
final client = _FakeClient((_) async {});
await guardedSoftLogoutRefresh(client);
expect(client.attempts, 1);
});
test(
'transienter Fehler (z. B. Netz weg / Token-Rotations-Race mit dem '
'Push-Isolate): Retry heilt, kein Fehler nach außen',
() async {
final client = _FakeClient((attempt) async {
if (attempt == 1) throw Exception('Netzwerkfehler');
});
await guardedSoftLogoutRefresh(client);
expect(client.attempts, 2);
},
);
test(
'dauerhafter Fehler: genau 3 Versuche, und der Guard wirft NIE '
'(CLAUDE.md: Fehlerpfade dürfen nie in einem stillen Logout enden '
'ein Throw hier würde im SDK zu logout()+clear() eskalieren)',
() async {
final client = _FakeClient((_) async {
throw Exception('Server dauerhaft nicht erreichbar');
});
// Darf trotz 3 Fehlversuchen normal (ohne Exception) zurückkehren.
await guardedSoftLogoutRefresh(client);
expect(client.attempts, 3);
},
// Der Guard wartet real 2s+4s zwischen den Versuchen.
timeout: const Timeout(Duration(seconds: 30)),
);
test('auch ein Error (nicht nur Exception) im Refresh entkommt nie', () async {
final client = _FakeClient((_) async {
throw StateError('unerwarteter Zustand');
});
await guardedSoftLogoutRefresh(client);
expect(client.attempts, 3);
}, timeout: const Timeout(Duration(seconds: 30)));
}