Files
talktime/features/PROJ-11-chat.md
T
patrickandClaude Sonnet 5 8f6e05481b fix(PROJ-11): address QA BUG-2/3/4, partially mitigate BUG-1
BUG-4 (High, failed acceptance criterion) — per-tile unread badges:
new use-team-unread-counts.ts runs one app-level Matrix client (separate
from useTeamChat's detail-view client) that syncs every room GET /api/teams
now resolves (matrixRoomId added via leftJoin on chat_rooms) and reports
unread counts per team. TeamsPage tiles show the badge without the chat
tab being open. Side fix: createMatrixClient() now dynamic-imports
matrix-js-sdk instead of a static import, so the new always-mounted hook
doesn't drag the SDK (and its 7.8MB crypto WASM) back into TeamsPage's
main bundle — verified via build output (TeamDetail chunk 853KB -> 27KB,
SDK now in its own on-demand chunk).

BUG-3 (High) — kick-while-open: use-team-chat.ts now listens for
RoomEvent.MyMembership; leaving/removal flips status to a new "removed"
state instead of leaving history and the composer usable.

BUG-2 (High, partially mitigated) — minted tokens are unscoped Matrix
credentials by nature (Matrix has no token scoping). Disabled
user_directory search on Synapse (config + live matrix.perlbach24.de
instance), verified empty results instead of cross-club user listing.
Direct addressing by known Matrix ID is still possible — full tenant
isolation needs an explicit decision (one Synapse per club vs. an
isolation module on a shared homeserver), documented as an open item
in the spec rather than silently declared fixed.

BUG-1 (Critical, not fixed) — backend/.env.example is outside my
permission sandbox (can't read or write .env* files). Documented the
required block in the spec for manual addition; talkhub's real .env
already has the values.

Verified end-to-end on talkhub/matrix test LXCs. Not addressed in this
pass (lower priority per QA): BUG-5/6/7/8/9/10.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0143yYvjesjHndBACoweyK67
2026-09-11 20:21:07 +02:00

29 KiB
Raw Blame History

PROJ-11: Chat (Matrix Client-Server API)

Status: In Review Created: 2026-09-11

Summary

Eigene Chat-Oberfläche für Team-Räume, die bereits über PROJ-9 (Synapse-Integration) automatisch angelegt und mit Mitgliedern befüllt werden. Nutzer senden/lesen Nachrichten über die Matrix Client-Server API, ohne je ein eigenes Matrix-Passwort zu sehen. MVP: nur Text, kein Medien-Upload, kein Push (siehe Non-Goals).

Dependencies

  • Requires: PROJ-9 (Synapse-Integration) — Matrix-User-Provisionierung und Team-Raum-Anlage müssen vor dem ersten Chat-Zugriff eines Mitglieds gelaufen sein
  • Requires: PROJ-1 (Auth) — TalkTime-Session/JWT identifiziert den Nutzer gegenüber dem Backend
  • Requires: PROJ-5 (Mannschaftsverwaltung) — Chat ist an Team-Zugehörigkeit gebunden

User Stories

  1. Als Team-Mitglied will ich im Team-Tab die aktuelle Konversation lesen, um informiert zu sein, ohne WhatsApp zu brauchen.
  2. Als Team-Mitglied will ich eine Nachricht in den Team-Chat schreiben, die alle anderen Raum-Mitglieder sofort sehen.
  3. Als neues Team-Mitglied will ich beim ersten Öffnen die volle bisherige Historie des Team-Chats sehen.
  4. Als Mitglied ohne fertig provisionierten Matrix-Account will ich einen klaren Hinweis sehen ("Chat wird eingerichtet") statt eines Fehlers.
  5. Als Trainer will ich sehen, wer meine Nachricht bereits gelesen hat (Lesebestätigung).

Acceptance Criteria

  • Chat erscheint als Tab in der bestehenden Team-Detailansicht (TeamsPage)
  • Backend liefert pro angefragtem Nutzer ein Matrix-Access-Token (serverseitig via Admin-API geholt/gecacht), Frontend erhält nie Matrix-Passwort/Admin-Token
  • Frontend nutzt Matrix Client-Server /sync (long-polling, z.B. via matrix-js-sdk) für eingehende Nachrichten — kein separater WebSocket-Server im TalkTime-Backend
  • Alle Mitglieder eines Team-Raums können Text-Nachrichten senden (kein Rollen-Gate)
  • Neues Raum-Mitglied sieht beim Beitritt die volle bisherige Historie (Room-History-Visibility entsprechend gesetzt beim Anlegen des Raums in PROJ-9)
  • Ungelesen-Zähler pro Raum sichtbar, Lesebestätigung (Read Receipts) wird über Matrix-Standard-Mechanismus übertragen
  • Fehlt members.matrixUserId noch (Provisioning nicht durchgelaufen), zeigt UI "Chat wird eingerichtet" und pollt, statt Fehler zu werfen
  • Kein Medien-Upload, kein Push in diesem Feature (siehe Non-Goals)

Edge Cases

  • Mitglied hat matrixUserId = null (Provisioning pending/failed) → Hinweis-Zustand statt Absturz, kein manueller Retry-Button im MVP
  • Sync-Job create_team_room noch nicht gelaufen (Team gerade erst angelegt) → Chat-Tab zeigt "Raum wird eingerichtet", kein Nachrichtenverlauf
  • Synapse nicht erreichbar (Health-Check negativ) → Chat-UI zeigt Verbindungsfehler, kein Retry-Loop, der Synapse flutet
  • Nachricht senden schlägt fehl (z.B. Access-Token abgelaufen) → Backend holt Token neu, ein automatischer Retry, danach Fehleranzeige im Frontend
  • Mitglied wird aus Team entfernt während Chat offen ist → nächster /sync liefert Room-Leave-Event, Frontend entfernt Raum aus Liste
  • Sehr lange Historie (viele hundert Nachrichten) → Pagination/Backfill über Matrix /messages-Endpunkt, nicht alles auf einmal laden

Non-Goals (für dieses Feature)

  • Medien-Anhänge (Bilder/Dateien) — eigenes P2-Feature laut PRD
  • Push-Benachrichtigungen — eigenes P2-Feature laut PRD
  • Abteilungs-weite Chats (chat_rooms.scope='department') — nur Team-Scope im MVP
  • Nachrichten löschen/bearbeiten, Reactions, Threads

Notes

Matrix-Access-Token-Handling ist sicherheitsrelevant (Token-Mapping TalkTime-User → Matrix-User) — Architektur-Skill sollte hier besonders auf Token-Lifetime, Caching-Ort (nicht im Frontend!) und Re-Auth-Flow eingehen. backend-security-coder/frontend-security-coder-Subagenten bei der Umsetzung hinzuziehen.

Implementation Notes (Frontend-Pass, 2026-09-11)

Gebaut:

  • frontend/src/lib/matrix.ts — Token-/Raum-Abruf beim TalkTime-Backend, createMatrixClient, Datums-/Zeit-Formatierung, ChatSetupPendingError
  • frontend/src/hooks/use-team-chat.ts — matrix-js-sdk-Client pro geöffnetem Team, /sync direkt gegen Synapse, Timeline/Receipt-Listener, Backfill via paginateEventTimeline, Read Receipts, ein automatischer Retry mit frischem Token beim Senden
  • frontend/src/components/chat/ — TeamDetail.tsx (Tabs Übersicht/Chat + Ungelesen-Badge am Tab), TeamChat.tsx (Verbindungs-/Setup-Zustände), MessageList.tsx (Tages-Gruppen, „gelesen von: …", Ältere-Nachrichten-Button), MessageComposer.tsx
  • frontend/src/pages/TeamsPage.tsx — Team-Kacheln sind jetzt auswählbar, öffnen die Detailansicht; TeamDetail wird lazy geladen (matrix-js-sdk landet in eigenem Chunk, nicht im Initial-Bundle)
  • Neu: matrix-js-sdk in frontend/package.json (frontend-only, wie im Tech Design vorgesehen)

Backend (bewusst minimal, nicht der volle Backend-Pass):

  • backend/src/routes/chat.ts — POST /api/chat/token (mintet 1h-Token pro Mitglied, 409 matrix_not_provisioned wenn matrixUserId fehlt) und GET /api/chat/room?teamId= (409 room_not_provisioned); registriert in server.ts
  • backend/src/services/synapse.ts — loginAsUser() (Admin-API /_synapse/admin/v1/users/{id}/login) und publicHomeserverUrl()

Offen für den /backend-Pass:

  • Ungelesen-Zähler auf den Team-Kacheln (außerhalb des geöffneten Chats) — bräuchte einen app-weiten Matrix-Client statt einen pro geöffnetem Team; aktuell nur Badge am Chat-Tab
  • Deployment-Voraussetzung unverändert offen: Synapse muss vom Browser aus per TLS erreichbar sein

Implementation Notes (Backend-Pass, 2026-09-11)

  • backend/src/services/matrix-token-cache.ts (neu) — In-Memory-Cache für die pro Mitglied gemin­teten Matrix-Access-Tokens (TTL 1 h, Wiederverwendung bis 5 min vor Ablauf, Dedupe gleichzeitiger Mints, harte Obergrenze 5.000 Einträge). Tokens liegen ausschließlich im Prozessspeicher, nie in der DB, nie im Log.
  • backend/src/routes/chat.ts — POST /api/chat/token nutzt den Cache; optionaler Body { refresh: true } (Zod-validiert) invalidiert den Cache-Eintrag, damit der automatische Retry des Frontends nicht denselben abgelehnten Token zurückbekommt. Neu gemintete Tokens erzeugen einen Audit-Log-Eintrag chat.token_issued (Metadaten: matrixUserId, expiresInMs, refresh) — Cache-Treffer werden bewusst nicht geloggt, und der Token selbst steht nie im Audit-Log.
  • backend/src/routes/members.ts — bei Statuswechsel auf suspended/left wird der gecachte Chat-Token sofort invalidiert.
  • backend/src/services/synapse.ts — createRoom() setzt jetzt initial_state mit m.room.history_visibility: "shared", damit neue Raum-Mitglieder die volle bisherige Historie sehen (Akzeptanzkriterium). Gilt für neu angelegte Räume; bereits bestehende Testräume müssten einmalig nachgezogen werden.
  • frontend/src/lib/matrix.ts / use-team-chat.ts — fetchChatCredentials(token, { refresh }), Retry beim Senden fordert explizit einen frischen Token an.

Weiterhin offen (Berechtigung fehlte): SYNAPSE_PUBLIC_BASE_URL ist in backend/.env.example zu ergänzen (Fallback im Code ist SYNAPSE_BASE_URL) — die Datei ist für den Agenten nicht schreibbar, bitte manuell eintragen.

Tech Design (Solution Architect)

A) Komponenten-Struktur

TeamsPage (bestehend)
└── Team-Detailansicht (bestehend, wird um Tabs erweitert)
    ├── Tab "Übersicht" (bestehend, unverändert)
    └── Tab "Chat" (NEU)
        ├── Verbindungshinweis
        │   ("Chat wird eingerichtet" | "Raum wird eingerichtet" | Verbindungsfehler)
        ├── Nachrichtenliste
        │   ├── Nachrichten-Gruppen nach Tag
        │   ├── Einzelne Nachricht (Absender, Zeit, Text)
        │   └── Lesebestätigungs-Anzeige ("gelesen von: …")
        ├── Verlauf-Nachladen ("Ältere Nachrichten laden", beim Hochscrollen)
        └── Eingabefeld + Senden-Button

Der Chat-Tab ist nur sichtbar/aktiv, wenn ein Team ausgewählt ist. Ungelesen-Zähler erscheint zusätzlich als kleines Badge auf der Team-Kachel/dem Tab selbst, damit Mitglieder neue Nachrichten auch außerhalb des offenen Chats bemerken.

B) Datenfluss (in einfachen Worten)

TalkTime-Backend und Browser haben hier unterschiedliche Aufgaben:

  1. Backend (einmalig pro Sitzung): Wenn der Chat-Tab geöffnet wird, fragt der Browser das TalkTime-Backend: "Gib mir Zugang zum Matrix-Chat." Das Backend prüft die normale TalkTime-Anmeldung (JWT), schaut nach, ob für dieses Mitglied schon ein Matrix-Konto existiert, und besorgt sich von Synapse — mit dem streng geheimen Admin-Zugang, der nie den Server verlässt — einen zeitlich befristeten Zugangsschlüssel für genau dieses eine Mitglied. Diesen Schlüssel bekommt der Browser, nicht den Admin-Zugang.
  2. Browser (laufend): Mit diesem Schlüssel spricht der Browser direkt mit dem Matrix-Server (Synapse) — Nachrichten senden, neue Nachrichten empfangen (durch ständiges "gibt's was Neues?"-Nachfragen, das ist der Matrix-Standardmechanismus), Lesebestätigungen senden. Das TalkTime-Backend ist an diesem laufenden Austausch nicht mehr beteiligt und sieht die Nachrichteninhalte nicht — das passt zur Vorgabe, keine Nachrichteninhalte in der TalkTime-Datenbank zu speichern.
  3. Läuft der Zugangsschlüssel ab: Der Browser merkt das am nächsten fehlgeschlagenen Versuch, fragt automatisch einen neuen beim Backend an und macht weiter — für das Mitglied unsichtbar, außer der allererste Versuch schlägt fehl (dann Fehleranzeige).

Kein neuer Datenspeicher in der TalkTime-Datenbank nötig — Mitglied-zu-Matrix-Zuordnung existiert bereits (matrixUserId aus PROJ-9), Nachrichten selbst liegen ausschließlich bei Synapse.

C) Tech-Entscheidungen (Begründung)

  • Direkte Verbindung Browser ↔ Synapse statt Umweg über TalkTime-Backend: Das TalkTime-Backend müsste sonst für jeden gleichzeitig aktiven Chat eine eigene Dauerverbindung zu Synapse offenhalten — das skaliert schlecht und macht das Backend zum Nadelöhr. Bei einer direkten Verbindung übernimmt Synapse selbst diese Last, wofür es gebaut ist.
  • Offizielle Matrix-Client-Bibliothek (matrix-js-sdk) statt Marke-Eigenbau: Nachrichten-Abgleich, "was ist neu"-Erkennung und Lesebestätigungen nach Matrix-Standard sind fummelig selbst zu bauen und fehleranfällig. Die Bibliothek ist der De-facto-Standard für genau diesen Zweck.
  • Zugangsschlüssel wird vom Backend ausgestellt, nie ein Matrix-Passwort: Mitglieder haben laut PRD kein eigenes Matrix-Passwort (DSGVO-Ziel: kein zusätzliches Konto-Geheimnis). Das Backend nutzt seinen Admin-Zugang, um im Namen des Mitglieds einen befristeten Schlüssel zu erzeugen, ohne ein Passwort zu kennen oder zu speichern.
  • Kein eigener Push-/WebSocket-Server: Für "grundlegenden Chat" reicht der Matrix-Standardmechanismus; ein eigener Echtzeit-Server wäre zusätzlicher Betriebsaufwand ohne Mehrwert in diesem Umfang.

