diff --git a/CHANGES.md b/CHANGES.md index fd3e1d6..968d738 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -48,7 +48,7 @@ To be released. at the root. - Added support for consent-respecting quote posts using [FEP-044f]. - [[#27], [#28], [#29], [#31], [#32]] + [[#27], [#28], [#29], [#30], [#31], [#32], [#33]] BotKit now serializes quote policies on outgoing messages, handles incoming `QuoteRequest` activities, automatically accepts or rejects them @@ -62,6 +62,9 @@ To be released. sends a `QuoteRequest` to the quoted message's author, applies accepted `QuoteAuthorization` stamps to the stored message, and strips rejected quote targets from the stored message before delivering an `Update`. + BotKit also verifies `QuoteAuthorization` stamps on received third-party + quote posts and handles deleted stamps by forwarding the `Delete` activity + before stripping the quote from the bot's own post. - Added `QuotePolicy`, `QuotePolicyOption`, `QuoteRequest`, and `QuoteRequestEventHandler` types. [[#27], [#28], [#31]] @@ -70,6 +73,8 @@ To be released. types. [[#27], [#29], [#32]] - Added `Bot.onQuoteAccepted` and `Bot.onQuoteRejected` event handlers. [[#27], [#29], [#32]] + - Added `QuoteRevokedEventHandler` type and `Bot.onQuoteRevoked` event + handler. [[#27], [#30], [#33]] - Added `ReadonlyBot.quotePolicy`, `CreateBotOptions.quotePolicy`, and `BotProfile.quotePolicy` properties. [[#27], [#28], [#31]] - Added `SessionPublishOptions.quotePolicy` and @@ -77,6 +82,9 @@ To be released. [[#27], [#28], [#31]] - Added `Message.quotePolicy` and `AuthorizedMessage.quoteApprovalState` properties. [[#27], [#29], [#32]] + - Added `Message.quoteApproved` property for inspecting whether a + received quote post has valid FEP-044f approval. + [[#27], [#30], [#33]] - Added `AuthorizedMessage.unauthorizeQuote()` method for revoking an existing quote authorization stamp by the quoted message or its URI. [[#27], [#28], [#31]] @@ -98,9 +106,11 @@ To be released. `Repository.removeQuoteAuthorization()`. - Added quote authorization reference methods: `Repository.addQuoteAuthorizationReference()`, - `Repository.findQuoteAuthorizationReference()`, and + `Repository.findQuoteAuthorizationReference()`, + `Repository.findQuoteAuthorizationReferenceIdentifiers()`, + `Repository.findQuoteAuthorizationReferenceAttribution()`, and `Repository.removeQuoteAuthorizationReference()`. - [[#27], [#29], [#32]] + [[#27], [#29], [#30], [#32], [#33]] - Added optional `Repository.migrate()` method for adopting data stored by BotKit 0.4 or earlier. - Added `Repository.forIdentifier()` method and `ActorScopedRepository` @@ -136,8 +146,10 @@ To be released. [#27]: https://github.com/fedify-dev/botkit/issues/27 [#28]: https://github.com/fedify-dev/botkit/issues/28 [#29]: https://github.com/fedify-dev/botkit/issues/29 +[#30]: https://github.com/fedify-dev/botkit/issues/30 [#31]: https://github.com/fedify-dev/botkit/pull/31 [#32]: https://github.com/fedify-dev/botkit/pull/32 +[#33]: https://github.com/fedify-dev/botkit/pull/33 ### @fedify/botkit-sqlite diff --git a/deno.lock b/deno.lock index 630e96c..40130cb 100644 --- a/deno.lock +++ b/deno.lock @@ -28,6 +28,7 @@ "npm:@fedify/markdown-it-mention@0.3": "0.3.0", "npm:@fedify/vocab@^2.3.1": "2.3.1", "npm:@js-temporal/polyfill@~0.5.1": "0.5.1", + "npm:@logtape/logtape@*": "2.2.3", "npm:@logtape/logtape@^2.2.3": "2.2.3", "npm:@multiformats/base-x@^4.0.1": "4.0.1", "npm:@opentelemetry/api@^1.9.1": "1.9.1", diff --git a/docs/concepts/events.md b/docs/concepts/events.md index 0034ac9..8311811 100644 --- a/docs/concepts/events.md +++ b/docs/concepts/events.md @@ -313,8 +313,8 @@ the *Message* concept document. [FEP-044f]: https://w3id.org/fep/044f -Quote accepted and rejected ---------------------------- +Quote accepted, rejected, and revoked +------------------------------------- *This API is available since BotKit 0.5.0.* @@ -336,10 +336,9 @@ bot.onQuoteAccepted = (_session, message, approver) => { }; ~~~~ -When the request is rejected, or when a previously accepted -`QuoteAuthorization` stamp is later deleted by the quoted author, BotKit -removes the quote target and fallback quote link from the stored message, -sends an `Update` activity, and then calls `~Bot.onQuoteRejected`: +When the request is rejected, BotKit removes the quote target and fallback +quote link from the stored message, sends an `Update` activity, and then calls +`~Bot.onQuoteRejected`: ~~~~ typescript twoslash import { type Bot } from "@fedify/botkit"; @@ -351,6 +350,22 @@ bot.onQuoteRejected = (_session, message, rejecter) => { }; ~~~~ +If a previously accepted `QuoteAuthorization` stamp is later deleted by the +quoted author's server, BotKit forwards the `Delete` activity to the quote +post's audience, removes the quote target and fallback quote link from the +stored message, sends an `Update` activity, and then calls +`~Bot.onQuoteRevoked`: + +~~~~ typescript twoslash +import { type Bot } from "@fedify/botkit"; +const bot = {} as unknown as Bot; +// ---cut-before--- +bot.onQuoteRevoked = (_session, message, revoker) => { + console.log(`${revoker.id} revoked approval for ${message.id}`); + console.log(message.quoteTarget); // undefined +}; +~~~~ + Message ------- diff --git a/docs/concepts/message.md b/docs/concepts/message.md index 03463c4..c8b5011 100644 --- a/docs/concepts/message.md +++ b/docs/concepts/message.md @@ -342,9 +342,9 @@ console.log(message.quoteApprovalState); // "pending" If the author accepts the quote request, BotKit stores the received authorization stamp on the message and sends an `Update` activity. If the -author rejects it, BotKit removes the quote target and the fallback quote link -from the stored message and sends an `Update` activity with the stripped -content. +author rejects it, or the author's server later revokes the authorization +stamp, BotKit removes the quote target and the fallback quote link from the +stored message and sends an `Update` activity with the stripped content. ### Polls @@ -619,6 +619,16 @@ You can get the message that is quoted in the message through the `~Message.quoteTarget` property. It is either another `Message` object or `undefined` if the message is not a quote. +For messages received from the fediverse, `~Message.quoteApproved` reports +whether the quote has FEP-044f approval. It is `undefined` when the message +does not quote anything, `true` for self-quotes and quotes with a valid +`QuoteAuthorization` stamp, and `false` when the stamp is missing, invalid, or +could not be fetched. Legacy quote links such as Misskey quote tags and +`quoteUrl` without a `QuoteAuthorization` stamp are reported as unapproved. + +BotKit checks `~Message.quoteApproved` when it materializes the message, so a +message can reflect a later stamp revocation when it is loaded again. + For authorized messages created by your bot, `~AuthorizedMessage.quoteApprovalState` describes whether the quote target still awaits FEP-044f approval. It is `"pending"` for remote quote targets diff --git a/packages/botkit-postgres/src/mod.test.ts b/packages/botkit-postgres/src/mod.test.ts index 2d64fa4..2f268f7 100644 --- a/packages/botkit-postgres/src/mod.test.ts +++ b/packages/botkit-postgres/src/mod.test.ts @@ -1103,10 +1103,27 @@ if (postgresUrl == null) { await repo.findQuoteAuthorizationReference("other", authorization), undefined, ); + assert.deepStrictEqual( + await Array.fromAsync( + repo.findQuoteAuthorizationReferenceIdentifiers(authorization), + ), + ["bot"], + ); assert.deepStrictEqual( await repo.findQuoteAuthorizationReference("bot", authorization), firstMessageId, ); + await repo.addQuoteAuthorizationReference( + "other", + authorization, + firstMessageId, + ); + assert.deepStrictEqual( + await Array.fromAsync( + repo.findQuoteAuthorizationReferenceIdentifiers(authorization), + ), + ["bot", "other"], + ); await repo.addQuoteAuthorizationReference( "bot", @@ -1125,6 +1142,12 @@ if (postgresUrl == null) { await repo.findQuoteAuthorizationReference("bot", authorization), undefined, ); + assert.deepStrictEqual( + await Array.fromAsync( + repo.findQuoteAuthorizationReferenceIdentifiers(authorization), + ), + ["other"], + ); } finally { await harness.cleanup(); } diff --git a/packages/botkit-postgres/src/mod.ts b/packages/botkit-postgres/src/mod.ts index 2914bfb..17da99a 100644 --- a/packages/botkit-postgres/src/mod.ts +++ b/packages/botkit-postgres/src/mod.ts @@ -297,6 +297,13 @@ async function initializePostgresRepositorySchemaInTransaction( [], prepare, ); + await execute( + sql, + `CREATE INDEX IF NOT EXISTS "idx_quote_authorization_refs_authorization" + ON "${validatedSchema}"."quote_authorization_refs" (authorization_uri)`, + [], + prepare, + ); await execute( sql, `ALTER TABLE "${validatedSchema}"."quote_authorization_refs" @@ -1147,6 +1154,21 @@ export class PostgresRepository implements Repository, AsyncDisposable { return rows[0]?.message_id; } + async *findQuoteAuthorizationReferenceIdentifiers( + authorization: URL, + ): AsyncIterable { + await this.ensureReady(); + const rows = await this.query<{ readonly bot_id: string }>( + this.sql, + `SELECT bot_id + FROM ${this.table("quote_authorization_refs")} + WHERE authorization_uri = $1 + ORDER BY bot_id`, + [authorization.href], + ); + for (const row of rows) yield row.bot_id; + } + async findQuoteAuthorizationReferenceAttribution( identifier: string, authorization: URL, diff --git a/packages/botkit-sqlite/src/mod.test.ts b/packages/botkit-sqlite/src/mod.test.ts index 038ee44..c8b78bf 100644 --- a/packages/botkit-sqlite/src/mod.test.ts +++ b/packages/botkit-sqlite/src/mod.test.ts @@ -387,10 +387,27 @@ describe("SqliteRepository", () => { await repo.findQuoteAuthorizationReference("other", authorization), undefined, ); + assert.deepStrictEqual( + await Array.fromAsync( + repo.findQuoteAuthorizationReferenceIdentifiers(authorization), + ), + ["bot"], + ); assert.deepStrictEqual( await repo.findQuoteAuthorizationReference("bot", authorization), firstMessageId, ); + await repo.addQuoteAuthorizationReference( + "other", + authorization, + firstMessageId, + ); + assert.deepStrictEqual( + await Array.fromAsync( + repo.findQuoteAuthorizationReferenceIdentifiers(authorization), + ), + ["bot", "other"], + ); await repo.addQuoteAuthorizationReference( "bot", @@ -409,6 +426,12 @@ describe("SqliteRepository", () => { await repo.findQuoteAuthorizationReference("bot", authorization), undefined, ); + assert.deepStrictEqual( + await Array.fromAsync( + repo.findQuoteAuthorizationReferenceIdentifiers(authorization), + ), + ["other"], + ); } finally { repo.close(); } diff --git a/packages/botkit-sqlite/src/mod.ts b/packages/botkit-sqlite/src/mod.ts index e723b20..23a37ce 100644 --- a/packages/botkit-sqlite/src/mod.ts +++ b/packages/botkit-sqlite/src/mod.ts @@ -457,6 +457,10 @@ export class SqliteRepository implements Repository, Disposable { PRIMARY KEY (bot_id, authorization) ) `); + this.db.exec(` + CREATE INDEX IF NOT EXISTS idx_quote_authorization_refs_authorization + ON quote_authorization_refs(authorization) + `); if (!this.hasColumn("quote_authorization_refs", "attribution")) { this.db.exec(` ALTER TABLE quote_authorization_refs ADD COLUMN attribution TEXT @@ -1127,6 +1131,17 @@ export class SqliteRepository implements Repository, Disposable { return Promise.resolve(row?.message_id); } + async *findQuoteAuthorizationReferenceIdentifiers( + authorization: URL, + ): AsyncIterable { + const stmt = this.db.prepare(` + SELECT bot_id FROM quote_authorization_refs + WHERE authorization = ? ORDER BY bot_id + `); + const rows = stmt.all(authorization.href) as { bot_id: string }[]; + for (const row of rows) yield row.bot_id; + } + findQuoteAuthorizationReferenceAttribution( identifier: string, authorization: URL, diff --git a/packages/botkit/src/bot-impl.test.ts b/packages/botkit/src/bot-impl.test.ts index 4de21ee..07c6dce 100644 --- a/packages/botkit/src/bot-impl.test.ts +++ b/packages/botkit/src/bot-impl.test.ts @@ -45,9 +45,10 @@ import { Undo, Update, } from "@fedify/vocab"; +import { configureSync, type LogRecord, resetSync } from "@logtape/logtape"; import assert from "node:assert"; import { describe, test } from "node:test"; -import { BotImpl } from "./bot-impl.ts"; +import { BotImpl, MigrationGatedRepository } from "./bot-impl.ts"; import type { CustomEmoji } from "./emoji.ts"; import type { FollowRequest } from "./follow.ts"; import { createMessage, isQuoteLink } from "./message-impl.ts"; @@ -62,6 +63,7 @@ import type { Like, Reaction } from "./reaction.ts"; import { MemoryRepository, type Uuid } from "./repository.ts"; import { SessionImpl } from "./session-impl.ts"; import type { Session } from "./session.ts"; +import { hideRepositoryMethods } from "./helpers.ts"; import { mention, strong, text } from "./text.ts"; describe("BotImpl.getActorSummary()", () => { @@ -2213,6 +2215,7 @@ test("BotImpl.onCreated()", async (t) => { msg.quoteTarget.id, targetId, ); + assert.deepStrictEqual(msg.quoteApproved, true); assert.deepStrictEqual(replied, []); assert.deepStrictEqual(mentioned, []); assert.deepStrictEqual(messaged, quoted); @@ -2275,6 +2278,7 @@ test("BotImpl.onCreated()", async (t) => { const [, msg] = quoted[0]; assert.ok(msg.quoteTarget != null); assert.deepStrictEqual(msg.quoteTarget.id, targetId); + assert.deepStrictEqual(msg.quoteApproved, true); assert.deepStrictEqual(replied, []); assert.deepStrictEqual(mentioned, []); assert.deepStrictEqual(messaged, quoted); @@ -2368,6 +2372,7 @@ test("BotImpl.onCreated()", async (t) => { msg.quoteTarget.id, fepTargetId, ); + assert.deepStrictEqual(msg.quoteApproved, true); assert.deepStrictEqual(replied, []); assert.deepStrictEqual(mentioned, []); assert.deepStrictEqual(messaged, quoted); @@ -2859,6 +2864,76 @@ test("BotImpl.dispatchNodeInfo()", () => { }); }); +test("MigrationGatedRepository tolerates missing quote reference indexes", async () => { + const underlying = hideRepositoryMethods(new MemoryRepository(), [ + "findQuoteAuthorizationReferenceIdentifiers", + "findQuoteAuthorizationReferenceAttribution", + ]); + const repository = new MigrationGatedRepository(underlying, "bot"); + const records: LogRecord[] = []; + + configureSync({ + sinks: { + capture: (record) => records.push(record), + }, + loggers: [ + { + category: ["botkit", "bot"], + sinks: ["capture"], + lowestLevel: "warning", + }, + { + category: ["logtape", "meta"], + lowestLevel: null, + }, + ], + reset: true, + }); + + try { + assert.deepStrictEqual( + await Array.fromAsync( + repository.findQuoteAuthorizationReferenceIdentifiers( + new URL("https://remote.example/stamps/1"), + ), + ), + [], + ); + assert.deepStrictEqual( + await Array.fromAsync( + repository.findQuoteAuthorizationReferenceIdentifiers( + new URL("https://remote.example/stamps/1"), + ), + ), + [], + ); + assert.deepStrictEqual( + await repository.findQuoteAuthorizationReferenceAttribution( + "bot", + new URL("https://remote.example/stamps/1"), + ), + undefined, + ); + assert.deepStrictEqual( + await repository.findQuoteAuthorizationReferenceAttribution( + "bot", + new URL("https://remote.example/stamps/1"), + ), + undefined, + ); + } finally { + resetSync(); + } + + assert.deepStrictEqual( + records.map((record) => record.properties.method), + [ + "findQuoteAuthorizationReferenceIdentifiers", + "findQuoteAuthorizationReferenceAttribution", + ], + ); +}); + test("BotImpl.fetch()", async () => { const bot = new BotImpl({ kv: new MemoryKvStore(), @@ -3195,6 +3270,10 @@ interface SentActivity { interface MockInboxContext extends InboxContext { sentActivities: SentActivity[]; forwardedRecipients: ("followers" | Recipient)[]; + forwardedActivities: { + recipients: "followers" | Recipient[]; + options: unknown; + }[]; } function createMockInboxContext( @@ -3224,7 +3303,16 @@ function createMockInboxContext( return Promise.resolve(); }; ctx.forwardedRecipients = []; - ctx.forwardActivity = (_, recipients) => { + ctx.forwardedActivities = []; + ctx.forwardActivity = (_, recipients, options) => { + ctx.forwardedActivities.push({ + recipients: recipients === "followers" + ? "followers" + : Array.isArray(recipients) + ? recipients + : [recipients], + options, + }); if (recipients === "followers") { ctx.forwardedRecipients.push("followers"); } else if (Array.isArray(recipients)) { @@ -4401,13 +4489,19 @@ test("BotImpl.onDeleted() strips revoked quote authorizations", async () => { ), messageId, ); - let rejected: AuthorizedMessage | undefined; - let rejecter: Actor | undefined; - bot.onQuoteRejected = (_session, message, actor) => { - rejected = message; - rejecter = actor; + let rejectedCalled = false; + let revoked: AuthorizedMessage | undefined; + let revoker: Actor | undefined; + bot.onQuoteRejected = () => { + rejectedCalled = true; + }; + bot.onQuoteRevoked = (_session, message, actor) => { + revoked = message; + revoker = actor; }; ctx.sentActivities = []; + ctx.forwardedRecipients = []; + ctx.forwardedActivities = []; await bot.onDeleted( ctx, @@ -4433,17 +4527,173 @@ test("BotImpl.onDeleted() strips revoked quote authorizations", async () => { ), undefined, ); + assert.deepStrictEqual(ctx.forwardedRecipients, ["followers", author]); + assert.deepStrictEqual(ctx.forwardedActivities.length, 2); + assert.deepStrictEqual( + ctx.forwardedActivities[0].options, + { + skipIfUnsigned: true, + preferSharedInbox: true, + excludeBaseUris: [new URL("https://example.com")], + }, + ); assert.deepStrictEqual(ctx.sentActivities.length, 2); assert.deepStrictEqual(ctx.sentActivities[0].recipients, "followers"); assert.ok(ctx.sentActivities[0].activity instanceof Update); assert.deepStrictEqual(ctx.sentActivities[1].recipients, [author]); assert.ok(ctx.sentActivities[1].activity instanceof Update); - assert.deepStrictEqual(rejected?.id, quote.id); - assert.deepStrictEqual(rejected?.quoteTarget, undefined); - assert.deepStrictEqual(rejecter?.id, author.id); + assert.deepStrictEqual(rejectedCalled, false); + assert.deepStrictEqual(revoked?.id, quote.id); + assert.deepStrictEqual(revoked?.quoteTarget, undefined); + assert.deepStrictEqual(revoker?.id, author.id); +}); + +test("BotImpl.onDeleted() keeps newer quote authorizations", async () => { + class ConcurrentQuoteAuthorizationRepository extends MemoryRepository { + replaceOnNextUpdate = false; + + constructor( + readonly replacementAuthorization: URL, + readonly attribution: URL, + ) { + super(); + } + + override async updateMessage( + identifier: string, + id: Uuid, + updater: ( + existing: Create | Announce, + ) => + | Create + | Announce + | undefined + | Promise, + ): Promise { + if (identifier === "bot" && this.replaceOnNextUpdate) { + this.replaceOnNextUpdate = false; + const existing = await this.getMessage(identifier, id); + if (existing instanceof Create) { + const object = await existing.getObject(); + if (object instanceof Note) { + await super.updateMessage( + identifier, + id, + (current) => + current instanceof Create + ? current.clone({ + object: object.clone({ + quoteAuthorization: this.replacementAuthorization, + }), + }) + : current, + ); + await this.addQuoteAuthorizationReference( + identifier, + this.replacementAuthorization, + id, + this.attribution, + ); + } + } + } + return await super.updateMessage(identifier, id, updater); + } + } + const newerAuthorization = new URL("https://remote.example/stamps/2"); + const author = new Person({ + id: new URL("https://remote.example/users/alice"), + preferredUsername: "alice", + }); + const repository = new ConcurrentQuoteAuthorizationRepository( + newerAuthorization, + author.id!, + ); + const bot = new BotImpl({ + kv: new MemoryKvStore(), + repository, + username: "bot", + }); + const ctx = createMockInboxContext(bot, "https://example.com", "bot"); + const session = new SessionImpl(bot, ctx); + const target = new Note({ + id: new URL("https://remote.example/notes/original"), + attribution: author, + content: "Original.", + to: PUBLIC_COLLECTION, + }); + Object.defineProperty(ctx, "lookupObject", { + value: (id: URL) => + Promise.resolve(id.href === target.id?.href ? target : null), + }); + const targetMessage = await createMessage(target, session, {}); + const quote = await session.publish(text`Please approve this.`, { + quoteTarget: targetMessage, + }); + const parsed = ctx.parseUri(quote.id); + assert.ok(parsed?.type === "object"); + const messageId = parsed.values.id as Uuid; + const revokedAuthorization = new QuoteAuthorization({ + id: new URL("https://remote.example/stamps/1"), + attribution: author.id, + interactingObject: quote.id, + interactionTarget: target.id, + }); + await bot.onFollowAccepted( + ctx, + new Accept({ + actor: author, + object: ctx.getObjectUri(QuoteRequest, { + identifier: bot.identifier, + id: messageId, + }), + result: revokedAuthorization, + }), + ); + let revoked: AuthorizedMessage | undefined; + bot.onQuoteRevoked = (_session, message) => { + revoked = message; + }; + repository.replaceOnNextUpdate = true; + ctx.sentActivities = []; + ctx.forwardedRecipients = []; + ctx.forwardedActivities = []; + + await bot.onDeleted( + ctx, + new Delete({ + actor: author, + object: revokedAuthorization.id, + }), + ); + + const stored = await repository.getMessage("bot", messageId); + assert.ok(stored instanceof Create); + const object = await stored.getObject(ctx); + assert.ok(object instanceof Note); + assert.deepStrictEqual(object.quoteId, target.id); + assert.deepStrictEqual(object.quoteAuthorizationId, newerAuthorization); + assert.deepStrictEqual( + await repository.findQuoteAuthorizationReference( + "bot", + revokedAuthorization.id!, + ), + undefined, + ); + assert.deepStrictEqual( + await repository.findQuoteAuthorizationReference( + "bot", + newerAuthorization, + ), + messageId, + ); + assert.deepStrictEqual(ctx.forwardedRecipients, ["followers", author]); + assert.deepStrictEqual(ctx.forwardedActivities.length, 2); + assert.deepStrictEqual(ctx.sentActivities, []); + assert.deepStrictEqual(revoked, undefined); }); -test("BotImpl.onDeleted() ignores quote revocations from other actors", async () => { +test("BotImpl.onDeleted() ignores same-origin quote revocations without attribution", async () => { const repository = new MemoryRepository(); const bot = new BotImpl({ kv: new MemoryKvStore(), @@ -4494,7 +4744,15 @@ test("BotImpl.onDeleted() ignores quote revocations from other actors", async () result: authorization, }), ); + let revoked: AuthorizedMessage | undefined; + let revoker: Actor | undefined; + bot.onQuoteRevoked = (_session, message, actor) => { + revoked = message; + revoker = actor; + }; ctx.sentActivities = []; + ctx.forwardedRecipients = []; + ctx.forwardedActivities = []; await bot.onDeleted( ctx, @@ -4508,6 +4766,7 @@ test("BotImpl.onDeleted() ignores quote revocations from other actors", async () assert.ok(stored instanceof Create); const object = await stored.getObject(ctx); assert.ok(object instanceof Note); + assert.deepStrictEqual(object.quoteId, target.id); assert.deepStrictEqual(object.quoteAuthorizationId, authorization.id); assert.deepStrictEqual( await repository.findQuoteAuthorizationReference( @@ -4516,7 +4775,308 @@ test("BotImpl.onDeleted() ignores quote revocations from other actors", async () ), messageId, ); - assert.deepStrictEqual(ctx.sentActivities.length, 0); + assert.deepStrictEqual(ctx.forwardedRecipients, []); + assert.deepStrictEqual(ctx.forwardedActivities, []); + assert.deepStrictEqual(ctx.sentActivities, []); + assert.deepStrictEqual(revoked, undefined); + assert.deepStrictEqual(revoker, undefined); +}); + +test("BotImpl.onDeleted() forwards quote revocations to mentions", async () => { + const repository = new MemoryRepository(); + const bot = new BotImpl({ + kv: new MemoryKvStore(), + repository, + username: "bot", + }); + const ctx = createMockInboxContext(bot, "https://example.com", "bot"); + const messageId = "01950000-0000-7000-8000-000000000209" as Uuid; + const author = new Person({ + id: new URL("https://remote.example/users/alice"), + preferredUsername: "alice", + }); + const mentioned = new Person({ + id: new URL("https://mentioned.example/users/bob"), + preferredUsername: "bob", + }); + Object.defineProperty(ctx, "lookupObject", { + value: (id: URL) => + Promise.resolve(id.href === mentioned.id?.href ? mentioned : null), + }); + const authorization = new URL("https://remote.example/stamps/1"); + await repository.addMessage( + "bot", + messageId, + new Create({ + id: new URL(`https://example.com/ap/actor/bot/create/${messageId}`), + actor: new URL("https://example.com/ap/actor/bot"), + object: new Note({ + id: new URL(`https://example.com/ap/actor/bot/note/${messageId}`), + attribution: new URL("https://example.com/ap/actor/bot"), + content: "Quote @bob.", + tos: [ctx.getFollowersUri(bot.identifier), mentioned.id!], + tags: [new Mention({ href: mentioned.id })], + quote: new URL("https://remote.example/notes/original"), + quoteAuthorization: authorization, + }), + }), + ); + await repository.addQuoteAuthorizationReference( + "bot", + authorization, + messageId, + author.id!, + ); + + await bot.onDeleted( + ctx, + new Delete({ + actor: author, + object: authorization, + }), + ); + + const stored = await repository.getMessage("bot", messageId); + assert.ok(stored instanceof Create); + const object = await stored.getObject(ctx); + assert.ok(object instanceof Note); + assert.deepStrictEqual(object.quoteId, null); + assert.deepStrictEqual(object.quoteAuthorizationId, null); + assert.deepStrictEqual(ctx.forwardedRecipients, [ + "followers", + mentioned, + author, + ]); + assert.deepStrictEqual(ctx.forwardedActivities.length, 2); +}); + +test("BotImpl.onDeleted() forwards quote revocations to reply targets", async () => { + const repository = new MemoryRepository(); + const bot = new BotImpl({ + kv: new MemoryKvStore(), + repository, + username: "bot", + }); + const ctx = createMockInboxContext(bot, "https://example.com", "bot"); + const session = new SessionImpl(bot, ctx); + const author = new Person({ + id: new URL("https://remote.example/users/alice"), + preferredUsername: "alice", + }); + const replyAuthor = new Person({ + id: new URL("https://reply.example/users/bob"), + preferredUsername: "bob", + }); + const mentioned = new Person({ + id: new URL("https://mentioned.example/users/carol"), + preferredUsername: "carol", + }); + const target = new Note({ + id: new URL("https://remote.example/notes/original"), + attribution: author, + content: "Original.", + to: PUBLIC_COLLECTION, + }); + const replyTarget = new Note({ + id: new URL("https://reply.example/notes/thread"), + attribution: replyAuthor, + content: "Thread starter.", + to: PUBLIC_COLLECTION, + }); + let replyTargetLookups = 0; + let mentionedLookups = 0; + Object.defineProperty(ctx, "lookupObject", { + value: (id: URL) => { + if (id.href === target.id?.href) return Promise.resolve(target); + if (id.href === replyTarget.id?.href) { + replyTargetLookups++; + return Promise.resolve(replyTarget); + } + if (id.href === mentioned.id?.href) { + mentionedLookups++; + return Promise.resolve(mentioned); + } + return Promise.resolve(null); + }, + }); + const targetMessage = await createMessage(target, session, {}); + const replyMessage = await createMessage(replyTarget, session, {}); + const quote = await session.publish( + text`Please approve this, ${mentioned}.`, + { + quoteTarget: targetMessage, + replyTarget: replyMessage, + }, + ); + const parsed = ctx.parseUri(quote.id); + assert.ok(parsed?.type === "object"); + const messageId = parsed.values.id as Uuid; + const authorization = new QuoteAuthorization({ + id: new URL("https://remote.example/stamps/1"), + attribution: author.id, + interactingObject: quote.id, + interactionTarget: target.id, + }); + await bot.onFollowAccepted( + ctx, + new Accept({ + actor: author, + object: ctx.getObjectUri(QuoteRequest, { + identifier: bot.identifier, + id: messageId, + }), + result: authorization, + }), + ); + ctx.sentActivities = []; + ctx.forwardedRecipients = []; + ctx.forwardedActivities = []; + replyTargetLookups = 0; + mentionedLookups = 0; + + await bot.onDeleted( + ctx, + new Delete({ + actor: author, + object: authorization.id, + }), + ); + + assert.deepStrictEqual(ctx.forwardedRecipients, [ + "followers", + mentioned, + replyAuthor, + author, + ]); + assert.deepStrictEqual(ctx.forwardedActivities.length, 2); + assert.deepStrictEqual(ctx.sentActivities.length, 4); + assert.deepStrictEqual(ctx.sentActivities[0].recipients, "followers"); + assert.deepStrictEqual(ctx.sentActivities[1].recipients, [mentioned]); + assert.deepStrictEqual(ctx.sentActivities[2].recipients, [replyAuthor]); + assert.deepStrictEqual(ctx.sentActivities[3].recipients, [author]); + assert.deepStrictEqual(replyTargetLookups, 2); + assert.deepStrictEqual(mentionedLookups, 2); +}); + +test("BotImpl.onDeleted() forwards private quote revocations only to the quote audience", async () => { + const repository = new MemoryRepository(); + const bot = new BotImpl({ + kv: new MemoryKvStore(), + repository, + username: "bot", + }); + const ctx = createMockInboxContext(bot, "https://example.com", "bot"); + const messageId = "01950000-0000-7000-8000-000000000208" as Uuid; + const author = new Person({ + id: new URL("https://remote.example/users/alice"), + preferredUsername: "alice", + }); + const authorization = new URL("https://remote.example/stamps/1"); + await repository.addMessage( + "bot", + messageId, + new Create({ + id: new URL(`https://example.com/ap/actor/bot/create/${messageId}`), + actor: new URL("https://example.com/ap/actor/bot"), + object: new Note({ + id: new URL(`https://example.com/ap/actor/bot/note/${messageId}`), + attribution: new URL("https://example.com/ap/actor/bot"), + content: "Quote.", + to: author.id, + quote: new URL("https://remote.example/notes/original"), + quoteAuthorization: authorization, + }), + }), + ); + await repository.addQuoteAuthorizationReference( + "bot", + authorization, + messageId, + author.id!, + ); + + await bot.onDeleted( + ctx, + new Delete({ + actor: author, + object: authorization, + }), + ); + + const stored = await repository.getMessage("bot", messageId); + assert.ok(stored instanceof Create); + const object = await stored.getObject(ctx); + assert.ok(object instanceof Note); + assert.deepStrictEqual(object.quoteId, null); + assert.deepStrictEqual(object.quoteAuthorizationId, null); + assert.deepStrictEqual( + await repository.findQuoteAuthorizationReference("bot", authorization), + undefined, + ); + assert.deepStrictEqual(ctx.forwardedRecipients, [author]); + assert.deepStrictEqual(ctx.forwardedActivities.length, 1); + assert.deepStrictEqual(ctx.sentActivities.length, 1); + assert.deepStrictEqual(ctx.sentActivities[0].recipients, [author]); + assert.ok(ctx.sentActivities[0].activity instanceof Update); +}); + +test("BotImpl.onDeleted() ignores quote revocations from wrong origins", async () => { + const repository = new MemoryRepository(); + const bot = new BotImpl({ + kv: new MemoryKvStore(), + repository, + username: "bot", + }); + const ctx = createMockInboxContext(bot, "https://example.com", "bot"); + const messageId = "01950000-0000-7000-8000-000000000205" as Uuid; + const authorization = new URL("https://remote.example/stamps/1"); + const revoker = new Person({ + id: new URL("https://evil.example/users/alice"), + preferredUsername: "alice", + }); + await repository.addMessage( + "bot", + messageId, + new Create({ + id: new URL(`https://example.com/ap/actor/bot/create/${messageId}`), + actor: new URL("https://example.com/ap/actor/bot"), + to: PUBLIC_COLLECTION, + object: new Note({ + id: new URL(`https://example.com/ap/actor/bot/note/${messageId}`), + attribution: new URL("https://example.com/ap/actor/bot"), + content: "Quote.", + to: PUBLIC_COLLECTION, + quote: new URL("https://remote.example/notes/original"), + quoteAuthorization: authorization, + }), + }), + ); + await repository.addQuoteAuthorizationReference( + "bot", + authorization, + messageId, + revoker.id!, + ); + let revoked = false; + bot.onQuoteRevoked = () => { + revoked = true; + }; + + await bot.onDeleted( + ctx, + new Delete({ + actor: revoker, + object: authorization, + }), + ); + + assert.deepStrictEqual(revoked, false); + assert.deepStrictEqual(ctx.sentActivities, []); + assert.deepStrictEqual(ctx.forwardedRecipients, []); + assert.deepStrictEqual( + await repository.findQuoteAuthorizationReference("bot", authorization), + messageId, + ); }); test("BotImpl.onDeleted() strips revoked quotes with missing targets", async () => { diff --git a/packages/botkit/src/bot-impl.ts b/packages/botkit/src/bot-impl.ts index 593e16d..7cd38ee 100644 --- a/packages/botkit/src/bot-impl.ts +++ b/packages/botkit/src/bot-impl.ts @@ -81,6 +81,7 @@ import type { QuoteEventHandler, QuoteRejectedEventHandler, QuoteRequestEventHandler, + QuoteRevokedEventHandler, ReactionEventHandler, RejectEventHandler, ReplyEventHandler, @@ -108,6 +109,7 @@ import type { SharedMessage, } from "./message.ts"; import type { Vote } from "./poll.ts"; +import { validateQuoteAuthorization } from "./quote-authorization.ts"; import { QuoteRequestImpl } from "./quote-impl.ts"; import { normalizeQuotePolicy, type QuotePolicyOption } from "./quote.ts"; import type { Like, Reaction } from "./reaction.ts"; @@ -165,6 +167,7 @@ export const botEventHandlerNames = [ "onQuoteRequest", "onQuoteAccepted", "onQuoteRejected", + "onQuoteRevoked", "onMessage", "onSharedMessage", "onLike", @@ -240,6 +243,7 @@ export class BotImpl implements Bot { onQuoteRequest?: QuoteRequestEventHandler; onQuoteAccepted?: QuoteAcceptedEventHandler; onQuoteRejected?: QuoteRejectedEventHandler; + onQuoteRevoked?: QuoteRevokedEventHandler; onMessage?: MessageEventHandler; onSharedMessage?: SharedMessageEventHandler; onLike?: LikeEventHandler; @@ -837,7 +841,20 @@ export class BotImpl implements Bot { } const rejecter = await this.#validateQuoteRejection(ctx, reject, object); if (rejecter == null) return; - await this.#stripRejectedQuote(ctx, id, object, rejecter); + const stripped = await this.#stripRejectedQuote( + ctx, + id, + object, + rejecter, + this.onQuoteRejected != null, + ); + if (stripped != null && this.onQuoteRejected != null) { + await this.onQuoteRejected( + stripped.session, + stripped.message, + rejecter, + ); + } } async onDeleted( @@ -868,7 +885,17 @@ export class BotImpl implements Bot { object, ); if (actor == null) return; - await this.#stripRejectedQuote(ctx, id, object, actor); + await this.#forwardQuoteAuthorizationDeletion(ctx, object, actor); + const stripped = await this.#stripRejectedQuote( + ctx, + id, + object, + actor, + this.onQuoteRevoked != null, + ); + if (stripped != null && this.onQuoteRevoked != null) { + await this.onQuoteRevoked(stripped.session, stripped.message, actor); + } } async #stripRejectedQuote( @@ -876,8 +903,16 @@ export class BotImpl implements Bot { id: Uuid, object: MessageClass, actor: Actor, - ): Promise { + materializeMessage: boolean, + ): Promise< + | { + readonly session: SessionImpl; + readonly message: AuthorizedMessage; + } + | undefined + > { const quoteId = object.quoteId; + const quoteAuthorizationId = object.quoteAuthorizationId; let strippedObject: MessageClass | undefined; const wasUpdated = await this.repository.updateMessage( id, @@ -886,7 +921,10 @@ export class BotImpl implements Bot { const existingObject = await existing.getObject(ctx); if ( !isMessageObject(existingObject) || existingObject.id == null || - (quoteId != null && existingObject.quoteId?.href !== quoteId.href) + (quoteId != null && existingObject.quoteId?.href !== quoteId.href) || + (quoteAuthorizationId != null && + existingObject.quoteAuthorizationId?.href !== + quoteAuthorizationId.href) ) { return; } @@ -903,18 +941,17 @@ export class BotImpl implements Bot { } if (!wasUpdated || strippedObject == null) return; await this.#sendQuoteUpdate(ctx, strippedObject, actor); - if (this.onQuoteRejected != null) { - const session = this.getSession(ctx); - const message = await createMessage( - strippedObject, - session, - { [actor.id!.href]: actor }, - undefined, - undefined, - true, - ) as AuthorizedMessage; - await this.onQuoteRejected(session, message, actor); - } + if (!materializeMessage) return; + const session = this.getSession(ctx); + const message = await createMessage( + strippedObject, + session, + actor.id == null ? {} : { [actor.id.href]: actor }, + undefined, + undefined, + true, + ) as AuthorizedMessage; + return { session, message }; } async #validateQuoteApproval( @@ -952,13 +989,12 @@ export class BotImpl implements Bot { authorization = await lookupObjectSafely(this, ctx, accept.resultId); } if ( - !(authorization instanceof QuoteAuthorization) || - authorization.id == null || - authorization.id.href !== accept.resultId.href || - authorization.id.origin !== actor.id.origin || - authorization.attributionId?.href !== actor.id.href || - authorization.interactingObjectId?.href !== object.id!.href || - authorization.interactionTargetId?.href !== object.quoteId!.href + !validateQuoteAuthorization(authorization, { + authorizationId: accept.resultId, + quoteId: object.id!, + targetId: object.quoteId!, + targetActorId: actor.id, + }) ) return undefined; return { actor, authorization }; } @@ -1005,6 +1041,9 @@ export class BotImpl implements Bot { ) { return undefined; } + if (actor.id.origin !== object.quoteAuthorizationId.origin) { + return undefined; + } const attribution = await this.repository .findQuoteAuthorizationReferenceAttribution( object.quoteAuthorizationId, @@ -1013,6 +1052,99 @@ export class BotImpl implements Bot { return actor; } + async #forwardQuoteAuthorizationDeletion( + ctx: InboxContext, + object: MessageClass, + quoteActor: Actor, + ): Promise { + const visibility = await this.#getMessageVisibility(ctx, object); + const preferSharedInbox = visibility === "public" || + visibility === "unlisted" || visibility === "followers"; + const excludeBaseUris = [new URL(ctx.origin)]; + const followersUri = ctx.getFollowersUri(this.identifier); + const botActorUri = ctx.getActorUri(this.identifier); + const recipientIds = new Map(); + let forwardsToFollowers = false; + const addRecipientId = (id: URL | null | undefined) => { + if (id == null) return; + if (id.href === followersUri.href) { + forwardsToFollowers = true; + return; + } + if ( + id.href === PUBLIC_COLLECTION.href || + id.href === botActorUri.href + ) { + return; + } + recipientIds.set(id.href, id); + }; + for (const id of object.toIds) addRecipientId(id); + for (const id of object.ccIds) addRecipientId(id); + for await ( + const tag of object.getTags({ + contextLoader: ctx.contextLoader, + documentLoader: ctx.documentLoader, + suppressError: true, + }) + ) { + if (tag instanceof Mention) addRecipientId(tag.href); + } + if (forwardsToFollowers) { + await ctx.forwardActivity(this, "followers", { + skipIfUnsigned: true, + preferSharedInbox: true, + excludeBaseUris, + }); + } + const actorRecipients: Actor[] = []; + const actorRecipientIds = new Set(); + const knownActors = new Map(); + if (quoteActor.id != null) knownActors.set(quoteActor.id.href, quoteActor); + const addActorRecipient = (actor: Actor) => { + if ( + actor.id == null || + actor.id.href === botActorUri.href || + actorRecipientIds.has(actor.id.href) + ) return; + actorRecipientIds.add(actor.id.href); + actorRecipients.push(actor); + }; + const resolvedRecipients = await Promise.all( + Array.from(recipientIds.values(), async (id) => { + const knownActor = knownActors.get(id.href); + if (knownActor != null) return knownActor; + const recipient = await lookupObjectSafely(this, ctx, id); + return isActor(recipient) ? recipient : undefined; + }), + ); + for (const recipient of resolvedRecipients) { + if (recipient != null) addActorRecipient(recipient); + } + if (object.replyTargetId != null) { + const replyTarget = await lookupObjectSafely( + this, + ctx, + object.replyTargetId, + ); + if (isMessageObject(replyTarget)) { + const replyActor = await replyTarget.getAttribution({ + contextLoader: ctx.contextLoader, + documentLoader: ctx.documentLoader, + suppressError: true, + }); + if (isActor(replyActor)) addActorRecipient(replyActor); + } + } + addActorRecipient(quoteActor); + if (actorRecipients.length < 1) return; + await ctx.forwardActivity(this, actorRecipients, { + skipIfUnsigned: true, + preferSharedInbox, + excludeBaseUris, + }); + } + async #sendQuoteUpdate( ctx: InboxContext, object: MessageClass, @@ -1403,10 +1535,12 @@ export class BotImpl implements Bot { parsed.values.id as Uuid, ); if ( - authorization?.attributionId?.href !== - ctx.getActorUri(this.identifier).href || - authorization.interactingObjectId?.href !== object.id.href || - authorization.interactionTargetId?.href !== targetId.href + !validateQuoteAuthorization(authorization, { + authorizationId: object.quoteAuthorizationId, + quoteId: object.id, + targetId, + targetActorId: ctx.getActorUri(this.identifier), + }) ) { return false; } @@ -1661,16 +1795,15 @@ export class BotImpl implements Bot { // @ts-ignore: quoteTarget.class satisfies (typeof messageClasses)[number] messageClasses.includes(quoteTarget.class) && quoteTarget.values.identifier === this.identifier; - const hasValidQuoteAuthorization = !requiresQuoteAuthorization || - (fepQuoteUrl != null && isLocalQuoteTarget && - await this.#hasValidQuoteAuthorization(ctx, object, fepQuoteUrl)); - if (requiresQuoteAuthorization && isLocalQuoteTarget) { - if (!hasValidQuoteAuthorization) return; + if ( + requiresQuoteAuthorization && isLocalQuoteTarget && + !await this.#hasValidQuoteAuthorization(ctx, object, fepQuoteUrl) + ) { + return; } if ( this.onQuote != null && - isLocalQuoteTarget && - hasValidQuoteAuthorization + isLocalQuoteTarget ) { const message = await getMessage(); if ( @@ -2121,6 +2254,12 @@ export function wrapBotImpl( set onQuoteRejected(value) { bot.onQuoteRejected = value; }, + get onQuoteRevoked() { + return bot.onQuoteRevoked; + }, + set onQuoteRevoked(value) { + bot.onQuoteRevoked = value; + }, get onMessage() { return bot.onMessage; }, @@ -2177,6 +2316,7 @@ export function wrapBotImpl( export class MigrationGatedRepository implements Repository { readonly #repository: Repository; readonly #migration: Promise; + readonly #missingQuoteAuthorizationReferenceMethods = new Set(); constructor(repository: Repository, identifier: string) { this.#repository = repository; @@ -2186,6 +2326,15 @@ export class MigrationGatedRepository implements Repository { this.#migration.catch(() => {}); } + #warnMissingQuoteAuthorizationReferenceMethod(method: string): void { + if (this.#missingQuoteAuthorizationReferenceMethods.has(method)) return; + this.#missingQuoteAuthorizationReferenceMethods.add(method); + logger.warn( + "Repository does not implement {method}; quote-authorization revocation and cleanup are disabled for this repository.", + { method }, + ); + } + async setKeyPairs( identifier: string, keyPairs: CryptoKeyPair[], @@ -2409,11 +2558,38 @@ export class MigrationGatedRepository implements Repository { ); } + async *findQuoteAuthorizationReferenceIdentifiers( + authorization: URL, + ): AsyncIterable { + await this.#migration; + if ( + typeof this.#repository.findQuoteAuthorizationReferenceIdentifiers !== + "function" + ) { + this.#warnMissingQuoteAuthorizationReferenceMethod( + "findQuoteAuthorizationReferenceIdentifiers", + ); + return; + } + yield* this.#repository.findQuoteAuthorizationReferenceIdentifiers( + authorization, + ); + } + async findQuoteAuthorizationReferenceAttribution( identifier: string, authorization: URL, ): Promise { await this.#migration; + if ( + typeof this.#repository.findQuoteAuthorizationReferenceAttribution !== + "function" + ) { + this.#warnMissingQuoteAuthorizationReferenceMethod( + "findQuoteAuthorizationReferenceAttribution", + ); + return undefined; + } return await this.#repository.findQuoteAuthorizationReferenceAttribution( identifier, authorization, @@ -2487,6 +2663,7 @@ export class BotGroupImpl implements BotGroup { onQuoteRequest?: QuoteRequestEventHandler; onQuoteAccepted?: QuoteAcceptedEventHandler; onQuoteRejected?: QuoteRejectedEventHandler; + onQuoteRevoked?: QuoteRevokedEventHandler; onMessage?: MessageEventHandler; onSharedMessage?: SharedMessageEventHandler; onLike?: LikeEventHandler; diff --git a/packages/botkit/src/bot.test.ts b/packages/botkit/src/bot.test.ts index 64d4583..cfc873c 100644 --- a/packages/botkit/src/bot.test.ts +++ b/packages/botkit/src/bot.test.ts @@ -86,6 +86,15 @@ test("createBot()", async () => { assert.strictEqual(bot.onQuoteRejected, onQuoteRejected); assert.strictEqual(impl.onQuoteRejected, onQuoteRejected); + function onQuoteRevoked( + _session: Session, + _message: AuthorizedMessage, + _revoker: Actor, + ) {} + bot.onQuoteRevoked = onQuoteRevoked; + assert.strictEqual(bot.onQuoteRevoked, onQuoteRevoked); + assert.strictEqual(impl.onQuoteRevoked, onQuoteRevoked); + function onMention( _session: Session, _message: Message, diff --git a/packages/botkit/src/bot.ts b/packages/botkit/src/bot.ts index d9eb77d..de12ed4 100644 --- a/packages/botkit/src/bot.ts +++ b/packages/botkit/src/bot.ts @@ -33,6 +33,7 @@ import type { QuoteEventHandler, QuoteRejectedEventHandler, QuoteRequestEventHandler, + QuoteRevokedEventHandler, ReactionEventHandler, RejectEventHandler, ReplyEventHandler, @@ -110,6 +111,13 @@ export interface BotEventHandlers { */ onQuoteRejected?: QuoteRejectedEventHandler; + /** + * An event handler invoked when a quote authorization for the bot's quote + * post is revoked. + * @since 0.5.0 + */ + onQuoteRevoked?: QuoteRevokedEventHandler; + /** * An event handler for a message shown to the bot's timeline. To listen * to this event, your bot needs to follow others first. diff --git a/packages/botkit/src/events.ts b/packages/botkit/src/events.ts index 4c0b9bd..1535eaa 100644 --- a/packages/botkit/src/events.ts +++ b/packages/botkit/src/events.ts @@ -144,6 +144,21 @@ export type QuoteRejectedEventHandler = ( rejecter: Actor, ) => void | Promise; +/** + * An event handler invoked when an authorization stamp for the bot's quote + * post is revoked. + * @typeParam TContextData The type of the context data. + * @param session The session of the bot. + * @param message The bot's quote message after the quote target was removed. + * @param revoker The actor who revoked the quote authorization stamp. + * @since 0.5.0 + */ +export type QuoteRevokedEventHandler = ( + session: Session, + message: AuthorizedMessage, + revoker: Actor, +) => void | Promise; + /** * An event handler for a message shown to the bot's timeline. To listen to * this event, your bot needs to follow others first. diff --git a/packages/botkit/src/helpers.ts b/packages/botkit/src/helpers.ts new file mode 100644 index 0000000..c336fc8 --- /dev/null +++ b/packages/botkit/src/helpers.ts @@ -0,0 +1,29 @@ +// BotKit by Fedify: A framework for creating ActivityPub bots +// Copyright (C) 2025–2026 Hong Minhee +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as +// published by the Free Software Foundation, either version 3 of the +// License, or (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . +import type { Repository } from "./repository.ts"; + +export function hideRepositoryMethods( + repository: Repository, + hiddenMethods: readonly PropertyKey[], +): Repository { + return new Proxy(repository, { + get(target, prop, receiver) { + if (hiddenMethods.includes(prop)) return undefined; + const value = Reflect.get(target, prop, receiver); + return typeof value === "function" ? value.bind(target) : value; + }, + }); +} diff --git a/packages/botkit/src/instance-impl.ts b/packages/botkit/src/instance-impl.ts index 634b94e..ae2e250 100644 --- a/packages/botkit/src/instance-impl.ts +++ b/packages/botkit/src/instance-impl.ts @@ -680,7 +680,7 @@ export class InstanceImpl ctx: InboxContext, del: Delete, ): Promise { - const bots = await this.#resolveTargets(ctx, () => { + const bots = await this.#resolveTargets(ctx, async () => { const targets = new Set(); for (const uri of [...del.toIds, ...del.ccIds]) { const parsed = ctx.parseUri(uri); @@ -688,6 +688,18 @@ export class InstanceImpl if (parsed.identifier != null) targets.add(parsed.identifier); } } + if ( + del.objectId != null && + typeof this.repository.findQuoteAuthorizationReferenceIdentifiers === + "function" + ) { + for await ( + const identifier of this.repository + .findQuoteAuthorizationReferenceIdentifiers(del.objectId) + ) { + targets.add(identifier); + } + } return targets; }); for (const bot of bots) await bot.onDeleted(ctx, del); diff --git a/packages/botkit/src/instance-routing.test.ts b/packages/botkit/src/instance-routing.test.ts index ffb8fe5..1305b00 100644 --- a/packages/botkit/src/instance-routing.test.ts +++ b/packages/botkit/src/instance-routing.test.ts @@ -34,6 +34,7 @@ import type { Bot } from "./bot.ts"; import { BotImpl } from "./bot-impl.ts"; import { InstanceImpl } from "./instance-impl.ts"; import { MemoryRepository, type Uuid } from "./repository.ts"; +import { hideRepositoryMethods } from "./helpers.ts"; function createMockInboxContext( instance: InstanceImpl, @@ -480,13 +481,13 @@ describe("shared inbox routing", () => { assert.deepStrictEqual(events, ["quote:alpha"]); }); - test("routes quote authorization Deletes to addressed bots", async () => { + test("routes quote authorization Deletes by reference index", async () => { const { instance, repository, alpha, beta, ctx } = createHarness(); const events: string[] = []; - alpha.onQuoteRejected = (session) => - void (events.push(`rejected:${session.bot.identifier}`)); - beta.onQuoteRejected = (session) => - void (events.push(`rejected:${session.bot.identifier}`)); + alpha.onQuoteRevoked = (session) => + void (events.push(`revoked:${session.bot.identifier}`)); + beta.onQuoteRevoked = (session) => + void (events.push(`revoked:${session.bot.identifier}`)); const author = new Person({ id: new URL("https://remote.example/actors/john"), preferredUsername: "john", @@ -541,11 +542,10 @@ describe("shared inbox routing", () => { new Delete({ actor: author, object: authorization, - to: new URL("https://example.com/ap/actor/alpha"), }), ); - assert.deepStrictEqual(events, ["rejected:alpha"]); + assert.deepStrictEqual(events, ["revoked:alpha"]); assert.deepStrictEqual( await repository.findQuoteAuthorizationReference("alpha", authorization), undefined, @@ -558,6 +558,116 @@ describe("shared inbox routing", () => { assert.deepStrictEqual(object.quoteId, null); }); + test("routes Deletes when quote reference indexes are unsupported", async () => { + const repository = hideRepositoryMethods(new MemoryRepository(), [ + "findQuoteAuthorizationReferenceIdentifiers", + ]); + const instance = new InstanceImpl({ + kv: new MemoryKvStore(), + repository, + }); + instance.createBot("alpha", { username: "alphabot" }); + const ctx = createMockInboxContext( + instance, + "https://example.com/", + undefined, + ); + + await assert.doesNotReject(() => + instance.onDeleted( + ctx, + new Delete({ + actor: remotePerson("john"), + object: new URL("https://remote.example/stamps/1"), + to: new URL("https://example.com/ap/actor/alpha"), + }), + ) + ); + }); + + test("routes dynamic bot quote authorization Deletes by reference index", async () => { + const repository = new MemoryRepository(); + const instance = new InstanceImpl({ + kv: new MemoryKvStore(), + repository, + }); + const group = instance.createBot((_ctx, identifier) => + identifier.startsWith("region_") ? { username: identifier } : null + ); + const ctx = createMockInboxContext( + instance, + "https://example.com/", + undefined, + ); + const events: string[] = []; + group.onQuoteRevoked = (session) => + void (events.push(`revoked:${session.bot.identifier}`)); + const author = new Person({ + id: new URL("https://remote.example/actors/john"), + preferredUsername: "john", + }); + const target = new Note({ + id: new URL("https://remote.example/notes/original"), + attribution: author, + content: "Original.", + to: PUBLIC_COLLECTION, + }); + Object.defineProperty(ctx, "lookupObject", { + value: (id: URL) => + Promise.resolve(id.href === target.id?.href ? target : null), + configurable: true, + }); + const messageId = "01950000-0000-7000-8000-000000000306" as Uuid; + const authorization = new URL("https://remote.example/stamps/2"); + await repository.addMessage( + "region_kr", + messageId, + new Create({ + id: new URL( + `https://example.com/ap/actor/region_kr/create/${messageId}`, + ), + actor: new URL("https://example.com/ap/actor/region_kr"), + to: PUBLIC_COLLECTION, + object: new Note({ + id: new URL( + `https://example.com/ap/actor/region_kr/note/${messageId}`, + ), + attribution: new URL("https://example.com/ap/actor/region_kr"), + to: PUBLIC_COLLECTION, + content: `

Quote.

\n\n


RE: ${target.id!.href}

`, + quote: target.id, + quoteUrl: target.id, + quoteAuthorization: authorization, + }), + }), + ); + await repository.addQuoteAuthorizationReference( + "region_kr", + authorization, + messageId, + author.id!, + ); + + await instance.onDeleted( + ctx, + new Delete({ + actor: author, + object: authorization, + }), + ); + + assert.deepStrictEqual(events, ["revoked:region_kr"]); + assert.deepStrictEqual( + await repository.findQuoteAuthorizationReference( + "region_kr", + authorization, + ), + undefined, + ); + }); + test("routes Create to followers of the author", async () => { const { instance, repository, alpha, beta, ctx } = createHarness(); const events: string[] = []; diff --git a/packages/botkit/src/message-impl.test.ts b/packages/botkit/src/message-impl.test.ts index 3926c16..4cac436 100644 --- a/packages/botkit/src/message-impl.test.ts +++ b/packages/botkit/src/message-impl.test.ts @@ -30,6 +30,7 @@ import { Person, PUBLIC_COLLECTION, Question, + QuoteAuthorization, Tombstone, Undo, Update, @@ -187,6 +188,242 @@ test("createMessage()", async () => { assert.deepStrictEqual(unknownMessage.visibility, "unknown"); }); +test("createMessage() verifies quote approvals", async (t) => { + const bot = new BotImpl({ kv: new MemoryKvStore(), username: "bot" }); + const ctx = createMockContext(bot, "https://example.com"); + const session = new SessionImpl(bot, ctx); + const author = new Person({ + id: new URL("https://author.example/users/alice"), + preferredUsername: "alice", + }); + const quoter = new Person({ + id: new URL("https://quote.example/users/bob"), + preferredUsername: "bob", + }); + const targetId = new URL("https://author.example/notes/original"); + const quoteId = new URL("https://quote.example/notes/quote"); + const authorizationId = new URL("https://author.example/stamps/1"); + const target = new Note({ + id: targetId, + attribution: author, + content: "Original.", + to: PUBLIC_COLLECTION, + }); + + const materialize = async ( + options: { + readonly attribution?: Person; + readonly quote?: URL; + readonly quoteUrl?: URL; + readonly quoteAuthorization?: URL; + readonly authorization?: QuoteAuthorization | null; + readonly authorizationError?: unknown; + readonly throwOnAuthorization?: boolean; + readonly signal?: AbortSignal; + readonly onAuthorizationLookup?: ( + options: Parameters[1], + ) => void; + } = {}, + ) => { + Object.defineProperty(ctx, "lookupObject", { + value: ( + id: URL, + lookupOptions?: Parameters[1], + ) => { + if (id.href === targetId.href) return Promise.resolve(target); + if (id.href === authorizationId.href) { + options.onAuthorizationLookup?.(lookupOptions); + if (options.authorizationError != null) { + return Promise.reject(options.authorizationError); + } + if (options.throwOnAuthorization === true) { + return Promise.reject(new TypeError("Fetch failed.")); + } + return Promise.resolve(options.authorization ?? null); + } + return Promise.resolve(null); + }, + configurable: true, + }); + return await createMessage( + new Note({ + id: quoteId, + attribution: options.attribution ?? quoter, + content: "Quote.", + to: PUBLIC_COLLECTION, + quote: options.quote, + quoteUrl: options.quoteUrl, + quoteAuthorization: options.quoteAuthorization, + }), + session, + {}, + undefined, + undefined, + undefined, + options.signal, + ); + }; + + const noQuote = await createMessage( + new Note({ + id: new URL("https://quote.example/notes/plain"), + attribution: quoter, + content: "Plain.", + to: PUBLIC_COLLECTION, + }), + session, + {}, + ); + assert.deepStrictEqual(noQuote.quoteApproved, undefined); + + await t.test("self-quote", async () => { + const message = await materialize({ + attribution: author, + quote: targetId, + }); + assert.deepStrictEqual(message.quoteApproved, true); + }); + + await t.test("valid stamp", async () => { + const message = await materialize({ + quote: targetId, + quoteAuthorization: authorizationId, + authorization: new QuoteAuthorization({ + id: authorizationId, + attribution: author.id, + interactingObject: quoteId, + interactionTarget: targetId, + }), + }); + assert.deepStrictEqual(message.quoteApproved, true); + }); + + await t.test("wrong attributedTo", async () => { + const message = await materialize({ + quote: targetId, + quoteAuthorization: authorizationId, + authorization: new QuoteAuthorization({ + id: authorizationId, + attribution: quoter.id, + interactingObject: quoteId, + interactionTarget: targetId, + }), + }); + assert.deepStrictEqual(message.quoteApproved, false); + }); + + await t.test("wrong interactingObject", async () => { + const message = await materialize({ + quote: targetId, + quoteAuthorization: authorizationId, + authorization: new QuoteAuthorization({ + id: authorizationId, + attribution: author.id, + interactingObject: new URL("https://quote.example/notes/other"), + interactionTarget: targetId, + }), + }); + assert.deepStrictEqual(message.quoteApproved, false); + }); + + await t.test("wrong interactionTarget", async () => { + const message = await materialize({ + quote: targetId, + quoteAuthorization: authorizationId, + authorization: new QuoteAuthorization({ + id: authorizationId, + attribution: author.id, + interactingObject: quoteId, + interactionTarget: new URL("https://author.example/notes/other"), + }), + }); + assert.deepStrictEqual(message.quoteApproved, false); + }); + + await t.test("cross-origin stamp", async () => { + const message = await materialize({ + quote: targetId, + quoteAuthorization: authorizationId, + authorization: new QuoteAuthorization({ + id: new URL("https://other.example/stamps/1"), + attribution: author.id, + interactingObject: quoteId, + interactionTarget: targetId, + }), + }); + assert.deepStrictEqual(message.quoteApproved, false); + }); + + await t.test("fetch failure", async () => { + const message = await materialize({ + quote: targetId, + quoteAuthorization: authorizationId, + throwOnAuthorization: true, + }); + assert.deepStrictEqual(message.quoteApproved, false); + }); + + await t.test("passes abort signal to remote stamp lookup", async () => { + const controller = new AbortController(); + let lookupSignal: AbortSignal | undefined; + const message = await materialize({ + quote: targetId, + quoteAuthorization: authorizationId, + authorization: new QuoteAuthorization({ + id: authorizationId, + attribution: author.id, + interactingObject: quoteId, + interactionTarget: targetId, + }), + signal: controller.signal, + onAuthorizationLookup: (options) => { + lookupSignal = options?.signal; + }, + }); + assert.deepStrictEqual(message.quoteApproved, true); + assert.deepStrictEqual(lookupSignal, controller.signal); + }); + + await t.test("preserves abort errors from remote stamp lookup", async () => { + const controller = new AbortController(); + const reason = new DOMException("Aborted.", "AbortError"); + controller.abort(reason); + await assert.rejects( + () => + materialize({ + quote: targetId, + quoteAuthorization: authorizationId, + authorizationError: reason, + signal: controller.signal, + }), + (error) => error === reason, + ); + }); + + await t.test("preserves custom errors after abort", async () => { + const controller = new AbortController(); + controller.abort(); + const error = new TypeError("Lookup cancelled."); + await assert.rejects( + () => + materialize({ + quote: targetId, + quoteAuthorization: authorizationId, + authorizationError: error, + signal: controller.signal, + }), + (actual) => actual === error, + ); + }); + + await t.test("legacy quote", async () => { + const message = await materialize({ + quoteUrl: targetId, + }); + assert.deepStrictEqual(message.quoteApproved, false); + }); +}); + test("AuthorizedMessageImpl.delete()", async () => { const repository = new MemoryRepository(); const bot = new BotImpl({ diff --git a/packages/botkit/src/message-impl.ts b/packages/botkit/src/message-impl.ts index 4404e69..9a31aff 100644 --- a/packages/botkit/src/message-impl.ts +++ b/packages/botkit/src/message-impl.ts @@ -54,6 +54,7 @@ import type { } from "./message.ts"; import type { AuthorizedLike, AuthorizedReaction } from "./reaction.ts"; import type { Uuid } from "./repository.ts"; +import { validateQuoteAuthorization } from "./quote-authorization.ts"; import { parseQuotePolicy, type QuotePolicy, @@ -99,6 +100,7 @@ export class MessageImpl readonly replyTarget?: Message | undefined; readonly quoteTarget?: Message | undefined; quotePolicy?: QuotePolicy | undefined; + readonly quoteApproved?: boolean | undefined; mentions: readonly Actor[]; hashtags: readonly Hashtag[]; readonly attachments: readonly Document[]; @@ -127,6 +129,7 @@ export class MessageImpl this.replyTarget = message.replyTarget; this.quoteTarget = message.quoteTarget; this.quotePolicy = message.quotePolicy; + this.quoteApproved = message.quoteApproved; this.mentions = message.mentions; this.hashtags = message.hashtags; this.attachments = message.attachments; @@ -766,6 +769,7 @@ export async function createMessage( replyTarget: Message | undefined, quote: Message | undefined, authorized: true, + signal?: AbortSignal, ): Promise>; export async function createMessage( raw: T, @@ -774,6 +778,7 @@ export async function createMessage( replyTarget?: Message, quote?: Message, authorized?: boolean, + signal?: AbortSignal, ): Promise>; export async function createMessage( raw: T, @@ -782,6 +787,7 @@ export async function createMessage( replyTarget?: Message, quoteTarget?: Message, authorized: boolean = false, + signal?: AbortSignal, ): Promise> { if (raw.id == null) throw new TypeError("The raw.id is required."); else if (raw.content == null) { @@ -792,6 +798,7 @@ export async function createMessage( contextLoader: session.context.contextLoader, documentLoader, suppressError: true, + signal, }; const rawActor = raw.attributionId?.href === session.actorId?.href ? await session.getActor() @@ -857,7 +864,15 @@ export async function createMessage( rt instanceof Article || rt instanceof ChatMessage || rt instanceof Note || rt instanceof Question ) { - replyTarget = await createMessage(rt, session, cachedObjects); + replyTarget = await createMessage( + rt, + session, + cachedObjects, + undefined, + undefined, + undefined, + signal, + ); } } if (quoteTarget == null) { @@ -896,7 +911,15 @@ export async function createMessage( qt instanceof Article || qt instanceof ChatMessage || qt instanceof Note || qt instanceof Question ) { - quoteTarget = await createMessage(qt, session, cachedObjects); + quoteTarget = await createMessage( + qt, + session, + cachedObjects, + undefined, + undefined, + undefined, + signal, + ); } } const quotePolicy = actor.id == null && raw.attributionId == null @@ -906,6 +929,16 @@ export async function createMessage( actor.id ?? raw.attributionId!, actor.followersId, ); + const quoteApproved = quoteTarget == null ? undefined : actor.id != null && + quoteTarget.actor.id != null && + actor.id.href === quoteTarget.actor.id.href + ? true + : await verifyQuoteApproval( + raw, + quoteTarget, + session, + signal, + ); const quoteApprovalState = !authorized || quoteTarget == null ? undefined : quoteTarget.actor.id?.href === actor.id?.href @@ -935,6 +968,7 @@ export async function createMessage( replyTarget, quoteTarget, quotePolicy, + quoteApproved, quoteApprovalState, mentions, hashtags, @@ -944,6 +978,53 @@ export async function createMessage( }); } +async function verifyQuoteApproval( + raw: MessageClass, + quoteTarget: Message, + session: SessionImpl, + signal?: AbortSignal, +): Promise { + if ( + raw.id == null || + raw.quoteAuthorizationId == null || + quoteTarget.actor.id == null + ) { + return false; + } + try { + const parsed = parseLocalUri( + session.context, + raw.quoteAuthorizationId, + session.bot.legacyObjectUrisIdentifier, + ); + const authorization = parsed?.type === "object" && + parsed.class === QuoteAuthorization && + parsed.values.identifier === session.bot.identifier + ? await session.bot.repository.getQuoteAuthorization( + parsed.values.id as Uuid, + ) + : await session.context.lookupObject( + raw.quoteAuthorizationId, + { + contextLoader: session.context.contextLoader, + documentLoader: await session.context.getDocumentLoader( + session.bot, + ), + signal, + }, + ); + return validateQuoteAuthorization(authorization, { + authorizationId: raw.quoteAuthorizationId, + quoteId: raw.id, + targetId: quoteTarget.id, + targetActorId: quoteTarget.actor.id, + }); + } catch (error) { + if (signal?.aborted === true) throw error; + return false; + } +} + export function getMessageVisibility( toIds: URL[], ccIds: URL[], diff --git a/packages/botkit/src/message.ts b/packages/botkit/src/message.ts index d193674..e0ce4e5 100644 --- a/packages/botkit/src/message.ts +++ b/packages/botkit/src/message.ts @@ -157,6 +157,13 @@ export interface Message { */ readonly quotePolicy?: QuotePolicy; + /** + * Whether this message's quote target has been approved according to + * FEP-044f. It is `undefined` when this message is not a quote. + * @since 0.5.0 + */ + readonly quoteApproved?: boolean; + /** * The published time of the message. */ diff --git a/packages/botkit/src/quote-authorization.test.ts b/packages/botkit/src/quote-authorization.test.ts new file mode 100644 index 0000000..b678e60 --- /dev/null +++ b/packages/botkit/src/quote-authorization.test.ts @@ -0,0 +1,127 @@ +// BotKit by Fedify: A framework for creating ActivityPub bots +// Copyright (C) 2025-2026 Hong Minhee +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as +// published by the Free Software Foundation, either version 3 of the +// License, or (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . +import { QuoteAuthorization } from "@fedify/vocab"; +import assert from "node:assert"; +import { test } from "node:test"; +import { validateQuoteAuthorization } from "./quote-authorization.ts"; + +const authorizationId = new URL("https://example.com/stamps/1"); +const quoteId = new URL("https://quote.example/notes/1"); +const targetId = new URL("https://example.com/notes/1"); +const targetActorId = new URL("https://example.com/users/alice"); + +function createAuthorization( + values: { + readonly id?: URL; + readonly attribution?: URL; + readonly interactingObject?: URL; + readonly interactionTarget?: URL; + } = {}, +): QuoteAuthorization { + return new QuoteAuthorization({ + id: values.id ?? authorizationId, + attribution: values.attribution ?? targetActorId, + interactingObject: values.interactingObject ?? quoteId, + interactionTarget: values.interactionTarget ?? targetId, + }); +} + +test("validateQuoteAuthorization() accepts matching authorization", () => { + assert.ok( + validateQuoteAuthorization(createAuthorization(), { + authorizationId, + quoteId, + targetId, + targetActorId, + }), + ); +}); + +test("validateQuoteAuthorization() rejects mismatched authorizations", () => { + const cases: readonly [ + string, + QuoteAuthorization, + URL | undefined, + ][] = [ + [ + "attributionId", + createAuthorization({ + attribution: new URL("https://example.com/users/bob"), + }), + authorizationId, + ], + [ + "interactingObjectId", + createAuthorization({ + interactingObject: new URL("https://quote.example/notes/2"), + }), + authorizationId, + ], + [ + "interactionTargetId", + createAuthorization({ + interactionTarget: new URL("https://example.com/notes/2"), + }), + authorizationId, + ], + [ + "authorizationId", + createAuthorization(), + new URL("https://example.com/stamps/2"), + ], + [ + "origin", + createAuthorization({ + id: new URL("https://malicious.example/stamps/1"), + }), + new URL("https://malicious.example/stamps/1"), + ], + ]; + + for (const [name, authorization, expectedAuthorizationId] of cases) { + assert.ok( + !validateQuoteAuthorization(authorization, { + authorizationId: expectedAuthorizationId, + quoteId, + targetId, + targetActorId, + }), + name, + ); + } +}); + +test("validateQuoteAuthorization() rejects non-authorization objects", () => { + assert.ok( + !validateQuoteAuthorization({}, { + authorizationId, + quoteId, + targetId, + targetActorId, + }), + ); +}); + +test("validateQuoteAuthorization() rejects missing target actors", () => { + assert.ok( + !validateQuoteAuthorization(createAuthorization(), { + authorizationId, + quoteId, + targetId, + targetActorId: null, + }), + ); +}); diff --git a/packages/botkit/src/quote-authorization.ts b/packages/botkit/src/quote-authorization.ts new file mode 100644 index 0000000..fea650c --- /dev/null +++ b/packages/botkit/src/quote-authorization.ts @@ -0,0 +1,66 @@ +// BotKit by Fedify: A framework for creating ActivityPub bots +// Copyright (C) 2025–2026 Hong Minhee +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as +// published by the Free Software Foundation, either version 3 of the +// License, or (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . +import { QuoteAuthorization } from "@fedify/vocab"; + +/** + * Expected identifiers for validating a quote authorization stamp. + * + * @since 0.5.0 + */ +export interface QuoteAuthorizationValidationOptions { + /** + * The expected quote authorization stamp ID. + */ + readonly authorizationId?: URL; + + /** + * The ID of the quote object that uses the authorization. + */ + readonly quoteId: URL; + + /** + * The ID of the quote target object. + */ + readonly targetId: URL; + + /** + * The actor that owns the quote target object. + */ + readonly targetActorId: URL | null; +} + +/** + * Checks whether an object is a matching FEP-044f quote authorization stamp. + * + * @param authorization The fetched or stored object to validate. + * @param options The identifiers the authorization must match. + * @returns `true` if the object is a quote authorization for the quote. + * @since 0.5.0 + */ +export function validateQuoteAuthorization( + authorization: unknown, + options: QuoteAuthorizationValidationOptions, +): authorization is QuoteAuthorization { + return authorization instanceof QuoteAuthorization && + authorization.id != null && + options.targetActorId != null && + (options.authorizationId == null || + authorization.id.href === options.authorizationId.href) && + authorization.id.origin === options.targetActorId.origin && + authorization.attributionId?.href === options.targetActorId.href && + authorization.interactingObjectId?.href === options.quoteId.href && + authorization.interactionTargetId?.href === options.targetId.href; +} diff --git a/packages/botkit/src/repository.test.ts b/packages/botkit/src/repository.test.ts index ee3920b..8817ccb 100644 --- a/packages/botkit/src/repository.test.ts +++ b/packages/botkit/src/repository.test.ts @@ -38,6 +38,7 @@ import { type Repository, type Uuid, } from "./repository.ts"; +import { hideRepositoryMethods } from "./helpers.ts"; function createKvRepository(): Repository { return new KvRepository(new MemoryKvStore()); @@ -149,10 +150,27 @@ for (const [name, factory] of Object.entries(factories)) { await repository.findQuoteAuthorizationReference("other", authorization), undefined, ); + assert.deepStrictEqual( + await Array.fromAsync( + repository.findQuoteAuthorizationReferenceIdentifiers(authorization), + ), + ["bot"], + ); assert.deepStrictEqual( await repository.findQuoteAuthorizationReference("bot", authorization), firstMessageId, ); + await repository.addQuoteAuthorizationReference( + "other", + authorization, + firstMessageId, + ); + assert.deepStrictEqual( + await Array.fromAsync( + repository.findQuoteAuthorizationReferenceIdentifiers(authorization), + ), + ["bot", "other"], + ); await repository.addQuoteAuthorizationReference( "bot", @@ -171,6 +189,12 @@ for (const [name, factory] of Object.entries(factories)) { await repository.findQuoteAuthorizationReference("bot", authorization), undefined, ); + assert.deepStrictEqual( + await Array.fromAsync( + repository.findQuoteAuthorizationReferenceIdentifiers(authorization), + ), + ["other"], + ); }); } @@ -279,6 +303,42 @@ test("MemoryCachedRepository keeps quote authorization reference cache on remove ); }); +test("MemoryCachedRepository tolerates missing quote reference index methods", async () => { + const underlying = new MemoryRepository(); + const authorization = new URL("https://remote.example/stamps/1"); + const messageId = "01942976-3400-7f34-872e-2cbf0f9eeac4" as Uuid; + await underlying.addQuoteAuthorizationReference( + "bot", + authorization, + messageId, + new URL("https://remote.example/actors/alice"), + ); + const repository = new MemoryCachedRepository( + hideRepositoryMethods(underlying, [ + "findQuoteAuthorizationReferenceAttribution", + "findQuoteAuthorizationReferenceIdentifiers", + ]), + ); + + assert.deepStrictEqual( + await repository.findQuoteAuthorizationReference("bot", authorization), + messageId, + ); + assert.deepStrictEqual( + await repository.findQuoteAuthorizationReferenceAttribution( + "bot", + authorization, + ), + undefined, + ); + assert.deepStrictEqual( + await Array.fromAsync( + repository.findQuoteAuthorizationReferenceIdentifiers(authorization), + ), + [], + ); +}); + test("KvRepository serializes concurrent quote authorization inserts", async () => { const repository = new KvRepository(new RacingQuoteAuthorizationKvStore()); const firstId = "01942976-3400-7f34-872e-2cbf0f9eeac4" as Uuid; @@ -1990,6 +2050,7 @@ describe("KvRepository.migrate()", () => { followRequestId: URL; followeeId: URL; followeeFollowJson: unknown; + quoteAuthorizationRefId: URL; sentFollowId: Uuid; sentFollowJson: unknown; }> { @@ -2040,6 +2101,12 @@ describe("KvRepository.migrate()", () => { }); await kv.set(["_botkit", "followees", followeeId.href], followeeFollowJson); + const quoteAuthorizationRefId = new URL("https://example.com/stamps/1"); + await kv.set( + ["_botkit", "quoteAuthorizationRefs", quoteAuthorizationRefId.href], + messageId, + ); + const sentFollowId: Uuid = "e35ff5d8-ede9-4f5e-9b83-4bfcd4c9a69c"; const sentFollow = new Follow({ id: new URL(`https://example.com/ap/follow/${sentFollowId}`), @@ -2066,6 +2133,7 @@ describe("KvRepository.migrate()", () => { followRequestId, followeeId, followeeFollowJson, + quoteAuthorizationRefId, sentFollowId, sentFollowJson, }; @@ -2188,6 +2256,29 @@ describe("KvRepository.migrate()", () => { ); }); + test("migrates quote authorization references with their reverse index", async () => { + const kv = new MemoryKvStore(); + const seed = await seedLegacyData(kv); + const repo = new KvRepository(kv); + await repo.migrate("bot"); + + assert.deepStrictEqual( + await repo.findQuoteAuthorizationReference( + "bot", + seed.quoteAuthorizationRefId, + ), + seed.messageId, + ); + assert.deepStrictEqual( + await Array.fromAsync( + repo.findQuoteAuthorizationReferenceIdentifiers( + seed.quoteAuthorizationRefId, + ), + ), + ["bot"], + ); + }); + test("migrates follow requests eagerly", async () => { const kv = new MemoryKvStore(); const seed = await seedLegacyData(kv); diff --git a/packages/botkit/src/repository.ts b/packages/botkit/src/repository.ts index ab500ec..6d0fe93 100644 --- a/packages/botkit/src/repository.ts +++ b/packages/botkit/src/repository.ts @@ -395,6 +395,17 @@ export interface Repository { authorization: URL, ): Promise; + /** + * Finds bot identifiers whose quote messages depend on a received quote + * authorization stamp. + * @param authorization The URI of the received quote authorization stamp. + * @returns The identifiers of bot actors that reference the stamp. + * @since 0.5.0 + */ + findQuoteAuthorizationReferenceIdentifiers( + authorization: URL, + ): AsyncIterable; + /** * Finds the actor that issued a received quote authorization stamp. * @param identifier The identifier of the bot actor that owns the message. @@ -839,6 +850,10 @@ export class ActorScopedRepository { findQuoteAuthorizationReferenceAttribution( authorization: URL, ): Promise { + if ( + typeof this.repository.findQuoteAuthorizationReferenceAttribution !== + "function" + ) return Promise.resolve(undefined); return this.repository.findQuoteAuthorizationReferenceAttribution( this.identifier, authorization, @@ -991,6 +1006,15 @@ export class KvRepository implements Repository { ); } + #quoteAuthorizationReferenceIndexKey(authorization: URL): KvKey { + return [ + ...this.prefix, + "index", + "quoteAuthorizationRefs", + authorization.href, + ]; + } + /** * Migrates data stored by BotKit 0.4 or earlier, which was not scoped by * bot actor identifiers, so that it belongs to the given identifier. @@ -1001,9 +1025,9 @@ export class KvRepository implements Repository { * reusing the repository for another bot, even concurrently, does not * adopt the same rows again. Legacy keys are copied, not moved, and * the completion is recorded last, so a partially failed run is simply - * retried by the adopter on the next call without data loss. Followees - * are also entered into the reverse lookup index used by - * {@link KvRepository.findFollowedBots}. + * retried by the adopter on the next call without data loss. Followees and + * quote authorization references are also entered into their reverse lookup + * indexes. * * Calling this method again after a successful migration is a no-op. * @param identifier The identifier of the bot actor that adopts the legacy @@ -1077,6 +1101,25 @@ export class KvRepository implements Repository { } await this.#addToFolloweeIndex(identifier, followeeId); } + if (category === "quoteAuthorizationRefs" && rest.length === 1) { + let authorization: URL; + try { + authorization = new URL(rest[0]); + } catch (error) { + // A malformed legacy key cannot be indexed; storage errors from + // the indexing itself must propagate so the done marker is not + // written and the adopter retries: + logger.warn( + "Skipping the malformed legacy quote authorization reference key {authorization}.", + { authorization: rest[0], error }, + ); + continue; + } + await this.#addToQuoteAuthorizationReferenceIndex( + identifier, + authorization, + ); + } } } // The completion is recorded last so that an interrupted migration is @@ -1831,6 +1874,10 @@ export class KvRepository implements Repository { ? messageId : { messageId, attribution: attribution.href }, ); + await this.#addToQuoteAuthorizationReferenceIndex( + identifier, + authorization, + ); } async findQuoteAuthorizationReference( @@ -1844,6 +1891,15 @@ export class KvRepository implements Repository { return value?.messageId; } + async *findQuoteAuthorizationReferenceIdentifiers( + authorization: URL, + ): AsyncIterable { + const identifiers = await this.kv.get( + this.#quoteAuthorizationReferenceIndexKey(authorization), + ) ?? []; + for (const identifier of identifiers) yield identifier; + } + async findQuoteAuthorizationReferenceAttribution( identifier: string, authorization: URL, @@ -1867,6 +1923,10 @@ export class KvRepository implements Repository { await this.kv.delete( this.#quoteAuthorizationReferenceKey(identifier, authorization), ); + await this.#removeFromQuoteAuthorizationReferenceIndex( + identifier, + authorization, + ); } async #addToFolloweeIndex( @@ -1911,6 +1971,48 @@ export class KvRepository implements Repository { } } + async #addToQuoteAuthorizationReferenceIndex( + identifier: string, + authorization: URL, + ): Promise { + const key = this.#quoteAuthorizationReferenceIndexKey(authorization); + while (true) { + const prev = await this.kv.get(key); + if (prev != null && prev.includes(identifier)) return; + const next = prev == null ? [identifier] : [...prev, identifier]; + if (this.kv.cas == null) { + await this.kv.set(key, next); + return; + } + if (await this.kv.cas(key, prev, next)) return; + logger.trace( + "CAS operation failed, retrying to index quote authorization reference {authorization} for bot {identifier}.", + { authorization: authorization.href, identifier }, + ); + } + } + + async #removeFromQuoteAuthorizationReferenceIndex( + identifier: string, + authorization: URL, + ): Promise { + const key = this.#quoteAuthorizationReferenceIndexKey(authorization); + while (true) { + const prev = await this.kv.get(key); + if (prev == null || !prev.includes(identifier)) return; + const next = prev.filter((id) => id !== identifier); + if (this.kv.cas == null) { + await this.kv.set(key, next); + return; + } + if (await this.kv.cas(key, prev, next)) return; + logger.trace( + "CAS operation failed, retrying to unindex quote authorization reference {authorization} for bot {identifier}.", + { authorization: authorization.href, identifier }, + ); + } + } + async vote( identifier: string, messageId: Uuid, @@ -2380,6 +2482,16 @@ export class MemoryRepository implements Repository { ); } + async *findQuoteAuthorizationReferenceIdentifiers( + authorization: URL, + ): AsyncIterable { + for (const [identifier, data] of this.#data) { + if (data.quoteAuthorizationRefs.has(authorization.href)) { + yield identifier; + } + } + } + findQuoteAuthorizationReferenceAttribution( identifier: string, authorization: URL, @@ -2774,11 +2886,14 @@ export class MemoryCachedRepository implements Repository { authorization, ); if (found != null) { - const attribution = await this.underlying - .findQuoteAuthorizationReferenceAttribution( - identifier, - authorization, - ); + const attribution = + typeof this.underlying.findQuoteAuthorizationReferenceAttribution === + "function" + ? await this.underlying.findQuoteAuthorizationReferenceAttribution( + identifier, + authorization, + ) + : undefined; await this.cache.addQuoteAuthorizationReference( identifier, authorization, @@ -2789,6 +2904,41 @@ export class MemoryCachedRepository implements Repository { return found; } + async *findQuoteAuthorizationReferenceIdentifiers( + authorization: URL, + ): AsyncIterable { + if ( + typeof this.underlying.findQuoteAuthorizationReferenceIdentifiers !== + "function" + ) return; + for await ( + const identifier of this.underlying + .findQuoteAuthorizationReferenceIdentifiers(authorization) + ) { + const found = await this.underlying.findQuoteAuthorizationReference( + identifier, + authorization, + ); + if (found != null) { + const attribution = + typeof this.underlying.findQuoteAuthorizationReferenceAttribution === + "function" + ? await this.underlying.findQuoteAuthorizationReferenceAttribution( + identifier, + authorization, + ) + : undefined; + await this.cache.addQuoteAuthorizationReference( + identifier, + authorization, + found, + attribution, + ); + } + yield identifier; + } + } + async findQuoteAuthorizationReferenceAttribution( identifier: string, authorization: URL, @@ -2798,16 +2948,17 @@ export class MemoryCachedRepository implements Repository { authorization, ); if (cached != null) return cached; + if ( + typeof this.underlying.findQuoteAuthorizationReferenceAttribution !== + "function" + ) return undefined; const found = await this.underlying.findQuoteAuthorizationReference( identifier, authorization, ); if (found == null) return undefined; const attribution = await this.underlying - .findQuoteAuthorizationReferenceAttribution( - identifier, - authorization, - ); + .findQuoteAuthorizationReferenceAttribution(identifier, authorization); if (attribution != null) { await this.cache.addQuoteAuthorizationReference( identifier,