From fa5ce9ad9cd1e0dbf8a68b44c6a6ae6a9456bdca Mon Sep 17 00:00:00 2001 From: Gnuxie <50846879+Gnuxie@users.noreply.github.com> Date: Fri, 10 Jan 2025 17:06:54 +0000 Subject: [PATCH] Fix report poller (#662) Fixes https://github.com/the-draupnir-project/Draupnir/issues/258 Fixes https://github.com/the-draupnir-project/Draupnir/issues/408 Fixes https://github.com/the-draupnir-project/Draupnir/issues/409 * Create a way to only forward reports in WebAPIs. Honestly, I'm going to revert this because I think I have found a better way of testing the report poller. * Begin improving and fixing the report poller. We need to change the ReportManager so that we can interface it out for testing. The reason being that the report poller is inactive in the harness and so we can't use that with a protection handle to test. Instead I want to instantiate a report poller with a mocked report manager. * Update integration test nginx to mirror reports to synapse. We need this so that we can test the report poller without needing to do gymnastics to selectively forward reports. * Interface out ReportManager. Needed so we can test the report poller without doing gymnastics with setting up fake protections. * Fix report poller from paginating over the same reports. https://github.com/the-draupnir-project/planning/issues/38. * Revert "Create a way to only forward reports in WebAPIs." This reverts commit 59b335f658cc023d5d4de67fb757ba6d482ab1b1. We don't need this anymore. * Update for MPS v2.4.0 Gives us the synapse admin client, updates schema, and gives us the fix for https://github.com/the-draupnir-project/Draupnir/issues/560 --- package.json | 4 +- src/Draupnir.ts | 6 +- src/report/ReportManager.ts | 105 ++++++++++++++++-------- src/report/ReportPoller.ts | 81 ++++++++----------- src/webapis/WebAPIs.ts | 5 +- test/integration/reportPollingTest.ts | 112 +++++++++++--------------- test/nginx.conf | 7 +- yarn.lock | 16 ++-- 8 files changed, 173 insertions(+), 163 deletions(-) diff --git a/package.json b/package.json index 52acd988..abf8b3da 100644 --- a/package.json +++ b/package.json @@ -70,8 +70,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.3.0", - "matrix-protection-suite-for-matrix-bot-sdk": "npm:@gnuxie/matrix-protection-suite-for-matrix-bot-sdk@2.3.2", + "matrix-protection-suite": "npm:@gnuxie/matrix-protection-suite@2.4.0", + "matrix-protection-suite-for-matrix-bot-sdk": "npm:@gnuxie/matrix-protection-suite-for-matrix-bot-sdk@2.4.0", "parse-duration": "^1.0.2", "pg": "^8.8.0", "shell-quote": "^1.7.3", diff --git a/src/Draupnir.ts b/src/Draupnir.ts index 3233813c..103bb139 100644 --- a/src/Draupnir.ts +++ b/src/Draupnir.ts @@ -32,7 +32,7 @@ import { UnlistedUserRedactionQueue } from "./queues/UnlistedUserRedactionQueue" import { ThrottlingQueue } from "./queues/ThrottlingQueue"; import ManagementRoomOutput from "./managementroom/ManagementRoomOutput"; import { ReportPoller } from "./report/ReportPoller"; -import { ReportManager } from "./report/ReportManager"; +import { StandardReportManager } from "./report/ReportManager"; import { MatrixReactionHandler } from "./commands/interface-manager/MatrixReactionHandler"; import { MatrixSendClient, @@ -106,7 +106,7 @@ export class Draupnir implements Client, MatrixAdaptorContext { * Handle user reports from the homeserver. * FIXME: ReportManager should be a protection. */ - public readonly reportManager: ReportManager; + public readonly reportManager: StandardReportManager; public readonly reactionHandler: MatrixReactionHandler; @@ -157,7 +157,7 @@ export class Draupnir implements Client, MatrixAdaptorContext { clientUserID, clientPlatform ); - this.reportManager = new ReportManager(this); + this.reportManager = new StandardReportManager(this); if (config.pollReports) { this.reportPoller = new ReportPoller(this, this.reportManager); } diff --git a/src/report/ReportManager.ts b/src/report/ReportManager.ts index 462a31d2..c27625d0 100644 --- a/src/report/ReportManager.ts +++ b/src/report/ReportManager.ts @@ -102,10 +102,32 @@ enum Kind { ESCALATED_REPORT, } +export interface ReportManager { + handleTimelineEvent(roomID: StringRoomID, event: RoomEvent): void; + handleServerAbuseReport({ + roomID, + reporterId, + event, + reason, + }: { + roomID: StringRoomID; + reporterId: string; + event: RoomEvent; + reason?: string; + }): Promise; + handleReaction({ + roomID, + event, + }: { + roomID: StringRoomID; + event: RoomEvent; + }): Promise; +} + /** * A class designed to respond to abuse reports. */ -export class ReportManager { +export class StandardReportManager { private displayManager: DisplayManager; constructor(public draupnir: Draupnir) { this.displayManager = new DisplayManager(this); @@ -580,7 +602,7 @@ interface IUIAction { * @param report Details on the abuse report. */ canExecute( - manager: ReportManager, + manager: StandardReportManager, report: IReport, moderationroomID: string ): Promise; @@ -590,20 +612,20 @@ interface IUIAction { * * @param report Details on the abuse report. */ - title(manager: ReportManager, report: IReport): Promise; + title(manager: StandardReportManager, report: IReport): Promise; /** * A human-readable help message to display for the end-user. * * @param report Details on the abuse report. */ - help(manager: ReportManager, report: IReport): Promise; + help(manager: StandardReportManager, report: IReport): Promise; /** * Attempt to execute the action. */ execute( - manager: ReportManager, + manager: StandardReportManager, report: IReport, moderationroomID: string, displayManager: DisplayManager @@ -618,25 +640,25 @@ class IgnoreBadReport implements IUIAction { public emoji = "🚯"; public needsConfirmation = true; public async canExecute( - _manager: ReportManager, + _manager: StandardReportManager, _report: IReport ): Promise { return true; } public async title( - _manager: ReportManager, + _manager: StandardReportManager, _report: IReport ): Promise { return "Ignore"; } public async help( - _manager: ReportManager, + _manager: StandardReportManager, _report: IReport ): Promise { return "Ignore bad report"; } public async execute( - manager: ReportManager, + manager: StandardReportManager, report: IReportWithAction ): Promise { await manager.draupnir.client.sendEvent( @@ -667,7 +689,7 @@ class RedactMessage implements IUIAction { public emoji = "🗍"; public needsConfirmation = true; public async canExecute( - manager: ReportManager, + manager: StandardReportManager, report: IReport ): Promise { try { @@ -681,16 +703,19 @@ class RedactMessage implements IUIAction { } } public async title( - _manager: ReportManager, + _manager: StandardReportManager, _report: IReport ): Promise { return "Redact"; } - public async help(_manager: ReportManager, report: IReport): Promise { + public async help( + _manager: StandardReportManager, + report: IReport + ): Promise { return `Redact event ${report.event_id}`; } public async execute( - manager: ReportManager, + manager: StandardReportManager, report: IReport, _moderationroomID: string ): Promise { @@ -707,7 +732,7 @@ class KickAccused implements IUIAction { public emoji = "⚽"; public needsConfirmation = true; public async canExecute( - manager: ReportManager, + manager: StandardReportManager, report: IReport ): Promise { try { @@ -721,16 +746,19 @@ class KickAccused implements IUIAction { } } public async title( - _manager: ReportManager, + _manager: StandardReportManager, _report: IReport ): Promise { return "Kick"; } - public async help(_manager: ReportManager, report: IReport): Promise { + public async help( + _manager: StandardReportManager, + report: IReport + ): Promise { return `Kick ${htmlEscape(report.accused_id)} from room ${htmlEscape(report.room_alias_or_id)}`; } public async execute( - manager: ReportManager, + manager: StandardReportManager, report: IReport ): Promise { await manager.draupnir.client.kickUser(report.accused_id, report.room_id); @@ -746,7 +774,7 @@ class MuteAccused implements IUIAction { public emoji = "🤐"; public needsConfirmation = true; public async canExecute( - manager: ReportManager, + manager: StandardReportManager, report: IReport ): Promise { try { @@ -761,16 +789,19 @@ class MuteAccused implements IUIAction { } } public async title( - _manager: ReportManager, + _manager: StandardReportManager, _report: IReport ): Promise { return "Mute"; } - public async help(_manager: ReportManager, report: IReport): Promise { + public async help( + _manager: StandardReportManager, + report: IReport + ): Promise { return `Mute ${htmlEscape(report.accused_id)} in room ${htmlEscape(report.room_alias_or_id)}`; } public async execute( - manager: ReportManager, + manager: StandardReportManager, report: IReport ): Promise { await manager.draupnir.client.setUserPowerLevel( @@ -790,7 +821,7 @@ class BanAccused implements IUIAction { public emoji = "🚫"; public needsConfirmation = true; public async canExecute( - manager: ReportManager, + manager: StandardReportManager, report: IReport ): Promise { try { @@ -804,16 +835,19 @@ class BanAccused implements IUIAction { } } public async title( - _manager: ReportManager, + _manager: StandardReportManager, _report: IReport ): Promise { return "Ban"; } - public async help(_manager: ReportManager, report: IReport): Promise { + public async help( + _manager: StandardReportManager, + report: IReport + ): Promise { return `Ban ${htmlEscape(report.accused_id)} from room ${htmlEscape(report.room_alias_or_id)}`; } public async execute( - manager: ReportManager, + manager: StandardReportManager, report: IReport ): Promise { await manager.draupnir.client.banUser(report.accused_id, report.room_id); @@ -829,25 +863,25 @@ class Help implements IUIAction { public emoji = "❓"; public needsConfirmation = false; public async canExecute( - _manager: ReportManager, + _manager: StandardReportManager, _report: IReport ): Promise { return true; } public async title( - _manager: ReportManager, + _manager: StandardReportManager, _report: IReport ): Promise { return "Help"; } public async help( - _manager: ReportManager, + _manager: StandardReportManager, _report: IReport ): Promise { return "This help"; } public async execute( - manager: ReportManager, + manager: StandardReportManager, report: IReport, moderationroomID: string ): Promise { @@ -884,7 +918,7 @@ class EscalateToServerModerationRoom implements IUIAction { public emoji = "⏫"; public needsConfirmation = true; public async canExecute( - manager: ReportManager, + manager: StandardReportManager, report: IReport, moderationroomID: string ): Promise { @@ -901,16 +935,19 @@ class EscalateToServerModerationRoom implements IUIAction { return true; } public async title( - _manager: ReportManager, + _manager: StandardReportManager, _report: IReport ): Promise { return "Escalate"; } - public async help(manager: ReportManager, _report: IReport): Promise { + public async help( + manager: StandardReportManager, + _report: IReport + ): Promise { return `Escalate report to ${getHomeserver(await manager.draupnir.client.getUserId())} server moderators`; } public async execute( - manager: ReportManager, + manager: StandardReportManager, report: IReport, _moderationroomID: string, displayManager: DisplayManager @@ -939,7 +976,7 @@ class EscalateToServerModerationRoom implements IUIAction { } class DisplayManager { - constructor(private owner: ReportManager) {} + constructor(private owner: StandardReportManager) {} /** * Display the report and any UI button. diff --git a/src/report/ReportPoller.ts b/src/report/ReportPoller.ts index 73d79894..a8e7e8ed 100644 --- a/src/report/ReportPoller.ts +++ b/src/report/ReportPoller.ts @@ -8,18 +8,19 @@ // https://github.com/matrix-org/mjolnir // -import { MatrixSendClient } from "matrix-protection-suite-for-matrix-bot-sdk"; +import { + MatrixSendClient, + SynapseAdminClient, +} from "matrix-protection-suite-for-matrix-bot-sdk"; import { ReportManager } from "./ReportManager"; -import { LogLevel, LogService } from "matrix-bot-sdk"; +import { LogLevel } from "matrix-bot-sdk"; import ManagementRoomOutput from "../managementroom/ManagementRoomOutput"; import { Draupnir } from "../Draupnir"; import { ActionException, ActionExceptionKind, Ok, - SynapseReport, Task, - Value, isError, } from "matrix-protection-suite"; @@ -50,10 +51,23 @@ export class ReportPoller { */ private timeout: ReturnType | null = null; + private readonly synapseAdminClient: SynapseAdminClient; + + private readonly pollPeriod: number; + constructor( private draupnir: Draupnir, - private manager: ReportManager - ) {} + private manager: ReportManager, + options: { pollPeriod?: number } = {} + ) { + if (draupnir.synapseAdminClient === undefined) { + throw new TypeError( + `Unable to find synapse admin client for report poller` + ); + } + this.synapseAdminClient = draupnir.synapseAdminClient; + this.pollPeriod = options.pollPeriod ?? 30_000; // a minute in milliseconds + } private schedulePoll() { if (this.timeout === null) { @@ -65,7 +79,7 @@ export class ReportPoller { */ this.timeout = setTimeout( this.tryGetAbuseReports.bind(this), - 30_000 // a minute in milliseconds + this.pollPeriod ); } else { throw new InvalidStateError("poll already scheduled"); @@ -73,46 +87,19 @@ export class ReportPoller { } private async getAbuseReports() { - let response: - | { - event_reports: unknown[]; - next_token: number | undefined; - } - | undefined; - try { - response = await this.draupnir.client.doRequest( - "GET", - "/_synapse/admin/v1/event_reports", - { - // short for direction: forward; i.e. show newest last - dir: "f", - from: this.from.toString(), - } - ); - } catch (ex) { + const response = await this.synapseAdminClient.getAbuseReports({ + direction: "f", + from: this.from, + }); + if (isError(response)) { await this.draupnir.managementRoomOutput.logMessage( LogLevel.ERROR, "getAbuseReports", - `failed to poll events: ${ex}` + `failed to poll events: ${response.error.toReadableString()}` ); return; } - if (response === undefined) { - throw new TypeError( - `we should have got a response from /event_reports/, code is wrong.` - ); - } - for (const rawReport of response.event_reports) { - const reportResult = Value.Decode(SynapseReport, rawReport); - if (isError(reportResult)) { - LogService.error( - "ReportPoller", - `Failed to decode a synapse report ${reportResult.error.uuid}`, - rawReport - ); - continue; - } - const report = reportResult.ok; + for (const report of response.ok.event_reports) { // FIXME: shouldn't we have a SafeMatrixSendClient in the BotSDKMPS that gives us ActionResult's with // Decoded events. // Problem is that our current event model isn't going to match up with extensible events. @@ -143,7 +130,7 @@ export class ReportPoller { await this.manager.handleServerAbuseReport({ roomID: report.room_id, - reporterId: report.sender, + reporterId: report.user_id, event: event, ...(report.reason ? { reason: report.reason } : {}), }); @@ -152,13 +139,15 @@ export class ReportPoller { /* * This API endpoint returns an opaque `next_token` number that we * need to give back to subsequent requests for pagination, so here we - * save it in account data + * save it in account data. Except it's not opaque, and there's no way + * to use this API as a poll without cheating and using the total. Brill. */ - if (response.next_token !== undefined) { - this.from = response.next_token; + const nextToken = response.ok.next_token ?? response.ok.total ?? 0; + if (nextToken !== this.from) { + this.from = nextToken; try { await this.draupnir.client.setAccountData(REPORT_POLL_EVENT_TYPE, { - from: response.next_token, + from: nextToken, }); } catch (ex) { await this.draupnir.managementRoomOutput.logMessage( diff --git a/src/webapis/WebAPIs.ts b/src/webapis/WebAPIs.ts index cdede927..ad770b6b 100644 --- a/src/webapis/WebAPIs.ts +++ b/src/webapis/WebAPIs.ts @@ -11,7 +11,7 @@ import { Server } from "http"; import express from "express"; import { MatrixClient } from "matrix-bot-sdk"; -import { ReportManager } from "../report/ReportManager"; +import { StandardReportManager } from "../report/ReportManager"; import { IConfig } from "../config"; import { StringRoomID, @@ -35,7 +35,7 @@ export class WebAPIs { private httpServer?: Server | undefined; constructor( - private reportManager: ReportManager, + private reportManager: StandardReportManager, private readonly config: IConfig ) { // Setup JSON parsing. @@ -257,7 +257,6 @@ export class WebAPIs { // with all Matrix homeservers, rather than just Synapse. event = await reporterClient.getEvent(roomID, eventID); } - const reason = request.body["reason"]; await this.reportManager.handleServerAbuseReport({ roomID, diff --git a/test/integration/reportPollingTest.ts b/test/integration/reportPollingTest.ts index 8cc9c848..169e06f3 100644 --- a/test/integration/reportPollingTest.ts +++ b/test/integration/reportPollingTest.ts @@ -11,31 +11,30 @@ import { MatrixClient } from "matrix-bot-sdk"; import { newTestUser } from "./clientHelper"; import { DraupnirTestContext } from "./mjolnirSetupUtils"; -import { - ActionResult, - Ok, - Protection, - ProtectionDescription, - Task, - describeConfig, -} from "matrix-protection-suite"; import { MatrixRoomReference, StringRoomID, } from "@the-draupnir-project/matrix-basic-types"; -import { Type } from "@sinclair/typebox"; +import { randomUUID } from "crypto"; +import expect from "expect"; +import { createMock } from "ts-auto-mock"; +import { ReportManager } from "../../src/report/ReportManager"; +import { ReportPoller } from "../../src/report/ReportPoller"; describe("Test: Report polling", function () { let client: MatrixClient; + let reportPoller: ReportPoller | undefined; this.beforeEach(async function () { client = await newTestUser(this.config.homeserverUrl, { name: { contains: "protection-settings" }, }); }); + this.afterEach(function () { + reportPoller?.stop(); + }); it("Draupnir correctly retrieves a report from synapse", async function ( this: DraupnirTestContext ) { - this.timeout(40000); const draupnir = this.draupnir; if (draupnir === undefined) { throw new TypeError(`Test didn't setup properly`); @@ -47,63 +46,44 @@ describe("Test: Report polling", function () { await draupnir.protectedRoomsSet.protectedRoomsManager.addRoom( MatrixRoomReference.fromRoomID(protectedRoomId as StringRoomID) ); - - const eventId = await client.sendMessage(protectedRoomId, { - msgtype: "m.text", - body: "uwNd3q", + const testReportReason = randomUUID(); + const reportsFound = new Set(); + const duplicateReports = new Set(); + const reportManager = createMock({ + handleServerAbuseReport({ event, reason }) { + if (reason === testReportReason) { + if (reportsFound.has(event.event_id)) { + duplicateReports.add(event.event_id); + } + reportsFound.add(event.event_id); + } + return Promise.resolve(undefined); + }, }); - await new Promise((resolve) => { - const testProtectionDescription: ProtectionDescription = { - name: "jYvufI", - description: "A test protection", - capabilities: {}, - defaultCapabilities: {}, - factory: function ( - _description, - _protectedRoomsSet, - _context, - _capabilities, - _settings - ): ActionResult> { - return Ok({ - handleEventReport(report) { - if (report.reason === "x5h1Je") { - resolve(null); - } - return Promise.resolve(Ok(undefined)); - }, - description: testProtectionDescription, - requiredEventPermissions: [], - requiredPermissions: [], - requiredStatePermissions: [], - }); - }, - protectionSettings: describeConfig({ schema: Type.Object({}) }), - }; - void Task( - (async () => { - await draupnir.protectedRoomsSet.protections.addProtection( - testProtectionDescription, - draupnir.protectedRoomsSet, - draupnir - ); - await client.doRequest( - "POST", - `/_matrix/client/r0/rooms/${encodeURIComponent(protectedRoomId)}/report/${encodeURIComponent(eventId)}`, - "", - { - reason: "x5h1Je", - } - ); - })() - ); + reportPoller = new ReportPoller(draupnir, reportManager, { + pollPeriod: 500, }); - // So I kid you not, it seems like we can quit before the webserver for reports sends a respond to the client (via L#26) - // because the promise above gets resolved before we finish awaiting the report sending request on L#31, - // then mocha's cleanup code runs (and shuts down the webserver) before the webserver can respond. - // Wait a minute 😲😲🤯 it's not even supposed to be using the webserver if this is testing report polling. - // Ok, well apparently that needs a big refactor to change, but if you change the config before running this test, - // then you can ensure that report polling works. https://github.com/matrix-org/mjolnir/issues/326. - await new Promise((resolve) => setTimeout(resolve, 1000)); + const reportEvent = async () => { + const eventId = await client.sendMessage(protectedRoomId, { + msgtype: "m.text", + body: "uwNd3q", + }); + await client.doRequest( + "POST", + `/_matrix/client/r0/rooms/${encodeURIComponent(protectedRoomId)}/report/${encodeURIComponent(eventId)}`, + "", + { + reason: testReportReason, + } + ); + }; + reportPoller.start({ from: 1 }); + for (let i = 0; i < 20; i++) { + await reportEvent(); + } + // wait for them to come down the poll. + await new Promise((resolve) => setTimeout(resolve, 3000)); + expect(reportsFound.size).toBe(20); + expect(duplicateReports.size).toBe(0); } as unknown as Mocha.AsyncFunc); }); diff --git a/test/nginx.conf b/test/nginx.conf index d88884b4..9692191a 100644 --- a/test/nginx.conf +++ b/test/nginx.conf @@ -11,6 +11,7 @@ http { listen [::]:8081 ipv6only=off; location ~ ^/_matrix/client/(r0|v3)/rooms/([^/]*)/report/(.*)$ { + mirror /report_mirror; # Abuse reports should be sent to Mjölnir. # The r0 endpoint is deprecated but still used by many clients. # As of this writing, the v3 endpoint is the up-to-date version. @@ -31,6 +32,10 @@ http { location / { # Everything else should be sent to Synapse. proxy_pass http://127.0.0.1:9999; - } } + location /report_mirror { + internal; + proxy_pass http://127.0.0.1:9999$request_uri; + } + } } diff --git a/yarn.lock b/yarn.lock index 3eb59358..b1dc848a 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2677,17 +2677,17 @@ 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.3.2": - version "2.3.2" - resolved "https://registry.yarnpkg.com/@gnuxie/matrix-protection-suite-for-matrix-bot-sdk/-/matrix-protection-suite-for-matrix-bot-sdk-2.3.2.tgz#c16f72a0c05980ad54fab7fa53bfd5c5be54edc2" - integrity sha512-WbEJ2erZcZ8KgF5BBcIvC9EjahDYXz+De8B5o4+a8zOK7jXt2ky1rp9KEj9eAE7JisgbLNQnprarpMpX380prw== +"matrix-protection-suite-for-matrix-bot-sdk@npm:@gnuxie/matrix-protection-suite-for-matrix-bot-sdk@2.4.0": + version "2.4.0" + resolved "https://registry.yarnpkg.com/@gnuxie/matrix-protection-suite-for-matrix-bot-sdk/-/matrix-protection-suite-for-matrix-bot-sdk-2.4.0.tgz#5598bbd28ca7f94174049daf12d52c0a3388b3aa" + integrity sha512-p5G86TBW2TY0boiQhWPXchJBDpsWi70iYM2nJ8BthnXBx6Ck8ON2Km36EEctXks68ylXnFitV2FGvAnN4+mNvA== dependencies: "@gnuxie/typescript-result" "^1.0.0" -"matrix-protection-suite@npm:@gnuxie/matrix-protection-suite@2.3.0": - version "2.3.0" - resolved "https://registry.yarnpkg.com/@gnuxie/matrix-protection-suite/-/matrix-protection-suite-2.3.0.tgz#1a7346f1ed26f7e6459001ac0ff025ca11ab66d9" - integrity sha512-YpBrQxRiFg0DVd/ru16W7pcqHH7sZv7r+JFUn5ZIL8Ya0Vt+MpWo22ZFkU0WGf5+I1KfgYLzV/C2nBisS4ZskA== +"matrix-protection-suite@npm:@gnuxie/matrix-protection-suite@2.4.0": + version "2.4.0" + resolved "https://registry.yarnpkg.com/@gnuxie/matrix-protection-suite/-/matrix-protection-suite-2.4.0.tgz#60659023fd6e5aec4e1282f7726da3baab074ee5" + integrity sha512-oEA0Vi/VJsHWXNQ0RMiLUID+YpgTH05+OGnHGVyeSS/8HP5Ziij3ZGMJvemZ2pij8Uq6az0hVV/NZUOefCoI1A== dependencies: "@gnuxie/typescript-result" "^1.0.0" await-lock "^2.2.2"