Appservice Improvements Fixed (#1061)

* Add Version command to AS mode admin room

This replicates the status command output giving version string data in a convenient format to AS Admins.

* Add Provision Limit and Force Provision Command

* Add SelfServiceProvisioning Config Flag that defaults to False

This defaults to false due to the currently known problem where Self Service Provisioning ACLs are broken.

So this config exists to allow that codepath to be disabled while we work on fixing it.

* Add Copyright Headers

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* Split Version Command integration test out into new file.

* Fix Self Service Provision Message

Co-authored-by: Gnuxie <50846879+Gnuxie@users.noreply.github.com>

* FIx AS.TS

* Clean up Cat's tests

---------

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
Co-authored-by: Gnuxie <50846879+Gnuxie@users.noreply.github.com>
Co-authored-by: gnuxie <Gnuxie@protonmail.com>
This commit is contained in:
authored and GitHub committed 2026-04-02 11:33:09 +01:00
1 parent 5e1563752b
commit 30611aaf26
13 files changed
+428 -51

No files matched your search

+5
View File
@@ -0,0 +1,5 @@
---
"draupnir": patch
---
Add version command for appservice mode.
+6
View File
@@ -0,0 +1,6 @@
---
"draupnir": major
---
Require Self Service Provisioning to be Manually Enabled in config. Breaks
existing workflows.
+5
View File
@@ -0,0 +1,5 @@
---
"draupnir": minor
---
Add appservice provision limits and add appservice force provision command.
+89 -46
View File
@@ -1,5 +1,6 @@
// Copyright 2022 Gnuxie <Gnuxie@protonmail.com>
// Copyright 2022 The Matrix.org Foundation C.I.C.
// SPDX-FileCopyrightText: 2026 Catalan Lover <catalanlover@protonmail.com>
//
// SPDX-License-Identifier: AFL-3.0 AND Apache-2.0
//
@@ -185,6 +186,7 @@ export class MjolnirAppService {
const serverName = config.homeserver.domain;
const mjolnirManager = await AppServiceDraupnirManager.makeDraupnirManager(
serverName,
config.maxDraupnirsPerUser ?? 1,
dataStore,
bridge,
accessControl,
@@ -263,60 +265,101 @@ export class MjolnirAppService {
log.info(
`${mxEvent.sender} has sent an invitation to the appservice bot ${this.bridge.botUserId}, attempting to provision them a draupnir`
);
// Join the room and try to send the welcome flow
const client = this.bridge.getBot().getClient();
try {
await this.bridge.getBot().getClient().joinRoom(mxEvent.room_id);
await this.bridge
.getBot()
.getClient()
.sendText(
// Join the room so we can notify the requester and then reject the invite.
try {
await client.joinRoom(mxEvent.room_id);
} catch (e: unknown) {
log.error(
`Failed to join the room by ${mxEvent.sender} to process provisioning invite`,
e
);
return;
}
if (!this.config.allowSelfServiceProvisioning) {
await client
.sendText(
mxEvent.room_id,
"Self-service provisioning is disabled. Please ask an admin to provision a Draupnir for you."
)
.catch((e: unknown) => {
log.error(
`Failed to notify ${mxEvent.sender} that self-service provisioning is disabled`,
e
);
});
return;
}
try {
await client.sendText(
mxEvent.room_id,
"Your Draupnir is currently being provisioned. Please wait while we set up the rooms."
);
} catch (e: unknown) {
log.error(
`Failed to join the room by ${mxEvent.sender} to display the welcome flow`,
e
);
}
try {
const result = await this.draupnirManager.provisionNewDraupnir(
mxEvent.sender as StringUserID
);
if (isError(result)) {
} catch (e: unknown) {
log.error(
`Failed to send provisioning welcome flow to ${mxEvent.sender}; aborting provisioning`,
e
);
// We don´t want to continue with provisioning because we don´t have a working communications channel with the user.
return;
}
// Ideallly we need to rework provisionNewDraupnir because its current state does not catch all expected errors.
let provisioningFailed = false;
try {
const provisionResult = await this.draupnirManager.provisionNewDraupnir(
mxEvent.sender as StringUserID
);
if (isError(provisionResult)) {
log.error(
`Failed to provision a draupnir for ${mxEvent.sender} after they invited ${this.bridge.botUserId}`,
provisionResult.error
);
provisioningFailed = true;
}
} catch (e: unknown) {
log.error(
`Failed to provision a draupnir for ${mxEvent.sender} after they invited ${this.bridge.botUserId}`,
result.error
e
);
provisioningFailed = true;
}
if (provisioningFailed) {
try {
await client.sendText(
mxEvent.room_id,
"Please make sure you are allowed to provision a bot. Otherwise please notify the admin. The provisioning request was rejected."
);
} catch (e: unknown) {
log.error(
`Failed to send provisioning failure flow to ${mxEvent.sender}`,
e
);
}
return;
}
// Send a notice that the invite must be accepted.
try {
await client.sendText(
mxEvent.room_id,
"Please accept the invitations to the newly provisioned rooms. These will be the home of your Draupnir Instance. This room will not be used in the future."
);
} catch (e: unknown) {
log.error(
`Failed to send provisioning success flow to ${mxEvent.sender}`,
e
);
}
// Send a notice that the invite must be accepted
await this.bridge
.getBot()
.getClient()
.sendText(
mxEvent.room_id,
"Please accept the inviations to the newly provisioned rooms. These will be the home of your Draupnir Instance. This room will not be used in the future."
);
} catch (e: unknown) {
log.error(
`Failed to provision a draupnir for ${mxEvent.sender} after they invited ${this.bridge.botUserId}:`,
e
);
// continue, we still want to reject this invitation.
// Send a notice that the provisioning failed
await this.bridge
.getBot()
.getClient()
.sendText(
mxEvent.room_id,
"Please make sure you are allowed to provision a bot. Otherwise please notify the admin. The provisioning request was rejected."
);
}
try {
// reject the invite to keep the room clean and make sure the invetee doesn't get confused and think this is their draupnir.
await this.bridge.getBot().getClient().leaveRoom(mxEvent.room_id);
} catch (e: unknown) {
log.warn("Unable to reject an invite to a room", e);
} finally {
try {
// Reject the invite to keep the room clean and make sure the invitee doesn't get confused and think this is their draupnir.
await client.leaveRoom(mxEvent.room_id);
} catch (e: unknown) {
log.warn("Unable to reject an invite to a room", e);
}
}
}
@@ -1,5 +1,6 @@
// Copyright 2022 - 2024 Gnuxie <Gnuxie@protonmail.com>
// Copyright 2022 The Matrix.org Foundation C.I.C.
// SPDX-FileCopyrightText: 2026 Catalan Lover <catalanlover@protonmail.com>
//
// SPDX-License-Identifier: AFL-3.0 AND Apache-2.0
//
@@ -66,6 +67,7 @@ export class AppServiceDraupnirManager {
private constructor(
private readonly serverName: string,
private readonly maxDraupnirsPerUser: number,
private readonly dataStore: DataStore,
private readonly bridge: Bridge,
private readonly accessControl: AccessControl,
@@ -103,6 +105,7 @@ export class AppServiceDraupnirManager {
*/
public static async makeDraupnirManager(
serverName: string,
maxDraupnirsPerUser: number,
dataStore: DataStore,
bridge: Bridge,
accessControl: AccessControl,
@@ -115,6 +118,7 @@ export class AppServiceDraupnirManager {
): Promise<AppServiceDraupnirManager> {
const draupnirManager = new AppServiceDraupnirManager(
serverName,
maxDraupnirsPerUser,
dataStore,
bridge,
accessControl,
@@ -199,6 +203,19 @@ export class AppServiceDraupnirManager {
*/
public async provisionNewDraupnir(
requestingUserID: StringUserID
): Promise<ActionResult<MjolnirRecord>> {
return await this.provisionNewDraupnirInternal(requestingUserID, false);
}
public async provisionNewDraupnirBypassingUserLimit(
requestingUserID: StringUserID
): Promise<ActionResult<MjolnirRecord>> {
return await this.provisionNewDraupnirInternal(requestingUserID, true);
}
private async provisionNewDraupnirInternal(
requestingUserID: StringUserID,
bypassUserLimit: boolean
): Promise<ActionResult<MjolnirRecord>> {
const access = this.accessControl.getUserAccess(requestingUserID);
if (access.outcome !== Access.Allowed) {
@@ -208,7 +225,10 @@ export class AppServiceDraupnirManager {
}
const provisionedMjolnirs =
await this.dataStore.lookupByOwner(requestingUserID);
if (provisionedMjolnirs.length === 0) {
if (
bypassUserLimit ||
provisionedMjolnirs.length < this.maxDraupnirsPerUser
) {
const mjolnirLocalPart = `draupnir_${randomUUID()}`;
const mjIntent = await this.makeMatrixIntent(mjolnirLocalPart);
const draupnirUserID = StringUserID(mjIntent.userId);
@@ -263,7 +283,7 @@ export class AppServiceDraupnirManager {
return Ok(record);
} else {
return ActionError.Result(
`User: ${requestingUserID} has already provisioned ${provisionedMjolnirs.length} draupnirs.`
`User: ${requestingUserID} has already provisioned ${provisionedMjolnirs.length} draupnirs, which meets the configured limit of ${this.maxDraupnirsPerUser}.`
);
}
}
@@ -1,4 +1,5 @@
// SPDX-FileCopyrightText: 2024 Gnuxie <Gnuxie@protonmail.com>
// SPDX-FileCopyrightText: 2026 Catalan Lover <catalanlover@protonmail.com>
//
// SPDX-License-Identifier: Apache-2.0
//
@@ -17,10 +18,14 @@ import {
AppserviceListUnstartedCommand,
AppserviceRestartDraupnirCommand,
} from "./ListCommand";
import { AppserviceProvisionForUserCommand } from "./ProvisionCommand";
import { AppserviceVersionCommand } from "./VersionCommand";
AppserviceBotCommands.internCommand(AppserviceBotHelpCommand, ["admin", "help"])
.internCommand(AppserviceAllowCommand, ["admin", "allow"])
.internCommand(AppserviceRemoveCommand, ["admin", "remove"])
.internCommand(AppserviceProvisionForUserCommand, ["admin", "provision"])
.internCommand(AppserviceVersionCommand, ["admin", "version"])
.internCommand(AppserviceRestartDraupnirCommand, ["admin", "restart"])
.internCommand(AppserviceListUnstartedCommand, [
"admin",
@@ -0,0 +1,46 @@
// SPDX-FileCopyrightText: 2026 Catalan Lover <catalanlover@protonmail.com>
//
// SPDX-License-Identifier: AFL-3.0
import { AppserviceAdaptorContext } from "./AppserviceBotPrerequisite";
import { ActionResult, isError, Ok } from "matrix-protection-suite";
import {
MatrixUserIDPresentationType,
describeCommand,
tuple,
} from "@the-draupnir-project/interface-manager";
import { AppserviceBotInterfaceAdaptor } from "./AppserviceBotInterfaceAdaptor";
export const AppserviceProvisionForUserCommand = describeCommand({
parameters: tuple({
name: "user",
acceptor: MatrixUserIDPresentationType,
description:
"The user to provision a bot for, bypassing user allocation limit",
}),
summary:
"Provision a new Draupnir for a user while bypassing the per-user allocation limit.",
async executor(
context: AppserviceAdaptorContext,
_info,
_keywords,
_rest,
user
): Promise<ActionResult<void>> {
const result =
await context.appservice.draupnirManager.provisionNewDraupnirBypassingUserLimit(
user.toString()
);
if (isError(result)) {
return result;
}
return Ok(undefined);
},
});
AppserviceBotInterfaceAdaptor.describeRenderer(
AppserviceProvisionForUserCommand,
{
isAlwaysSupposedToUseDefaultRenderer: true,
}
);
@@ -0,0 +1,48 @@
// SPDX-FileCopyrightText: 2026 Catalan Lover <catalanlover@protonmail.com>
//
// SPDX-License-Identifier: AFL-3.0
import { AppserviceAdaptorContext } from "./AppserviceBotPrerequisite";
import { ActionResult, Ok, isError } from "matrix-protection-suite";
import {
DeadDocumentJSX,
describeCommand,
} from "@the-draupnir-project/interface-manager";
import { AppserviceBotInterfaceAdaptor } from "./AppserviceBotInterfaceAdaptor";
import { CURRENT_BRANCH, SOFTWARE_VERSION } from "../../config";
type AppserviceVersionInfo = {
version: string;
branch: string;
};
export const AppserviceVersionCommand = describeCommand({
summary:
"Show Draupnir version and branch information for this appservice deployment.",
parameters: [],
async executor(
_context: AppserviceAdaptorContext
): Promise<ActionResult<AppserviceVersionInfo>> {
return Ok({
version: SOFTWARE_VERSION,
branch: CURRENT_BRANCH,
});
},
});
AppserviceBotInterfaceAdaptor.describeRenderer(AppserviceVersionCommand, {
JSXRenderer(result) {
if (isError(result)) {
return Ok(undefined);
}
return Ok(
<root>
<b>Version: </b>
<code>{result.ok.version}</code>
<br />
<b>Branch: </b>
<code>{result.ok.branch}</code>
</root>
);
},
});
@@ -16,5 +16,14 @@ adminRoom: "#draupnir-admin:localhost:9999"
# The directory the bot should store various bits of information in
dataPath: "/data/storage"
# Maximum number of bots each user can provision.
# Defaults to 1 when omitted.
maxDraupnirsPerUser: 1
# Allow users to self-provision by inviting the appservice bot.
# When false, provisioning must be done through admin commands.
# Defaults to false when omitted.
allowSelfServiceProvisioning: false
roomStateBackingStore:
enabled: false
@@ -1,5 +1,6 @@
// Copyright 2022 Gnuxie <Gnuxie@protonmail.com>
// Copyright 2022 The Matrix.org Foundation C.I.C.
// SPDX-FileCopyrightText: 2026 Catalan Lover <catalanlover@protonmail.com>
//
// SPDX-License-Identifier: AFL-3.0 AND Apache-2.0
//
@@ -100,5 +101,20 @@ export const AppserviceConfig = Type.Object({
"A directory where the appservice can storestore persistent data.",
default: "/data/storage",
}),
maxDraupnirsPerUser: Type.Optional(
Type.Integer({
description:
"Maximum number of Draupnir instances a single user may provision.",
minimum: 1,
default: 1,
})
),
allowSelfServiceProvisioning: Type.Optional(
Type.Boolean({
description:
"Allow users to self-provision by inviting the appservice bot. When false, provisioning must be done through admin commands.",
default: false,
})
),
logging: Type.Optional(LoggingOptsSchema),
});
@@ -1,5 +1,6 @@
// Copyright 2022 Gnuxie <Gnuxie@protonmail.com>
// Copyright 2022 The Matrix.org Foundation C.I.C.
// SPDX-FileCopyrightText: 2026 Catalan Lover <catalanlover@protonmail.com>
//
// SPDX-License-Identifier: AFL-3.0 AND Apache-2.0
//
@@ -8,20 +9,26 @@
// https://github.com/matrix-org/mjolnir
// </text>
import { readTestConfig, setupHarness } from "../utils/harness";
import { readTestConfig, setupHarnessWithConfig } from "../utils/harness";
import { newTestUser } from "../../integration/clientHelper";
import { getFirstReply } from "../../integration/commands/commandUtils";
import { MatrixClient } from "@vector-im/matrix-bot-sdk";
import { MjolnirAppService } from "../../../src/appservice/AppService";
import { isOk } from "matrix-protection-suite";
import { StringUserID } from "@the-draupnir-project/matrix-basic-types";
interface Context extends Mocha.Context {
// FIXME: Ideally the matrix client interface would implement [Symbol.dispose]
// to call stop and then we could use await using in the tests.
moderator?: MatrixClient;
allowedUser?: MatrixClient;
appservice?: MjolnirAppService | undefined;
}
describe("Test that the app service can provision a draupnir on invite of the appservice bot", function () {
afterEach(function (this: Context) {
this.moderator?.stop();
this.allowedUser?.stop();
if (this.appservice) {
return this.appservice.close();
} else {
@@ -31,7 +38,8 @@ describe("Test that the app service can provision a draupnir on invite of the ap
});
it("A moderator that requests a draupnir via a matrix invitation will be invited to a new policy and management room", async function (this: Context) {
const config = readTestConfig();
this.appservice = await setupHarness();
config.allowSelfServiceProvisioning = true;
this.appservice = await setupHarnessWithConfig(config);
const appservice = this.appservice;
// create a user to act as the moderator
const moderator = await newTestUser(config.homeserver.url, {
@@ -76,4 +84,107 @@ describe("Test that the app service can provision a draupnir on invite of the ap
});
});
});
it("Users cannot self-service provision when self service provisioning is disabled", async function (this: Context) {
const config = readTestConfig();
config.allowSelfServiceProvisioning = false;
this.appservice = await setupHarnessWithConfig(config);
const appservice = this.appservice;
const moderator = await newTestUser(config.homeserver.url, {
name: { contains: "self-service-disabled" },
});
this.moderator = moderator;
const roomWeWantProtecting = await moderator.createRoom();
const roomsInvitedTo: string[] = [];
moderator.on("room.invite", (roomId: string) => {
roomsInvitedTo.push(roomId);
});
await moderator.start();
await moderator.inviteUser(
appservice.bridge.getBot().getUserId(),
roomWeWantProtecting
);
// Give the appservice time to process the invite and ensure no management/policy rooms were created.
await new Promise((resolve) => setTimeout(resolve, 3000));
if (roomsInvitedTo.length !== 0) {
throw new TypeError(
`Expected no self-service provisioning invites when disabled, got ${roomsInvitedTo.length}`
);
}
});
it("Allows provisioning multiple bots per user up to maxDraupnirsPerUser", async function (this: Context) {
const config = readTestConfig();
config.maxDraupnirsPerUser = 2;
this.appservice = await setupHarnessWithConfig(config);
const appservice = this.appservice;
const allowedUser = await newTestUser(config.homeserver.url, {
name: { contains: "multi-allowed" },
});
this.allowedUser = allowedUser;
const allowedUserID = (await allowedUser.getUserId()) as StringUserID;
(await appservice.accessControl.allow(allowedUserID)).expect(
"Failed to allow user to provision bot"
);
(
await appservice.draupnirManager.provisionNewDraupnir(allowedUserID)
).expect("Failed to provision first bot for user");
(
await appservice.draupnirManager.provisionNewDraupnir(allowedUserID)
).expect("Failed to provision second bot for user");
const thirdProvisionResult =
await appservice.draupnirManager.provisionNewDraupnir(allowedUserID);
if (isOk(thirdProvisionResult)) {
throw new TypeError(
`Expected provisioning to fail after reaching limit for user ${allowedUserID}`
);
}
allowedUser.stop();
});
it("Admin provisioning path can bypass per-user allocation limit", async function (this: Context) {
const config = readTestConfig();
config.maxDraupnirsPerUser = 1;
this.appservice = await setupHarnessWithConfig(config);
const appservice = this.appservice;
const allowedUser = await newTestUser(config.homeserver.url, {
name: { contains: "admin-bypass-allowed" },
});
this.allowedUser = allowedUser;
const allowedUserID = (await allowedUser.getUserId()) as StringUserID;
(await appservice.accessControl.allow(allowedUserID)).expect(
"Failed to allow admin bypass user"
);
(
await appservice.draupnirManager.provisionNewDraupnir(allowedUserID)
).expect("Failed to provision initial bot for admin bypass user");
(
await appservice.commands.sendTextCommand(
"@test-admin:localhost:9999" as StringUserID,
`!admin provision ${allowedUserID}`
)
).expect("Failed to provision bot while bypassing user limit");
const ownedDraupnirs =
await appservice.draupnirManager.getOwnedDraupnir(allowedUserID);
if (ownedDraupnirs.length !== 2) {
throw new TypeError(
`Expected 2 draupnirs after admin bypass, got ${ownedDraupnirs.length}`
);
}
allowedUser.stop();
});
});
@@ -0,0 +1,43 @@
// SPDX-FileCopyrightText: 2026 Catalan Lover <catalanlover@protonmail.com>
//
// SPDX-License-Identifier: AFL-3.0
import expect from "expect";
import { MjolnirAppService } from "../../../src/appservice/AppService";
import { setupHarness } from "../utils/harness";
import { isError } from "matrix-protection-suite";
import { StringUserID } from "@the-draupnir-project/matrix-basic-types";
interface Context extends Mocha.Context {
appservice?: MjolnirAppService;
}
describe("Test appservice version command", function () {
beforeEach(async function (this: Context) {
this.appservice = await setupHarness();
});
afterEach(function (this: Context) {
if (this.appservice) {
return this.appservice.close();
} else {
console.warn("Missing Appservice in this context, so cannot stop it.");
return Promise.resolve(); // TS7030: Not all code paths return a value.
}
});
it("Can return version and branch from the appservice admin room", async function (this: Context) {
const appservice = this.appservice;
if (appservice === undefined) {
throw new TypeError(`Test setup failed`);
}
const result = await appservice.commands.sendTextCommand(
"@test:localhost:9999" as StringUserID,
"!admin version"
);
if (isError(result)) {
throw new TypeError(`Command should have succeeded`);
}
expect(result.ok).toHaveProperty("version");
expect(result.ok).toHaveProperty("branch");
});
});
+21 -1
View File
@@ -1,5 +1,6 @@
// Copyright 2022 Gnuxie <Gnuxie@protonmail.com>
// Copyright 2022 The Matrix.org Foundation C.I.C.
// SPDX-FileCopyrightText: 2026 Catalan Lover <catalanlover@protonmail.com>
//
// SPDX-License-Identifier: AFL-3.0 AND Apache-2.0
//
@@ -28,8 +29,15 @@ export function readTestConfig(): AppserviceConfig {
export async function setupHarness(): Promise<MjolnirAppService> {
const config = readTestConfig();
return await setupHarnessWithConfig(config);
}
export async function setupHarnessWithConfig(
config: AppserviceConfig
): Promise<MjolnirAppService> {
const utilityUser = await newTestUser(config.homeserver.url, {
name: { contains: "utility" },
isAdmin: true,
});
if (
typeof config.adminRoom !== "string" ||
@@ -39,7 +47,19 @@ export async function setupHarness(): Promise<MjolnirAppService> {
"This test expects the harness config to have a room alias."
);
}
await ensureAliasedRoomExists(utilityUser, config.adminRoom);
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,