diff --git a/backend/src/routes/invitations.ts b/backend/src/routes/invitations.ts
index 592246c..5876ce4 100644
--- a/backend/src/routes/invitations.ts
+++ b/backend/src/routes/invitations.ts
@@ -11,10 +11,11 @@ import {
members,
users,
teamMembers,
+ chatRooms,
orgRoleEnum,
} from "../db/schema.js";
import { sendMail } from "../services/mail.js";
-import { createUser, toMatrixUserId, toLocalpart, SynapseError } from "../services/synapse.js";
+import { createUser, addUserToRoom, toMatrixUserId, toLocalpart, SynapseError } from "../services/synapse.js";
import { enqueueSyncJob } from "../services/sync-worker.js";
import { requesterIsOrganizationAdmin } from "../lib/authz.js";
import { logAudit } from "../lib/audit.js";
@@ -231,6 +232,42 @@ export async function invitationRoutes(app: FastifyInstance) {
memberId: member.id,
role: invitation.role,
});
+
+ // BUG-18: this used to only write the team_members row — the member never
+ // actually joined the Matrix room, so PROJ-11 chat stayed stuck on "Raum
+ // wird eingerichtet" forever for anyone who joined via invitation. Mirror
+ // the join logic teams.ts uses when adding a member to a team.
+ const [room] = await db
+ .select({ matrixRoomId: chatRooms.matrixRoomId })
+ .from(chatRooms)
+ .where(eq(chatRooms.teamId, invitation.teamId))
+ .limit(1);
+ const [provisioned] = await db
+ .select({ matrixUserId: members.matrixUserId })
+ .from(members)
+ .where(eq(members.id, member.id))
+ .limit(1);
+
+ if (room && provisioned?.matrixUserId) {
+ try {
+ await addUserToRoom(room.matrixRoomId, provisioned.matrixUserId);
+ } catch (err) {
+ console.warn(
+ `[synapse] inline room join failed for member ${member.id}, queuing retry:`,
+ err,
+ );
+ await enqueueSyncJob(invitation.organizationId, "join_team_room", {
+ teamId: invitation.teamId,
+ memberId: member.id,
+ });
+ }
+ } else {
+ // Room or matrix account not provisioned yet — worker retries once both exist.
+ await enqueueSyncJob(invitation.organizationId, "join_team_room", {
+ teamId: invitation.teamId,
+ memberId: member.id,
+ });
+ }
}
await db
diff --git a/backend/src/server.ts b/backend/src/server.ts
index 1e5ffd2..fad8f25 100644
--- a/backend/src/server.ts
+++ b/backend/src/server.ts
@@ -1,5 +1,5 @@
import "dotenv/config";
-import Fastify, { type FastifyRequest, type FastifyReply } from "fastify";
+import Fastify, { type FastifyRequest, type FastifyReply, type FastifyError } from "fastify";
import cors from "@fastify/cors";
import jwt from "@fastify/jwt";
import rateLimit from "@fastify/rate-limit";
@@ -32,6 +32,21 @@ app.decorate("authenticate", async (request: FastifyRequest, reply: FastifyReply
}
});
+// BUG-19: without this, an uncaught error (e.g. a Postgres unique-constraint
+// violation) falls through to Fastify's default handler, which echoes
+// `err.message` verbatim — for Drizzle that includes the full SQL statement
+// and bound parameters. 5xx now always gets a generic message; 4xx (route
+// validation, rate limiting) keeps its original, already-safe message.
+app.setErrorHandler((err: FastifyError, request, reply) => {
+ const statusCode = err.statusCode ?? 500;
+ if (statusCode >= 500) {
+ app.log.error({ err, url: request.url, method: request.method }, "unhandled route error");
+ reply.code(statusCode).send({ error: "Internal server error" });
+ return;
+ }
+ reply.code(statusCode).send({ error: err.message });
+});
+
await app.register(healthRoutes);
await app.register(authRoutes);
await app.register(dashboardRoutes);
diff --git a/features/PROJ-11-chat.md b/features/PROJ-11-chat.md
index da0d025..73f1b6c 100644
--- a/features/PROJ-11-chat.md
+++ b/features/PROJ-11-chat.md
@@ -394,3 +394,260 @@ Priorität: P3.
- Der BUG-4-Fix hat **zwei neue P1-Befunde** eingeführt (BUG-11 Bundle-Regression, BUG-12 kein Token-Refresh/Fehlerbehandlung im App-weiten Client) sowie drei P2 (BUG-13/14/15).
**Empfohlene Fix-Reihenfolge:** BUG-1 (committen + verifizieren) → Synapse-TLS-Erreichbarkeit → BUG-12 → BUG-11 → BUG-13/14 → BUG-2 (Architekturentscheidung) → BUG-5/15 → BUG-6/8 → Rest.
+
+---
+
+## QA Re-Test (2026-09-11, Runde 3 — LIVE)
+
+**Getestet gegen:** commit `c200faf` (Working Tree: nur `DEVLOG.md` + `backend/.env.example` modifiziert, kein Code-Delta zu Runde 2)
+**Testart:** **Erstmals vollständiger Live-End-to-End-Test** gegen die produktiven TLS-Domains `https://talkhub.perlbach24.de` (App/API) und `https://matrix.perlbach24.de` (Synapse). Frisch angelegte Testdaten: 2 Vereine, 4 Nutzer, 1 Abteilung, 1 Saison, 1 Mannschaft, echter Matrix-Raum. Dazu Red-Team-Probing mit echten, vom Backend ausgestellten Matrix-Access-Tokens.
+**Einschränkung:** Cross-Browser (Chrome/Firefox/Safari) und Responsive (375/768/1440 px) konnten weiterhin **nicht** getestet werden — in dieser Umgebung steht kein Browser/Playwright zur Verfügung. Die Bewertung bleibt auf Markup-/CSS-Ebene. Alles andere wurde diesmal am laufenden System verifiziert, nicht statisch.
+
+### Infrastruktur-Status (Voraussetzung aus Runde 2)
+
+| Prüfung | Runde 2 | Runde 3 |
+|---------|---------|---------|
+| `https://matrix.perlbach24.de/_matrix/client/versions` | **502** | **200** — Synapse v1.12, TLS ok |
+| `https://talkhub.perlbach24.de/` | n/a | **200** |
+| `GET /api/health` | n/a | **200** `{"status":"ok"}` |
+| `GET /api/synapse/health` (authentifiziert) | n/a | **200** `{"status":"ok"}` |
+| `POST /api/chat/token` → `homeserverUrl` | nicht prüfbar | **`https://matrix.perlbach24.de`** — korrekt TLS, kein stiller HTTP-Fallback |
+
+Der Blocker aus Runde 1/2 („Synapse von außen nicht erreichbar") ist **aufgelöst**.
+
+### Live-Verifikation des Chat-Flows (neu möglich)
+
+Vollständig durchlaufen und bestätigt:
+
+1. `POST /api/auth/register` → JWT
+2. `POST /api/organizations` → Verein + Mitglied + Matrix-User automatisch provisioniert
+3. `POST /api/departments`, `/api/seasons`, `/api/teams` → Team angelegt, **Matrix-Raum inline erfolgreich erzeugt** (`!WpjmnFUqIjFbcJJPTR:matrix.perlbach24.de`)
+4. `POST /api/chat/token` → `{homeserverUrl, userId, accessToken, expiresInMs: 3599983}`
+5. `PUT /_matrix/client/v3/rooms/{id}/send/m.room.message` direkt an Synapse → **HTTP 200, Event-ID zurück**
+6. `GET /_matrix/client/v3/sync` → Nachricht im Timeline-Verlauf, korrekter Sender
+7. Zweites Mitglied hinzugefügt → sieht die **vor seinem Beitritt** gesendete Nachricht (AC 5 live bestätigt)
+8. Ungelesen-Zähler: nach 3 Nachrichten `unread_notifications: {notification_count: 3}`
+9. Read Receipt gesendet → eigener Zähler auf 0, **Sender sieht `m.receipt` mit `@erika.beispiel` im `ephemeral`-Stream** (AC 6 live bestätigt)
+
+**Raum-State live geprüft:** `m.room.history_visibility` = **`shared`** ✔, `m.room.join_rules` = **`invite`** ✔.
+
+Damit ist auch das **Risiko aus PROJ-9 aufgelöst**: `initial_state` zusammen mit `preset: private_chat` bricht die Raum-Anlage *nicht* — gegen echte Synapse verifiziert.
+
+### Status der Vorrunden-Bugs (live nachgeprüft)
+
+| Bug | Runde 2 | Runde 3 | Verifikation |
+|-----|---------|---------|--------------|
+| **BUG-1** (Critical) Env-Doku | offen | **weiterhin offen (Doku), funktional erledigt** | `backend/.env.example` bleibt für mich permission-gesperrt (Read *und* `git diff` verweigert) und ist im Working Tree **weiterhin uncommittet `M`**. Live ist die echte `.env` korrekt: `homeserverUrl` liefert die HTTPS-Domain. Funktional kein Blocker mehr, aber der Fix ist **nicht committet** → geht bei jedem Clone/Redeploy verloren. Runterstufung auf **P1 (Doku/Deployment-Reproduzierbarkeit)**, nicht mehr Critical. |
+| **BUG-2** (High) Tenant-Isolation | teilgemildert | **Mitigation live bestätigt — Restrisiko live ausnutzbar** | `user_directory/search` mit echtem Nutzer-Token → `{"limited":false,"results":[]}` ✔. Aber siehe **BUG-20**: das Restrisiko ist jetzt praktisch demonstriert, nicht mehr theoretisch. |
+| **BUG-3** (High) Kick-Handling | behoben (statisch) | **BEHOBEN — live bestätigt** | Long-Poll-`/sync` offen gehalten, parallel `DELETE /api/teams/{id}/members/{mid}` → der Client erhält den Raum prompt unter `rooms.leave` mit `m.room.member {"membership":"leave","reason":"Removed from TalkTime group"}`. Genau das Event, auf das `RoomEvent.MyMembership` reagiert. Senden danach: `M_FORBIDDEN`. `GET /api/chat/room` danach: `403`. |
+| **BUG-4** (High) Ungelesen-Zähler | behoben | **bestätigt** | `GET /api/teams` liefert `matrixRoomId`; Zähler live korrekt (3 → 0 nach Receipt). |
+| **BUG-5** (Medium) Kein Backoff | offen | offen | unverändert im Code. |
+| **BUG-6** (Medium) history_visibility Bestandsräume | offen | offen | Neue Räume ✔ (live). Für Bestandsräume weiterhin keine Migration. |
+| **BUG-7** (Medium) Bundle | offen | offen | unverändert. |
+| **BUG-8** (High) Helmet/CORS | offen | **offen — live bestätigt** | siehe unten, jetzt am laufenden System gemessen. |
+| **BUG-9** (Low) Diverses | offen | offen | `GET /api/chat/room` 12× hintereinander → **12× 200**, kein Route-Limit ✔bestätigt. In `frontend/` existiert **gar keine** ESLint-Config (`ls | grep eslint` leer). |
+| **BUG-10** (Low) Logout widerruft Token nicht | offen | offen | unverändert. |
+| **BUG-11** (High) SDK lädt zu früh | offen | offen | `TeamsPage.tsx:64` mountet `useTeamUnreadCounts` weiterhin unbedingt. |
+| **BUG-12** (High) Kein Token-Refresh/Error-Handling | offen | offen | `use-team-unread-counts.ts:85-92` hat weiterhin nur `PREPARED`/`SYNCING`, keinen `ERROR`-Zweig und keinen `refresh`-Pfad. |
+| **BUG-13** (Medium) `matrixRoomId`-Leak | offen | offen | `teams.ts:50-64` prüft weiterhin nur `requesterIsOrganizationMember`. Ausnutzbarkeit live entkräftet: `join_rule=invite` bestätigt, Join wird abgelehnt. |
+| **BUG-14** (Medium) `leftJoin` ohne `scope`-Filter | offen | offen | `teams.ts:62` weiterhin ohne `scope='team'`. (Hinweis: `chat.ts:137` filtert korrekt auf `scope` — die beiden Routen sind inkonsistent.) |
+| **BUG-15** (Medium) Doppelte Clients / Rate-Limit | offen | **offen — Limit live bestätigt** | 14× `POST /api/chat/token` → `200`×10, dann `429`×4. Das 10/min-Limit greift exakt wie beschrieben; das Risiko aus BUG-15 ist damit real. |
+| **BUG-16 / BUG-17** (Low) | offen | offen | `refreshCounts` (Zeile 59-67) hat weiterhin keinen `cancelled`-Guard vor `setCounts`; `getUnreadNotificationCount()` weiterhin in Zeile 64. |
+
+### Neue Bugs (Runde 3)
+
+**BUG-18 (Critical) — Über eine Einladung mit `teamId` beigetretene Mitglieder landen NIE im Matrix-Raum; Chat bleibt dauerhaft bei „Raum wird eingerichtet"**
+
+Der primäre Onboarding-Pfad aus [[PROJ-7]] ist für den Chat kaputt. `backend/src/routes/invitations.ts:228-234` legt beim Annehmen einer Einladung nur die DB-Zeile an:
+```ts
+if (invitation.teamId) {
+ await db.insert(teamMembers).values({
+ teamId: invitation.teamId,
+ memberId: member.id,
+ role: invitation.role,
+ });
+}
+```
+Es fehlt — anders als im korrekten Pfad `teams.ts:129-152` — jeder Aufruf von `addUserToRoom()` **und** jedes `enqueueSyncJob(..., "join_team_room", ...)`.
+
+Live reproduziert:
+- Einladung mit `teamId` erstellt, angenommen (`Max Mustermann`) → Matrix-User `@max.mustermann:matrix.perlbach24.de` wird korrekt provisioniert, `POST /api/chat/token` liefert 200.
+- `GET /api/chat/room?teamId=…` liefert **200 mit der Raum-ID** (TalkTime hält ihn für ein Team-Mitglied).
+- `GET /_matrix/client/v3/joined_rooms` → **`{"joined_rooms":[]}`**, `/sync` → weder `join` noch `invite`. Der Nutzer ist auf Synapse-Seite in **keinem** Raum.
+- Nach >70 s (Sync-Worker-Intervall 30 s) unverändert leer → **heilt nicht von selbst**. `runReconciliation()` prüft ausschließlich User-Provisionierung (`getUser`), **nicht** Raum-Mitgliedschaften — es gibt also keinen Reparaturmechanismus.
+
+Frontend-Symptom (aus `use-team-chat.ts:144-153`): `/api/chat/room` liefert 200, also greift der `ChatSetupPendingError`-Poll nicht; stattdessen läuft der Matrix-Client an, `client.getRoom(roomId)` ist `null` → `status="pending"`, `pendingReason="room_not_provisioned"`. Weil dieser Zweig **innerhalb** des Sync-Handlers liegt, wird **kein `pollTimer` gesetzt** — die UI zeigt „Raum wird eingerichtet" **dauerhaft und unveränderlich**, auch nach Reload. Ein stilles, permanentes Versagen ohne Fehlermeldung und ohne Retry.
+
+Repro: Einladung mit `teamId` erstellen → annehmen → einloggen → Team öffnen → Chat-Tab. Erwartet: Chat. Tatsächlich: endlos „Raum wird eingerichtet".
+Erwartet: im Accept-Handler denselben `addUserToRoom()`-/`enqueueSyncJob("join_team_room")`-Pfad wie in `teams.ts` fahren; zusätzlich `runReconciliation()` um Raum-Mitgliedschaften erweitern.
+Priorität: **P0** — betrifft jedes per Einladung ins Team geholte Mitglied, also den Regelfall. Höher als alle bisherigen offenen Punkte.
+
+**BUG-19 (High) — Doppelter `teamMembers`-Insert wirft ungefiltertes 500 mit vollem SQL-Statement und Parametern an den Client; blockiert zugleich die Reparatur von BUG-18**
+
+`POST /api/teams/:teamId/members` macht `db.insert(teamMembers).values(...)` ohne Duplikatsprüfung und ohne `onConflictDoNothing`. Existiert die Zeile schon (genau der BUG-18-Fall), antwortet die API:
+```json
+{"statusCode":500,"error":"Internal Server Error","message":"Failed query: insert into \"team_members\" (\"id\", \"team_id\", \"member_id\", \"role\", \"created_at\") values (default, $1, $2, default, default)\nparams: 4d78b3e3-4455-4703-ba64-87eedea2fde8,16406e9a-8751-4756-bcf7-4bc452d455b5"}
+```
+Zwei Probleme:
+1. **Information Disclosure:** Tabellen-/Spaltennamen und Query-Parameter gehen an den Client. Ursache ist ein fehlender `app.setErrorHandler(...)` in `backend/src/server.ts` (bestätigt: `grep setErrorHandler` → kein Treffer) — jede unbehandelte DB-Exception in **jeder** Route leakt so ihre Query. Verstößt gegen `.claude/rules/backend.md` („Always handle and log errors from pg/Drizzle calls").
+2. **Kein Reparaturweg für BUG-18:** Ein Admin, der ein per Einladung beigetretenes Mitglied nachträglich in den Raum bekommen will, kann es nicht über die UI/API nachtragen — der einzige funktionierende Pfad `POST /api/teams/:teamId/members` schlägt für genau diese Mitglieder mit 500 fehl. Verifiziert: für ein Mitglied *ohne* vorhandene `teamMembers`-Zeile liefert dieselbe Route `201` und der Nutzer landet sofort im Raum.
+
+Priorität: **P1** (Fix: globaler Error-Handler, der 500er generisch macht und intern loggt, + `onConflictDoNothing()` bzw. Vorabprüfung mit 409).
+
+**BUG-20 (High) — Matrix-IDs sind deterministisch aus Klarnamen ableitbar; die BUG-2-Mitigation (`user_directory` aus) ist dadurch weitgehend wirkungslos**
+
+`toLocalpart()` in `backend/src/services/synapse.ts:40-48` bildet `vorname.nachname` (nur Suffix-Zähler bei Kollision). Die Matrix-ID eines Mitglieds ist damit **ratbar**, ohne das abgeschaltete User-Directory zu brauchen. Live bestätigt: `Max Mustermann` → `@max.mustermann:matrix.perlbach24.de`.
+
+Vollständig live ausgenutzt, mit einem regulär über `POST /api/chat/token` bezogenen Token eines Nutzers aus **Verein A**:
+```
+POST /_matrix/client/v3/createRoom {"is_direct":true,"preset":"trusted_private_chat",
+ "invite":["@max.mustermann:matrix.perlbach24.de"]}
+ → 200 {"room_id":"!UxMHdCCDXJRywHvyGK:…"}
+PUT /_matrix/client/v3/rooms/…/send/m.room.message
+ → 200 {"event_id":"$HGZtH6mS6yldeDcFa-mP6_zFcK-7cYuwbwKRV88j4kE"}
+```
+Beim Zielnutzer erscheint im `/sync` tatsächlich `rooms.invite: ['!UxMHdCCDXJRywHvyGK:…']`, Raum-State zeigt ihn als `invite`. Raum-Erstellung ist für Nutzer-Tokens also uneingeschränkt erlaubt (`publicRooms` ist immerhin leer).
+
+Bewertung: Das in Runde 2 als „Restrisiko, nur bei bekannter Matrix-ID" eingestufte Problem ist **keine theoretische Einschränkung** — die IDs sind durch das Namensschema systematisch erratbar (Vereinsmitglieder-Namen sind oft öffentlich, z. B. Mannschaftslisten). Damit ist Spam/Belästigung über Vereinsgrenzen hinweg praktisch möglich, was der PRD-Erfolgsmetrik „0 Tenant-Isolation-Vorfälle" widerspricht.
+Erwartet: mindestens eines von — (a) Raum-Erstellung für provisionierte Nutzer per Synapse-Konfiguration/Modul unterbinden, (b) zufällige/opake Localparts statt Klarnamen, (c) verbindlich ein Synapse pro Verein (dann ist der Radius auf den eigenen Verein begrenzt).
+Priorität: **P1** — vor Multi-Tenant-Betrieb zwingend; bei „ein Synapse pro Verein" auf P2 reduzierbar.
+
+**BUG-21 (Medium) — Fehlende Security-Header und reflektierendes CORS am laufenden System bestätigt (Konkretisierung von BUG-8)**
+
+Live gemessen an `https://talkhub.perlbach24.de`:
+```
+$ curl -sI https://talkhub.perlbach24.de/api/health
+HTTP/2 200 … server: openresty
+(keine Strict-Transport-Security, kein X-Frame-Options,
+ kein X-Content-Type-Options, keine Referrer-Policy)
+
+$ curl -sI -H "Origin: https://evil.example.com" …/api/health
+access-control-allow-origin: https://evil.example.com
+
+$ curl -si -X OPTIONS -H "Origin: https://evil.example.com" \
+ -H "Access-Control-Request-Headers: authorization" …/api/chat/token
+HTTP/2 204
+access-control-allow-origin: https://evil.example.com
+access-control-allow-headers: authorization
+```
+Der Reverse Proxy setzt die Header also **nicht** ersatzweise — die Annahme aus Runde 1 („sofern nicht am Reverse Proxy gesetzt") ist damit widerlegt. `server.ts:23` ist unverändert `cors, { origin: true }`.
+Entlastend (live geprüft): `access-control-allow-credentials` wird **nicht** gesetzt, und der JWT wird als `Authorization`-Header geführt, nicht als Cookie — eine fremde Origin kann also nicht einfach im Namen des Nutzers `/api/chat/token` abrufen. Das Risiko bleibt „Härtung fehlt / Schadensbegrenzung bei XSS", keine direkte Ausnutzbarkeit. Deshalb Medium statt High.
+Priorität: P2 (Fix in PROJ-1/PROJ-10, nicht PROJ-11-Scope).
+
+**BUG-22 (Low) — Matrix-Localpart und Anzeigename sind bei Vereins-Erstanlage kaputt**
+
+`organizations.ts:53-54` setzt `firstName = requester.email.split("@")[0]` und `lastName = ""`. Ergebnis live: E-Mail `qa-a-18686@example.org` → Matrix-ID **`@qaa18686.:matrix.perlbach24.de`** (führende Trennzeichen entfernt, **Localpart endet auf einem Punkt**) und Anzeigename **`"qa-a-18686 "`** mit Leerzeichen am Ende (sichtbar in jeder Chat-Nachricht des Vereinsadministrators).
+Der Vereinsadministrator — meist der erste und sichtbarste Nutzer — tritt im Chat also mit seinem E-Mail-Präfix statt mit seinem Namen auf. Zudem kollidieren zwei Administratoren mit gleichem E-Mail-Präfix bei verschiedenen Providern im Localpart (wird per Suffix aufgelöst, aber unschön).
+Erwartet: bei der Vereinsanlage Vor-/Nachname abfragen, oder den Anzeigenamen nachträglich setzbar machen und den Trailing-Punkt/-Leerraum trimmen.
+Priorität: P3.
+
+### Akzeptanzkriterien (Runde 3, live)
+
+| # | Kriterium | R1 | R2 | R3 | Live-Beleg |
+|---|-----------|----|----|----|------------|
+| 1 | Chat-Tab in Team-Detailansicht | PASS | PASS | PASS | `TeamDetail.tsx` (statisch, kein Browser verfügbar) |
+| 2 | Backend liefert Token, kein Passwort/Admin-Token im Frontend | PASS | PASS | **PASS (live)** | Response enthält nur `homeserverUrl/userId/accessToken/expiresInMs` |
+| 3 | `/sync` via matrix-js-sdk, kein eigener WebSocket-Server | PASS | PASS | **PASS (live)** | `/sync` direkt gegen `matrix.perlbach24.de` funktioniert |
+| 4 | Alle Raum-Mitglieder dürfen senden | PASS | PASS | **PASS (live)** | zwei verschiedene Mitglieder haben erfolgreich gesendet |
+| 5 | Volle Historie für neue Mitglieder | Vorbehalt | Vorbehalt | **PASS (live), Vorbehalt Bestandsräume** | `history_visibility: shared`; später beigetretenes Mitglied sieht die ältere Nachricht. BUG-6 (Altbestand) offen |
+| 6 | Ungelesen-Zähler pro Raum + Read Receipts | FAIL | PASS | **PASS (live)** | `notification_count: 3` → 0; `m.receipt` beim Sender sichtbar |
+| 7 | Fehlendes `matrixUserId` → Hinweis + Polling | PASS | PASS | **PASS (live)** | Verein B: `POST /api/chat/token` → `409 matrix_not_provisioned` |
+| 8 | Kein Medien-Upload, kein Push | PASS | PASS | PASS | unverändert |
+
+**Summe: 7 PASS, 1 PASS mit Vorbehalt, 0 FAIL** — erstmals überwiegend live belegt statt nur aus dem Code abgeleitet.
+
+> Wichtig: Die Kriterien sind auf dem **API-/Protokollpfad** erfüllt. Für über Einladungen ins Team geholte Mitglieder greift durch **BUG-18** faktisch keines der Kriterien 3-6, weil sie nie im Raum landen.
+
+### Edge Cases (Runde 3)
+
+| Edge Case | R2 | R3 | Beleg |
+|-----------|----|----|-------|
+| `matrixUserId = null` | PASS | **PASS (live)** | `409 matrix_not_provisioned` bei frisch angelegtem Verein B |
+| `create_team_room` nicht gelaufen | PASS | PASS | Inline-Anlage hat live funktioniert, Fallback-Job unverändert |
+| Synapse nicht erreichbar, kein Flut-Retry | FAIL | **FAIL** | BUG-5/BUG-12 unverändert |
+| Token abgelaufen beim Senden → 1 Retry | PASS | PASS | Detail-Chat ok; Unread-Client weiterhin ohne Refresh (BUG-12) |
+| Rauswurf bei offenem Chat | PASS | **PASS (live)** | Leave-Event im Long-Poll-`/sync` empfangen |
+| Lange Historie → Pagination | PASS | PASS | unverändert |
+| **Neu:** Beitritt über Einladung mit `teamId` | — | **FAIL** | BUG-18 |
+| **Neu:** Mitglied doppelt zum Team hinzufügen | — | **FAIL** | BUG-19, 500 + SQL-Leak |
+
+### Security-Audit (Runde 3, Red Team gegen das laufende System)
+
+| Prüfung | Ergebnis | Beleg |
+|---------|----------|-------|
+| Auth-Bypass auf Chat-Routen | PASS | ohne JWT 401 |
+| IDOR: Raum-ID eines fremden Teams | PASS (live) | `/api/chat/room` vor Team-Beitritt → `403 Forbidden` |
+| Zugriff nach Entfernung aus dem Team | PASS (live) | `/api/chat/room` → 403, Matrix-Send → `M_FORBIDDEN` |
+| Raum-Vorschau ohne Mitgliedschaft | PASS (live) | `M_FORBIDDEN … room previews are disabled` |
+| Raum beitreten mit geleakter Raum-ID (BUG-13) | MITIGIERT (live) | `join_rule: invite` bestätigt |
+| Admin-Token-Leak an Browser | PASS | nur `syt_…`-Nutzertoken im Response |
+| Matrix-Registrierung offen? | PASS | `M_FORBIDDEN Registration has been disabled` |
+| Öffentliches Raumverzeichnis | PASS | `publicRooms` → leer |
+| **`user_directory` (BUG-2-Mitigation)** | **PASS (live)** | `user_directory/search` → `{"limited":false,"results":[]}` |
+| **Raum-Erstellung durch Nutzer-Token** | **FAIL** | erlaubt → BUG-20 |
+| **Cross-Club-DM an geratene Matrix-ID** | **FAIL** | live ausgenutzt → BUG-20 |
+| Rate Limiting `/api/chat/token` | PASS (live) | 10× 200, dann 429 |
+| Rate Limiting `/api/chat/room` | FAIL | 12× 200, kein Route-Limit (BUG-9) |
+| Security-Header / CORS | **FAIL (live)** | BUG-21 |
+| Fehlerbehandlung / Info-Disclosure | **FAIL (live)** | BUG-19, SQL im 500-Response |
+| XSS über Nachrichteninhalt | PASS | `` als Nachricht gesendet, kommt im `/sync` als reiner `body`-String zurück; Rendering als JSX-Textknoten, kein `dangerouslySetInnerHTML` (Browser-Gegenprobe steht aus) |
+| SQL-Injection / Input-Validierung | PASS | Drizzle parametrisiert, Zod `safeParse`; invalide UUID → 400 |
+| Token in Logs/DB/Response | PASS | unverändert |
+
+### Regressionstest (Features mit Status „Deployed")
+
+| Feature | Ergebnis |
+|---------|----------|
+| PROJ-1 Auth | **PASS (live)** — Register/Login/JWT funktionieren gegen Produktion |
+| PROJ-2 Verein + Vereinsadministrator | **PASS mit Mangel (live)** — Anlage inkl. Matrix-Provisionierung funktioniert; Namens-/Localpart-Ableitung fehlerhaft (BUG-22) |
+| PROJ-3 Mitgliederverwaltung | **PASS (live)** — `GET /api/members` inkl. Pagination ok |
+| PROJ-4 Abteilungsverwaltung | **PASS (live)** |
+| PROJ-5 Mannschaftsverwaltung | **FAIL (live)** — Team-Anlage und Mitglied-Hinzufügen ok, aber `POST /api/teams/:id/members` wirft bei Duplikat 500 mit SQL-Leak (BUG-19). `GET /api/teams` weiterhin mit `leftJoin`-Kardinalitätsrisiko (BUG-14) |
+| PROJ-7 Einladungssystem | **FAIL (live)** — Einladung/Accept/Rollenvergabe funktionieren, aber die `teamId`-Variante führt zu einem Mitglied, das nie im Chat-Raum landet (BUG-18). Kernregression für den Chat |
+| PROJ-8 Audit Log | PASS — `chat.token_issued` unverändert |
+| PROJ-9 Synapse-Integration | **PASS (live) — Risiko aus R1/R2 aufgelöst** — `initial_state` + `preset: private_chat` erzeugt live korrekt einen Raum mit `history_visibility: shared`. Einschränkung: `runReconciliation()` deckt Raum-Mitgliedschaften nicht ab (Grundlage von BUG-18) |
+| PROJ-10 Deployment | **PASS mit Mangel** — Live-Deployment läuft mit korrekten Werten; `.env.example` weiterhin uncommittet/undokumentiert (BUG-1) |
+
+### Cross-Browser & Responsive
+
+Weiterhin **nicht durchgeführt** — kein Browser/Playwright in dieser Umgebung. Unverändert offen aus Runde 1:
+- `TeamChat.tsx:68` nutzt weiterhin `h-[60vh] … sm:h-[65vh]`. Auf iOS Safari mit eingeblendeter Tastatur kann der Nachrichtenbereich kollabieren; `dvh` wäre robuster. **Muss manuell bei 375 px auf echtem iOS Safari geprüft werden.**
+- Breakpoints/Accessibility (`role="log"`, `aria-live`, `