diff --git a/.changeset/ready-jokes-melt.md b/.changeset/ready-jokes-melt.md new file mode 100644 index 00000000..edc1ee6f --- /dev/null +++ b/.changeset/ready-jokes-melt.md @@ -0,0 +1,21 @@ +--- +"draupnir": minor +--- + +Add managed management/admin room support to bot mode / appservice mode. + +\- Add managed management / admin room support + +- Add new config options for zero touch provisioning of managed rooms: + +\- managedManagementRoom and initialManager for bot mode + +\- managedAdminRoom and initialManager for appservice mode + +\- If managed modes are enabled while unmanaged rooms are defined the bot will +crash. + +\- Fix TrustedReporters being broken by managed management rooms due to explicit +reliance on a management room in the config. + +\- Add integration testing for Zero Touch Provisioning workflows. diff --git a/apps/draupnir/src/DraupnirBotMode.ts b/apps/draupnir/src/DraupnirBotMode.ts index 54857248..21ceeae1 100644 --- a/apps/draupnir/src/DraupnirBotMode.ts +++ b/apps/draupnir/src/DraupnirBotMode.ts @@ -48,7 +48,10 @@ import { ResultError } from "@gnuxie/typescript-result"; import { SafeModeCause, SafeModeReason } from "./safemode/SafeModeCause"; import { SafeModeBootOption } from "./safemode/BootOption"; import { TopLevelStores } from "./backingstore/DraupnirStores"; +import { MANAGEMENT_ROOM_ACCOUNT_DATA_EVENT_TYPE } from "./managedRoomAccountData"; +import { makeManagementRoom } from "./appservice/AppServiceDraupnirManager"; import { patchMatrixClient } from "./utils"; +import { resolveManagedRoom } from "./managedRoomBootstrap"; const log = new Logger("DraupnirBotMode"); @@ -116,17 +119,6 @@ export class DraupnirBotModeToggle implements BotModeTogle { if (!isStringUserID(clientUserID)) { throw new TypeError(`${clientUserID} is not a valid mxid`); } - if ( - !isStringRoomAlias(config.managementRoom) && - !isStringRoomID(config.managementRoom) - ) { - throw new TypeError( - `${config.managementRoom} is not a valid room id or alias` - ); - } - const configManagementRoomReference = MatrixRoomReference.fromRoomIDOrAlias( - config.managementRoom - ); const clientsInRoomMap = new StandardClientsInRoomMap(); const clientCapabilityFactory = new ClientCapabilityFactory( clientsInRoomMap, @@ -142,14 +134,37 @@ export class DraupnirBotModeToggle implements BotModeTogle { clientUserID, client ); - const managementRoom = ( - await clientPlatform - .toRoomResolver() - .resolveRoom(configManagementRoomReference) - ).expect("Unable to resolve Draupnir's management room"); - (await clientPlatform.toRoomJoiner().joinRoom(managementRoom)).expect( - "Unable to join Draupnir's management room" - ); + const managementRoom = await resolveManagedRoom({ + managedRoomEnabled: config.managedManagementRoom, + configuredRoom: config.managementRoom, + initialManager: config.initialManager, + clientUserID, + client, + clientPlatform, + accountDataEventType: MANAGEMENT_ROOM_ACCOUNT_DATA_EVENT_TYPE, + roomKindDescription: "management room", + parseConfiguredRoom(configuredRoom: string): MatrixRoomReference { + if ( + !isStringRoomAlias(configuredRoom) && + !isStringRoomID(configuredRoom) + ) { + throw new TypeError( + `${configuredRoom} is not a valid room id or alias` + ); + } + return MatrixRoomReference.fromRoomIDOrAlias(configuredRoom); + }, + async createManagedRoom(initialManager, resolvedClientUserID) { + return ( + await makeManagementRoom( + clientPlatform.toRoomCreator(), + clientPlatform.toClientCapabilitiesNegotiation(), + initialManager, + resolvedClientUserID + ) + ).expect("Unable to create managed management room"); + }, + }); const clientProvider = async (userID: StringUserID) => { if (userID !== clientUserID) { throw new TypeError(`Bot mode shouldn't be requesting any other mxids`); diff --git a/apps/draupnir/src/appservice/AppService.ts b/apps/draupnir/src/appservice/AppService.ts index 91bf0809..7768af73 100644 --- a/apps/draupnir/src/appservice/AppService.ts +++ b/apps/draupnir/src/appservice/AppService.ts @@ -55,6 +55,9 @@ import { SqliteRoomStateBackingStore } from "../backingstore/better-sqlite3/Sqli import { TopLevelStores } from "../backingstore/DraupnirStores"; import { patchMatrixClient } from "../utils"; import { Result } from "@gnuxie/typescript-result"; +import { ADMIN_ROOM_ACCOUNT_DATA_EVENT_TYPE } from "../managedRoomAccountData"; +import { makeManagementRoom } from "./AppServiceDraupnirManager"; +import { resolveManagedRoom } from "../managedRoomBootstrap"; const log = new Logger("AppService"); /** @@ -198,27 +201,51 @@ export class MjolnirAppService { joinedRoomsSafe(bridge.getBot().getClient()) ) ).expect("Unable to initialize client rooms for the appservice bot user"); - const botRoomJoiner = clientCapabilityFactory - .makeClientPlatform(botUserID, bridge.getBot().getClient()) - .toRoomJoiner(); - const adminRoom = (() => { - if (isStringRoomID(config.adminRoom)) { - return MatrixRoomReference.fromRoomID(config.adminRoom); - } else if (isStringRoomAlias(config.adminRoom)) { - return MatrixRoomReference.fromRoomIDOrAlias(config.adminRoom); - } else { - const parseResult = MatrixRoomReference.fromPermalink(config.adminRoom); + const botClient = bridge.getBot().getClient(); + const botClientPlatform = clientCapabilityFactory.makeClientPlatform( + botUserID, + botClient + ); + const botRoomJoiner = botClientPlatform.toRoomJoiner(); + const accessControlRoom = await resolveManagedRoom({ + managedRoomEnabled: config.managedAdminRoom, + configuredRoom: config.adminRoom, + initialManager: config.initialManager, + clientUserID: botUserID, + client: botClient, + clientPlatform: botClientPlatform, + accountDataEventType: ADMIN_ROOM_ACCOUNT_DATA_EVENT_TYPE, + roomKindDescription: "admin room", + parseConfiguredRoom(configuredRoom: string): MatrixRoomReference { + if (isStringRoomID(configuredRoom)) { + return MatrixRoomReference.fromRoomID(configuredRoom); + } + if (isStringRoomAlias(configuredRoom)) { + return MatrixRoomReference.fromRoomIDOrAlias(configuredRoom); + } + const parseResult = MatrixRoomReference.fromPermalink(configuredRoom); if (isError(parseResult)) { throw new TypeError( - `${config.adminRoom} needs to be a room id, alias or permalink` + `${configuredRoom} needs to be a room id, alias or permalink` ); } return parseResult.ok; - } - })(); - const accessControlRoom = ( - await botRoomJoiner.resolveRoom(adminRoom) - ).expect("Unable to resolve the admin room"); + }, + async createManagedRoom(initialManager, resolvedClientUserID) { + return ( + await makeManagementRoom( + botClientPlatform.toRoomCreator(), + botClientPlatform.toClientCapabilitiesNegotiation(), + initialManager, + resolvedClientUserID, + { + invitees: [initialManager], + roomName: `${initialManager}'s Draupnir Appservice Admin room`, + } + ) + ).expect("Unable to create managed admin room"); + }, + }); const appserviceBotPolicyRoomManager = await roomStateManagerFactory.getPolicyRoomManager(botUserID); const accessControl = ( diff --git a/apps/draupnir/src/appservice/AppServiceDraupnirManager.ts b/apps/draupnir/src/appservice/AppServiceDraupnirManager.ts index de700d63..72d58507 100644 --- a/apps/draupnir/src/appservice/AppServiceDraupnirManager.ts +++ b/apps/draupnir/src/appservice/AppServiceDraupnirManager.ts @@ -52,7 +52,7 @@ import { MatrixRoomID, } from "@the-draupnir-project/matrix-basic-types"; import { TopLevelStores } from "../backingstore/DraupnirStores"; -import { Result } from "@gnuxie/typescript-result"; +import { Result, ResultError } from "@gnuxie/typescript-result"; const log = new Logger("AppServiceDraupnirManager"); @@ -468,7 +468,11 @@ export async function makeManagementRoom( roomCreator: RoomCreator, clientCapabilitiesNegotiation: ClientCapabilitiesNegotiation, requestingUserID: StringUserID, - draupnirUserID: StringUserID + draupnirUserID: StringUserID, + options?: { + invitees?: StringUserID[]; + roomName?: string; + } ): Promise> { const capabilities = await clientCapabilitiesNegotiation.getClientCapabilities(); @@ -477,15 +481,20 @@ export async function makeManagementRoom( "Failed to fetch room versions from client capabilities" ); } - const isRoomVersionWithPriviligedCreators = - RoomVersionMirror.isVersionWithPrivilegedCreators( - capabilities.ok.capabilities["m.room_versions"].default + const defaultRoomVersion = + capabilities.ok.capabilities["m.room_versions"].default; + if (typeof defaultRoomVersion !== "string") { + return ResultError.Result( + "Client capabilities did not contain a valid default room version" ); + } + const isRoomVersionWithPrivilegedCreators = + RoomVersionMirror.isVersionWithPrivilegedCreators(defaultRoomVersion); return await roomCreator.createRoom({ preset: "private_chat", - invite: [requestingUserID], - name: `${requestingUserID}'s Draupnir`, - power_level_content_override: isRoomVersionWithPriviligedCreators + invite: options?.invitees ?? [requestingUserID], + name: options?.roomName ?? `${requestingUserID}'s Draupnir`, + power_level_content_override: isRoomVersionWithPrivilegedCreators ? { users: { [requestingUserID]: 150, diff --git a/apps/draupnir/src/appservice/config/config.example.yaml b/apps/draupnir/src/appservice/config/config.example.yaml index 45850c9d..09752b85 100644 --- a/apps/draupnir/src/appservice/config/config.example.yaml +++ b/apps/draupnir/src/appservice/config/config.example.yaml @@ -13,6 +13,12 @@ db: # See docs/access_control.md adminRoom: "#draupnir-admin:localhost:9999" +# Enable automatic admin-room recovery/creation on first boot. +managedAdminRoom: false + +# The initial manager to invite if the admin room has to be created. +# initialManager: "@admin:example.org" + # The directory the bot should store various bits of information in dataPath: "/data/storage" diff --git a/apps/draupnir/src/appservice/config/config.harness.yaml b/apps/draupnir/src/appservice/config/config.harness.yaml index 4d1b7201..c87c8724 100644 --- a/apps/draupnir/src/appservice/config/config.harness.yaml +++ b/apps/draupnir/src/appservice/config/config.harness.yaml @@ -8,6 +8,10 @@ db: adminRoom: "#draupnir-admin:localhost:9999" +managedAdminRoom: false + +# initialManager: "@admin:example.org" + # The directory the bot should store various bits of information in dataPath: "./test/harness/mjolnir-data/" diff --git a/apps/draupnir/src/appservice/config/config.ts b/apps/draupnir/src/appservice/config/config.ts index 66cd7e1f..21c8e5ae 100644 --- a/apps/draupnir/src/appservice/config/config.ts +++ b/apps/draupnir/src/appservice/config/config.ts @@ -14,6 +14,7 @@ import * as fs from "fs"; import { load } from "js-yaml"; import { Value } from "@sinclair/typebox/value"; import { EDStatic } from "matrix-protection-suite"; +import { StringUserIDSchema } from "matrix-protection-suite"; export function read(configPath: string): AppserviceConfig { const content = fs.readFileSync(configPath, "utf8"); @@ -81,10 +82,16 @@ export const AppserviceConfig = Type.Object({ }, { description: "Details for the database backend" } ), - adminRoom: Type.String({ - description: - "The admin room for the appservice bot. Not called managementRoom like draupnir on purpose, so they're not mixed in code somehow.", + managedAdminRoom: Type.Boolean({ + default: false, }), + initialManager: Type.Optional(StringUserIDSchema), + adminRoom: Type.Optional( + Type.String({ + description: + "The admin room for the appservice bot. Not called managementRoom like draupnir on purpose, so they're not mixed in code somehow.", + }) + ), roomStateBackingStore: Type.Optional( Type.Object( { diff --git a/apps/draupnir/src/config.ts b/apps/draupnir/src/config.ts index 77a73918..d702102b 100644 --- a/apps/draupnir/src/config.ts +++ b/apps/draupnir/src/config.ts @@ -1,3 +1,4 @@ +// SPDX-FileCopyrightText: 2026 Catalan Lover // Copyright 2022 Gnuxie // Copyright 2019, 2021 The Matrix.org Foundation C.I.C. // @@ -18,6 +19,7 @@ import Config from "config"; import path from "path"; import { SafeModeBootOption } from "./safemode/BootOption"; import { Logger, setGlobalLoggerProvider } from "matrix-protection-suite"; +import { StringUserID } from "@the-draupnir-project/matrix-basic-types"; LogService.setLogger(new RichConsoleLogger()); setGlobalLoggerProvider(new RichConsoleLogger()); @@ -84,7 +86,9 @@ export interface IConfig { /** Draupnir will accept invites from members of this space if `autojoinOnlyIfManager` is false. */ acceptInvitesFromSpace: string | undefined; recordIgnoredInvites: boolean; - managementRoom: string; + managedManagementRoom: boolean; + initialManager: StringUserID | undefined; + managementRoom: string | undefined; logLevel: "DEBUG" | "INFO" | "WARN" | "ERROR"; logMutedModules: string[]; verifyPermissionsOnStartup: boolean; @@ -205,6 +209,8 @@ const defaultConfig: IConfig = { acceptInvitesFromSpace: "!noop:example.org", autojoinOnlyIfManager: true, recordIgnoredInvites: false, + managedManagementRoom: false, + initialManager: undefined, managementRoom: "!noop:example.org", logLevel: "INFO", logMutedModules: ["MatrixHttpClient", "MatrixClientLite"], diff --git a/apps/draupnir/src/managedRoomAccountData.ts b/apps/draupnir/src/managedRoomAccountData.ts new file mode 100644 index 00000000..1d103092 --- /dev/null +++ b/apps/draupnir/src/managedRoomAccountData.ts @@ -0,0 +1,33 @@ +// SPDX-FileCopyrightText: 2026 Catalan Lover +// +// SPDX-License-Identifier: AFL-3.0 + +import { Type } from "@sinclair/typebox"; +import { EDStatic, StringRoomIDSchema } from "matrix-protection-suite"; +import { + BotSDKMatrixAccountData, + MatrixSendClient, +} from "matrix-protection-suite-for-matrix-bot-sdk"; + +export const MANAGEMENT_ROOM_ACCOUNT_DATA_EVENT_TYPE = + "space.draupnir.management.room"; +export const ADMIN_ROOM_ACCOUNT_DATA_EVENT_TYPE = "space.draupnir.admin.room"; + +export const ManagedRoomAccountDataRecordSchema = Type.Object({ + room_id: StringRoomIDSchema, +}); + +export type ManagedRoomAccountDataRecord = EDStatic< + typeof ManagedRoomAccountDataRecordSchema +>; + +export function makeManagedRoomAccountDataStore( + client: MatrixSendClient, + eventType: string +): BotSDKMatrixAccountData { + return new BotSDKMatrixAccountData( + eventType, + ManagedRoomAccountDataRecordSchema, + client + ); +} diff --git a/apps/draupnir/src/managedRoomBootstrap.ts b/apps/draupnir/src/managedRoomBootstrap.ts new file mode 100644 index 00000000..edabc0a2 --- /dev/null +++ b/apps/draupnir/src/managedRoomBootstrap.ts @@ -0,0 +1,101 @@ +// SPDX-FileCopyrightText: 2026 Catalan Lover +// +// SPDX-License-Identifier: AFL-3.0 + +import { ClientPlatform } from "matrix-protection-suite"; +import { MatrixSendClient } from "matrix-protection-suite-for-matrix-bot-sdk"; +import { + isStringUserID, + MatrixRoomID, + MatrixRoomReference, + StringUserID, +} from "@the-draupnir-project/matrix-basic-types"; +import { makeManagedRoomAccountDataStore } from "./managedRoomAccountData"; + +export type ManagedRoomBootstrapOptions = { + managedRoomEnabled: boolean; + configuredRoom: string | undefined; + initialManager: StringUserID | undefined; + clientUserID: StringUserID; + client: MatrixSendClient; + clientPlatform: ClientPlatform; + accountDataEventType: string; + roomKindDescription: string; + parseConfiguredRoom(configuredRoom: string): MatrixRoomReference; + createManagedRoom( + initialManager: StringUserID, + clientUserID: StringUserID + ): Promise; +}; + +export async function resolveManagedRoom( + options: ManagedRoomBootstrapOptions +): Promise { + if (options.managedRoomEnabled && options.configuredRoom !== undefined) { + throw new TypeError( + `managed ${options.roomKindDescription} mode cannot be used with an explicit ${options.roomKindDescription}; remove the configured room or disable managed mode` + ); + } + + const roomAccountData = makeManagedRoomAccountDataStore( + options.client, + options.accountDataEventType + ); + const roomResolver = options.clientPlatform.toRoomResolver(); + const roomJoiner = options.clientPlatform.toRoomJoiner(); + + if (options.managedRoomEnabled) { + const storedRoom = (await roomAccountData.requestAccountData()).expect( + `Unable to read managed ${options.roomKindDescription} account data` + ); + if (storedRoom !== undefined) { + const roomReference = MatrixRoomReference.fromRoomID(storedRoom.room_id); + const room = (await roomResolver.resolveRoom(roomReference)).expect( + `Unable to resolve managed Draupnir ${options.roomKindDescription}` + ); + (await roomJoiner.joinRoom(room)).expect( + `Unable to join managed Draupnir ${options.roomKindDescription}` + ); + return room; + } + + if (options.initialManager === undefined) { + throw new TypeError( + `managed ${options.roomKindDescription} mode is enabled but initialManager is not configured` + ); + } + if (!isStringUserID(options.initialManager)) { + throw new TypeError( + `${options.initialManager} is not a valid initial manager mxid` + ); + } + + const createdRoom = await options.createManagedRoom( + options.initialManager, + options.clientUserID + ); + ( + await roomAccountData.storeAccountData({ + room_id: createdRoom.toRoomIDOrAlias(), + }) + ).expect(`Unable to persist managed ${options.roomKindDescription}`); + return createdRoom; + } + + if (options.configuredRoom === undefined) { + throw new TypeError( + `${options.roomKindDescription} is required when managed ${options.roomKindDescription} mode is disabled` + ); + } + + const configuredRoomReference = options.parseConfiguredRoom( + options.configuredRoom + ); + const configuredRoom = ( + await roomResolver.resolveRoom(configuredRoomReference) + ).expect(`Unable to resolve Draupnir's ${options.roomKindDescription}`); + (await roomJoiner.joinRoom(configuredRoom)).expect( + `Unable to join Draupnir's ${options.roomKindDescription}` + ); + return configuredRoom; +} diff --git a/apps/draupnir/src/protections/TrustedReporters.ts b/apps/draupnir/src/protections/TrustedReporters.ts index 98a50272..ef9f1f0a 100644 --- a/apps/draupnir/src/protections/TrustedReporters.ts +++ b/apps/draupnir/src/protections/TrustedReporters.ts @@ -1,3 +1,4 @@ +// SPDX-FileCopyrightText: 2026 Catalan Lover // Copyright 2022 Gnuxie // Copyright 2022 The Matrix.org Foundation C.I.C. // @@ -194,7 +195,7 @@ export class TrustedReporters if (met.length > 0) { await this.draupnir.client.sendMessage( - this.draupnir.config.managementRoom, + this.draupnir.managementRoomID, { msgtype: "m.notice", body: diff --git a/apps/draupnir/test/appservice/integration/managedBootstrapStartTest.ts b/apps/draupnir/test/appservice/integration/managedBootstrapStartTest.ts new file mode 100644 index 00000000..901797ea --- /dev/null +++ b/apps/draupnir/test/appservice/integration/managedBootstrapStartTest.ts @@ -0,0 +1,40 @@ +// SPDX-FileCopyrightText: 2026 Catalan Lover +// +// SPDX-License-Identifier: AFL-3.0 + +/// + +import { StringUserID } from "@the-draupnir-project/matrix-basic-types"; +import { readTestConfig, setupHarnessWithConfig } from "../utils/harness"; +import { newTestUser } from "../../integration/clientHelper"; +import { MjolnirAppService } from "../../../src/appservice/AppService"; + +describe("Managed room bootstrap startup integration", function (this: Mocha.Suite) { + it("appservice mode reaches started stage with managed admin room enabled", async function (this: Mocha.Context) { + this.timeout(120000); + const config = readTestConfig(); + const initialManager = await newTestUser(config.homeserver.url, { + name: { contains: "managed-admin" }, + }); + config.managedAdminRoom = true; + config.adminRoom = undefined; + config.initialManager = (await initialManager.getUserId()) as StringUserID; + + let appservice: MjolnirAppService | undefined; + try { + appservice = await setupHarnessWithConfig(config, { + ensureAdminRoomAlias: false, + }); + if (appservice.accessControlRoomID.length === 0) { + throw new TypeError( + "Managed appservice bootstrap did not produce an admin room" + ); + } + } finally { + initialManager.stop(); + if (appservice) { + await appservice.close(); + } + } + }); +}); diff --git a/apps/draupnir/test/appservice/utils/harness.ts b/apps/draupnir/test/appservice/utils/harness.ts index 80392257..789e9a62 100644 --- a/apps/draupnir/test/appservice/utils/harness.ts +++ b/apps/draupnir/test/appservice/utils/harness.ts @@ -1,3 +1,4 @@ +// SPDX-FileCopyrightText: 2026 Catalan Lover // Copyright 2022 Gnuxie // Copyright 2022 The Matrix.org Foundation C.I.C. // SPDX-FileCopyrightText: 2026 Catalan Lover @@ -33,33 +34,38 @@ export async function setupHarness(): Promise { } export async function setupHarnessWithConfig( - config: AppserviceConfig + config: AppserviceConfig, + options: { + ensureAdminRoomAlias?: boolean; + } = {} ): Promise { - const utilityUser = await newTestUser(config.homeserver.url, { - name: { contains: "utility" }, - isAdmin: true, - }); - if ( - typeof config.adminRoom !== "string" || - !isStringRoomAlias(config.adminRoom) - ) { - throw new TypeError( - "This test expects the harness config to have a room alias." + if (options.ensureAdminRoomAlias ?? true) { + const utilityUser = await newTestUser(config.homeserver.url, { + name: { contains: "utility" }, + isAdmin: true, + }); + if ( + typeof config.adminRoom !== "string" || + !isStringRoomAlias(config.adminRoom) + ) { + throw new TypeError( + "This test expects the harness config to have a room alias." + ); + } + await utilityUser + .deleteRoomAlias(config.adminRoom) + // Ignore if alias doesn't exist yet. + .catch((_: unknown) => undefined); + const adminRoomID = await ensureAliasedRoomExists( + utilityUser, + config.adminRoom + ); + await utilityUser.setUserPowerLevel( + "@draupnir-moderation:localhost:9999", + adminRoomID, + 100 ); } - await utilityUser - .deleteRoomAlias(config.adminRoom) - // Ignore if alias doesn't exist yet. - .catch((_: unknown) => undefined); - const adminRoomID = await ensureAliasedRoomExists( - utilityUser, - config.adminRoom - ); - await utilityUser.setUserPowerLevel( - "@draupnir-moderation:localhost:9999", - adminRoomID, - 100 - ); return await MjolnirAppService.run( 9000, config, diff --git a/apps/draupnir/test/integration/managedBootstrapStartTest.ts b/apps/draupnir/test/integration/managedBootstrapStartTest.ts new file mode 100644 index 00000000..d8b75bab --- /dev/null +++ b/apps/draupnir/test/integration/managedBootstrapStartTest.ts @@ -0,0 +1,52 @@ +// SPDX-FileCopyrightText: 2026 Catalan Lover +// +// SPDX-License-Identifier: AFL-3.0 AND Apache-2.0 + +import { configRead, getStoragePath, IConfig } from "../../src/config"; +import { DraupnirBotModeToggle } from "../../src/DraupnirBotMode"; +import { makeTopLevelStores } from "../../src/backingstore/DraupnirStores"; +import { draupnirClient, makeBotModeToggle } from "./mjolnirSetupUtils"; +import { DefaultEventDecoder } from "matrix-protection-suite"; +import { StringUserID } from "@the-draupnir-project/matrix-basic-types"; + +function makeManagedBotModeConfig(): IConfig { + const config = configRead(); + const username = `managed_bootstrap_${Date.now()}_${Math.floor(Math.random() * 10000)}`; + const initialManager = `managed_bootstrap_manager_${Date.now()}_${Math.floor(Math.random() * 10000)}`; + const homeserverDomain = new URL(config.homeserverUrl).host; + config.pantalaimon.username = username; + config.pantalaimon.password = username; + config.managementRoom = undefined; + config.managedManagementRoom = true; + config.initialManager = + `@${initialManager}:${homeserverDomain}` as StringUserID; + return config; +} + +describe("Managed room bootstrap startup integration", function (this: Mocha.Suite) { + it("bot mode reaches started stage with managed management room enabled", async function (this: Mocha.Context) { + this.timeout(120000); + const config = makeManagedBotModeConfig(); + const storagePath = getStoragePath(config.dataPath); + const stores = makeTopLevelStores(storagePath, DefaultEventDecoder, { + isRoomStateBackingStoreEnabled: + config.roomStateBackingStore.enabled ?? false, + }); + + let toggle: DraupnirBotModeToggle | undefined; + try { + toggle = await makeBotModeToggle(config, { + stores, + allowSafeMode: false, + deleteManagementRoomAliasOnStart: false, + ensureManagementRoomAlias: false, + }); + await draupnirClient()?.start(); + await toggle.encryptionInitialized(); + } finally { + await toggle?.[Symbol.asyncDispose](); + draupnirClient()?.stop(); + stores.dispose(); + } + }); +}); diff --git a/apps/draupnir/test/integration/mjolnirSetupUtils.ts b/apps/draupnir/test/integration/mjolnirSetupUtils.ts index 429f6d42..697222b9 100644 --- a/apps/draupnir/test/integration/mjolnirSetupUtils.ts +++ b/apps/draupnir/test/integration/mjolnirSetupUtils.ts @@ -1,3 +1,4 @@ +// SPDX-FileCopyrightText: 2026 Catalan Lover // Copyright 2022 - 2024 Gnuxie // Copyright 2021 - 2022 The Matrix.org Foundation C.I.C. // @@ -144,11 +145,13 @@ export async function makeBotModeToggle( eraseAccountData, allowSafeMode, deleteManagementRoomAliasOnStart: deleteAlias, + ensureManagementRoomAlias, }: { stores: TopLevelStores; eraseAccountData?: boolean; allowSafeMode?: boolean; deleteManagementRoomAliasOnStart?: boolean; + ensureManagementRoomAlias?: boolean; } = { stores: { dispose() {} } } ): Promise { await configureMjolnir(config); @@ -175,13 +178,48 @@ export async function makeBotModeToggle( config.homeserverUrl, await client.getUserId() ); - // defaults to true - if (deleteAlias === undefined || deleteAlias) { - await client - .deleteRoomAlias(config.managementRoom) - .catch((_: unknown) => undefined); + if (config.managedManagementRoom && config.initialManager !== undefined) { + const managerMatch = /^@([^:]+):/.exec(config.initialManager); + if (managerMatch === null || managerMatch[1] === undefined) { + throw new TypeError( + `${config.initialManager} is not a valid initial manager mxid` + ); + } + const managerLocalpart = managerMatch[1]; + try { + await registerUser( + config.homeserverUrl, + managerLocalpart, + managerLocalpart, + managerLocalpart, + true + ); + } catch (e) { + if ( + typeof e !== "object" || + e === null || + !("body" in e) || + typeof e.body !== "object" || + e.body === null || + !("errcode" in e.body) || + e.body.errcode !== "M_USER_IN_USE" + ) { + throw e; + } + } + } + // defaults to true + if (ensureManagementRoomAlias ?? true) { + if (config.managementRoom === undefined) { + throw new TypeError("managementRoom is required when ensuring alias"); + } + if (deleteAlias === undefined || deleteAlias) { + await client + .deleteRoomAlias(config.managementRoom) + .catch((_: unknown) => undefined); + } + await ensureAliasedRoomExists(client, config.managementRoom); } - await ensureAliasedRoomExists(client, config.managementRoom); const toggle = await DraupnirBotModeToggle.create( client, new SafeMatrixEmitterWrapper(client, DefaultEventDecoder), diff --git a/config/default.yaml b/config/default.yaml index e4e42064..75c7f89a 100644 --- a/config/default.yaml +++ b/config/default.yaml @@ -52,6 +52,12 @@ acceptInvitesFromSpace: "!example:example.org" # Whether Draupnir should report ignored invites to the management room (if autojoinOnlyIfManager is true). recordIgnoredInvites: false +# Enable automatic management-room recovery/creation on first boot. +managedManagementRoom: false + +# The initial manager to invite if the management room has to be created. +# initialManager: "@admin:example.org" + # The room ID (or room alias) of the management room, anyone in this room can issue commands to Draupnir. # # Draupnir has no more granular access controls other than this, be sure you trust everyone in this room - secure it!