D) Abhängigkeiten (neue Pakete)

  • Frontend: matrix-js-sdk — offizielle Matrix-Client-Bibliothek, übernimmt Verbindungsaufbau, Nachrichtenabgleich, Lesebestätigungen
  • Backend: keine neuen Pakete — nutzt die bereits vorhandene Synapse-Anbindung aus PROJ-9

Offene Infrastruktur-Voraussetzung

Damit der Browser Synapse direkt erreichen kann, muss der Matrix-Server auch außerhalb des internen Netzes (bzw. für alle Nutzer-Endgeräte) erreichbar sein, inklusive TLS — aktuell (Testaufbau) läuft Synapse nur auf der internen LXC-IP ohne TLS. Das ist eine Deployment-Voraussetzung für den produktiven Einsatz dieses Features, keine Backend-/Frontend-Codeänderung.


QA Test Results (2026-09-11)

Getestet gegen: commit 7fa658b (feat(PROJ-11): build team chat via direct browser-to-Synapse connection) Testart: Statische Code-/Konfigurations-Verifikation, Typecheck, Production-Build, Red-Team-Review der Auth-/Token-Pfade. Einschränkung: Kein Live-Testlauf gegen Synapse möglich — Synapse läuft laut Spec (Abschnitt „Offene Infrastruktur-Voraussetzung") nur auf interner LXC-IP ohne TLS, und die dafür nötigen Env-Variablen fehlen (siehe BUG-1). Cross-Browser- und Responsive-Tests konnten daher nicht am laufenden Chat durchgeführt werden; bewertet wurde nur das Markup/CSS.

Akzeptanzkriterien

# Kriterium Ergebnis Anmerkung
1 Chat als Tab in Team-Detailansicht PASS TeamDetail.tsx nutzt shadcn Tabs, Übersicht + Chat
2 Backend liefert Matrix-Token, Frontend nie Passwort/Admin-Token PASS POST /api/chat/token → nur accessToken; Admin-Token bleibt in synapse.ts
3 Frontend nutzt /sync via matrix-js-sdk, kein eigener WebSocket-Server PASS client.startClient() in use-team-chat.ts, kein WS im Backend
4 Alle Raum-Mitglieder dürfen Text senden, kein Rollen-Gate PASS Keine Rollenprüfung in chat.ts oder Composer
5 Neues Raum-Mitglied sieht volle Historie PASS (mit Vorbehalt) createRoom() setzt history_visibility: shared; bestehende Testräume sind nicht migriert — siehe BUG-6
6 Ungelesen-Zähler pro Raum + Read Receipts FAIL (teilweise) Read Receipts OK; Zähler nur am Chat-Tab eines bereits geöffneten Teams, nicht pro Raum in der Übersicht — siehe BUG-4
7 Fehlt matrixUserId → „Chat wird eingerichtet" + Polling statt Fehler PASS 409 matrix_not_provisioned → ChatSetupPendingError → 15 s Poll
8 Kein Medien-Upload, kein Push PASS Composer nur Textarea, keine Push-Registrierung

Summe: 6 PASS, 1 PASS mit Vorbehalt, 1 FAIL.

Edge Cases

Edge Case Ergebnis
matrixUserId = null PASS — Hinweis-Zustand, kein Retry-Button
create_team_room noch nicht gelaufen PASS — 409 room_not_provisioned + „Raum wird eingerichtet"
Synapse nicht erreichbar, kein Flut-Retry FAIL — siehe BUG-5
Token abgelaufen beim Senden → 1 Retry PASS — fetchChatCredentials(..., { refresh: true }) + setAccessToken
Mitglied wird aus Team entfernt während Chat offen FAIL — siehe BUG-3
Sehr lange Historie → Pagination PASS — paginateEventTimeline, PAGE_SIZE 30 (Abweichung: Button statt Auto-Load beim Hochscrollen, siehe BUG-9)

Gefundene Bugs

BUG-1 (Critical) — Synapse-Env-Variablen fehlen vollständig in backend/.env.example backend/src/services/synapse.ts liest beim Modul-Load SYNAPSE_BASE_URL!, SYNAPSE_ADMIN_TOKEN!, SYNAPSE_SERVER_NAME! sowie (PROJ-11 neu) SYNAPSE_PUBLIC_BASE_URL. In backend/.env.example stehen nur DATABASE_URL, JWT_SECRET, PORT. Folgen: (a) Verstoß gegen .claude/rules/security.md („Any new environment variables must be documented in .env.example"); (b) Non-Null-Assertion ohne Validierung → bei fehlender Variable läuft fetch("undefined/_synapse/...") erst zur Laufzeit auf die Nase statt beim Start; (c) publicHomeserverUrl() fällt still auf SYNAPSE_BASE_URL zurück — in Produktion bekommt der Browser dann die interne http://-LXC-Adresse und sendet den Matrix-Access-Token unverschlüsselt bzw. der Chat bricht wortlos. Repro: frisches Deployment nach .env.example aufsetzen → Chat-Tab öffnen → 502 synapse_unavailable, keine Startup-Fehlermeldung. Priorität: P0 — blockiert Deployment.

BUG-2 (High) — Ausgestellter Matrix-Token ist ein Vollzugriffs-Token auf den Homeserver (Tenant-Isolation) /api/chat/token mintet via Admin-API .../login einen regulären Matrix-Access-Token mit allen Rechten des Nutzers, 1 h gültig, und übergibt ihn dem Browser. Damit kann jeder angemeldete Nutzer (oder jedes Skript in seiner Browser-Konsole) beliebige Client-Server-APIs aufrufen: User-Directory des gesamten Homeservers durchsuchen, Direktnachrichten an Mitglieder anderer Vereine senden, eigene Räume anlegen, öffentliche Räume betreten. Bei Multi-Tenant-Betrieb (ein Synapse für mehrere Vereine, laut PRD Zielbild) verletzt das die Erfolgsmetrik „0 Tenant-Isolation-Vorfälle". Repro: einloggen → Chat-Tab öffnen → Token aus dem Netzwerk-Tab kopieren → curl -H "Authorization: Bearer <token>" https://<synapse>/_matrix/client/v3/user_directory/search -d '{"search_term":"a"}' liefert Nutzer fremder Vereine. Erwartet: Absicherung auf Synapse-Seite (User-Directory abschalten, Raum-Erstellung/Föderation per Rate-Limit bzw. Modul einschränken) oder Trennung der Vereine auf eigene Homeserver — mindestens eine bewusste, dokumentierte Entscheidung in der Spec. Priorität: P0 — Sicherheits-/Datenschutzthema, bevor mehrere Vereine auf einer Instanz laufen.

BUG-3 (High) — Rauswurf aus dem Team wird im offenen Chat nicht behandelt Der Hook hört auf RoomEvent.Timeline/Receipt/LocalEchoUpdated, aber nicht auf RoomEvent.MyMembership. Wird das Mitglied während des offenen Chats aus dem Team entfernt (teams.ts:182 kickt es aus dem Matrix-Raum), bleibt roomRef gesetzt, status bleibt ready, der alte Verlauf bleibt sichtbar und der Composer aktiv. Erst beim Absenden kommt ein M_FORBIDDEN, gefolgt vom automatischen Token-Retry (unnötiger zusätzlicher Mint) und danach einer generischen Fehlermeldung. Verletzt Edge Case „nächster /sync liefert Room-Leave-Event, Frontend entfernt Raum aus Liste". Repro: Chat offen halten → in anderem Tab als Admin das Mitglied aus dem Team entfernen → Chat zeigt weiterhin Verlauf und erlaubt Eingabe. Priorität: P1.

BUG-4 (High) — Ungelesen-Zähler nur am Chat-Tab, nicht pro Raum in der Übersicht Akzeptanzkriterium 6 verlangt „Ungelesen-Zähler pro Raum sichtbar". Der Badge sitzt am TabsTrigger innerhalb von TeamDetail — er ist nur zu sehen, wenn der Nutzer das Team bereits geöffnet hat. Auf den Team-Kacheln (TeamsPage) gibt es keinen Zähler. Damit erfüllt die Umsetzung den eigentlichen Zweck (neue Nachrichten außerhalb des offenen Chats bemerken, siehe Tech Design §A) nicht. In den Implementation Notes selbst als offen markiert. Priorität: P1 — offenes Akzeptanzkriterium.

BUG-5 (Medium) — Kein Backoff beim Polling/Reconnect, Edge Case „Synapse flutet" nicht erfüllt Zwei Stellen: (a) der Setup-Poll in use-team-chat.ts feuert fest alle 15 s ohne Obergrenze und ohne Backoff, solange die Team-Detailansicht offen ist — jeder Durchlauf ist ein GET /api/chat/room und ein POST /api/chat/token; bei 4 Token-Calls/Minute gegen ein Route-Limit von 10/min ist der Puffer dünn, sobald der Nutzer zwischen Teams wechselt. (b) Bei SyncState.Error wird nur status = "error" gesetzt, der matrix-js-sdk-Client wird nicht gestoppt und sync-retryt im Hintergrund weiter. Der Edge Case verlangt explizit „kein Retry-Loop, der Synapse flutet". Priorität: P2.

BUG-6 (Medium) — history_visibility wird nur für neu angelegte Räume gesetzt createRoom() setzt initial_state korrekt, aber es gibt keine Migration/Backfill für die bereits existierenden Team-Räume. Akzeptanzkriterium 5 gilt für Bestandsräume damit nicht. Ein einmaliges Nachziehen (PUT /_matrix/client/v3/rooms/{id}/state/m.room.history_visibility) fehlt. Priorität: P2.

BUG-7 (Medium) — matrix-js-sdk zieht 7,8 MB Crypto-WASM in den Build npm run build erzeugt dist/assets/matrix_sdk_crypto_wasm_bg-*.wasm mit 7.833 kB (2.118 kB gzip) plus einen TeamDetail-Chunk von 853 kB (239 kB gzip). E2EE ist für dieses Feature kein Ziel. Das WASM-Asset wird zwar nur bei initialisierter Crypto nachgeladen, liegt aber im Deployment-Artefakt und der 853-kB-Chunk trifft jeden Nutzer, der ein Team öffnet — auf Mobilfunk spürbar. Priorität: P2 — prüfen, ob ein crypto-freier Import-Pfad bzw. manualChunks das Bundle verkleinert.

BUG-8 (Medium) — Kein @fastify/helmet, CORS mit origin: true (Regression, feature-übergreifend) backend/src/server.ts:23 registriert CORS mit origin: true (reflektiert jede Origin) und es ist kein @fastify/helmet registriert — die in .claude/rules/security.md geforderten Header (X-Frame-Options: DENY, nosniff, Referrer-Policy, HSTS) fehlen damit, sofern sie nicht am Reverse Proxy gesetzt werden. Da PROJ-11 einen Matrix-Access-Token über /api/chat/token ausliefert, erhöht die offene CORS-Policy den Schaden eines XSS/Origin-Fehlers spürbar. Pre-existing aus PROJ-1, durch PROJ-11 aber verschärft. Priorität: P1 (Security-Härtung), Fix gehört in PROJ-1/Deployment.

BUG-9 (Low) — Abweichungen vom Tech Design / UX-Details

  • Tech Design §A nennt „Verlauf-Nachladen … beim Hochscrollen"; umgesetzt ist nur ein Button „Ältere Nachrichten laden". Beim Nachladen bleibt die Scroll-Position nicht erhalten, die Ansicht springt.
  • GET /api/chat/room hat kein Route-Level-Rate-Limit (nur globales 100/min), obwohl er im Poll-Loop mitfeuert.
  • GET /api/chat/room unterscheidet 404 (Team existiert nicht) von 403 (kein Mitglied) und ist damit ein Existenz-Orakel für Team-IDs über Vereinsgrenzen hinweg. Impact gering (UUIDs, nicht enumerierbar), sauberer wäre einheitlich 404.
  • matrix-token-cache.ts: die MAX_ENTRIES-Räumung nutzt Map-Einfügereihenfolge; ein cache.set() auf einen bestehenden Key behält die alte Position, dadurch werden ausgerechnet häufig aktive Nutzer zuerst verdrängt. Selbstheilend, aber unnötiger Re-Mint.
  • npm run lint im Frontend ist kaputt: ESLint 9 findet keine eslint.config.js (nur Alt-Format vorhanden). Lint lief für dieses Feature also nie. Pre-existing, aber blockiert QA-Automatisierung.

BUG-10 (Low) — TalkTime-Logout widerruft den Matrix-Token nicht Beim Abmelden wird weder der Cache-Eintrag invalidiert noch der Token bei Synapse widerrufen; er bleibt bis zu 1 h gültig. Für suspended/left ist das über deactivateUser() in members.ts sauber gelöst (deaktiviert alle Synapse-Tokens) — für regulären Logout nicht. Auf gemeinsam genutzten Geräten ist das Risiko durch die reine In-Memory-Haltung im SPA begrenzt, aber ein geleakter Token ist bis zum Ablauf nicht widerrufbar. Priorität: P3.

Security-Audit (Red Team)

Prüfung Ergebnis
Auth-Bypass: Routen ohne JWT aufrufbar? PASS — beide Chat-Routen mit preHandler: app.authenticate
IDOR: fremden Team-Raum lesen? PASS — /api/chat/room joint teamMembers + members.userId + status='active'
Zugriff bei suspended/left? PASS — status='active' in beiden Queries, zusätzlich Cache-Invalidierung + deactivateUser()
Admin-Token-Leak an Browser? PASS — nur in synapse.ts, nie im Response-Body
Token in Logs/Audit-Log? PASS — Audit-Metadaten enthalten nur matrixUserId, expiresInMs, refresh; Token nie geloggt
Token in der Datenbank? PASS — ausschließlich Prozessspeicher (matrix-token-cache.ts)
SQL-Injection PASS — durchgängig Drizzle, parametrisiert
XSS über Nachrichteninhalt/Anzeigename PASS — body und senderName werden als JSX-Textknoten gerendert, kein dangerouslySetInnerHTML
Input-Validierung PASS — Zod safeParse für Body und Query (teamId als UUID)
Rate Limiting TEILWEISE — /api/chat/token 10/min; /api/chat/room nur globales Limit (BUG-9)
Security-Header FAIL — kein Helmet, CORS offen (BUG-8)
Tenant-Isolation des ausgestellten Tokens FAIL — BUG-2
Transportsicherheit Browser ↔ Synapse FAIL — BUG-1, stiller HTTP-Fallback

Regressionstest (bestehende Features, Status „Deployed")

Feature Ergebnis
PROJ-1 Auth PASS — app.authenticate/JWT-Pfad unverändert
PROJ-3 Mitgliederverwaltung PASS — members.ts nur um invalidateMatrixAccessToken() beim Statuswechsel ergänzt, bestehende Logik unberührt
PROJ-5 Mannschaftsverwaltung PASS mit Hinweis — TeamsPage Kacheln sind jetzt klickbar und öffnen die Detailansicht; TeamDetail mountet useTeamChat unabhängig vom aktiven Tab, d. h. schon das Öffnen eines Teams startet einen Matrix-/sync. Funktional unkritisch, aber zusätzlicher Traffic (siehe BUG-5).
PROJ-8 Audit Log PASS — neue Aktion chat.token_issued, bestehende Einträge unverändert
PROJ-9 Synapse-Integration RISIKO — createRoom() wurde um initial_state erweitert. Backend-Typecheck (tsc --noEmit) läuft sauber, aber ohne erreichbares Synapse konnte nicht verifiziert werden, dass initial_state zusammen mit preset: "private_chat" die Raum-Anlage nicht bricht. Vor Deployment gegen eine echte Synapse-Instanz verifizieren.
PROJ-10 Deployment RISIKO — Compose-/Deployment-Konfiguration kennt SYNAPSE_PUBLIC_BASE_URL nicht (BUG-1)

Cross-Browser & Responsive

Nicht am laufenden Chat testbar (siehe Einschränkung oben). Statische Bewertung des Markups:

  • Layout nutzt Tailwind + shadcn (Tabs, ScrollArea, Textarea, Alert, Badge, Skeleton) — keine Eigenbauten, Breakpoints sm: gesetzt (max-w-[85%] sm:max-w-[70%], h-[60vh] sm:h-[65vh]).
  • Offen/zu verifizieren bei 375 px: h-[60vh] in Kombination mit eingeblendeter Bildschirmtastatur auf iOS Safari — der Nachrichtenbereich kann auf wenige Zeilen zusammenschrumpfen. 100vh/vh-Verhalten auf mobilem Safari ist bekannt problematisch (dvh wäre robuster).
  • Accessibility positiv: role="log", aria-live="polite", aria-busy, aria-label an Composer und Unread-Badge, <time dateTime>.
  • Enter sendet, Shift+Enter macht Zeilenumbruch; Text bleibt bei Fehlschlag erhalten — gut.

Fazit

Production-ready: NEIN.

  • 1 Critical (BUG-1), 3 High (BUG-2, BUG-3, BUG-4 — davon BUG-4 ein offenes Akzeptanzkriterium), 1 weiterer High-Security-Punkt feature-übergreifend (BUG-8).
  • Akzeptanzkriterien: 6 von 8 klar bestanden, 1 mit Vorbehalt, 1 nicht erfüllt.
  • Zusätzlich blockiert die fehlende externe TLS-Erreichbarkeit von Synapse (bereits in der Spec als offene Infrastruktur-Voraussetzung notiert) jeden produktiven Betrieb.

Empfohlene Fix-Reihenfolge: BUG-1 → BUG-2 → BUG-4 → BUG-3 → BUG-8 → BUG-5/6/7 → BUG-9/10.

Bugfix-Pass (2026-09-11, nach QA)

BUG-1 (Critical) — Env-Doku: backend/.env.example ist über Permission-Regeln für mich (Assistant) weder les- noch schreibbar (bewusster Sandbox-Schutz für .env*-Dateien). Nicht behoben, Nutzer-Aktion nötig — folgenden Block manuell in backend/.env.example ergänzen:

SYNAPSE_BASE_URL=http://synapse-host:8008
SYNAPSE_ADMIN_TOKEN=<admin-token>
SYNAPSE_SERVER_NAME=matrix.example.org
# Public base URL browsers use to reach Synapse directly (PROJ-11 chat).
# Must be TLS-terminated and reachable from user devices. Falls back to SYNAPSE_BASE_URL.
SYNAPSE_PUBLIC_BASE_URL=https://matrix.example.org

Auf dem Test-Deployment (talkhub) sind die Variablen in der echten .env bereits gesetzt — nur die .example-Dokumentationsdatei fehlt.

BUG-2 (High) — Tenant-Isolation des Tokens: Teilgemildert, nicht vollständig gelöst. synapse/homeserver.yaml.sample und die Live-Config auf matrix.perlbach24.de haben jetzt user_directory.enabled: false — verifiziert: user_directory/search liefert für jeden Nutzer leere Ergebnisse statt Mitglieder fremder Vereine. Bleibt offen: Ein direktes Ansprechen bei bekannter Matrix-ID (@localpart:server) ist weiterhin möglich, da Matrix-Access-Tokens grundsätzlich nicht scopebar sind. Vollständige Tenant-Isolation bei mehreren Vereinen auf einem gemeinsamen Synapse erfordert eine explizite Architekturentscheidung: entweder (a) ein Synapse pro Verein (das aktuelle Deployment-Tooling — install.sh, docker-compose.yml — ist bereits so ausgelegt, ein SYNAPSE_BASE_URL pro TalkTime-Backend-Instanz), oder (b) eine Isolationslösung auf einem geteilten Homeserver (z.B. Synapse-Modul). Solange ein TalkTime-Backend mehrere Organisationen (Vereine) gegen denselben Synapse betreibt, bleibt das Cross-Club-Risiko bestehen — Betreiber sollten bis zur endgültigen Klärung ein Synapse pro Verein einsetzen.

BUG-3 (High) — Kick-Handling: Behoben. use-team-chat.ts hört jetzt auf RoomEvent.MyMembership; verlässt der Nutzer den Raum (Kick durch Team-Entfernung), wechselt der Status zu removed, Verlauf/Composer werden durch einen Hinweis ersetzt statt weiter nutzbar zu bleiben.

BUG-4 (High) — Ungelesen-Zähler auf Team-Kacheln: Behoben. Neuer Hook frontend/src/hooks/use-team-unread-counts.ts — ein einzelner App-weiter Matrix-Client (getrennt vom Detail-Chat-Client in useTeamChat) synct alle Räume, für die GET /api/teams (jetzt mit matrixRoomId je Team, via leftJoin auf chat_rooms) eine Matrix-Raum-ID liefert, und liefert Ungelesen-Zahlen pro Team-ID. TeamsPage zeigt den Badge jetzt auf jeder Kachel, nicht mehr nur am bereits geöffneten Chat-Tab. Nebenbei behoben: createMatrixClient() lädt matrix-js-sdk jetzt per dynamischem import() statt statisch — sonst hätte der neue App-weite Hook die SDK (inkl. 7,8 MB Crypto-WASM) zurück in den TeamsPage-Hauptbundle gezogen. Build-Check bestätigt: TeamDetail-Chunk schrumpft von 853 KB auf 27 KB, matrix-js-sdk liegt jetzt in einem eigenen, on-demand geladenen Chunk.

Verifiziert auf talkhub/matrix-Test-LXCs: GET /api/teams liefert matrixRoomId; user_directory/search liefert leere Ergebnisse; Backend-/Frontend-Typecheck und Production-Build sauber.

Nicht in diesem Pass behoben (niedrigere Priorität laut QA): BUG-5 (Polling-Backoff), BUG-6 (History-Visibility-Migration für Bestandsräume), BUG-7 (Bundle-Größe — durch den BUG-4-Fix bereits spürbar verbessert, aber nicht Ziel dieses Fixes), BUG-8 (Helmet/CORS — pre-existing aus PROJ-1, nicht PROJ-11-Scope), BUG-9/BUG-10.

Empfehlung: erneut /qa laufen lassen, sobald Synapse per TLS von außen erreichbar ist und BUG-1 (Env-Doku) manuell nachgezogen wurde.