KI-AGENT: Matrix-Mandanten-Space provisionieren

This commit is contained in:
2026-05-18 17:11:52 +02:00
parent c893574cb1
commit d0de3cb92e
5 changed files with 418 additions and 87 deletions

View File

@@ -32,3 +32,4 @@ 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
MATRIX_SERVICE_USER_LOCALPART=fedeo_service

View File

@@ -2,7 +2,7 @@ import { createHash, createHmac, randomBytes } from "node:crypto"
import { existsSync, readFileSync } from "node:fs"
import { resolve } from "node:path"
import { FastifyInstance } from "fastify"
import { authProfiles, authUsers } from "../../db/schema"
import { authProfiles, authUsers, tenants } from "../../db/schema"
import { and, eq } from "drizzle-orm"
import { secrets } from "../utils/secrets"
@@ -50,6 +50,11 @@ const normalizeMatrixLocalpartSeed = (value: string) => {
return normalized || "user"
}
const normalizeMatrixAliasSeed = (value: string) =>
normalizeMatrixLocalpartSeed(value)
.replace(/[.=]/g, "_")
.replace(/_+/g, "_")
export function matrixService(server: FastifyInstance) {
const homeserverUrl = () =>
trimTrailingSlash(
@@ -69,6 +74,16 @@ export function matrixService(server: FastifyInstance) {
readLocalDevRegistrationSharedSecret() ||
""
const serviceUserLocalpart = () =>
process.env.MATRIX_SERVICE_USER_LOCALPART ||
secrets.MATRIX_SERVICE_USER_LOCALPART ||
"fedeo_service"
const serviceUserPassword = () =>
createHmac("sha256", registrationSharedSecret())
.update(`${serverName()}:fedeo-service-user`)
.digest("base64url")
const getUserIdentitySeed = async (userId: string, tenantId: number | null) => {
const [user] = await server.db
.select({ email: authUsers.email })
@@ -99,6 +114,14 @@ export function matrixService(server: FastifyInstance) {
const matrixUserIdForUser = async (userId: string, tenantId: number | null) =>
`@${await matrixLocalpartForUser(userId, tenantId)}:${serverName()}`
const tenantSpaceAliasLocalpart = (tenant: { id: number, short?: string | null, name?: string | null }) => {
const seed = normalizeMatrixAliasSeed(tenant.short || tenant.name || `tenant_${tenant.id}`)
return `fedeo_${seed}_${tenant.id}`
}
const tenantSpaceAlias = (tenant: { id: number, short?: string | null, name?: string | null }) =>
`#${tenantSpaceAliasLocalpart(tenant)}:${serverName()}`
const buildSharedSecretMac = (
nonce: string,
username: string,
@@ -116,6 +139,69 @@ export function matrixService(server: FastifyInstance) {
return hmac.digest("hex")
}
const registerWithSharedSecret = async (
username: string,
password: string,
admin: boolean
) => {
const nonceResponse = await requestJson<{ nonce: string }>(
`${homeserverUrl()}/_synapse/admin/v1/register`
)
const mac = buildSharedSecretMac(nonceResponse.nonce, username, password, admin)
return requestJson(`${homeserverUrl()}/_synapse/admin/v1/register`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
nonce: nonceResponse.nonce,
username,
password,
admin,
mac,
}),
})
}
const loginMatrixUser = async (username: string, password: string) => {
return requestJson<{
access_token: string
user_id: string
}>(`${homeserverUrl()}/_matrix/client/v3/login`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
type: "m.login.password",
identifier: {
type: "m.id.user",
user: username,
},
password,
}),
})
}
const ensureServiceAccessToken = async () => {
if (!registrationSharedSecret()) {
throw Object.assign(
new Error("MATRIX_REGISTRATION_SHARED_SECRET is not configured"),
{ statusCode: 503 }
)
}
const username = serviceUserLocalpart()
const password = serviceUserPassword()
try {
await registerWithSharedSecret(username, password, true)
} catch (err: any) {
if (err.errcode !== "M_USER_IN_USE") {
throw err
}
}
return loginMatrixUser(username, password)
}
const getCurrentUserDisplayName = async (userId: string, tenantId: number | null) => {
if (tenantId) {
const [profile] = await server.db
@@ -167,6 +253,16 @@ export function matrixService(server: FastifyInstance) {
return body as T
}
const requestMatrixJson = async <T>(path: string, accessToken: string, init?: RequestInit): Promise<T> => {
return requestJson<T>(`${homeserverUrl()}${path}`, {
...init,
headers: {
...(init?.headers || {}),
Authorization: `Bearer ${accessToken}`,
},
})
}
const getStatus = async () => {
const configured = Boolean(homeserverUrl() && serverName())
@@ -218,24 +314,8 @@ export function matrixService(server: FastifyInstance) {
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,
}),
})
await registerWithSharedSecret(username, password, false)
return {
matrixUserId,
@@ -259,10 +339,132 @@ export function matrixService(server: FastifyInstance) {
}
}
const getCurrentTenant = async (tenantId: number | null) => {
if (!tenantId) {
throw Object.assign(
new Error("No active tenant selected"),
{ statusCode: 400 }
)
}
const [tenant] = await server.db
.select({
id: tenants.id,
name: tenants.name,
short: tenants.short,
})
.from(tenants)
.where(eq(tenants.id, tenantId))
.limit(1)
if (!tenant) {
throw Object.assign(
new Error("Tenant not found"),
{ statusCode: 404 }
)
}
return tenant
}
const getTenantSpaceStatus = async (tenantId: number | null) => {
const tenant = await getCurrentTenant(tenantId)
const alias = tenantSpaceAlias(tenant)
try {
const directoryEntry = await requestJson<{
room_id: string
servers: string[]
}>(`${homeserverUrl()}/_matrix/client/v3/directory/room/${encodeURIComponent(alias)}`)
return {
tenantId: tenant.id,
tenantName: tenant.name,
alias,
exists: true,
roomId: directoryEntry.room_id,
servers: directoryEntry.servers,
}
} catch (err: any) {
if (err.statusCode === 404 || err.errcode === "M_NOT_FOUND") {
return {
tenantId: tenant.id,
tenantName: tenant.name,
alias,
exists: false,
roomId: null,
servers: [],
}
}
throw err
}
}
const provisionCurrentTenantSpace = async (userId: string, tenantId: number | null) => {
const tenant = await getCurrentTenant(tenantId)
const existing = await getTenantSpaceStatus(tenant.id)
const userAccount = await provisionCurrentUser(userId, tenant.id)
if (existing.exists) {
return {
...existing,
created: false,
alreadyExisted: true,
invitedUserId: userAccount.matrixUserId,
}
}
const serviceLogin = await ensureServiceAccessToken()
const aliasLocalpart = tenantSpaceAliasLocalpart(tenant)
const createdRoom = await requestMatrixJson<{ room_id: string }>(
"/_matrix/client/v3/createRoom",
serviceLogin.access_token,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
creation_content: {
type: "m.space",
},
name: `FEDEO · ${tenant.name}`,
topic: `Kommunikationsbereich für ${tenant.name}`,
preset: "private_chat",
visibility: "private",
room_alias_name: aliasLocalpart,
invite: [userAccount.matrixUserId],
initial_state: [
{
type: "m.room.history_visibility",
state_key: "",
content: {
history_visibility: "invited",
},
},
],
}),
}
)
return {
tenantId: tenant.id,
tenantName: tenant.name,
alias: tenantSpaceAlias(tenant),
exists: true,
created: true,
alreadyExisted: false,
roomId: createdRoom.room_id,
invitedUserId: userAccount.matrixUserId,
serviceUserId: serviceLogin.user_id,
}
}
return {
getStatus,
matrixUserIdForUser,
getCurrentUserDisplayName,
provisionCurrentUser,
getTenantSpaceStatus,
provisionCurrentTenantSpace,
}
}

