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
This commit is contained in:
patrickandClaude Sonnet 5 committed 2026-09-11 20:21:07 +02:00
1 parent 7fa658b4ff
commit 8f6e05481b
9 files changed
+342 -7

No files matched your search

+4
View File
@@ -54,8 +54,12 @@ export async function teamRoutes(app: FastifyInstance) {
departmentId: teams.departmentId,
seasonId: teams.seasonId,
createdAt: teams.createdAt,
// Exposed so the frontend can run one app-level Matrix client for
// unread-badge tiles instead of minting a client per opened team (BUG-4).
matrixRoomId: chatRooms.matrixRoomId,
})
.from(teams)
.leftJoin(chatRooms, eq(chatRooms.teamId, teams.id))
.where(eq(teams.organizationId, organizationId))
.orderBy(teams.name);
+1 -1
View File
@@ -22,7 +22,7 @@
| PROJ-8 | Audit Log | Deployed | [PROJ-8-audit-log.md](PROJ-8-audit-log.md) | 2026-09-11 |
| PROJ-9 | Synapse-Integration (Health Check, User Provisioning, Raum-Sync) | Deployed | [PROJ-9-synapse-integration-health-check.md](PROJ-9-synapse-integration-health-check.md) | 2026-09-11 |
| PROJ-10 | Deployment (Docker Compose + Synapse Installer) | Deployed | [PROJ-10-deployment.md](PROJ-10-deployment.md) | 2026-09-11 |
| PROJ-11 | Chat (Matrix Client-Server API) | In Progress | [PROJ-11-chat.md](PROJ-11-chat.md) | 2026-09-11 |
| PROJ-11 | Chat (Matrix Client-Server API) | In Review | [PROJ-11-chat.md](PROJ-11-chat.md) | 2026-09-11 |
| PROJ-12 | Automatische Löschfrist für ausgetretene Mitglieder (DSGVO) | Planned | [PROJ-12-mitglieder-loeschfrist.md](PROJ-12-mitglieder-loeschfrist.md) | 2026-09-11 |
Noch nicht gestartet (siehe [docs/PRD.md](../docs/PRD.md) Core Features): dedizierte Tenant-Isolation-Tests. P2 (Medien-Anhänge, Push) bewusst aus PROJ-11 ausgeklammert, siehe dessen Non-Goals.
+159 -1
View File
@@ -1,6 +1,6 @@
# PROJ-11: Chat (Matrix Client-Server API)
**Status:** In Progress
**Status:** In Review
**Created:** 2026-09-11
## Summary
@@ -117,3 +117,161 @@ Kein neuer Datenspeicher in der TalkTime-Datenbank nötig — Mitglied-zu-Matrix
### 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.
+11
View File
@@ -38,6 +38,17 @@ export function TeamChat({ chat, teamName }: TeamChatProps) {
);
}
if (status === "removed") {
return (
<Alert>
<AlertTitle>Kein Zugriff mehr</AlertTitle>
<AlertDescription>
Du wurdest aus diesem Team entfernt und hast keinen Zugriff mehr auf den Chat.
</AlertDescription>
</Alert>
);
}
if (status === "error") {
return (
<Alert variant="destructive">
+15 -2
View File
@@ -27,7 +27,7 @@ export interface ChatMessage {
readBy: string[];
}
export type ChatStatus = "loading" | "pending" | "error" | "ready";
export type ChatStatus = "loading" | "pending" | "error" | "ready" | "removed";
interface UseTeamChatResult {
status: ChatStatus;
@@ -121,13 +121,26 @@ export function useTeamChat(authToken: string | null, teamId: string | null): Us
const creds = await fetchChatCredentials(authToken!);
if (cancelled) return;
client = createMatrixClient(creds);
client = await createMatrixClient(creds);
if (cancelled) {
client.stopClient();
return;
}
clientRef.current = client;
setOwnUserId(creds.userId);
client.on(RoomEvent.Timeline, onTimeline);
client.on(RoomEvent.Receipt, onTimeline);
client.on(RoomEvent.LocalEchoUpdated, onTimeline);
client.on(RoomEvent.MyMembership, (room: Room, membership: string) => {
// Kicked/removed from the team while the chat was open (teams.ts calls
// removeUserFromRoom on team removal) — freeze the view instead of
// leaving a stale timeline + working composer up.
if (cancelled || room.roomId !== roomId) return;
if (membership !== "join") {
setStatus("removed");
}
});
client.on(ClientEvent.Sync, (state: SyncState) => {
if (cancelled) return;
if (state === SyncState.Prepared || state === SyncState.Syncing) {
@@ -0,0 +1,115 @@
import { useEffect, useRef, useState } from "react";
import type { MatrixClient } from "matrix-js-sdk";
import { ChatSetupPendingError, createMatrixClient, fetchChatCredentials } from "@/lib/matrix";
// Raw event/state names instead of importing RoomEvent/ClientEvent/SyncState —
// those are runtime enum objects, not types, so importing them here would pull
// matrix-js-sdk into TeamsPage's main bundle even though createMatrixClient()
// loads it dynamically. Values match matrix-js-sdk's public API 1:1.
const ROOM_EVENT_TIMELINE = "Room.timeline";
const ROOM_EVENT_RECEIPT = "Room.receipt";
const CLIENT_EVENT_SYNC = "sync";
const SYNC_STATE_PREPARED = "PREPARED";
const SYNC_STATE_SYNCING = "SYNCING";
interface TeamRoom {
id: string;
matrixRoomId: string | null;
}
/**
* One app-level Matrix client that tracks unread counts for every team room
* the logged-in member has already joined, so badges can show up on the team
* tiles without a chat tab being open (BUG-4 from PROJ-11 QA).
*
* Deliberately separate from `useTeamChat`, which still runs its own client
* for the open chat detail view (message history, sending). Running two
* clients while a chat is open is a little wasteful but keeps this a small,
* reviewable addition instead of merging both concerns.
*/
export function useTeamUnreadCounts(
authToken: string | null,
teams: TeamRoom[],
): Record<string, number> {
const [counts, setCounts] = useState<Record<string, number>>({});
const clientRef = useRef<MatrixClient | null>(null);
const roomIdToTeamId = useRef<Map<string, string>>(new Map());
const roomIds = teams
.map((t) => t.matrixRoomId)
.filter((id): id is string => Boolean(id))
.sort()
.join(",");
useEffect(() => {
roomIdToTeamId.current = new Map(
teams.filter((t) => t.matrixRoomId).map((t) => [t.matrixRoomId as string, t.id]),
);
}, [teams]);
useEffect(() => {
if (!authToken || !roomIds) {
setCounts({});
return;
}
let cancelled = false;
let client: MatrixClient | null = null;
function refreshCounts() {
if (!client) return;
const next: Record<string, number> = {};
for (const [roomId, teamId] of roomIdToTeamId.current) {
const room = client.getRoom(roomId);
if (room) next[teamId] = room.getUnreadNotificationCount() ?? 0;
}
setCounts(next);
}
async function connect() {
try {
const creds = await fetchChatCredentials(authToken!);
if (cancelled) return;
client = await createMatrixClient(creds);
if (cancelled) {
client.stopClient();
return;
}
clientRef.current = client;
// matrix-js-sdk types client.on()'s event name against its own enum, which we
// deliberately avoid importing as a value (see note above) — the string
// literals are its actual runtime values, so this cast is safe.
const on = client.on.bind(client) as (event: string, cb: (...args: unknown[]) => void) => void;
on(ROOM_EVENT_TIMELINE, refreshCounts);
on(ROOM_EVENT_RECEIPT, refreshCounts);
on(CLIENT_EVENT_SYNC, (state) => {
if (
!cancelled &&
(state === SYNC_STATE_PREPARED || state === SYNC_STATE_SYNCING)
) {
refreshCounts();
}
});
await client.startClient({ initialSyncLimit: 1 });
} catch (err) {
// Not provisioned yet, or Synapse unreachable — tiles just show no badge.
if (!(err instanceof ChatSetupPendingError) && !cancelled) {
console.warn("[chat] unread-count client failed to start:", err);
}
}
}
void connect();
return () => {
cancelled = true;
if (client) {
client.removeAllListeners();
client.stopClient();
}
clientRef.current = null;
};
}, [authToken, roomIds]);
return counts;
}
+7 -2
View File
@@ -1,4 +1,4 @@
import { createClient, type MatrixClient } from "matrix-js-sdk";
import type { MatrixClient } from "matrix-js-sdk";
export interface ChatCredentials {
homeserverUrl: string;
@@ -67,8 +67,13 @@ export async function fetchTeamRoomId(token: string, teamId: string): Promise<st
/**
* Creates a Matrix client that talks to Synapse directly.
* Chat traffic never passes through the TalkTime backend.
*
* Dynamic import so matrix-js-sdk (and its ~7.8 MB crypto WASM) only loads
* once a caller actually needs a client — keeps it out of pages that merely
* show unread badges or list teams without anyone opening chat.
*/
export function createMatrixClient(creds: ChatCredentials): MatrixClient {
export async function createMatrixClient(creds: ChatCredentials): Promise<MatrixClient> {
const { createClient } = await import("matrix-js-sdk");
return createClient({
baseUrl: creds.homeserverUrl,
accessToken: creds.accessToken,
+15 -1
View File
@@ -1,5 +1,6 @@
import { Suspense, lazy, useState } from "react";
import { Link } from "react-router-dom";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Card, CardContent } from "@/components/ui/card";
@@ -13,6 +14,7 @@ import {
import { useAuth } from "@/lib/auth-context";
import { useOrganization } from "@/lib/organization-context";
import { useApiList } from "@/hooks/use-api-list";
import { useTeamUnreadCounts } from "@/hooks/use-team-unread-counts";
// Lazy: pulls in matrix-js-sdk only once a team is actually opened.
const TeamDetail = lazy(() =>
import("@/components/chat/TeamDetail").then((m) => ({ default: m.TeamDetail })),
@@ -31,6 +33,7 @@ interface Team {
name: string;
departmentId: string;
seasonId: string;
matrixRoomId: string | null;
}
export function TeamsPage() {
@@ -58,6 +61,7 @@ export function TeamsPage() {
orgQuery ? `/api/teams?${orgQuery}` : null,
"teams",
);
const unreadCounts = useTeamUnreadCounts(token, teams);
async function onCreate() {
if (!activeOrganizationId || !name.trim() || !departmentId || !seasonId) return;
@@ -164,7 +168,17 @@ export function TeamsPage() {
}`}
>
<CardContent className="pt-6 space-y-1">
<p className="font-medium">{team.name}</p>
<div className="flex items-center justify-between gap-2">
<p className="font-medium">{team.name}</p>
{unreadCounts[team.id] > 0 && (
<Badge
variant="secondary"
aria-label={`${unreadCounts[team.id]} ungelesene Nachrichten`}
>
{unreadCounts[team.id]}
</Badge>
)}
</div>
<p className="text-xs text-muted-foreground">
{departmentName(team.departmentId)} · {seasonName(team.seasonId)}
</p>
+15
View File
@@ -50,3 +50,18 @@ report_stats: false
macaroon_secret_key: "<langer-zufalls-string>"
form_secret: "<langer-zufalls-string>"
# PROJ-11 (Chat): TalkTime mintet Nutzern per Admin-API einen regulären, nicht
# gescopten Matrix-Access-Token. Matrix kennt keine Token-Scopes — dieser Token
# kann grundsätzlich jede Client-Server-API des Homeservers aufrufen. Solange
# EIN Synapse mehrere TalkTime-Organisationen bedient (mehrere Vereine in
# derselben Postgres-DB, aber ein gemeinsames SYNAPSE_BASE_URL), könnte ein
# Nutzer damit theoretisch Mitglieder anderer Vereine über die User-Directory
# finden und anschreiben. user_directory.enabled schaltet die homeserver-weite
# Suche ab — verhindert das Auffinden fremder Nutzer, aber KEIN vollständiger
# Tenant-Isolation-Fix: direktes Ansprechen bei bekannter Matrix-ID bleibt
# möglich. Siehe features/PROJ-11-chat.md "Known Limitation" für die offene
# Architekturentscheidung (ein Synapse pro Verein vs. Isolation auf einem
# gemeinsamen Homeserver).
user_directory:
enabled: false