KI-AGENT: Matrix-Mandanten-Space provisionieren
This commit is contained in:
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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" })
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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 () {
|
||||
|
||||
Reference in New Issue
Block a user