View File

@@ -27,4 +27,26 @@ export default async function communicationRoutes(server: FastifyInstance) {
.send({ error: err.message || "Matrix provisioning failed" })
}
})
server.get("/communication/matrix/tenant-space", async (req, reply) => {
try {
return await matrix.getTenantSpaceStatus(req.user.tenant_id)
} catch (err: any) {
req.log.error(err)
return reply
.code(err.statusCode || 500)
.send({ error: err.message || "Matrix tenant space status failed" })
}
})
server.post("/communication/matrix/tenant-space/provision", async (req, reply) => {
try {
return await matrix.provisionCurrentTenantSpace(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 tenant space provisioning failed" })
}
})
}

View File

@@ -41,6 +41,7 @@ export let secrets = {
MATRIX_HOMESERVER_URL?: string
MATRIX_SERVER_NAME?: string
MATRIX_REGISTRATION_SHARED_SECRET?: string
MATRIX_SERVICE_USER_LOCALPART?: string
}
export async function loadSecrets () {

View File

@@ -4,9 +4,12 @@ const { $api } = useNuxtApp()
const status = ref(null)
const identity = ref(null)
const tenantSpace = ref(null)
const provisionResult = ref(null)
const tenantSpaceProvisionResult = ref(null)
const loading = ref(false)
const provisioning = ref(false)
const tenantSpaceProvisioning = ref(false)
const lastUpdated = ref(null)
const statusItems = computed(() => [
@@ -45,13 +48,15 @@ const statusItems = computed(() => [
const loadMatrixInfo = async () => {
loading.value = true
try {
const [statusRes, identityRes] = await Promise.all([
const [statusRes, identityRes, tenantSpaceRes] = await Promise.all([
$api("/api/communication/matrix/status"),
$api("/api/communication/matrix/me")
$api("/api/communication/matrix/me"),
$api("/api/communication/matrix/tenant-space")
])
status.value = statusRes
identity.value = identityRes
tenantSpace.value = tenantSpaceRes
lastUpdated.value = new Date()
} catch (error) {
toast.add({
@@ -91,6 +96,37 @@ const provisionMatrixAccount = async () => {
}
}
const provisionTenantSpace = async () => {
tenantSpaceProvisioning.value = true
try {
const res = await $api("/api/communication/matrix/tenant-space/provision", {
method: "POST"
})
tenantSpaceProvisionResult.value = res
tenantSpace.value = {
tenantId: res.tenantId,
tenantName: res.tenantName,
alias: res.alias,
exists: true,
roomId: res.roomId,
servers: res.servers || []
}
toast.add({
title: res.alreadyExisted ? "Mandanten-Space ist bereits vorhanden" : "Mandanten-Space erstellt",
color: "success"
})
} catch (error) {
toast.add({
title: "Mandanten-Space konnte nicht erstellt werden",
color: "error"
})
} finally {
tenantSpaceProvisioning.value = false
}
}
const formatDateTime = (value) => {
if (!value) return "-"
@@ -156,6 +192,7 @@ onMounted(loadMatrixInfo)
</div>
<div class="grid gap-4 lg:grid-cols-[minmax(0,1fr)_360px]">
<div class="space-y-4">
<UCard :ui="{ root: 'rounded-lg' }">
<template #header>
<div class="flex items-center gap-2">
@@ -231,6 +268,74 @@ onMounted(loadMatrixInfo)
</template>
</UCard>
<UCard :ui="{ root: 'rounded-lg' }">
<template #header>
<div class="flex items-center gap-2">
<UIcon name="i-heroicons-building-office-2" class="size-5 text-primary" />
<h2 class="text-base font-semibold text-highlighted">
Mandanten-Space
</h2>
</div>
</template>
<div class="space-y-4">
<div class="grid gap-3 sm:grid-cols-2">
<div>
<p class="text-xs font-medium uppercase text-muted">
Alias
</p>
<p class="mt-1 break-all font-mono text-sm text-highlighted">
{{ tenantSpace?.alias || "-" }}
</p>
</div>
<div>
<p class="text-xs font-medium uppercase text-muted">
Status
</p>
<UBadge
class="mt-1"
:color="tenantSpace?.exists ? 'success' : 'neutral'"
variant="soft"
>
{{ tenantSpace?.exists ? "Vorhanden" : "Noch nicht erstellt" }}
</UBadge>
</div>
</div>
<div>
<p class="text-xs font-medium uppercase text-muted">
Raum-ID
</p>
<p class="mt-1 break-all font-mono text-sm text-highlighted">
{{ tenantSpace?.roomId || "-" }}
</p>
</div>
<UAlert
v-if="tenantSpaceProvisionResult"
icon="i-heroicons-check-circle"
color="success"
variant="soft"
:title="tenantSpaceProvisionResult.alreadyExisted ? 'Mandanten-Space vorhanden' : 'Mandanten-Space erstellt'"
:description="tenantSpaceProvisionResult.alias"
/>
</div>
<template #footer>
<div class="flex justify-end">
<UButton
icon="i-heroicons-plus"
:loading="tenantSpaceProvisioning"
:disabled="!status?.reachable || !status?.provisioningConfigured"
@click="provisionTenantSpace"
>
Mandanten-Space erstellen
</UButton>
</div>
</template>
</UCard>
</div>
<UCard :ui="{ root: 'rounded-lg' }">
<template #header>
<div class="flex items-center gap-2">
@@ -244,7 +349,7 @@ onMounted(loadMatrixInfo)
<div class="space-y-3 text-sm text-muted">
<div class="flex gap-2">
<UIcon name="i-heroicons-building-office-2" class="mt-0.5 size-4 shrink-0" />
<span>Mandanten-Space automatisch anlegen.</span>
<span>Team- und Projekt-Räume im Mandanten-Space anlegen.</span>
</div>
<div class="flex gap-2">
<UIcon name="i-heroicons-users" class="mt-0.5 size-4 shrink-0" />