Add functionality for config.protectAllJoinedRooms via a protection + adjacent changes. (#711)

* Initial ProtectAllJoinedRoomsProtection.

We need something like this to implement `config.protectAllJoinedRooms`,
we also need something to go alongside that removes rooms as they
are added or removed.

it will probably be a behaviour of the same protection that we will
split out.

* Move it cos i cba and we need to merge with a protection to unprotect

on leave and ban.

* IDK i keep getting distracted i need my notebook back aaaa.

* Create RoomsSetBehaviourProtection out of redundant protections.

* Add toggle for RoomsSetBehaviour.

* Enable the RoomsSetBehaviour protection by default.

* Add the behaviour protection to protections index.

* Whoopsie unprotected parted rooms should actually call itself.

* Fix bugs meow.

* handleExternalInvite -> handleExternalMembership

* Make sure leave events get propagated in bot mode.

* Add batcher to ProtectJoinedRooms component.

* Hook into handleExternalMembership for RoomsSetBehaviour.

* Remove crap from fixtures.ts

* leave all rooms when starting integration tests.

* Change how protections are informed of membership.

* Add test for Joinig and protecting rooms on invite.

* Tidy up UnprotectPartedRooms rendering.

* Update for MPS v2.7.0

Added

- Generic item batching is now available for protections to use by
  using the `StandardBatcher`.

Changed

- `Task` has been improved to be more liberal in the closures it
  accepts.  And `Task` now has more options for logging how tasks have
  failed.

- The `Protection` callback `handleExternalInvite` has been renamed to
  `handleExternalMembership`.

Fixed

- An issue where adding rooms to the protected rooms set more than
  once could sometimes cause duplicate events to be propagated.

* Fix typo mare.

* Stop protecting rooms automatically when config.protectAllJoinedRooms is false.

* Update CHANGELOG.md
This commit is contained in:
Gnuxie
2025-02-01 17:53:10 +00:00
committed by GitHub
parent b4bf6b2c0b
commit acf0a406de
14 changed files with 520 additions and 19 deletions
+16
View File
@@ -14,6 +14,22 @@ and this project adheres to
## [Unreleased] - None
### Fixed
- `config.protectAllJoinedRooms` was unimplemented in versions `v2.0.2` and
below. This went under the radar in the beta programme because it would have
only been detectable for first time testers migrating over. Reported by
@cremesk.
- Draupnir will now automatically unprotect rooms when the bot is kicked, and
send an alert to the management room.
### Added
- `RoomSetBehaviourProtection` to add the
`config.protectAllJoinedRoomsFunctionality`. This is also responsible for
unprotecting rooms as the bot is removed from them.
## [v2.0.2] 2025-01-24
### Added
+2 -2
View File
@@ -63,8 +63,8 @@
"jsdom": "^24.0.0",
"matrix-appservice-bridge": "^10.3.1",
"matrix-bot-sdk": "npm:@vector-im/matrix-bot-sdk@^0.7.1-element.6",
"matrix-protection-suite": "npm:@gnuxie/matrix-protection-suite@2.6.0",
"matrix-protection-suite-for-matrix-bot-sdk": "npm:@gnuxie/matrix-protection-suite-for-matrix-bot-sdk@2.6.0",
"matrix-protection-suite": "npm:@gnuxie/matrix-protection-suite@2.7.0",
"matrix-protection-suite-for-matrix-bot-sdk": "npm:@gnuxie/matrix-protection-suite-for-matrix-bot-sdk@2.7.0",
"pg": "^8.8.0",
"yaml": "^2.3.2"
},
+7 -3
View File
@@ -16,6 +16,7 @@ import {
EventReport,
LoggableConfigTracker,
Logger,
Membership,
MembershipEvent,
Ok,
PolicyRoomManager,
@@ -50,7 +51,6 @@ import { RendererMessageCollector } from "./capabilities/RendererMessageCollecto
import { DraupnirRendererMessageCollector } from "./capabilities/DraupnirRendererMessageCollector";
import { renderProtectionFailedToStart } from "./protections/ProtectedRoomsSetRenderers";
import { draupnirStatusInfo, renderStatusInfo } from "./commands/StatusCommand";
import { isInvitationForUser } from "./protections/invitation/inviteCore";
import {
StringRoomID,
StringUserID,
@@ -319,9 +319,13 @@ export class Draupnir implements Client, MatrixAdaptorContext {
public handleTimelineEvent(roomID: StringRoomID, event: RoomEvent): void {
if (
Value.Check(MembershipEvent, event) &&
isInvitationForUser(event, this.clientUserID)
event.state_key === this.clientUserID &&
// if the membership is join, make sure that we filter out protected rooms.
(event.content.membership === Membership.Join
? !this.protectedRoomsSet.isProtectedRoom(roomID)
: true)
) {
this.protectedRoomsSet.handleExternalInvite(roomID, event);
this.protectedRoomsSet.handleExternalMembership(roomID, event);
}
this.managementRoomMessageListener(roomID, event);
void Task(
+4
View File
@@ -96,6 +96,10 @@ export class DraupnirBotModeToggle implements BotModeTogle {
this.roomStateManagerFactory.handleTimelineEvent(roomID, event);
this.clientsInRoomMap.handleTimelineEvent(roomID, event);
});
this.matrixEmitter.on("room.leave", (roomID, event) => {
this.roomStateManagerFactory.handleTimelineEvent(roomID, event);
this.clientsInRoomMap.handleTimelineEvent(roomID, event);
});
}
public static async create(
client: MatrixSendClient,
@@ -17,6 +17,7 @@ import {
import { RedactionSynchronisationProtection } from "./RedactionSynchronisation";
import { PolicyChangeNotification } from "./PolicyChangeNotification";
import { JoinRoomsOnInviteProtection } from "./invitation/JoinRoomsOnInviteProtection";
import { RoomsSetBehaviour } from "./ProtectedRooms/RoomsSetBehaviourProtection";
export const DefaultEnabledProtectionsMigration =
new SchemedDataManager<MjolnirEnabledProtectionsEvent>([
@@ -143,4 +144,25 @@ export const DefaultEnabledProtectionsMigration =
[DRAUPNIR_SCHEMA_VERSION_KEY]: 5,
});
},
async function enableRoomsSetBehaviourProtection(input, toVersion) {
if (!Value.Check(MjolnirEnabledProtectionsEvent, input)) {
return ActionError.Result(
`The data for ${MjolnirEnabledProtectionsEventType} is corrupted.`
);
}
const enabledProtections = new Set(input.enabled);
const protection = findProtection(RoomsSetBehaviour.name);
if (protection === undefined) {
const message = `Cannot find the ${RoomsSetBehaviour.name} protection`;
return ActionException.Result(message, {
exception: new TypeError(message),
exceptionKind: ActionExceptionKind.Unknown,
});
}
enabledProtections.add(protection.name);
return Ok({
enabled: [...enabledProtections],
[DRAUPNIR_SCHEMA_VERSION_KEY]: toVersion,
});
},
]);
@@ -19,6 +19,7 @@ import "./MessageIsMedia";
import "./MessageIsVoice";
import "./NewJoinerProtection";
import "./PolicyChangeNotification";
import "./ProtectedRooms/RoomsSetBehaviourProtection";
import "./TrustedReporters";
import "./WordList";
@@ -0,0 +1,114 @@
// SPDX-FileCopyrightText: 2025 Gnuxie <Gnuxie@protonmail.com>
//
// SPDX-License-Identifier: AFL-3.0
import {
MatrixRoomReference,
StringRoomID,
StringUserID,
} from "@the-draupnir-project/matrix-basic-types";
import {
ClientRooms,
ConstantPeriodItemBatch,
isError,
Logger,
MembershipChange,
MembershipChangeType,
MembershipEvent,
ProtectedRoomsSet,
RoomMessageSender,
RoomSetResultBuilder,
StandardBatcher,
Task,
} from "matrix-protection-suite";
import { sendMatrixEventsFromDeadDocument } from "../../commands/interface-manager/MPSMatrixInterfaceAdaptor";
import { renderRoomSetResult } from "../../capabilities/CommonRenderers";
import { DeadDocumentJSX } from "@the-draupnir-project/interface-manager";
const log = new Logger("ProtectAllJoinedRooms");
export class ProtectedJoinedRooms {
private readonly batcher = new StandardBatcher(
() =>
new ConstantPeriodItemBatch<StringRoomID, void>(
this.syncProtectedRooms.bind(this),
{ waitPeriodMS: 1000 }
)
);
public constructor(
private readonly clientUserID: StringUserID,
private readonly managementRoomID: StringRoomID,
private readonly protectedRoomsSet: ProtectedRoomsSet,
private readonly clientRooms: ClientRooms,
private readonly roomMessageSender: RoomMessageSender
) {
// nothing to do.
}
handleMembershipChange(changes: MembershipChange[]): void {
for (const change of changes) {
if (change.userID === this.clientUserID) {
switch (change.membershipChangeType) {
case MembershipChangeType.NoChange: {
continue;
}
default: {
this.batcher.add(change.roomID);
return;
}
}
}
}
}
handleExternalMembership(
roomID: StringRoomID,
_event: MembershipEvent
): void {
this.batcher.add(roomID);
}
public async syncProtectedRooms() {
const policyRooms =
this.protectedRoomsSet.issuerManager.allWatchedLists.map((profile) =>
profile.room.toRoomIDOrAlias()
);
const roomsToProtect =
this.clientRooms.currentRevision.allJoinedRooms.filter((roomID) => {
return (
!policyRooms.includes(roomID) &&
!this.protectedRoomsSet.isProtectedRoom(roomID)
);
});
const setResult = new RoomSetResultBuilder();
for (const roomID of roomsToProtect) {
const protectResult =
await this.protectedRoomsSet.protectedRoomsManager.addRoom(
MatrixRoomReference.fromRoomID(roomID)
);
if (isError(protectResult)) {
log.error("Unable to protect the room", roomID, protectResult.error);
}
setResult.addResult(roomID, protectResult);
}
if (setResult.getResult().map.size === 0) {
return;
} else {
void Task(
sendMatrixEventsFromDeadDocument(
this.roomMessageSender,
this.managementRoomID,
<root>
{renderRoomSetResult(setResult.getResult(), {
summary: <p>Protecting new rooms.</p>,
})}
</root>,
{}
),
{
description: "Report newly protected rooms to the managmeent room.",
}
);
}
}
}
@@ -0,0 +1,99 @@
// SPDX-FileCopyrightText: 2025 Gnuxie <Gnuxie@protonmail.com>
//
// SPDX-License-Identifier: AFL-3.0
import {
AbstractProtection,
describeProtection,
MembershipChange,
MembershipEvent,
ProtectedRoomsSet,
ProtectionDescription,
RoomMembershipRevision,
UnknownConfig,
} from "matrix-protection-suite";
import { DraupnirProtection } from "../Protection";
import { Draupnir } from "../../Draupnir";
import { Ok, Result } from "@gnuxie/typescript-result";
import { ProtectedJoinedRooms } from "./ProtectJoinedRooms";
import { UnprotectPartedRooms } from "./UnprotectPartedRooms";
import { StringRoomID } from "@the-draupnir-project/matrix-basic-types";
export type RoomsSetBehaviourCapabailities = Record<string, never>;
export type RoomsSetBehaviourSettings = UnknownConfig;
export type RoomsSetBehaviourDescription = ProtectionDescription<
Draupnir,
RoomsSetBehaviourSettings,
RoomsSetBehaviourCapabailities
>;
export class RoomsSetBehaviour
extends AbstractProtection<RoomsSetBehaviourDescription>
implements DraupnirProtection<RoomsSetBehaviourDescription>
{
private readonly protectJoinedRooms = new ProtectedJoinedRooms(
this.draupnir.clientUserID,
this.draupnir.managementRoomID,
this.protectedRoomsSet,
this.draupnir.clientRooms,
this.draupnir.clientPlatform.toRoomMessageSender()
);
private readonly unprotectedPartedRooms = new UnprotectPartedRooms(
this.draupnir.clientUserID,
this.draupnir.managementRoomID,
this.protectedRoomsSet.protectedRoomsManager,
this.draupnir.clientPlatform.toRoomMessageSender()
);
public constructor(
description: RoomsSetBehaviourDescription,
capabilities: RoomsSetBehaviourCapabailities,
protectedRoomsSet: ProtectedRoomsSet,
private readonly draupnir: Draupnir
) {
super(description, capabilities, protectedRoomsSet, {});
if (this.draupnir.config.protectAllJoinedRooms) {
void this.protectJoinedRooms.syncProtectedRooms();
}
}
public handleMembershipChange(
_revision: RoomMembershipRevision,
changes: MembershipChange[]
): Promise<Result<void>> {
if (this.draupnir.config.protectAllJoinedRooms) {
this.protectJoinedRooms.handleMembershipChange(changes);
}
for (const change of changes) {
this.unprotectedPartedRooms.handleMembershipChange(change);
}
return Promise.resolve(Ok(undefined));
}
public handleExternalMembership(
roomID: StringRoomID,
event: MembershipEvent
): void {
if (this.draupnir.config.protectAllJoinedRooms) {
this.protectJoinedRooms.handleExternalMembership(roomID, event);
}
}
}
describeProtection<RoomsSetBehaviourCapabailities, Draupnir>({
name: RoomsSetBehaviour.name,
description:
"Unprotects parted rooms and update the list of protected rooms.",
capabilityInterfaces: {},
defaultCapabilities: {},
factory(description, protectedRoomsSet, draupnir, capabilities, _settings) {
return Ok(
new RoomsSetBehaviour(
description,
capabilities,
protectedRoomsSet,
draupnir
)
);
},
});
@@ -0,0 +1,99 @@
// SPDX-FileCopyrightText: 2025 Gnuxie <Gnuxie@protonmail.com>
//
// SPDX-License-Identifier: AFL-3.0
import {
MatrixRoomReference,
StringRoomID,
StringUserID,
} from "@the-draupnir-project/matrix-basic-types";
import {
isOk,
MembershipChange,
MembershipChangeType,
ProtectedRoomsManager,
RoomMessageSender,
Task,
} from "matrix-protection-suite";
import { sendMatrixEventsFromDeadDocument } from "../../commands/interface-manager/MPSMatrixInterfaceAdaptor";
import {
renderMentionPill,
renderRoomPill,
} from "../../commands/interface-manager/MatrixHelpRenderer";
import { DeadDocumentJSX } from "@the-draupnir-project/interface-manager";
export class UnprotectPartedRooms {
constructor(
private readonly clientUserID: StringUserID,
private readonly managementRoomID: StringRoomID,
private readonly protectedRoomsManager: ProtectedRoomsManager,
private readonly messageSender: RoomMessageSender
) {
// nothing to do.
}
public async handlePartedRoom(change: MembershipChange): Promise<void> {
const room = MatrixRoomReference.fromRoomID(change.roomID);
const unprotectResult = await this.protectedRoomsManager.removeRoom(room);
const removalDescription = (
<fragment>
Draupnir has been removed from {renderRoomPill(room)} by{" "}
{renderMentionPill(change.sender, change.sender)}
{change.content.reason ? (
<fragment>
{" "}
for reason: <code>{change.content.reason}</code>
</fragment>
) : (
""
)}
.
</fragment>
);
if (isOk(unprotectResult)) {
void Task(
sendMatrixEventsFromDeadDocument(
this.messageSender,
this.managementRoomID,
<root>{removalDescription} The room is now unprotected.</root>,
{}
),
{
description:
"Report recently unprotected rooms to the management room.",
}
);
} else {
void Task(
sendMatrixEventsFromDeadDocument(
this.messageSender,
this.managementRoomID,
<root>
{removalDescription} Draupnir could not unprotect the room. Please
use <code>!draupnir rooms remove {room.toRoomIDOrAlias()}</code> if
the room is still marked as protected.
</root>,
{}
),
{
description:
"Report recently unprotected rooms to the management room.",
}
);
}
}
public handleMembershipChange(change: MembershipChange): void {
if (change.userID === this.clientUserID) {
if (!this.protectedRoomsManager.isProtectedRoom(change.roomID)) {
return;
}
switch (change.membershipChangeType) {
case MembershipChangeType.Banned:
case MembershipChangeType.Kicked:
case MembershipChangeType.Left:
void this.handlePartedRoom(change);
}
}
}
}
@@ -83,7 +83,7 @@ export class JoinRoomsOnInviteProtection
this.watchRoomsOnInvite.handleProtectionDisable();
}
handleExternalInvite(roomID: StringRoomID, event: MembershipEvent): void {
handleExternalMembership(roomID: StringRoomID, event: MembershipEvent): void {
if (!isInvitationForUser(event, this.protectedRoomsSet.userID)) {
return;
}
-5
View File
@@ -21,7 +21,6 @@ import {
makeBotModeToggle,
teardownManagementRoom,
} from "./mjolnirSetupUtils";
import { MatrixRoomReference } from "@the-draupnir-project/matrix-basic-types";
patchMatrixClient();
@@ -38,10 +37,6 @@ export const mochaHooks = {
JSON.stringify(this.currentTest?.title)
); // Makes MatrixClient error logs a bit easier to parse.
console.log("mochaHooks.beforeEach");
const test = MatrixRoomReference.fromPermalink(
"https://matrix.to/#/!JzRjamSLPHAikHkPab%3Alocalhost%3A9999?via=localhost:9999"
);
console.log(test);
// Sometimes it takes a little longer to register users.
this.timeout(30000);
const config = (this.config = configRead());
+9
View File
@@ -21,6 +21,7 @@ import { IConfig } from "../../src/config";
import { Draupnir } from "../../src/Draupnir";
import { DraupnirBotModeToggle } from "../../src/DraupnirBotMode";
import {
MatrixSendClient,
SafeMatrixEmitter,
SafeMatrixEmitterWrapper,
} from "matrix-protection-suite-for-matrix-bot-sdk";
@@ -152,6 +153,7 @@ export async function makeBotModeToggle(
client.setAccountData(MJOLNIR_WATCHED_POLICY_ROOMS_EVENT_TYPE, {
references: [],
}),
leaveAllRooms(client),
]);
}
await overrideRatelimitForUser(
@@ -197,3 +199,10 @@ export async function teardownManagementRoom(
await client.deleteRoomAlias(alias);
await client.leaveRoom(roomId);
}
export async function leaveAllRooms(client: MatrixSendClient): Promise<void> {
const joinedRooms = await client.getJoinedRooms();
await Promise.allSettled(
joinedRooms.map((roomID) => client.leaveRoom(roomID))
);
}
@@ -0,0 +1,138 @@
// SPDX-FileCopyrightText: 2025 Gnuxie <Gnuxie@protonmail.com>
//
// SPDX-License-Identifier: AFL-3.0
import {
MatrixRoomReference,
StringRoomID,
} from "@the-draupnir-project/matrix-basic-types";
import { newTestUser } from "../clientHelper";
import { DraupnirTestContext } from "../mjolnirSetupUtils";
import expect from "expect";
import { MatrixSendClient } from "matrix-protection-suite-for-matrix-bot-sdk";
import { Draupnir } from "../../../src/Draupnir";
async function setupProtectedRooms(
draupnir: Draupnir,
moderator: MatrixSendClient,
{ numberOfRooms }: { numberOfRooms: number }
): Promise<StringRoomID[]> {
await moderator.joinRoom(draupnir.managementRoomID);
return await Promise.all(
[...Array(numberOfRooms)].map(async (_) => {
const room = await moderator.createRoom({
invite: [draupnir.clientUserID],
});
await moderator.setUserPowerLevel(draupnir.clientUserID, room, 90);
return room as StringRoomID;
})
);
}
describe("JoinRoomsOnInvite", function () {
it(
"Should automatically protect and unrpotect rooms when joining and leaving.\
The principle is that we add a bunch of rooms, and then kick the bot.\
You can go to the management room in a client and see what the output looks like for this flow.",
async function (this: DraupnirTestContext) {
const draupnir = this.draupnir;
if (draupnir === undefined) {
throw new TypeError(`setup didn't run properly`);
}
const moderator = await newTestUser(this.config.homeserverUrl, {
name: { contains: "moderator" },
});
// Mutate the config which is a little naughty, but protections
// currently access it dynamically.
draupnir.config.protectAllJoinedRooms = true;
const protectedRooms = await setupProtectedRooms(draupnir, moderator, {
numberOfRooms: 5,
});
await new Promise((resolve) => setTimeout(resolve, 2000));
expect(
protectedRooms.every((roomID: StringRoomID) =>
draupnir.protectedRoomsSet.isProtectedRoom(roomID)
)
).toBe(true);
// now test that kicking them works
await Promise.all(
protectedRooms.map((roomID, i) =>
moderator.kickUser(
draupnir.clientUserID,
roomID,
i === 0 ? "don't want this bot" : undefined
)
)
);
await new Promise((resolve) => setTimeout(resolve, 1000));
expect(
protectedRooms.every(
(roomID: StringRoomID) =>
!draupnir.protectedRoomsSet.isProtectedRoom(roomID)
)
).toBe(true);
// allow for messages to send to the mangement room.
await new Promise((resolve) => setTimeout(resolve, 1000));
} as unknown as Mocha.AsyncFunc
);
it(
"That rooms will automatically be unprotected when protectAllJoinedRooms is false",
async function (this: DraupnirTestContext) {
const draupnir = this.draupnir;
if (draupnir === undefined) {
throw new TypeError(`setup didn't run properly`);
}
const moderator = await newTestUser(this.config.homeserverUrl, {
name: { contains: "moderator" },
});
// Mutate the config which is a little naughty, but protections
// currently access it dynamically.
draupnir.config.protectAllJoinedRooms = false;
const protectedRooms = await setupProtectedRooms(draupnir, moderator, {
numberOfRooms: 5,
});
await new Promise((resolve) => setTimeout(resolve, 2000));
// we shouldn't be protecting rooms automatically
expect(
protectedRooms.every(
(roomID: StringRoomID) =>
!draupnir.protectedRoomsSet.isProtectedRoom(roomID)
)
).toBe(true);
// protect the rooms manually
await Promise.all(
protectedRooms.map((roomID) =>
draupnir.protectedRoomsSet.protectedRoomsManager.addRoom(
MatrixRoomReference.fromRoomID(roomID)
)
)
);
expect(
protectedRooms.every((roomID: StringRoomID) =>
draupnir.protectedRoomsSet.isProtectedRoom(roomID)
)
).toBe(true);
// now test that banning them works
await Promise.all(
protectedRooms.map((roomID, i) =>
// I would have liked this to be ban, but for some reason bot-sdk
// doesn't allow informing of rooms you are banned from!!!
moderator.kickUser(
draupnir.clientUserID,
roomID,
i === 0 ? "don't want this bot" : undefined
)
)
);
await new Promise((resolve) => setTimeout(resolve, 1000));
expect(
protectedRooms.every(
(roomID: StringRoomID) =>
!draupnir.protectedRoomsSet.isProtectedRoom(roomID)
)
).toBe(true);
// allow for messages to send to the mangement room.
await new Promise((resolve) => setTimeout(resolve, 1000));
} as unknown as Mocha.AsyncFunc
);
});
+8 -8
View File
@@ -2593,18 +2593,18 @@ matrix-appservice@^2.0.0:
request-promise "^4.2.6"
sanitize-html "^2.11.0"
"matrix-protection-suite-for-matrix-bot-sdk@npm:@gnuxie/matrix-protection-suite-for-matrix-bot-sdk@2.6.0":
version "2.6.0"
resolved "https://registry.yarnpkg.com/@gnuxie/matrix-protection-suite-for-matrix-bot-sdk/-/matrix-protection-suite-for-matrix-bot-sdk-2.6.0.tgz#dd1ffbf5ea1cbeba891248d989701d9ba268d074"
integrity sha512-JH3FMJddRGSA5SKbnlT9ZRPJ8+/6VWWeAa9hGv+FHBn2FeyfndEKWChoFE8s6s1c06OJvjLg5o/efuVhZzKyEw==
"matrix-protection-suite-for-matrix-bot-sdk@npm:@gnuxie/matrix-protection-suite-for-matrix-bot-sdk@2.7.0":
version "2.7.0"
resolved "https://registry.yarnpkg.com/@gnuxie/matrix-protection-suite-for-matrix-bot-sdk/-/matrix-protection-suite-for-matrix-bot-sdk-2.7.0.tgz#82e3b053033d635ecdceab034c5548640bbe2332"
integrity sha512-Om+2CSojGJFqGnC9S7W9OXfccmI288nH93DACESRBjrlqVUO6mkdNSk789s1UTW5LIqZ3bjiC72F3STDASTdfg==
dependencies:
"@gnuxie/typescript-result" "^1.0.0"
await-lock "^2.2.2"
"matrix-protection-suite@npm:@gnuxie/matrix-protection-suite@2.6.0":
version "2.6.0"
resolved "https://registry.yarnpkg.com/@gnuxie/matrix-protection-suite/-/matrix-protection-suite-2.6.0.tgz#ce3dfecb72f5215467b74ed55f388043bbb2365f"
integrity sha512-MQxsFZQ7Xyx8GdpEcr4Pxg0XHA7Apn1aXPnTVM6FiEKTsWOyS0uYxuxfM0Q4kfYf5mVCdvCHB/xNJGqn6IV06A==
"matrix-protection-suite@npm:@gnuxie/matrix-protection-suite@2.7.0":
version "2.7.0"
resolved "https://registry.yarnpkg.com/@gnuxie/matrix-protection-suite/-/matrix-protection-suite-2.7.0.tgz#953f89e3b02fd9b9edb21f0901f1ed22a2f41f48"
integrity sha512-ZTbz5t1Gh0Yxx+9+7EUp51HfBaccTazSpLKNPQDmjgjoVo6PT3da0CfTvGJR11EqLlBMey/D0zV418ajxjixrw==
dependencies:
"@gnuxie/typescript-result" "^1.0.0"
await-lock "^2.2.2"