From b21b1f11abac7be6aeea7575ab7a8a623dbb271c Mon Sep 17 00:00:00 2001 From: Hong Minhee Date: Tue, 7 Jul 2026 04:44:04 +0900 Subject: [PATCH 1/9] Verify quote authorization stamps Add validation for FEP-044f quote authorization stamps when materializing received messages, exposing the result through Message.quoteApproved. Route quote authorization Delete activities through the received stamp reference index, strip revoked quotes after forwarding the Delete, and fire Bot.onQuoteRevoked after the Update is sent. Add reverse lookup support across the in-memory, KV, SQLite, and PostgreSQL repositories, plus tests and documentation for the new behavior. Fixes https://github.com/fedify-dev/botkit/issues/30 Refs https://github.com/fedify-dev/botkit/issues/27 Assisted-by: Codex:gpt-5.5 --- CHANGES.md | 18 +- docs/concepts/events.md | 27 ++- docs/concepts/message.md | 16 +- packages/botkit-postgres/src/mod.test.ts | 23 +++ packages/botkit-postgres/src/mod.ts | 22 +++ packages/botkit-sqlite/src/mod.test.ts | 23 +++ packages/botkit-sqlite/src/mod.ts | 15 ++ packages/botkit/src/bot-impl.test.ts | 169 ++++++++++++++++--- packages/botkit/src/bot-impl.ts | 108 ++++++++---- packages/botkit/src/bot.test.ts | 9 + packages/botkit/src/bot.ts | 8 + packages/botkit/src/events.ts | 15 ++ packages/botkit/src/instance-impl.ts | 10 +- packages/botkit/src/instance-routing.test.ts | 96 ++++++++++- packages/botkit/src/message-impl.test.ts | 168 ++++++++++++++++++ packages/botkit/src/message-impl.ts | 57 +++++++ packages/botkit/src/message.ts | 7 + packages/botkit/src/quote-authorization.ts | 37 ++++ packages/botkit/src/repository.test.ts | 23 +++ packages/botkit/src/repository.ts | 117 +++++++++++++ 20 files changed, 890 insertions(+), 78 deletions(-) create mode 100644 packages/botkit/src/quote-authorization.ts 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/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..bc8a5e9 100644 --- a/packages/botkit/src/bot-impl.test.ts +++ b/packages/botkit/src/bot-impl.test.ts @@ -2099,12 +2099,21 @@ test("BotImpl.onCreated()", async (t) => { await bot.onCreated(ctx, create); - assert.deepStrictEqual(quoted, []); + assert.deepStrictEqual(quoted.length, 1); + const [, msg] = quoted[0]; + assert.ok(msg.quoteTarget != null); + assert.deepStrictEqual( + msg.quoteTarget.id, + new URL( + "https://example.com/ap/note/a6358f1b-c978-49d3-8065-37a1df6168de", + ), + ); + assert.deepStrictEqual(msg.quoteApproved, false); assert.deepStrictEqual(replied, []); assert.deepStrictEqual(mentioned, []); - assert.deepStrictEqual(messaged, []); + assert.deepStrictEqual(messaged, quoted); assert.deepStrictEqual(ctx.sentActivities, []); - assert.deepStrictEqual(ctx.forwardedRecipients, []); + assert.deepStrictEqual(ctx.forwardedRecipients, ["followers"]); quoted = []; messaged = []; @@ -2147,12 +2156,16 @@ test("BotImpl.onCreated()", async (t) => { await bot.onCreated(ctx, create); - assert.deepStrictEqual(quoted, []); + assert.deepStrictEqual(quoted.length, 1); + const [, msg] = quoted[0]; + assert.ok(msg.quoteTarget != null); + assert.deepStrictEqual(msg.quoteTarget.id, targetId); + assert.deepStrictEqual(msg.quoteApproved, false); assert.deepStrictEqual(replied, []); assert.deepStrictEqual(mentioned, []); - assert.deepStrictEqual(messaged, []); + assert.deepStrictEqual(messaged, quoted); assert.deepStrictEqual(ctx.sentActivities, []); - assert.deepStrictEqual(ctx.forwardedRecipients, []); + assert.deepStrictEqual(ctx.forwardedRecipients, ["followers"]); quoted = []; messaged = []; @@ -2213,6 +2226,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 +2289,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 +2383,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); @@ -2443,10 +2459,14 @@ test("BotImpl.onCreated()", async (t) => { await bot.onCreated(ctx, create); - assert.deepStrictEqual(quoted, []); + assert.deepStrictEqual(quoted.length, 1); + const [, msg] = quoted[0]; + assert.ok(msg.quoteTarget != null); + assert.deepStrictEqual(msg.quoteTarget.id, targetId); + assert.deepStrictEqual(msg.quoteApproved, false); assert.deepStrictEqual(replied, []); assert.deepStrictEqual(mentioned, []); - assert.deepStrictEqual(messaged, []); + assert.deepStrictEqual(messaged, quoted); assert.deepStrictEqual(ctx.sentActivities.length, 2); const [actorDelete, followersDelete] = ctx.sentActivities; assert.deepStrictEqual( @@ -2458,7 +2478,7 @@ test("BotImpl.onCreated()", async (t) => { assert.deepStrictEqual(followersDelete.recipients, "followers"); assert.ok(followersDelete.activity instanceof Delete); assert.deepStrictEqual(followersDelete.activity.objectId, authorizationUrl); - assert.deepStrictEqual(ctx.forwardedRecipients, []); + assert.deepStrictEqual(ctx.forwardedRecipients, ["followers"]); quoted = []; messaged = []; @@ -3195,6 +3215,10 @@ interface SentActivity { interface MockInboxContext extends InboxContext { sentActivities: SentActivity[]; forwardedRecipients: ("followers" | Recipient)[]; + forwardedActivities: { + recipients: "followers" | Recipient[]; + options: unknown; + }[]; } function createMockInboxContext( @@ -3224,7 +3248,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 +4434,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 +4472,28 @@ test("BotImpl.onDeleted() strips revoked quote authorizations", async () => { ), undefined, ); + assert.deepStrictEqual(ctx.forwardedRecipients, ["followers"]); + assert.deepStrictEqual(ctx.forwardedActivities.length, 1); + 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() ignores quote revocations from other actors", async () => { +test("BotImpl.onDeleted() strips quote revocations from same-origin actors", async () => { const repository = new MemoryRepository(); const bot = new BotImpl({ kv: new MemoryKvStore(), @@ -4494,7 +4544,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,15 +4566,84 @@ 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.quoteAuthorizationId, authorization.id); + assert.deepStrictEqual(object.quoteId, null); + assert.deepStrictEqual(object.quoteAuthorizationId, null); assert.deepStrictEqual( await repository.findQuoteAuthorizationReference( "bot", authorization.id!, ), + undefined, + ); + assert.deepStrictEqual(ctx.forwardedRecipients, ["followers"]); + assert.deepStrictEqual(ctx.forwardedActivities.length, 1); + 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, [otherActor]); + assert.ok(ctx.sentActivities[1].activity instanceof Update); + assert.deepStrictEqual(revoked?.id, quote.id); + assert.deepStrictEqual(revoked?.quoteTarget, undefined); + assert.deepStrictEqual(revoker?.id, otherActor.id); +}); + +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, ); - assert.deepStrictEqual(ctx.sentActivities.length, 0); }); 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..9a2c039 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,14 @@ 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); + if (stripped != null && this.onQuoteRejected != null) { + await this.onQuoteRejected( + stripped.session, + stripped.message, + rejecter, + ); + } } async onDeleted( @@ -868,7 +879,15 @@ export class BotImpl implements Bot { object, ); if (actor == null) return; - await this.#stripRejectedQuote(ctx, id, object, actor); + await ctx.forwardActivity(this, "followers", { + skipIfUnsigned: true, + preferSharedInbox: true, + excludeBaseUris: [new URL(ctx.origin)], + }); + const stripped = await this.#stripRejectedQuote(ctx, id, object, actor); + if (stripped != null && this.onQuoteRevoked != null) { + await this.onQuoteRevoked(stripped.session, stripped.message, actor); + } } async #stripRejectedQuote( @@ -876,7 +895,13 @@ export class BotImpl implements Bot { id: Uuid, object: MessageClass, actor: Actor, - ): Promise { + ): Promise< + | { + readonly session: SessionImpl; + readonly message: AuthorizedMessage; + } + | undefined + > { const quoteId = object.quoteId; let strippedObject: MessageClass | undefined; const wasUpdated = await this.repository.updateMessage( @@ -903,18 +928,16 @@ 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); - } + 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 +975,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,11 +1027,9 @@ export class BotImpl implements Bot { ) { return undefined; } - const attribution = await this.repository - .findQuoteAuthorizationReferenceAttribution( - object.quoteAuthorizationId, - ); - if (attribution?.href !== actor.id.href) return undefined; + if (actor.id.origin !== object.quoteAuthorizationId.origin) { + return undefined; + } return actor; } @@ -1403,10 +1423,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 +1683,12 @@ 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; + await this.#hasValidQuoteAuthorization(ctx, object, fepQuoteUrl); } if ( this.onQuote != null && - isLocalQuoteTarget && - hasValidQuoteAuthorization + isLocalQuoteTarget ) { const message = await getMessage(); if ( @@ -2121,6 +2139,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; }, @@ -2409,6 +2433,15 @@ export class MigrationGatedRepository implements Repository { ); } + async *findQuoteAuthorizationReferenceIdentifiers( + authorization: URL, + ): AsyncIterable { + await this.#migration; + yield* this.#repository.findQuoteAuthorizationReferenceIdentifiers( + authorization, + ); + } + async findQuoteAuthorizationReferenceAttribution( identifier: string, authorization: URL, @@ -2487,6 +2520,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/instance-impl.ts b/packages/botkit/src/instance-impl.ts index 634b94e..c063258 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,14 @@ export class InstanceImpl if (parsed.identifier != null) targets.add(parsed.identifier); } } + if (del.objectId != null) { + 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..983940e 100644 --- a/packages/botkit/src/instance-routing.test.ts +++ b/packages/botkit/src/instance-routing.test.ts @@ -480,13 +480,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 +541,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 +557,89 @@ describe("shared inbox routing", () => { assert.deepStrictEqual(object.quoteId, null); }); + 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..ba658d5 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,173 @@ 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 throwOnAuthorization?: boolean; + } = {}, + ) => { + Object.defineProperty(ctx, "lookupObject", { + value: (id: URL) => { + if (id.href === targetId.href) return Promise.resolve(target); + if (id.href === authorizationId.href) { + 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, + {}, + ); + }; + + 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("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..f6b4a65 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; @@ -906,6 +909,15 @@ 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, + ); const quoteApprovalState = !authorized || quoteTarget == null ? undefined : quoteTarget.actor.id?.href === actor.id?.href @@ -935,6 +947,7 @@ export async function createMessage( replyTarget, quoteTarget, quotePolicy, + quoteApproved, quoteApprovalState, mentions, hashtags, @@ -944,6 +957,50 @@ export async function createMessage( }); } +async function verifyQuoteApproval( + raw: MessageClass, + quoteTarget: Message, + session: SessionImpl, +): 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, + ), + }, + ); + return validateQuoteAuthorization(authorization, { + authorizationId: raw.quoteAuthorizationId, + quoteId: raw.id, + targetId: quoteTarget.id, + targetActorId: quoteTarget.actor.id, + }); + } catch { + 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.ts b/packages/botkit/src/quote-authorization.ts new file mode 100644 index 0000000..c124b5e --- /dev/null +++ b/packages/botkit/src/quote-authorization.ts @@ -0,0 +1,37 @@ +// 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"; + +export interface QuoteAuthorizationValidationOptions { + readonly authorizationId?: URL; + readonly quoteId: URL; + readonly targetId: URL; + readonly targetActorId: URL; +} + +export function validateQuoteAuthorization( + authorization: unknown, + options: QuoteAuthorizationValidationOptions, +): authorization is QuoteAuthorization { + return authorization instanceof QuoteAuthorization && + authorization.id != 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..c106474 100644 --- a/packages/botkit/src/repository.test.ts +++ b/packages/botkit/src/repository.test.ts @@ -149,10 +149,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 +188,12 @@ for (const [name, factory] of Object.entries(factories)) { await repository.findQuoteAuthorizationReference("bot", authorization), undefined, ); + assert.deepStrictEqual( + await Array.fromAsync( + repository.findQuoteAuthorizationReferenceIdentifiers(authorization), + ), + ["other"], + ); }); } diff --git a/packages/botkit/src/repository.ts b/packages/botkit/src/repository.ts index ab500ec..be69119 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. @@ -991,6 +1002,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. @@ -1831,6 +1851,10 @@ export class KvRepository implements Repository { ? messageId : { messageId, attribution: attribution.href }, ); + await this.#addToQuoteAuthorizationReferenceIndex( + identifier, + authorization, + ); } async findQuoteAuthorizationReference( @@ -1844,6 +1868,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 +1900,10 @@ export class KvRepository implements Repository { await this.kv.delete( this.#quoteAuthorizationReferenceKey(identifier, authorization), ); + await this.#removeFromQuoteAuthorizationReferenceIndex( + identifier, + authorization, + ); } async #addToFolloweeIndex( @@ -1911,6 +1948,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 +2459,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, @@ -2789,6 +2878,34 @@ export class MemoryCachedRepository implements Repository { return found; } + async *findQuoteAuthorizationReferenceIdentifiers( + authorization: URL, + ): AsyncIterable { + for await ( + const identifier of this.underlying + .findQuoteAuthorizationReferenceIdentifiers(authorization) + ) { + const found = await this.underlying.findQuoteAuthorizationReference( + identifier, + authorization, + ); + if (found != null) { + const attribution = await this.underlying + .findQuoteAuthorizationReferenceAttribution( + identifier, + authorization, + ); + await this.cache.addQuoteAuthorizationReference( + identifier, + authorization, + found, + attribution, + ); + } + yield identifier; + } + } + async findQuoteAuthorizationReferenceAttribution( identifier: string, authorization: URL, From e30c748bed9b4b1a99f8c0aed4a9d547727cf878 Mon Sep 17 00:00:00 2001 From: Hong Minhee Date: Tue, 7 Jul 2026 05:26:10 +0900 Subject: [PATCH 2/9] Index migrated quote stamps Legacy quote authorization reference rows were copied into the scoped key space, but their reverse lookup index also has to be rebuilt while the adopting bot owns the migration. Rebuilding it in the same pass keeps lookup behavior consistent after migration. Malformed legacy keys are skipped like malformed followees, while storage failures still leave the done marker unwritten so a later migration attempt can retry the index write. Addresses review feedback from: https://github.com/fedify-dev/botkit/pull/33#pullrequestreview-4639380653 Assisted-by: Codex:gpt-5.5 --- packages/botkit/src/repository.test.ts | 31 ++++++++++++++++++++++++++ packages/botkit/src/repository.ts | 25 ++++++++++++++++++--- 2 files changed, 53 insertions(+), 3 deletions(-) diff --git a/packages/botkit/src/repository.test.ts b/packages/botkit/src/repository.test.ts index c106474..0535c61 100644 --- a/packages/botkit/src/repository.test.ts +++ b/packages/botkit/src/repository.test.ts @@ -2013,6 +2013,7 @@ describe("KvRepository.migrate()", () => { followRequestId: URL; followeeId: URL; followeeFollowJson: unknown; + quoteAuthorizationRefId: URL; sentFollowId: Uuid; sentFollowJson: unknown; }> { @@ -2063,6 +2064,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}`), @@ -2089,6 +2096,7 @@ describe("KvRepository.migrate()", () => { followRequestId, followeeId, followeeFollowJson, + quoteAuthorizationRefId, sentFollowId, sentFollowJson, }; @@ -2211,6 +2219,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 be69119..7b734c9 100644 --- a/packages/botkit/src/repository.ts +++ b/packages/botkit/src/repository.ts @@ -1021,9 +1021,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 @@ -1097,6 +1097,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 From 3c7f830f87c9769f21cf245a66f3674ca47416c7 Mon Sep 17 00:00:00 2001 From: Hong Minhee Date: Tue, 7 Jul 2026 05:32:28 +0900 Subject: [PATCH 3/9] Tighten quote stamp validation Quote authorization revocations now require the recorded attribution instead of trusting every actor on the stamp origin. Remote Delete forwarding is limited to quotes whose audience is already shared with followers, which avoids exposing private quote stamps to unrelated followers. Remote quote authorization lookups now receive the createMessage abort signal, and the validation helper documents its exported API while rejecting missing target actors explicitly. https://github.com/fedify-dev/botkit/pull/33#discussion_r3531710365 https://github.com/fedify-dev/botkit/pull/33#discussion_r3531710368 https://github.com/fedify-dev/botkit/pull/33#discussion_r3531744332 https://github.com/fedify-dev/botkit/pull/33#discussion_r3531757359 https://github.com/fedify-dev/botkit/pull/33#discussion_r3531757367 Assisted-by: Codex:gpt-5.5 --- packages/botkit/src/bot-impl.test.ts | 81 ++++++++++++++++--- packages/botkit/src/bot-impl.ts | 27 +++++-- packages/botkit/src/message-impl.test.ts | 35 +++++++- packages/botkit/src/message-impl.ts | 27 ++++++- .../botkit/src/quote-authorization.test.ts | 37 +++++++++ packages/botkit/src/quote-authorization.ts | 31 ++++++- 6 files changed, 217 insertions(+), 21 deletions(-) create mode 100644 packages/botkit/src/quote-authorization.test.ts diff --git a/packages/botkit/src/bot-impl.test.ts b/packages/botkit/src/bot-impl.test.ts index bc8a5e9..536bc28 100644 --- a/packages/botkit/src/bot-impl.test.ts +++ b/packages/botkit/src/bot-impl.test.ts @@ -4493,7 +4493,7 @@ test("BotImpl.onDeleted() strips revoked quote authorizations", async () => { assert.deepStrictEqual(revoker?.id, author.id); }); -test("BotImpl.onDeleted() strips quote revocations from same-origin actors", async () => { +test("BotImpl.onDeleted() ignores same-origin quote revocations without attribution", async () => { const repository = new MemoryRepository(); const bot = new BotImpl({ kv: new MemoryKvStore(), @@ -4566,25 +4566,82 @@ test("BotImpl.onDeleted() strips quote revocations from same-origin actors", asy 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(object.quoteId, target.id); + assert.deepStrictEqual(object.quoteAuthorizationId, authorization.id); assert.deepStrictEqual( await repository.findQuoteAuthorizationReference( "bot", authorization.id!, ), + messageId, + ); + assert.deepStrictEqual(ctx.forwardedRecipients, []); + assert.deepStrictEqual(ctx.forwardedActivities, []); + assert.deepStrictEqual(ctx.sentActivities, []); + assert.deepStrictEqual(revoked, undefined); + assert.deepStrictEqual(revoker, undefined); +}); + +test("BotImpl.onDeleted() does not forward private quote revocations to followers", 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, ["followers"]); - assert.deepStrictEqual(ctx.forwardedActivities.length, 1); - assert.deepStrictEqual(ctx.sentActivities.length, 2); - assert.deepStrictEqual(ctx.sentActivities[0].recipients, "followers"); + assert.deepStrictEqual(ctx.forwardedRecipients, []); + assert.deepStrictEqual(ctx.forwardedActivities, []); + assert.deepStrictEqual(ctx.sentActivities.length, 1); + assert.deepStrictEqual(ctx.sentActivities[0].recipients, [author]); assert.ok(ctx.sentActivities[0].activity instanceof Update); - assert.deepStrictEqual(ctx.sentActivities[1].recipients, [otherActor]); - assert.ok(ctx.sentActivities[1].activity instanceof Update); - assert.deepStrictEqual(revoked?.id, quote.id); - assert.deepStrictEqual(revoked?.quoteTarget, undefined); - assert.deepStrictEqual(revoker?.id, otherActor.id); }); test("BotImpl.onDeleted() ignores quote revocations from wrong origins", async () => { diff --git a/packages/botkit/src/bot-impl.ts b/packages/botkit/src/bot-impl.ts index 9a2c039..5517b4f 100644 --- a/packages/botkit/src/bot-impl.ts +++ b/packages/botkit/src/bot-impl.ts @@ -879,11 +879,7 @@ export class BotImpl implements Bot { object, ); if (actor == null) return; - await ctx.forwardActivity(this, "followers", { - skipIfUnsigned: true, - preferSharedInbox: true, - excludeBaseUris: [new URL(ctx.origin)], - }); + await this.#forwardQuoteAuthorizationDeletion(ctx, object); const stripped = await this.#stripRejectedQuote(ctx, id, object, actor); if (stripped != null && this.onQuoteRevoked != null) { await this.onQuoteRevoked(stripped.session, stripped.message, actor); @@ -1030,9 +1026,30 @@ export class BotImpl implements Bot { if (actor.id.origin !== object.quoteAuthorizationId.origin) { return undefined; } + const attribution = await this.repository + .findQuoteAuthorizationReferenceAttribution( + object.quoteAuthorizationId, + ); + if (attribution?.href !== actor.id.href) return undefined; return actor; } + async #forwardQuoteAuthorizationDeletion( + ctx: InboxContext, + object: MessageClass, + ): Promise { + const visibility = await this.#getMessageVisibility(ctx, object); + if ( + visibility !== "public" && visibility !== "unlisted" && + visibility !== "followers" + ) return; + await ctx.forwardActivity(this, "followers", { + skipIfUnsigned: true, + preferSharedInbox: true, + excludeBaseUris: [new URL(ctx.origin)], + }); + } + async #sendQuoteUpdate( ctx: InboxContext, object: MessageClass, diff --git a/packages/botkit/src/message-impl.test.ts b/packages/botkit/src/message-impl.test.ts index ba658d5..539140f 100644 --- a/packages/botkit/src/message-impl.test.ts +++ b/packages/botkit/src/message-impl.test.ts @@ -218,12 +218,20 @@ test("createMessage() verifies quote approvals", async (t) => { readonly quoteAuthorization?: URL; readonly authorization?: QuoteAuthorization | null; readonly throwOnAuthorization?: boolean; + readonly signal?: AbortSignal; + readonly onAuthorizationLookup?: ( + options: Parameters[1], + ) => void; } = {}, ) => { Object.defineProperty(ctx, "lookupObject", { - value: (id: URL) => { + 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.throwOnAuthorization === true) { return Promise.reject(new TypeError("Fetch failed.")); } @@ -245,6 +253,10 @@ test("createMessage() verifies quote approvals", async (t) => { }), session, {}, + undefined, + undefined, + undefined, + options.signal, ); }; @@ -347,6 +359,27 @@ test("createMessage() verifies quote approvals", async (t) => { 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("legacy quote", async () => { const message = await materialize({ quoteUrl: targetId, diff --git a/packages/botkit/src/message-impl.ts b/packages/botkit/src/message-impl.ts index f6b4a65..2fa4598 100644 --- a/packages/botkit/src/message-impl.ts +++ b/packages/botkit/src/message-impl.ts @@ -769,6 +769,7 @@ export async function createMessage( replyTarget: Message | undefined, quote: Message | undefined, authorized: true, + signal?: AbortSignal, ): Promise>; export async function createMessage( raw: T, @@ -777,6 +778,7 @@ export async function createMessage( replyTarget?: Message, quote?: Message, authorized?: boolean, + signal?: AbortSignal, ): Promise>; export async function createMessage( raw: T, @@ -785,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) { @@ -795,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() @@ -860,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) { @@ -899,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 @@ -917,6 +937,7 @@ export async function createMessage( raw, quoteTarget, session, + signal, ); const quoteApprovalState = !authorized || quoteTarget == null ? undefined @@ -961,6 +982,7 @@ async function verifyQuoteApproval( raw: MessageClass, quoteTarget: Message, session: SessionImpl, + signal?: AbortSignal, ): Promise { if ( raw.id == null || @@ -988,6 +1010,7 @@ async function verifyQuoteApproval( documentLoader: await session.context.getDocumentLoader( session.bot, ), + signal, }, ); return validateQuoteAuthorization(authorization, { diff --git a/packages/botkit/src/quote-authorization.test.ts b/packages/botkit/src/quote-authorization.test.ts new file mode 100644 index 0000000..ca33abf --- /dev/null +++ b/packages/botkit/src/quote-authorization.test.ts @@ -0,0 +1,37 @@ +// 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"; + +test("validateQuoteAuthorization() rejects missing target actors", () => { + const authorization = new QuoteAuthorization({ + id: new URL("https://example.com/stamps/1"), + attribution: new URL("https://example.com/users/alice"), + interactingObject: new URL("https://quote.example/notes/1"), + interactionTarget: new URL("https://example.com/notes/1"), + }); + + assert.ok( + !validateQuoteAuthorization(authorization, { + authorizationId: authorization.id!, + quoteId: new URL("https://quote.example/notes/1"), + targetId: new URL("https://example.com/notes/1"), + targetActorId: null, + }), + ); +}); diff --git a/packages/botkit/src/quote-authorization.ts b/packages/botkit/src/quote-authorization.ts index c124b5e..fea650c 100644 --- a/packages/botkit/src/quote-authorization.ts +++ b/packages/botkit/src/quote-authorization.ts @@ -15,19 +15,48 @@ // 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; - readonly targetActorId: 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 && From 8ecb6aa95d844e800ac693121b03ddf2eb341a73 Mon Sep 17 00:00:00 2001 From: Hong Minhee Date: Tue, 7 Jul 2026 07:21:28 +0900 Subject: [PATCH 4/9] Enforce quote authorization checks Local FEP-044f quote handling now stops before user handlers when the stored authorization is missing, invalid, or revoked during the audience check. The cleanup path still runs first so widened local quote stamps are revoked before the activity is dropped. Older custom repository implementations may not yet expose the new quote authorization reverse-index methods. Treat those methods as optional at runtime and fall back to empty routing or missing attribution instead of crashing. https://github.com/fedify-dev/botkit/pull/33#discussion_r3531926239 https://github.com/fedify-dev/botkit/pull/33#discussion_r3531926243 https://github.com/fedify-dev/botkit/pull/33#discussion_r3531926248 https://github.com/fedify-dev/botkit/pull/33#discussion_r3531926252 Assisted-by: Codex:gpt-5.5 --- packages/botkit/src/bot-impl.test.ts | 68 ++++++++++++-------- packages/botkit/src/bot-impl.ts | 15 ++++- packages/botkit/src/instance-impl.ts | 6 +- packages/botkit/src/instance-routing.test.ts | 42 +++++++++++- packages/botkit/src/repository.test.ts | 49 ++++++++++++++ packages/botkit/src/repository.ts | 43 +++++++++---- 6 files changed, 177 insertions(+), 46 deletions(-) diff --git a/packages/botkit/src/bot-impl.test.ts b/packages/botkit/src/bot-impl.test.ts index 536bc28..1dc07ed 100644 --- a/packages/botkit/src/bot-impl.test.ts +++ b/packages/botkit/src/bot-impl.test.ts @@ -47,7 +47,7 @@ import { } from "@fedify/vocab"; 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"; @@ -59,7 +59,7 @@ import type { } from "./message.ts"; import type { Vote } from "./poll.ts"; import type { Like, Reaction } from "./reaction.ts"; -import { MemoryRepository, type Uuid } from "./repository.ts"; +import { MemoryRepository, type Repository, type Uuid } from "./repository.ts"; import { SessionImpl } from "./session-impl.ts"; import type { Session } from "./session.ts"; import { mention, strong, text } from "./text.ts"; @@ -2099,21 +2099,12 @@ test("BotImpl.onCreated()", async (t) => { await bot.onCreated(ctx, create); - assert.deepStrictEqual(quoted.length, 1); - const [, msg] = quoted[0]; - assert.ok(msg.quoteTarget != null); - assert.deepStrictEqual( - msg.quoteTarget.id, - new URL( - "https://example.com/ap/note/a6358f1b-c978-49d3-8065-37a1df6168de", - ), - ); - assert.deepStrictEqual(msg.quoteApproved, false); + assert.deepStrictEqual(quoted, []); assert.deepStrictEqual(replied, []); assert.deepStrictEqual(mentioned, []); - assert.deepStrictEqual(messaged, quoted); + assert.deepStrictEqual(messaged, []); assert.deepStrictEqual(ctx.sentActivities, []); - assert.deepStrictEqual(ctx.forwardedRecipients, ["followers"]); + assert.deepStrictEqual(ctx.forwardedRecipients, []); quoted = []; messaged = []; @@ -2156,16 +2147,12 @@ test("BotImpl.onCreated()", async (t) => { await bot.onCreated(ctx, create); - assert.deepStrictEqual(quoted.length, 1); - const [, msg] = quoted[0]; - assert.ok(msg.quoteTarget != null); - assert.deepStrictEqual(msg.quoteTarget.id, targetId); - assert.deepStrictEqual(msg.quoteApproved, false); + assert.deepStrictEqual(quoted, []); assert.deepStrictEqual(replied, []); assert.deepStrictEqual(mentioned, []); - assert.deepStrictEqual(messaged, quoted); + assert.deepStrictEqual(messaged, []); assert.deepStrictEqual(ctx.sentActivities, []); - assert.deepStrictEqual(ctx.forwardedRecipients, ["followers"]); + assert.deepStrictEqual(ctx.forwardedRecipients, []); quoted = []; messaged = []; @@ -2459,14 +2446,10 @@ test("BotImpl.onCreated()", async (t) => { await bot.onCreated(ctx, create); - assert.deepStrictEqual(quoted.length, 1); - const [, msg] = quoted[0]; - assert.ok(msg.quoteTarget != null); - assert.deepStrictEqual(msg.quoteTarget.id, targetId); - assert.deepStrictEqual(msg.quoteApproved, false); + assert.deepStrictEqual(quoted, []); assert.deepStrictEqual(replied, []); assert.deepStrictEqual(mentioned, []); - assert.deepStrictEqual(messaged, quoted); + assert.deepStrictEqual(messaged, []); assert.deepStrictEqual(ctx.sentActivities.length, 2); const [actorDelete, followersDelete] = ctx.sentActivities; assert.deepStrictEqual( @@ -2478,7 +2461,7 @@ test("BotImpl.onCreated()", async (t) => { assert.deepStrictEqual(followersDelete.recipients, "followers"); assert.ok(followersDelete.activity instanceof Delete); assert.deepStrictEqual(followersDelete.activity.objectId, authorizationUrl); - assert.deepStrictEqual(ctx.forwardedRecipients, ["followers"]); + assert.deepStrictEqual(ctx.forwardedRecipients, []); quoted = []; messaged = []; @@ -2879,6 +2862,22 @@ test("BotImpl.dispatchNodeInfo()", () => { }); }); +test("MigrationGatedRepository tolerates missing quote reference indexes", async () => { + const underlying = hideRepositoryMethods(new MemoryRepository(), [ + "findQuoteAuthorizationReferenceIdentifiers", + ]); + const repository = new MigrationGatedRepository(underlying, "bot"); + + assert.deepStrictEqual( + await Array.fromAsync( + repository.findQuoteAuthorizationReferenceIdentifiers( + new URL("https://remote.example/stamps/1"), + ), + ), + [], + ); +}); + test("BotImpl.fetch()", async () => { const bot = new BotImpl({ kv: new MemoryKvStore(), @@ -3270,6 +3269,19 @@ function createMockInboxContext( return ctx; } +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; + }, + }); +} + test("BotImpl.onVote()", async (t) => { const repository = new MemoryRepository(); const bot = new BotImpl({ diff --git a/packages/botkit/src/bot-impl.ts b/packages/botkit/src/bot-impl.ts index 5517b4f..cd99efe 100644 --- a/packages/botkit/src/bot-impl.ts +++ b/packages/botkit/src/bot-impl.ts @@ -1700,8 +1700,11 @@ export class BotImpl implements Bot { // @ts-ignore: quoteTarget.class satisfies (typeof messageClasses)[number] messageClasses.includes(quoteTarget.class) && quoteTarget.values.identifier === this.identifier; - if (requiresQuoteAuthorization && isLocalQuoteTarget) { - await this.#hasValidQuoteAuthorization(ctx, object, fepQuoteUrl); + if ( + requiresQuoteAuthorization && isLocalQuoteTarget && + !await this.#hasValidQuoteAuthorization(ctx, object, fepQuoteUrl) + ) { + return; } if ( this.onQuote != null && @@ -2454,6 +2457,10 @@ export class MigrationGatedRepository implements Repository { authorization: URL, ): AsyncIterable { await this.#migration; + if ( + typeof this.#repository.findQuoteAuthorizationReferenceIdentifiers !== + "function" + ) return; yield* this.#repository.findQuoteAuthorizationReferenceIdentifiers( authorization, ); @@ -2464,6 +2471,10 @@ export class MigrationGatedRepository implements Repository { authorization: URL, ): Promise { await this.#migration; + if ( + typeof this.#repository.findQuoteAuthorizationReferenceAttribution !== + "function" + ) return undefined; return await this.#repository.findQuoteAuthorizationReferenceAttribution( identifier, authorization, diff --git a/packages/botkit/src/instance-impl.ts b/packages/botkit/src/instance-impl.ts index c063258..ae2e250 100644 --- a/packages/botkit/src/instance-impl.ts +++ b/packages/botkit/src/instance-impl.ts @@ -688,7 +688,11 @@ export class InstanceImpl if (parsed.identifier != null) targets.add(parsed.identifier); } } - if (del.objectId != null) { + if ( + del.objectId != null && + typeof this.repository.findQuoteAuthorizationReferenceIdentifiers === + "function" + ) { for await ( const identifier of this.repository .findQuoteAuthorizationReferenceIdentifiers(del.objectId) diff --git a/packages/botkit/src/instance-routing.test.ts b/packages/botkit/src/instance-routing.test.ts index 983940e..7ef89fa 100644 --- a/packages/botkit/src/instance-routing.test.ts +++ b/packages/botkit/src/instance-routing.test.ts @@ -33,7 +33,7 @@ import { describe, test } from "node:test"; 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 { MemoryRepository, type Repository, type Uuid } from "./repository.ts"; function createMockInboxContext( instance: InstanceImpl, @@ -87,6 +87,19 @@ function createHarness(): Harness { return { instance, repository, alpha, beta, ctx }; } +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; + }, + }); +} + function remotePerson(handle: string): Person { return new Person({ id: new URL(`https://example.com/ap/actor/${handle}`), @@ -557,6 +570,33 @@ 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({ diff --git a/packages/botkit/src/repository.test.ts b/packages/botkit/src/repository.test.ts index 0535c61..1324375 100644 --- a/packages/botkit/src/repository.test.ts +++ b/packages/botkit/src/repository.test.ts @@ -57,6 +57,19 @@ const factories: Record Repository> = { MemoryCachedRepository: createMemoryCachedRepository, }; +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; + }, + }); +} + for (const [name, factory] of Object.entries(factories)) { test(`${name} stores quote authorizations`, async () => { const repository = factory(); @@ -302,6 +315,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; diff --git a/packages/botkit/src/repository.ts b/packages/botkit/src/repository.ts index 7b734c9..6d0fe93 100644 --- a/packages/botkit/src/repository.ts +++ b/packages/botkit/src/repository.ts @@ -850,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, @@ -2882,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, @@ -2900,6 +2907,10 @@ export class MemoryCachedRepository implements Repository { async *findQuoteAuthorizationReferenceIdentifiers( authorization: URL, ): AsyncIterable { + if ( + typeof this.underlying.findQuoteAuthorizationReferenceIdentifiers !== + "function" + ) return; for await ( const identifier of this.underlying .findQuoteAuthorizationReferenceIdentifiers(authorization) @@ -2909,11 +2920,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, @@ -2934,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, From 586730718e78cd719cbd511ce5ba67d80e97ad80 Mon Sep 17 00:00:00 2001 From: Hong Minhee Date: Tue, 7 Jul 2026 08:12:09 +0900 Subject: [PATCH 5/9] Guard quote revocation fallbacks Stale quote authorization deletes can arrive after a message has already recorded a newer stamp. Compare the current stored authorization before stripping quote metadata so an old delete only removes the old reference. The migration-gated repository now warns once when a custom repository omits the optional quote reference index methods, preserving compatibility while making disabled cleanup visible. The tests cover the sibling fallback path, the full quote authorization validator decision table, and the stale delete race. The shared repository method-hiding helper keeps the fallback tests aligned. https://github.com/fedify-dev/botkit/pull/33#pullrequestreview-4640241203 Assisted-by: Codex:gpt-5.5 --- deno.lock | 1 + packages/botkit/src/bot-impl.test.ts | 226 ++++++++++++++++-- packages/botkit/src/bot-impl.ts | 30 ++- packages/botkit/src/helpers.ts | 29 +++ packages/botkit/src/instance-routing.test.ts | 16 +- .../botkit/src/quote-authorization.test.ts | 103 +++++++- packages/botkit/src/repository.test.ts | 14 +- 7 files changed, 360 insertions(+), 59 deletions(-) create mode 100644 packages/botkit/src/helpers.ts 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/packages/botkit/src/bot-impl.test.ts b/packages/botkit/src/bot-impl.test.ts index 1dc07ed..6c24360 100644 --- a/packages/botkit/src/bot-impl.test.ts +++ b/packages/botkit/src/bot-impl.test.ts @@ -45,6 +45,7 @@ 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, MigrationGatedRepository } from "./bot-impl.ts"; @@ -59,9 +60,10 @@ import type { } from "./message.ts"; import type { Vote } from "./poll.ts"; import type { Like, Reaction } from "./reaction.ts"; -import { MemoryRepository, type Repository, type Uuid } from "./repository.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()", () => { @@ -2865,16 +2867,70 @@ 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[] = []; - assert.deepStrictEqual( - await Array.fromAsync( - repository.findQuoteAuthorizationReferenceIdentifiers( + 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", + ], ); }); @@ -3269,19 +3325,6 @@ function createMockInboxContext( return ctx; } -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; - }, - }); -} - test("BotImpl.onVote()", async (t) => { const repository = new MemoryRepository(); const bot = new BotImpl({ @@ -4505,6 +4548,151 @@ test("BotImpl.onDeleted() strips revoked quote authorizations", async () => { 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"]); + assert.deepStrictEqual(ctx.forwardedActivities.length, 1); + assert.deepStrictEqual(ctx.sentActivities, []); + assert.deepStrictEqual(revoked, undefined); +}); + test("BotImpl.onDeleted() ignores same-origin quote revocations without attribution", async () => { const repository = new MemoryRepository(); const bot = new BotImpl({ diff --git a/packages/botkit/src/bot-impl.ts b/packages/botkit/src/bot-impl.ts index cd99efe..125754c 100644 --- a/packages/botkit/src/bot-impl.ts +++ b/packages/botkit/src/bot-impl.ts @@ -899,6 +899,7 @@ export class BotImpl implements Bot { | undefined > { const quoteId = object.quoteId; + const quoteAuthorizationId = object.quoteAuthorizationId; let strippedObject: MessageClass | undefined; const wasUpdated = await this.repository.updateMessage( id, @@ -907,7 +908,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; } @@ -2221,6 +2225,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; @@ -2230,6 +2235,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[], @@ -2460,7 +2474,12 @@ export class MigrationGatedRepository implements Repository { if ( typeof this.#repository.findQuoteAuthorizationReferenceIdentifiers !== "function" - ) return; + ) { + this.#warnMissingQuoteAuthorizationReferenceMethod( + "findQuoteAuthorizationReferenceIdentifiers", + ); + return; + } yield* this.#repository.findQuoteAuthorizationReferenceIdentifiers( authorization, ); @@ -2474,7 +2493,12 @@ export class MigrationGatedRepository implements Repository { if ( typeof this.#repository.findQuoteAuthorizationReferenceAttribution !== "function" - ) return undefined; + ) { + this.#warnMissingQuoteAuthorizationReferenceMethod( + "findQuoteAuthorizationReferenceAttribution", + ); + return undefined; + } return await this.#repository.findQuoteAuthorizationReferenceAttribution( identifier, authorization, 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-routing.test.ts b/packages/botkit/src/instance-routing.test.ts index 7ef89fa..1305b00 100644 --- a/packages/botkit/src/instance-routing.test.ts +++ b/packages/botkit/src/instance-routing.test.ts @@ -33,7 +33,8 @@ import { describe, test } from "node:test"; import type { Bot } from "./bot.ts"; import { BotImpl } from "./bot-impl.ts"; import { InstanceImpl } from "./instance-impl.ts"; -import { MemoryRepository, type Repository, type Uuid } from "./repository.ts"; +import { MemoryRepository, type Uuid } from "./repository.ts"; +import { hideRepositoryMethods } from "./helpers.ts"; function createMockInboxContext( instance: InstanceImpl, @@ -87,19 +88,6 @@ function createHarness(): Harness { return { instance, repository, alpha, beta, ctx }; } -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; - }, - }); -} - function remotePerson(handle: string): Person { return new Person({ id: new URL(`https://example.com/ap/actor/${handle}`), diff --git a/packages/botkit/src/quote-authorization.test.ts b/packages/botkit/src/quote-authorization.test.ts index ca33abf..ea19b7e 100644 --- a/packages/botkit/src/quote-authorization.test.ts +++ b/packages/botkit/src/quote-authorization.test.ts @@ -18,19 +18,102 @@ import assert from "node:assert"; import { test } from "node:test"; import { validateQuoteAuthorization } from "./quote-authorization.ts"; -test("validateQuoteAuthorization() rejects missing target actors", () => { - const authorization = new QuoteAuthorization({ - id: new URL("https://example.com/stamps/1"), - attribution: new URL("https://example.com/users/alice"), - interactingObject: new URL("https://quote.example/notes/1"), - interactionTarget: new URL("https://example.com/notes/1"), +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"), + ], + ]; + 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(authorization, { - authorizationId: authorization.id!, - quoteId: new URL("https://quote.example/notes/1"), - targetId: new URL("https://example.com/notes/1"), + !validateQuoteAuthorization(createAuthorization(), { + authorizationId, + quoteId, + targetId, targetActorId: null, }), ); diff --git a/packages/botkit/src/repository.test.ts b/packages/botkit/src/repository.test.ts index 1324375..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()); @@ -57,19 +58,6 @@ const factories: Record Repository> = { MemoryCachedRepository: createMemoryCachedRepository, }; -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; - }, - }); -} - for (const [name, factory] of Object.entries(factories)) { test(`${name} stores quote authorizations`, async () => { const repository = factory(); From 0f8193dc8484ac88efe449c277166d8f6df7d8e3 Mon Sep 17 00:00:00 2001 From: Hong Minhee Date: Tue, 7 Jul 2026 08:20:37 +0900 Subject: [PATCH 6/9] Forward quote revocations to recipients The revoked stamp Delete used to go only to followers, so direct quotes and mention-expanded audiences could miss the revocation even though they received the quoted message. The forwarding path now rebuilds concrete recipients from the stored message audience and mention tags, while keeping followers as a shared inbox fan-out when the original audience included them. https://github.com/fedify-dev/botkit/pull/33#discussion_r3532570559 Assisted-by: Codex:gpt-5.5 --- packages/botkit/src/bot-impl.test.ts | 82 +++++++++++++++++++++++++--- packages/botkit/src/bot-impl.ts | 76 +++++++++++++++++++++++--- 2 files changed, 143 insertions(+), 15 deletions(-) diff --git a/packages/botkit/src/bot-impl.test.ts b/packages/botkit/src/bot-impl.test.ts index 6c24360..9a71651 100644 --- a/packages/botkit/src/bot-impl.test.ts +++ b/packages/botkit/src/bot-impl.test.ts @@ -4527,8 +4527,8 @@ test("BotImpl.onDeleted() strips revoked quote authorizations", async () => { ), undefined, ); - assert.deepStrictEqual(ctx.forwardedRecipients, ["followers"]); - assert.deepStrictEqual(ctx.forwardedActivities.length, 1); + assert.deepStrictEqual(ctx.forwardedRecipients, ["followers", author]); + assert.deepStrictEqual(ctx.forwardedActivities.length, 2); assert.deepStrictEqual( ctx.forwardedActivities[0].options, { @@ -4687,8 +4687,8 @@ test("BotImpl.onDeleted() keeps newer quote authorizations", async () => { ), messageId, ); - assert.deepStrictEqual(ctx.forwardedRecipients, ["followers"]); - assert.deepStrictEqual(ctx.forwardedActivities.length, 1); + assert.deepStrictEqual(ctx.forwardedRecipients, ["followers", author]); + assert.deepStrictEqual(ctx.forwardedActivities.length, 2); assert.deepStrictEqual(ctx.sentActivities, []); assert.deepStrictEqual(revoked, undefined); }); @@ -4782,7 +4782,75 @@ test("BotImpl.onDeleted() ignores same-origin quote revocations without attribut assert.deepStrictEqual(revoker, undefined); }); -test("BotImpl.onDeleted() does not forward private quote revocations to followers", async () => { +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 private quote revocations only to the quote audience", async () => { const repository = new MemoryRepository(); const bot = new BotImpl({ kv: new MemoryKvStore(), @@ -4837,8 +4905,8 @@ test("BotImpl.onDeleted() does not forward private quote revocations to follower await repository.findQuoteAuthorizationReference("bot", authorization), undefined, ); - assert.deepStrictEqual(ctx.forwardedRecipients, []); - assert.deepStrictEqual(ctx.forwardedActivities, []); + 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); diff --git a/packages/botkit/src/bot-impl.ts b/packages/botkit/src/bot-impl.ts index 125754c..787b5d6 100644 --- a/packages/botkit/src/bot-impl.ts +++ b/packages/botkit/src/bot-impl.ts @@ -879,7 +879,7 @@ export class BotImpl implements Bot { object, ); if (actor == null) return; - await this.#forwardQuoteAuthorizationDeletion(ctx, object); + await this.#forwardQuoteAuthorizationDeletion(ctx, object, actor); const stripped = await this.#stripRejectedQuote(ctx, id, object, actor); if (stripped != null && this.onQuoteRevoked != null) { await this.onQuoteRevoked(stripped.session, stripped.message, actor); @@ -1041,16 +1041,76 @@ export class BotImpl implements Bot { async #forwardQuoteAuthorizationDeletion( ctx: InboxContext, object: MessageClass, + quoteActor: Actor, ): Promise { const visibility = await this.#getMessageVisibility(ctx, object); - if ( - visibility !== "public" && visibility !== "unlisted" && - visibility !== "followers" - ) return; - await ctx.forwardActivity(this, "followers", { + 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); + }; + for (const id of recipientIds.values()) { + const knownActor = knownActors.get(id.href); + if (knownActor != null) { + addActorRecipient(knownActor); + continue; + } + const recipient = await lookupObjectSafely(this, ctx, id); + if (isActor(recipient)) addActorRecipient(recipient); + } + addActorRecipient(quoteActor); + if (actorRecipients.length < 1) return; + await ctx.forwardActivity(this, actorRecipients, { skipIfUnsigned: true, - preferSharedInbox: true, - excludeBaseUris: [new URL(ctx.origin)], + preferSharedInbox, + excludeBaseUris, }); } From 9b45faf7cda8612286bae9fd03589aaef8027b5b Mon Sep 17 00:00:00 2001 From: Hong Minhee Date: Tue, 7 Jul 2026 08:20:45 +0900 Subject: [PATCH 7/9] Preserve quote stamp cancellation Remote quote stamp lookups can reject because the caller aborted the message materialization. Treating that as an ordinary failed stamp lookup lets cancelled work continue with a false approval result. The verification path now rethrows abort failures while still suppressing ordinary lookup and validation failures as an unapproved quote. https://github.com/fedify-dev/botkit/pull/33#discussion_r3532570563 Assisted-by: Codex:gpt-5.5 --- packages/botkit/src/message-impl.test.ts | 20 ++++++++++++++++++++ packages/botkit/src/message-impl.ts | 9 ++++++++- 2 files changed, 28 insertions(+), 1 deletion(-) diff --git a/packages/botkit/src/message-impl.test.ts b/packages/botkit/src/message-impl.test.ts index 539140f..13f6c27 100644 --- a/packages/botkit/src/message-impl.test.ts +++ b/packages/botkit/src/message-impl.test.ts @@ -217,6 +217,7 @@ test("createMessage() verifies quote approvals", async (t) => { readonly quoteUrl?: URL; readonly quoteAuthorization?: URL; readonly authorization?: QuoteAuthorization | null; + readonly authorizationError?: unknown; readonly throwOnAuthorization?: boolean; readonly signal?: AbortSignal; readonly onAuthorizationLookup?: ( @@ -232,6 +233,9 @@ test("createMessage() verifies quote approvals", async (t) => { 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.")); } @@ -380,6 +384,22 @@ test("createMessage() verifies quote approvals", async (t) => { 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("legacy quote", async () => { const message = await materialize({ quoteUrl: targetId, diff --git a/packages/botkit/src/message-impl.ts b/packages/botkit/src/message-impl.ts index 2fa4598..8331405 100644 --- a/packages/botkit/src/message-impl.ts +++ b/packages/botkit/src/message-impl.ts @@ -1019,7 +1019,14 @@ async function verifyQuoteApproval( targetId: quoteTarget.id, targetActorId: quoteTarget.actor.id, }); - } catch { + } catch (error) { + if ( + signal?.aborted === true && + (error === signal.reason || + (error instanceof DOMException && error.name === "AbortError")) + ) { + throw error; + } return false; } } From 6000a26c62ca91b2fdceb8c0f54f5951a6d6fd6d Mon Sep 17 00:00:00 2001 From: Hong Minhee Date: Tue, 7 Jul 2026 10:14:16 +0900 Subject: [PATCH 8/9] Forward quote revocations to reply actors The revocation forwarder now resolves message addressees in parallel, keeps the original recipient order, and includes the reply target actor before the quoted actor. That matches the audience used for quote updates so every server that saw the quote can also see the stamp Delete. The validation tests also cover cross-origin stamp IDs directly, which locks down the origin binding separately from the authorization ID match. https://github.com/fedify-dev/botkit/pull/33#discussion_r3532640307 https://github.com/fedify-dev/botkit/pull/33#discussion_r3532643331 https://github.com/fedify-dev/botkit/pull/33#discussion_r3532643384 Assisted-by: Codex:gpt-5.5 --- packages/botkit/src/bot-impl.test.ts | 89 +++++++++++++++++++ packages/botkit/src/bot-impl.ts | 31 +++++-- .../botkit/src/quote-authorization.test.ts | 7 ++ 3 files changed, 120 insertions(+), 7 deletions(-) diff --git a/packages/botkit/src/bot-impl.test.ts b/packages/botkit/src/bot-impl.test.ts index 9a71651..31cdda7 100644 --- a/packages/botkit/src/bot-impl.test.ts +++ b/packages/botkit/src/bot-impl.test.ts @@ -4850,6 +4850,95 @@ test("BotImpl.onDeleted() forwards quote revocations to mentions", async () => { 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 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, + }); + Object.defineProperty(ctx, "lookupObject", { + value: (id: URL) => + Promise.resolve( + id.href === target.id?.href + ? target + : id.href === replyTarget.id?.href + ? replyTarget + : null, + ), + }); + const targetMessage = await createMessage(target, session, {}); + const replyMessage = await createMessage(replyTarget, session, {}); + const quote = await session.publish(text`Please approve this.`, { + 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 = []; + + await bot.onDeleted( + ctx, + new Delete({ + actor: author, + object: authorization.id, + }), + ); + + assert.deepStrictEqual(ctx.forwardedRecipients, [ + "followers", + replyAuthor, + author, + ]); + assert.deepStrictEqual(ctx.forwardedActivities.length, 2); + assert.deepStrictEqual(ctx.sentActivities.length, 3); + assert.deepStrictEqual(ctx.sentActivities[0].recipients, "followers"); + assert.deepStrictEqual(ctx.sentActivities[1].recipients, [replyAuthor]); + assert.deepStrictEqual(ctx.sentActivities[2].recipients, [author]); +}); + test("BotImpl.onDeleted() forwards private quote revocations only to the quote audience", async () => { const repository = new MemoryRepository(); const bot = new BotImpl({ diff --git a/packages/botkit/src/bot-impl.ts b/packages/botkit/src/bot-impl.ts index 787b5d6..96aaddf 100644 --- a/packages/botkit/src/bot-impl.ts +++ b/packages/botkit/src/bot-impl.ts @@ -1096,14 +1096,31 @@ export class BotImpl implements Bot { actorRecipientIds.add(actor.id.href); actorRecipients.push(actor); }; - for (const id of recipientIds.values()) { - const knownActor = knownActors.get(id.href); - if (knownActor != null) { - addActorRecipient(knownActor); - continue; + 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); } - const recipient = await lookupObjectSafely(this, ctx, id); - if (isActor(recipient)) addActorRecipient(recipient); } addActorRecipient(quoteActor); if (actorRecipients.length < 1) return; diff --git a/packages/botkit/src/quote-authorization.test.ts b/packages/botkit/src/quote-authorization.test.ts index ea19b7e..b678e60 100644 --- a/packages/botkit/src/quote-authorization.test.ts +++ b/packages/botkit/src/quote-authorization.test.ts @@ -82,6 +82,13 @@ test("validateQuoteAuthorization() rejects mismatched authorizations", () => { 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) { From af4f38b3fc411e39bda7d1e6cdb04030866ae966 Mon Sep 17 00:00:00 2001 From: Hong Minhee Date: Tue, 7 Jul 2026 10:32:01 +0900 Subject: [PATCH 9/9] Respect aborts in quote cleanup Abort handling should not depend on the exact error type thrown by the remote lookup path. Once the caller's signal is aborted, quote stamp verification now propagates the error instead of treating it as an ordinary failed validation. Quote rejection and revocation also no longer materialize the stripped message unless a handler is registered. The stored message is still updated and the audience update is still sent, but handler-only lookup work is skipped when there is no callback to consume it. https://github.com/fedify-dev/botkit/pull/33#discussion_r3532979899 https://github.com/fedify-dev/botkit/pull/33#discussion_r3532987668 Assisted-by: Codex:gpt-5.5 --- packages/botkit/src/bot-impl.test.ts | 49 ++++++++++++++++-------- packages/botkit/src/bot-impl.ts | 18 ++++++++- packages/botkit/src/message-impl.test.ts | 16 ++++++++ packages/botkit/src/message-impl.ts | 8 +--- 4 files changed, 67 insertions(+), 24 deletions(-) diff --git a/packages/botkit/src/bot-impl.test.ts b/packages/botkit/src/bot-impl.test.ts index 31cdda7..07c6dce 100644 --- a/packages/botkit/src/bot-impl.test.ts +++ b/packages/botkit/src/bot-impl.test.ts @@ -4867,6 +4867,10 @@ test("BotImpl.onDeleted() forwards quote revocations to reply targets", async () 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, @@ -4879,22 +4883,31 @@ test("BotImpl.onDeleted() forwards quote revocations to reply targets", async () content: "Thread starter.", to: PUBLIC_COLLECTION, }); + let replyTargetLookups = 0; + let mentionedLookups = 0; Object.defineProperty(ctx, "lookupObject", { - value: (id: URL) => - Promise.resolve( - id.href === target.id?.href - ? target - : id.href === replyTarget.id?.href - ? replyTarget - : null, - ), + 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.`, { - quoteTarget: targetMessage, - replyTarget: replyMessage, - }); + 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; @@ -4918,6 +4931,8 @@ test("BotImpl.onDeleted() forwards quote revocations to reply targets", async () ctx.sentActivities = []; ctx.forwardedRecipients = []; ctx.forwardedActivities = []; + replyTargetLookups = 0; + mentionedLookups = 0; await bot.onDeleted( ctx, @@ -4929,14 +4944,18 @@ test("BotImpl.onDeleted() forwards quote revocations to reply targets", async () assert.deepStrictEqual(ctx.forwardedRecipients, [ "followers", + mentioned, replyAuthor, author, ]); assert.deepStrictEqual(ctx.forwardedActivities.length, 2); - assert.deepStrictEqual(ctx.sentActivities.length, 3); + assert.deepStrictEqual(ctx.sentActivities.length, 4); assert.deepStrictEqual(ctx.sentActivities[0].recipients, "followers"); - assert.deepStrictEqual(ctx.sentActivities[1].recipients, [replyAuthor]); - assert.deepStrictEqual(ctx.sentActivities[2].recipients, [author]); + 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 () => { diff --git a/packages/botkit/src/bot-impl.ts b/packages/botkit/src/bot-impl.ts index 96aaddf..7cd38ee 100644 --- a/packages/botkit/src/bot-impl.ts +++ b/packages/botkit/src/bot-impl.ts @@ -841,7 +841,13 @@ export class BotImpl implements Bot { } const rejecter = await this.#validateQuoteRejection(ctx, reject, object); if (rejecter == null) return; - const stripped = 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, @@ -880,7 +886,13 @@ export class BotImpl implements Bot { ); if (actor == null) return; await this.#forwardQuoteAuthorizationDeletion(ctx, object, actor); - const stripped = await this.#stripRejectedQuote(ctx, id, 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); } @@ -891,6 +903,7 @@ export class BotImpl implements Bot { id: Uuid, object: MessageClass, actor: Actor, + materializeMessage: boolean, ): Promise< | { readonly session: SessionImpl; @@ -928,6 +941,7 @@ export class BotImpl implements Bot { } if (!wasUpdated || strippedObject == null) return; await this.#sendQuoteUpdate(ctx, strippedObject, actor); + if (!materializeMessage) return; const session = this.getSession(ctx); const message = await createMessage( strippedObject, diff --git a/packages/botkit/src/message-impl.test.ts b/packages/botkit/src/message-impl.test.ts index 13f6c27..4cac436 100644 --- a/packages/botkit/src/message-impl.test.ts +++ b/packages/botkit/src/message-impl.test.ts @@ -400,6 +400,22 @@ test("createMessage() verifies quote approvals", async (t) => { ); }); + 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, diff --git a/packages/botkit/src/message-impl.ts b/packages/botkit/src/message-impl.ts index 8331405..9a31aff 100644 --- a/packages/botkit/src/message-impl.ts +++ b/packages/botkit/src/message-impl.ts @@ -1020,13 +1020,7 @@ async function verifyQuoteApproval( targetActorId: quoteTarget.actor.id, }); } catch (error) { - if ( - signal?.aborted === true && - (error === signal.reason || - (error instanceof DOMException && error.name === "AbortError")) - ) { - throw error; - } + if (signal?.aborted === true) throw error; return false; } }