From b322d0c173ee48373370d23d4a5efd3d3e28e0a4 Mon Sep 17 00:00:00 2001 From: florianfederspiel Date: Mon, 18 May 2026 15:37:12 +0200 Subject: [PATCH] =?UTF-8?q?KI-AGENT:=20Erste=20Matrix-Backendintegration?= =?UTF-8?q?=20erg=C3=A4nzen?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .env.example | 4 + backend/src/index.ts | 2 + backend/src/modules/matrix.service.ts | 201 ++++++++++++++++++++++++++ backend/src/routes/communication.ts | 30 ++++ backend/src/utils/secrets.ts | 4 +- matrix/README.md | 10 ++ 6 files changed, 250 insertions(+), 1 deletion(-) create mode 100644 backend/src/modules/matrix.service.ts create mode 100644 backend/src/routes/communication.ts diff --git a/.env.example b/.env.example index 8b217f5..f79fc53 100644 --- a/.env.example +++ b/.env.example @@ -28,3 +28,7 @@ MATRIX_DEV_LIVEKIT_RTC_MAX_PORT=50100 MATRIX_DEV_TURN_PORT=3478 MATRIX_DEV_TURN_MIN_PORT=49160 MATRIX_DEV_TURN_MAX_PORT=49200 + +# Backend-Integration gegen den lokalen Matrix-Stack +MATRIX_HOMESERVER_URL=http://localhost:8008 +MATRIX_REGISTRATION_SHARED_SECRET=copy-from-matrix-dev-synapse-homeserver-yaml diff --git a/backend/src/index.ts b/backend/src/index.ts index a5be66f..221859f 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -31,6 +31,7 @@ import publiclinksAuthenticatedRoutes from "./routes/publiclinks/publiclinks-aut import wikiRoutes from "./routes/wiki"; import portalContractRoutes from "./routes/portal/contracts"; import mcpRoutes from "./routes/mcp"; +import communicationRoutes from "./routes/communication"; //Public Links import publiclinksNonAuthenticatedRoutes from "./routes/publiclinks/publiclinks-non-authenticated"; @@ -150,6 +151,7 @@ async function main() { await subApp.register(wikiRoutes); await subApp.register(portalContractRoutes); await subApp.register(mcpRoutes); + await subApp.register(communicationRoutes); },{prefix: "/api"}) diff --git a/backend/src/modules/matrix.service.ts b/backend/src/modules/matrix.service.ts new file mode 100644 index 0000000..4e14b77 --- /dev/null +++ b/backend/src/modules/matrix.service.ts @@ -0,0 +1,201 @@ +import { createHmac, randomBytes } from "node:crypto" +import { FastifyInstance } from "fastify" +import { authProfiles, authUsers } from "../../db/schema" +import { and, eq } from "drizzle-orm" +import { secrets } from "../utils/secrets" + +type MatrixErrorResponse = { + errcode?: string + error?: string +} + +const trimTrailingSlash = (value: string) => value.replace(/\/+$/, "") + +export function matrixService(server: FastifyInstance) { + const homeserverUrl = () => + trimTrailingSlash( + process.env.MATRIX_HOMESERVER_URL || + secrets.MATRIX_HOMESERVER_URL || + "http://localhost:8008" + ) + + const serverName = () => + process.env.MATRIX_SERVER_NAME || + secrets.MATRIX_SERVER_NAME || + "localhost" + + const registrationSharedSecret = () => + process.env.MATRIX_REGISTRATION_SHARED_SECRET || + secrets.MATRIX_REGISTRATION_SHARED_SECRET || + "" + + const matrixLocalpartForUser = (userId: string) => + `u_${userId.toLowerCase()}` + + const matrixUserIdForUser = (userId: string) => + `@${matrixLocalpartForUser(userId)}:${serverName()}` + + const buildSharedSecretMac = ( + nonce: string, + username: string, + password: string, + admin: boolean + ) => { + const hmac = createHmac("sha1", registrationSharedSecret()) + hmac.update(nonce) + hmac.update("\0") + hmac.update(username) + hmac.update("\0") + hmac.update(password) + hmac.update("\0") + hmac.update(admin ? "admin" : "notadmin") + return hmac.digest("hex") + } + + const getCurrentUserDisplayName = async (userId: string, tenantId: number | null) => { + if (tenantId) { + const [profile] = await server.db + .select({ + firstName: authProfiles.first_name, + lastName: authProfiles.last_name, + }) + .from(authProfiles) + .where(and( + eq(authProfiles.user_id, userId), + eq(authProfiles.tenant_id, tenantId) + )) + .limit(1) + + const profileName = [profile?.firstName, profile?.lastName] + .filter(Boolean) + .join(" ") + .trim() + + if (profileName) return profileName + } + + const [user] = await server.db + .select({ email: authUsers.email }) + .from(authUsers) + .where(eq(authUsers.id, userId)) + .limit(1) + + return user?.email || matrixUserIdForUser(userId) + } + + const requestJson = async (url: string, init?: RequestInit): Promise => { + const response = await fetch(url, init) + const text = await response.text() + const body = text ? JSON.parse(text) : {} + + if (!response.ok) { + const error = body as MatrixErrorResponse + throw Object.assign( + new Error(error.error || `Matrix request failed with ${response.status}`), + { + statusCode: response.status, + errcode: error.errcode, + body, + } + ) + } + + return body as T + } + + const getStatus = async () => { + const configured = Boolean(homeserverUrl() && serverName()) + + if (!configured) { + return { + configured: false, + homeserverUrl: homeserverUrl(), + serverName: serverName(), + reachable: false, + } + } + + try { + const versions = await requestJson<{ versions: string[] }>( + `${homeserverUrl()}/_matrix/client/versions` + ) + + return { + configured: true, + homeserverUrl: homeserverUrl(), + serverName: serverName(), + reachable: true, + versions: versions.versions, + } + } catch (err: any) { + return { + configured: true, + homeserverUrl: homeserverUrl(), + serverName: serverName(), + reachable: false, + error: err.message, + } + } + } + + const provisionCurrentUser = async (userId: string, tenantId: number | null) => { + if (!registrationSharedSecret()) { + throw Object.assign( + new Error("MATRIX_REGISTRATION_SHARED_SECRET is not configured"), + { statusCode: 503 } + ) + } + + const username = matrixLocalpartForUser(userId) + const matrixUserId = matrixUserIdForUser(userId) + const password = randomBytes(32).toString("base64url") + const displayName = await getCurrentUserDisplayName(userId, tenantId) + + const nonceResponse = await requestJson<{ nonce: string }>( + `${homeserverUrl()}/_synapse/admin/v1/register` + ) + + const mac = buildSharedSecretMac(nonceResponse.nonce, username, password, false) + + try { + await requestJson(`${homeserverUrl()}/_synapse/admin/v1/register`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + nonce: nonceResponse.nonce, + username, + password, + admin: false, + mac, + }), + }) + + return { + matrixUserId, + localpart: username, + displayName, + created: true, + alreadyExisted: false, + } + } catch (err: any) { + if (err.errcode === "M_USER_IN_USE") { + return { + matrixUserId, + localpart: username, + displayName, + created: false, + alreadyExisted: true, + } + } + + throw err + } + } + + return { + getStatus, + matrixUserIdForUser, + getCurrentUserDisplayName, + provisionCurrentUser, + } +} diff --git a/backend/src/routes/communication.ts b/backend/src/routes/communication.ts new file mode 100644 index 0000000..1430268 --- /dev/null +++ b/backend/src/routes/communication.ts @@ -0,0 +1,30 @@ +import { FastifyInstance } from "fastify" +import { matrixService } from "../modules/matrix.service" + +export default async function communicationRoutes(server: FastifyInstance) { + const matrix = matrixService(server) + + server.get("/communication/matrix/status", async () => { + return matrix.getStatus() + }) + + server.get("/communication/matrix/me", async (req) => { + const userId = req.user.user_id + + return { + matrixUserId: matrix.matrixUserIdForUser(userId), + displayName: await matrix.getCurrentUserDisplayName(userId, req.user.tenant_id), + } + }) + + server.post("/communication/matrix/me/provision", async (req, reply) => { + try { + return await matrix.provisionCurrentUser(req.user.user_id, req.user.tenant_id) + } catch (err: any) { + req.log.error(err) + return reply + .code(err.statusCode || 500) + .send({ error: err.message || "Matrix provisioning failed" }) + } + }) +} diff --git a/backend/src/utils/secrets.ts b/backend/src/utils/secrets.ts index 6abeaf8..80b88a2 100644 --- a/backend/src/utils/secrets.ts +++ b/backend/src/utils/secrets.ts @@ -38,6 +38,9 @@ export let secrets = { DOKUBOX_IMAP_PASSWORD: string OPENAI_API_KEY: string STIRLING_API_KEY: string + MATRIX_HOMESERVER_URL?: string + MATRIX_SERVER_NAME?: string + MATRIX_REGISTRATION_SHARED_SECRET?: string } export async function loadSecrets () { @@ -58,4 +61,3 @@ export async function loadSecrets () { console.log("✅ Secrets aus Infisical geladen"); console.log(Object.keys(secrets).length + " Stück") } - diff --git a/matrix/README.md b/matrix/README.md index d433965..7ac6f8c 100644 --- a/matrix/README.md +++ b/matrix/README.md @@ -172,3 +172,13 @@ docker compose --profile matrix-dev exec matrix-dev-synapse \ Anschließend Element Web unter `http://localhost:8080` öffnen und mit dem lokalen Matrix-Nutzer anmelden. Wenn FEDEO selbst parallel lokal laufen soll, starte die FEDEO-Dienste separat wie gewohnt. Der lokale Matrix-Stack ist absichtlich über direkte Ports erreichbar, damit er unabhängig von DNS, TLS und Traefik getestet werden kann. + +## Erste FEDEO-Backend-Integration + +Das Backend stellt geschützte Matrix-Endpunkte unter `/api/communication/matrix/*` bereit: + +- `GET /api/communication/matrix/status`: prüft Konfiguration und Erreichbarkeit des Matrix-Homeservers +- `GET /api/communication/matrix/me`: zeigt die aus dem FEDEO-Nutzer abgeleitete Matrix-ID +- `POST /api/communication/matrix/me/provision`: legt den Matrix-Account für den angemeldeten FEDEO-Nutzer per Synapse-Shared-Secret-Registrierung an + +Für lokale Provisionierung muss `MATRIX_REGISTRATION_SHARED_SECRET` aus `matrix/dev/synapse/homeserver.yaml` in der Backend-Umgebung gesetzt werden. Die lokale Synapse-Konfiguration ist absichtlich nicht versioniert, weil sie Secrets enthält.