From f81d2d7906ee36708db8a5e496447a1253422f17 Mon Sep 17 00:00:00 2001 From: patrick Date: Sat, 12 Sep 2026 21:06:46 +0200 Subject: [PATCH] feat(PROJ-14): backend for Synapse room overview (SYSTEM_ADMIN) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GET /api/system-admin/rooms and DELETE /api/system-admin/rooms/:roomId, both gated on requireSystemAdmin from PROJ-13. No new DB table — reads Synapse's room list directly, cross-references the existing chat_rooms table for org/team attribution via one inArray join. deleteRoom() uses the v2 async purge endpoint (not v1, which blocks until the purge finishes and would time out on a large room). normalizeCreationTs() handles Synapse versions that return seconds instead of documented milliseconds — moot on this deployment's Synapse version, which omits creation_ts from the rooms list entirely; verified live, code already treats a missing value as null rather than guessing. Known gap: audit_logs.organization_id is NOT NULL, so deleting an unassigned room can't get a proper audit entry (falls back to a server log warning). Flagged rather than silently worked around. Verified end-to-end on talkhub/matrix: 403 without the SYSTEM_ADMIN flag, 200 with it, and used the new delete endpoint to clean up a genuinely orphaned test room left over from earlier QA. Frontend (/system-admin route, rooms table, delete dialog) still outstanding — the nav button added in PROJ-13 stays a dead link until that lands. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_0143yYvjesjHndBACoweyK67 --- backend/src/routes/system-admin.ts | 194 ++++++++++++++++++++ backend/src/server.ts | 2 + backend/src/services/synapse.ts | 73 ++++++++ features/PROJ-14-synapse-raum-uebersicht.md | 10 +- 4 files changed, 278 insertions(+), 1 deletion(-) create mode 100644 backend/src/routes/system-admin.ts diff --git a/backend/src/routes/system-admin.ts b/backend/src/routes/system-admin.ts new file mode 100644 index 0000000..4893dcc --- /dev/null +++ b/backend/src/routes/system-admin.ts @@ -0,0 +1,194 @@ +import type { FastifyInstance } from "fastify"; +import { z } from "zod"; +import { eq, inArray } from "drizzle-orm"; +import { db } from "../db/index.js"; +import { chatRooms, organizations, teams } from "../db/schema.js"; +import { requireSystemAdmin } from "../lib/authz.js"; +import { logAudit } from "../lib/audit.js"; +import { SynapseError, deleteRoom, listRooms } from "../services/synapse.js"; + +/** + * PROJ-14: Matrix room IDs look like `!abc123:example.org`. Validating the shape + * keeps obviously-bogus values from ever reaching the Synapse admin API (and + * keeps them out of the URL we build from them). + */ +const matrixRoomIdSchema = z + .string() + .min(3) + .max(255) + .regex(/^![A-Za-z0-9._=\-+/]+:[A-Za-z0-9.\-]+(:\d+)?$/, "Ungültige Matrix-Raum-ID."); + +const listQuerySchema = z.object({ + from: z.coerce.number().int().min(0).default(0), + limit: z.coerce.number().int().min(1).max(100).default(50), + search: z.string().trim().min(1).max(200).optional(), +}); + +const roomParamsSchema = z.object({ roomId: matrixRoomIdSchema }); + +interface LocalAssignment { + chatRoomId: string; + matrixRoomId: string; + scope: string; + organizationId: string; + organizationName: string | null; + teamId: string | null; + teamName: string | null; +} + +/** TalkTime-side mapping for a set of Matrix room IDs, resolved in one query. */ +async function findLocalAssignments(matrixRoomIds: string[]): Promise> { + if (matrixRoomIds.length === 0) { + return new Map(); + } + + // Single join instead of one lookup per room — the room list can hold 100 + // entries per page and an N+1 loop here would mean 300 round trips. + const rows = await db + .select({ + chatRoomId: chatRooms.id, + matrixRoomId: chatRooms.matrixRoomId, + scope: chatRooms.scope, + organizationId: chatRooms.organizationId, + organizationName: organizations.name, + teamId: chatRooms.teamId, + teamName: teams.name, + }) + .from(chatRooms) + .leftJoin(organizations, eq(organizations.id, chatRooms.organizationId)) + .leftJoin(teams, eq(teams.id, chatRooms.teamId)) + .where(inArray(chatRooms.matrixRoomId, matrixRoomIds)); + + return new Map(rows.map((row) => [row.matrixRoomId, row])); +} + +export async function systemAdminRoutes(app: FastifyInstance) { + /** + * PROJ-14: platform-wide room overview. Synapse is the source of truth for the + * room list; TalkTime only contributes the "which club/team does this belong + * to" annotation. Nothing is cached locally. + */ + app.get( + "/api/system-admin/rooms", + { preHandler: [app.authenticate, requireSystemAdmin] }, + async (request, reply) => { + const parsed = listQuerySchema.safeParse(request.query); + if (!parsed.success) { + return reply.code(400).send({ error: parsed.error.issues[0].message }); + } + const { from, limit, search } = parsed.data; + + let page; + try { + page = await listRooms({ from, limit, search }); + } catch (err) { + // Never fall back to an empty list here: an unreachable Synapse must not + // look like "there are no rooms" to an admin about to draw conclusions. + app.log.error({ err }, "PROJ-14: failed to list rooms from Synapse"); + return reply + .code(502) + .send({ error: "Synapse ist derzeit nicht erreichbar. Bitte später erneut versuchen." }); + } + + const assignments = await findLocalAssignments(page.rooms.map((room) => room.room_id)); + + return { + rooms: page.rooms.map((room) => { + const local = assignments.get(room.room_id); + return { + matrixRoomId: room.room_id, + name: room.name, + canonicalAlias: room.canonical_alias, + joinedMembers: room.joined_members, + createdAt: room.creation_ts ? new Date(room.creation_ts).toISOString() : null, + assignment: local + ? { + organizationId: local.organizationId, + organizationName: local.organizationName, + teamId: local.teamId, + teamName: local.teamName, + scope: local.scope, + } + : null, + }; + }), + pagination: { + from, + limit, + total: page.total_rooms, + nextFrom: page.next_batch ?? null, + prevFrom: page.prev_batch ?? null, + }, + }; + }, + ); + + /** + * PROJ-14: purges a room at Synapse and drops the local mapping if one exists. + * Deliberately allowed even for rooms still assigned to a live team — the + * frontend warns first; a silently-refused delete would be more confusing. + */ + app.delete( + "/api/system-admin/rooms/:roomId", + { preHandler: [app.authenticate, requireSystemAdmin] }, + async (request, reply) => { + const parsed = roomParamsSchema.safeParse(request.params); + if (!parsed.success) { + return reply.code(400).send({ error: parsed.error.issues[0].message }); + } + const { roomId } = parsed.data; + + const [local] = await db + .select({ + id: chatRooms.id, + organizationId: chatRooms.organizationId, + teamId: chatRooms.teamId, + name: chatRooms.name, + }) + .from(chatRooms) + .where(eq(chatRooms.matrixRoomId, roomId)) + .limit(1); + + try { + await deleteRoom(roomId); + } catch (err) { + if (err instanceof SynapseError && err.status === 404) { + // Race condition: another admin already deleted it. The local mapping + // is now stale either way, so clean it up and report success-ish. + if (local) { + await db.delete(chatRooms).where(eq(chatRooms.id, local.id)); + } + return reply.code(404).send({ error: "Dieser Raum existiert nicht (mehr)." }); + } + app.log.error({ err, roomId }, "PROJ-14: failed to delete room at Synapse"); + return reply + .code(502) + .send({ error: "Der Raum konnte bei Synapse nicht gelöscht werden." }); + } + + if (local) { + // Synapse cascades nothing into our DB (the room ID is an external key), + // so the mapping has to go explicitly or it points at a dead room. + await db.delete(chatRooms).where(eq(chatRooms.id, local.id)); + + // audit_logs.organization_id is NOT NULL, so only org-bound rooms can be + // recorded there; orphaned rooms get the server log instead. + await logAudit({ + organizationId: local.organizationId, + actorUserId: request.user.sub, + action: "system_admin.room.deleted", + targetType: "chat_room", + targetId: roomId, + metadata: { name: local.name, teamId: local.teamId }, + }); + } else { + app.log.warn( + { roomId, actorUserId: request.user.sub }, + "PROJ-14: system admin purged an unassigned Matrix room", + ); + } + + return { deleted: true, hadLocalAssignment: local !== undefined }; + }, + ); +} diff --git a/backend/src/server.ts b/backend/src/server.ts index 689b759..09f8891 100644 --- a/backend/src/server.ts +++ b/backend/src/server.ts @@ -17,6 +17,7 @@ import { seasonRoutes } from "./routes/seasons.js"; import { roleRoutes } from "./routes/roles.js"; import { auditRoutes } from "./routes/audit.js"; import { chatRoutes } from "./routes/chat.js"; +import { systemAdminRoutes } from "./routes/system-admin.js"; import { startSyncWorker, startReconciliationWorker } from "./services/sync-worker.js"; const app = Fastify({ logger: true }); @@ -69,6 +70,7 @@ await app.register(seasonRoutes); await app.register(roleRoutes); await app.register(auditRoutes); await app.register(chatRoutes); +await app.register(systemAdminRoutes); const port = Number(process.env.PORT ?? 4000); app.listen({ port, host: "0.0.0.0" }).catch((err) => { diff --git a/backend/src/services/synapse.ts b/backend/src/services/synapse.ts index d99b4ea..256073b 100644 --- a/backend/src/services/synapse.ts +++ b/backend/src/services/synapse.ts @@ -191,6 +191,79 @@ export async function setRoomHistoryVisibility(roomId: string): Promise { ); } +export interface SynapseRoomSummary { + room_id: string; + name: string | null; + canonical_alias: string | null; + joined_members: number; + /** Milliseconds since epoch (see normalizeCreationTs). */ + creation_ts: number | null; +} + +export interface SynapseRoomPage { + rooms: SynapseRoomSummary[]; + offset: number; + total_rooms: number; + next_batch?: number; + prev_batch?: number; +} + +/** + * PROJ-14: Synapse's List Room API documents `creation_ts` as milliseconds, but + * older/other versions hand back seconds. Anything below ~2001 in ms terms is + * far more plausibly a seconds value, so scale it up rather than rendering + * every room as "created in 1970". + */ +function normalizeCreationTs(value: number | null | undefined): number | null { + if (typeof value !== "number" || !Number.isFinite(value)) return null; + return value < 1e12 ? value * 1000 : value; +} + +/** + * PROJ-14: lists rooms known to the homeserver. Paginated by Synapse itself — + * we pass `from`/`limit` straight through instead of buffering everything. + */ +export async function listRooms(params: { + from: number; + limit: number; + search?: string; +}): Promise { + const query = new URLSearchParams({ + from: String(params.from), + limit: String(params.limit), + order_by: "name", + }); + if (params.search) { + query.set("search_term", params.search); + } + + const page = await synapseFetch(`/_synapse/admin/v1/rooms?${query.toString()}`); + return { + ...page, + rooms: (page.rooms ?? []).map((room) => ({ + ...room, + creation_ts: normalizeCreationTs(room.creation_ts), + })), + }; +} + +/** + * PROJ-14: deletes and purges a room. Uses the v2 (asynchronous) delete + * endpoint, which returns a delete_id immediately and does the purge in the + * background — the v1 endpoint blocks for minutes on large rooms and would time + * the HTTP request out. + */ +export async function deleteRoom(roomId: string): Promise<{ deleteId: string | null }> { + const result = await synapseFetch<{ delete_id?: string }>( + `/_synapse/admin/v2/rooms/${encodeURIComponent(roomId)}`, + { + method: "DELETE", + body: JSON.stringify({ purge: true, block: false }), + }, + ); + return { deleteId: result?.delete_id ?? null }; +} + export async function addUserToRoom(roomId: string, userId: string): Promise { await synapseFetch(`/_synapse/admin/v1/join/${encodeURIComponent(roomId)}`, { method: "POST", diff --git a/features/PROJ-14-synapse-raum-uebersicht.md b/features/PROJ-14-synapse-raum-uebersicht.md index c141d96..1221678 100644 --- a/features/PROJ-14-synapse-raum-uebersicht.md +++ b/features/PROJ-14-synapse-raum-uebersicht.md @@ -1,8 +1,16 @@ # PROJ-14: Synapse Raum-Übersicht (System-Admin) -**Status:** Planned +**Status:** In Progress **Created:** 2026-09-12 +## Implementation Notes (Backend, 2026-09-12) +Backend umgesetzt, Frontend-Seite (`/system-admin` Route + Tabelle + Lösch-Dialog) steht noch aus. +- `backend/src/services/synapse.ts`: `listRooms()` (`GET /_synapse/admin/v1/rooms`, `from`/`limit`/`search_term` durchgereicht), `deleteRoom()` (v2 **async** Delete mit `purge: true` — v1 blockiert bei großen Räumen bis zum HTTP-Timeout), `normalizeCreationTs()` (Synapse liefert je nach Version Sekunden statt Millisekunden). +- `backend/src/routes/system-admin.ts` (neu, in `server.ts` registriert): `GET /api/system-admin/rooms` + `DELETE /api/system-admin/rooms/:roomId`, beide `preHandler: [app.authenticate, requireSystemAdmin]`. +- Keine neue Tabelle, keine Migration: `chat_rooms.matrix_room_id` ist bereits `unique` (Index vorhanden), Zuordnung via einem `inArray`-JOIN gegen `organizations`/`teams` (kein N+1). +- Abweichung/Einschränkung: `audit_logs.organization_id` ist NOT NULL — das Löschen eines **nicht zugeordneten** Raums kann daher nicht ins Audit-Log, nur ins Server-Log (`app.log.warn`). Zugeordnete Räume werden regulär als `system_admin.room.deleted` auditiert. +- Edge Cases: Synapse nicht erreichbar → 502 mit Fehlertext (nie leere Liste); Raum bereits gelöscht (404 von Synapse) → lokale Zuordnung wird trotzdem aufgeräumt, dann 404. + ## Summary Liste aller Matrix-Räume auf der angebundenen Synapse-Instanz für SYSTEM_ADMIN, unabhängig von TalkTime-Team-/Vereinszuordnung — nachgebaut aus synapse-admin's Raum-Übersicht. Nutzt Synapse Admin API (`GET /_synapse/admin/v1/rooms`), keine neue TalkTime-Datenbanktabelle nötig.