From dc62e6371b348a45870e4727c469e96c1bfb8b99 Mon Sep 17 00:00:00 2001 From: Hong Minhee Date: Fri, 2 Oct 2026 01:19:55 +0900 Subject: [PATCH 1/2] Use Fedify's helpers for quote authorization BotKit implemented the FEP-044f protocol logic itself: building quote requests, authorization stamps, Accept/Reject/Delete activities, evaluating canQuote policies, and validating stamps. Fedify 2.4.0 now ships these as quoteInteraction in @fedify/interaction-controls, so BotKit uses that instead and gets Fedify's fixes for free. Persistence, delivery, visibility checks, and moderation stay in BotKit. Where Fedify's semantics differ, BotKit keeps its own behavior through small adapters: - A target without a canQuote rule is evaluated against the bot's quotePolicy instead of being denied. - Automatic approvals are evaluated before manual approvals, one axis at a time, so an explicit actor entry in manual approvals cannot override a broader automatic entry. - Incoming requests are verified on a normalized copy that fills in a missing quote attribution and lets quote win over a conflicting quoteUrl; the application still sees the original objects, and the requester is the resolved actor ID as before. - Follower lookup failures during policy evaluation are rethrown instead of becoming denials, so a transient repository error cannot revoke an existing authorization. - Stamps are verified according to how they were obtained: locally stored ones are trusted, and remote ones only when their ID is the one that was dereferenced. Two user-visible changes come with this. Stamp origins are now compared as FEP-fe34 origins, so stamps with opaque IDs are rejected. And the stamp ID named by an incoming Accept is captured before it is dereferenced, because getResult() replaced it with whatever ID the fetched document claimed, which let a same-origin substitute stamp approve a quote. The dependency is added to both deno.json and the pnpm catalog. Closes https://github.com/fedify-dev/botkit/issues/52 Assisted-by: Claude Code:claude-opus-5-5 Assisted-by: Codex:gpt-6-astra --- CHANGES.md | 13 +- .../quote-authorization-verification.md | 17 + deno.json | 1 + deno.lock | 69 ++- packages/botkit/package.json | 1 + packages/botkit/src/bot-impl.test.ts | 576 ++++++++++++++++++ packages/botkit/src/bot-impl.ts | 210 ++++--- packages/botkit/src/message-impl.ts | 19 +- .../botkit/src/quote-authorization.test.ts | 93 ++- packages/botkit/src/quote-authorization.ts | 64 +- packages/botkit/src/quote-impl.ts | 53 +- packages/botkit/src/session-impl.ts | 14 +- pnpm-lock.yaml | 18 + pnpm-workspace.yaml | 1 + 14 files changed, 978 insertions(+), 171 deletions(-) create mode 100644 changes.d/botkit/quote-authorization-verification.md diff --git a/CHANGES.md b/CHANGES.md index 2a5794b..4da9e20 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -15,14 +15,26 @@ To be released. - Added the `federationOptions` option to `createBot()` and `createInstance()` for private-address access in tests, circuit breaking, OpenTelemetry providers, and HTTP Signature negotiation. [[#41], [#43]] + - Changed [FEP-044f] quote authorization handling to use Fedify's + *@fedify/interaction-controls* package. Quote authorization stamps are + now checked against their owner's [FEP-fe34] origin, so stamps whose IDs + have no comparable origin, such as opaque URIs, are no longer accepted. + [[#52], [#53]] + - Fixed a bug where a remote server could approve a quote with a quote + authorization stamp other than the one named in its `Accept` activity, + as long as the substituted stamp was on the same origin. [[#52], [#53]] - Upgraded Fedify to 2.4.0, which adds support for [FEP-ef61] portable objects and hardens HTTP Signature verification and document loading. +[FEP-044f]: https://w3id.org/fep/044f +[FEP-fe34]: https://w3id.org/fep/fe34 [FEP-ef61]: https://w3id.org/fep/ef61 [#40]: https://github.com/fedify-dev/botkit/issues/40 [#41]: https://github.com/fedify-dev/botkit/issues/41 [#42]: https://github.com/fedify-dev/botkit/pull/42 [#43]: https://github.com/fedify-dev/botkit/pull/43 +[#52]: https://github.com/fedify-dev/botkit/issues/52 +[#53]: https://github.com/fedify-dev/botkit/pull/53 Version 0.5.6 @@ -294,7 +306,6 @@ Released on July 8, 2026. - Upgraded Fedify to 2.3.1, Hono to 4.12.27, and LogTape to 2.2.3. -[FEP-044f]: https://w3id.org/fep/044f [#16]: https://github.com/fedify-dev/botkit/issues/16 [#24]: https://github.com/fedify-dev/botkit/pull/24 [#27]: https://github.com/fedify-dev/botkit/issues/27 diff --git a/changes.d/botkit/quote-authorization-verification.md b/changes.d/botkit/quote-authorization-verification.md new file mode 100644 index 0000000..6098661 --- /dev/null +++ b/changes.d/botkit/quote-authorization-verification.md @@ -0,0 +1,17 @@ +--- +links: + '#52': https://github.com/fedify-dev/botkit/issues/52 + '#53': https://github.com/fedify-dev/botkit/pull/53 +--- + - Changed [FEP-044f] quote authorization handling to use Fedify's + *@fedify/interaction-controls* package. Quote authorization stamps are + now checked against their owner's [FEP-fe34] origin, so stamps whose IDs + have no comparable origin, such as opaque URIs, are no longer accepted. + [[#52], [#53]] + + - Fixed a bug where a remote server could approve a quote with a quote + authorization stamp other than the one named in its `Accept` activity, + as long as the substituted stamp was on the same origin. [[#52], [#53]] + +[FEP-044f]: https://w3id.org/fep/044f +[FEP-fe34]: https://w3id.org/fep/fe34 diff --git a/deno.json b/deno.json index 1b44f5d..a642e66 100644 --- a/deno.json +++ b/deno.json @@ -4,6 +4,7 @@ "imports": { "@fedify/denokv": "jsr:@fedify/denokv@~2.4.0", "@fedify/fedify": "jsr:@fedify/fedify@~2.4.0", + "@fedify/interaction-controls": "jsr:@fedify/interaction-controls@~2.4.0", "@fedify/vocab": "jsr:@fedify/vocab@~2.4.0", "@fedify/vocab-runtime": "jsr:@fedify/vocab-runtime@~2.4.0", "@js-temporal/polyfill": "npm:@js-temporal/polyfill@^0.5.1", diff --git a/deno.lock b/deno.lock index 3b87092..29364d8 100644 --- a/deno.lock +++ b/deno.lock @@ -4,6 +4,7 @@ "jsr:@fedify/denokv@2.4": "2.4.0", "jsr:@fedify/fedify@2.4": "2.4.0", "jsr:@fedify/fedify@^2.4.0": "2.4.0", + "jsr:@fedify/interaction-controls@2.4": "2.4.0", "jsr:@fedify/markdown-it-hashtag@0.3": "0.3.0", "jsr:@fedify/markdown-it-mention@0.3": "0.3.0", "jsr:@fedify/uri-template@^2.4.0": "2.4.0", @@ -31,7 +32,9 @@ "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", + "npm:@opentelemetry/core@^2.7.1": "2.11.0_@opentelemetry+api@1.9.1", "npm:@opentelemetry/sdk-metrics@2.7.1": "2.7.1_@opentelemetry+api@1.9.1", + "npm:@opentelemetry/sdk-trace-base@^2.7.1": "2.11.0_@opentelemetry+api@1.9.1", "npm:@opentelemetry/semantic-conventions@^1.40.0": "1.43.0", "npm:@rowanmanning/feed-parser@^2.1.3": "2.1.3", "npm:@types/markdown-it@^14.1.1": "14.1.2", @@ -61,6 +64,7 @@ "npm:redis@^6.1.0": "6.1.0_@opentelemetry+api@1.9.1", "npm:srvx@~0.11.21": "0.11.22", "npm:structured-field-values@^2.0.4": "2.0.4", + "npm:temporal-polyfill@^1.0.1": "1.0.5", "npm:tsdown@~0.12.8": "0.12.9_rolldown@1.0.0-beta.55", "npm:url-template@^3.1.1": "3.1.1", "npm:uuid@^11.1.0": "11.1.0", @@ -85,7 +89,9 @@ "jsr:@logtape/logtape@^2.2.0", "jsr:@standard-schema/spec", "npm:@opentelemetry/api", + "npm:@opentelemetry/core", "npm:@opentelemetry/sdk-metrics", + "npm:@opentelemetry/sdk-trace-base", "npm:@opentelemetry/semantic-conventions", "npm:byte-encodings", "npm:devalue", @@ -94,6 +100,15 @@ "npm:structured-field-values" ] }, + "@fedify/interaction-controls@2.4.0": { + "integrity": "5e04c563b677ac38411b805c0283b09deb15283b6327e14807db3488fa1a8d72", + "dependencies": [ + "jsr:@fedify/fedify@^2.4.0", + "jsr:@fedify/vocab-runtime@^2.4.0", + "jsr:@fedify/vocab@^2.4.0", + "npm:temporal-polyfill" + ] + }, "@fedify/markdown-it-hashtag@0.3.0": { "integrity": "0f4ebe15d0da2a1d1fe9ad74209811b6aa409e5cd1539b7934fc7ae5f7c88207", "dependencies": [ @@ -425,6 +440,13 @@ "@opentelemetry/api@1.9.1": { "integrity": "sha512-gLyJlPHPZYdAk1JENA9LeHejZe1Ti77/pTeFm/nMXmQH/HFZlcS/O2XJB+L8fkbrNSqhdtlvjBVjxwUYanNH5Q==" }, + "@opentelemetry/core@2.11.0_@opentelemetry+api@1.9.1": { + "integrity": "sha512-7YP44XH0tV6+Mb54x2YGf84i7yi+31MBZlE8JwvozkxyTvXbSp10X7cI7YE49ChJ3shMJoBmCJF3+1QFBJctGA==", + "dependencies": [ + "@opentelemetry/api", + "@opentelemetry/semantic-conventions" + ] + }, "@opentelemetry/core@2.7.1_@opentelemetry+api@1.9.1": { "integrity": "sha512-QAqIj32AtK6+pEVNG7EOVxHdE06RP+FM5qpiEJ4RtDcFIqKUZHYhl7/7UY5efhwmwNAg7j8QbJVBLxMerc0+gw==", "dependencies": [ @@ -432,11 +454,19 @@ "@opentelemetry/semantic-conventions" ] }, + "@opentelemetry/resources@2.11.0_@opentelemetry+api@1.9.1": { + "integrity": "sha512-Ie7+8q8MDF4FAEQCKVMTx3ReUvxiIAgIiiW3c9JdmP8+HMcDy20puT+AHjexnExgnbvBxjQ9fjkFDWrikJ2jQA==", + "dependencies": [ + "@opentelemetry/api", + "@opentelemetry/core@2.11.0_@opentelemetry+api@1.9.1", + "@opentelemetry/semantic-conventions" + ] + }, "@opentelemetry/resources@2.7.1_@opentelemetry+api@1.9.1": { "integrity": "sha512-DeT6KKolmC4e/dRQvMQ/RwlnzhaqeiFOXY5ngoOPJ07GgVVKxZOg9EcrNZb5aTzUn+iCrJldAgOfQm1O/QfPAQ==", "dependencies": [ "@opentelemetry/api", - "@opentelemetry/core", + "@opentelemetry/core@2.7.1_@opentelemetry+api@1.9.1", "@opentelemetry/semantic-conventions" ] }, @@ -444,8 +474,27 @@ "integrity": "sha512-MpDJdkiFDs3Pm1RHO3KByuZbuBdJEXEAkiC0+yJdsZGVCdf1RpHR6n+LHDcS7ffmfrt5kVCzJSCfm4z2C7v0uQ==", "dependencies": [ "@opentelemetry/api", - "@opentelemetry/core", - "@opentelemetry/resources" + "@opentelemetry/core@2.7.1_@opentelemetry+api@1.9.1", + "@opentelemetry/resources@2.7.1_@opentelemetry+api@1.9.1" + ] + }, + "@opentelemetry/sdk-trace-base@2.11.0_@opentelemetry+api@1.9.1": { + "integrity": "sha512-H19x/TX/LZdqiYOjM7fqtSxwlplC5pgelavqbQdHbhdq0q/AI/TGkM2dfGuuynTXmJPeF2HoZVoPDu+TGoW78A==", + "dependencies": [ + "@opentelemetry/api", + "@opentelemetry/core@2.11.0_@opentelemetry+api@1.9.1", + "@opentelemetry/resources@2.11.0_@opentelemetry+api@1.9.1", + "@opentelemetry/sdk-trace", + "@opentelemetry/semantic-conventions" + ] + }, + "@opentelemetry/sdk-trace@2.11.0_@opentelemetry+api@1.9.1": { + "integrity": "sha512-fFnTqGm8/G73GQVnxYi7LXa1ZVYEUvgL6XI1LpvV0bPC7WQ/ZGgKxCSl8FnlZBKto9JHHEFTO6s6CUpvvtwFrA==", + "dependencies": [ + "@opentelemetry/api", + "@opentelemetry/core@2.11.0_@opentelemetry+api@1.9.1", + "@opentelemetry/resources@2.11.0_@opentelemetry+api@1.9.1", + "@opentelemetry/semantic-conventions" ] }, "@opentelemetry/semantic-conventions@1.43.0": { @@ -927,6 +976,19 @@ "structured-field-values@2.0.4": { "integrity": "sha512-5zpJXYLPwW3WYUD/D58tQjIBs10l3Yx64jZfcKGs/RH79E2t9Xm/b9+ydwdMNVSksnsIY+HR/2IlQmgo0AcTAg==" }, + "temporal-polyfill@1.0.5": { + "integrity": "sha512-+H/mmY74i6wXCMcjIhGuSnODcyZnc4eois2myhOyX0UqRAKNXQY5m9iB1en5jQ/n4SXtjLXo1ElBI5bmn8tZwQ==", + "dependencies": [ + "temporal-spec", + "temporal-utils" + ] + }, + "temporal-spec@1.0.1": { + "integrity": "sha512-wxVoanmDeavXie1vu2JaQ3WIc3JZnWAOYFBsJyATaVsXsycKYUflGsyBmrRSnoCpZJpwPyr38VpgSUlQ8CbFxg==" + }, + "temporal-utils@1.0.3": { + "integrity": "sha512-9jbTSX3HvkOWvDpNpFzwX/d1mkCrgQgwLmVTOEiuocZGKHsrQJtttGZFmybJ2zYP5zCSZuU5ZyeS8+eOEPABiQ==" + }, "tinyexec@1.0.2": { "integrity": "sha512-W/KYk+NFhkmsYpuHq5JykngiOCnxeVL8v8dFnqxSD8qEEdRfXk1SDM6JzNqcERbcGYj9tMrDQBYV9cjgnunFIg==" }, @@ -1008,6 +1070,7 @@ "dependencies": [ "jsr:@fedify/denokv@2.4", "jsr:@fedify/fedify@2.4", + "jsr:@fedify/interaction-controls@2.4", "jsr:@fedify/vocab-runtime@2.4", "jsr:@fedify/vocab@2.4", "jsr:@hongminhee/x-forwarded-fetch@0.2", diff --git a/packages/botkit/package.json b/packages/botkit/package.json index fac8120..36e527a 100644 --- a/packages/botkit/package.json +++ b/packages/botkit/package.json @@ -94,6 +94,7 @@ ], "dependencies": { "@fedify/fedify": "catalog:", + "@fedify/interaction-controls": "catalog:", "@fedify/vocab": "catalog:", "@fedify/vocab-runtime": "catalog:", "@fedify/markdown-it-hashtag": "^0.3.0", diff --git a/packages/botkit/src/bot-impl.test.ts b/packages/botkit/src/bot-impl.test.ts index 69657e8..6d7777c 100644 --- a/packages/botkit/src/bot-impl.test.ts +++ b/packages/botkit/src/bot-impl.test.ts @@ -28,6 +28,8 @@ import { EmojiReact, Follow, Image, + InteractionPolicy, + InteractionRule, Like as RawLike, Link, Mention, @@ -6637,3 +6639,577 @@ test("BotImpl.onQuoteRequested() rejects another actor's quote", async () => { undefined, ); }); + +async function addQuoteTarget( + repository: MemoryRepository, + ctx: MockInboxContext, + interactionPolicy?: InteractionPolicy, +): Promise { + const id = "01941f29-7c00-7fe8-ab0a-7b593990a3c1"; + const actor = ctx.getActorUri("bot"); + const note = new Note({ + id: ctx.getObjectUri(Note, { identifier: "bot", id }), + attribution: actor, + to: PUBLIC_COLLECTION, + content: "Quote me", + interactionPolicy, + }); + await repository.addMessage( + "bot", + id, + new Create({ + id: ctx.getObjectUri(Create, { identifier: "bot", id }), + actor, + to: PUBLIC_COLLECTION, + object: note, + }), + ); + return note; +} + +class FailingFollowerRepository extends MemoryRepository { + failHasFollower = false; + + override hasFollower(identifier: string, followerId: URL): Promise { + if (this.failHasFollower) { + return Promise.reject(new TypeError("The follower lookup failed.")); + } + return super.hasFollower(identifier, followerId); + } +} + +test("BotImpl.onQuoteRequested() falls back to the bot's quote policy", async () => { + const repository = new MemoryRepository(); + const bot = new BotImpl({ + kv: new MemoryKvStore(), + repository, + username: "bot", + quotePolicy: "followers", + }); + const ctx = createMockInboxContext(bot, "https://example.com", "bot"); + const target = await addQuoteTarget(repository, ctx); + const follower = new Person({ + id: new URL("https://remote.example/users/alice"), + preferredUsername: "alice", + }); + const stranger = new Person({ + id: new URL("https://remote.example/users/bob"), + preferredUsername: "bob", + }); + await repository.addFollower( + "bot", + new URL("https://remote.example/activities/follow"), + follower, + ); + + const cases: readonly [Person, typeof Accept | typeof Reject][] = [ + [follower, Accept], + [stranger, Reject], + ]; + for (const [actor, expected] of cases) { + ctx.sentActivities = []; + await bot.onQuoteRequested( + ctx, + new QuoteRequest({ + id: new URL(`${actor.id!.href}/quote-requests/1`), + actor, + object: target.id, + instrument: new Note({ + id: new URL(`${actor.id!.href}/notes/quote`), + attribution: actor.id, + quote: target.id, + content: "Quoted.", + to: PUBLIC_COLLECTION, + }), + }), + ); + assert.deepStrictEqual(ctx.sentActivities.length, 1); + assert.ok(ctx.sentActivities[0].activity instanceof expected); + } +}); + +test("BotImpl.onQuoteRequested() falls back to public quote policy", async () => { + const repository = new MemoryRepository(); + const bot = new BotImpl({ + kv: new MemoryKvStore(), + repository, + username: "bot", + }); + const ctx = createMockInboxContext(bot, "https://example.com", "bot"); + const target = await addQuoteTarget(repository, ctx); + const actor = new Person({ + id: new URL("https://remote.example/users/alice"), + preferredUsername: "alice", + }); + + await bot.onQuoteRequested( + ctx, + new QuoteRequest({ + id: new URL("https://remote.example/quote-requests/1"), + actor, + object: target.id, + instrument: new Note({ + id: new URL("https://remote.example/notes/quote"), + attribution: actor.id, + quote: target.id, + content: "Quoted.", + to: PUBLIC_COLLECTION, + }), + }), + ); + + assert.deepStrictEqual(ctx.sentActivities.length, 1); + assert.ok(ctx.sentActivities[0].activity instanceof Accept); +}); + +test("BotImpl.onQuoteRequested() prefers automatic approvals", async () => { + const repository = new MemoryRepository(); + const bot = new BotImpl({ + kv: new MemoryKvStore(), + repository, + username: "bot", + }); + const states: string[] = []; + bot.onQuoteRequest = (_session, request) => void states.push(request.state); + const ctx = createMockInboxContext(bot, "https://example.com", "bot"); + const actor = new Person({ + id: new URL("https://remote.example/users/alice"), + preferredUsername: "alice", + }); + const target = await addQuoteTarget( + repository, + ctx, + new InteractionPolicy({ + canQuote: new InteractionRule({ + automaticApprovals: [PUBLIC_COLLECTION], + manualApprovals: [actor.id!], + }), + }), + ); + + await bot.onQuoteRequested( + ctx, + new QuoteRequest({ + id: new URL("https://remote.example/quote-requests/1"), + actor, + object: target.id, + instrument: new Note({ + id: new URL("https://remote.example/notes/quote"), + attribution: actor.id, + quote: target.id, + content: "Quoted.", + to: PUBLIC_COLLECTION, + }), + }), + ); + + assert.deepStrictEqual(ctx.sentActivities.length, 1); + assert.ok(ctx.sentActivities[0].activity instanceof Accept); + assert.deepStrictEqual(states, ["accepted"]); +}); + +test("BotImpl.onQuoteRequested() fills in missing quote attribution", async () => { + const repository = new MemoryRepository(); + const bot = new BotImpl({ + kv: new MemoryKvStore(), + repository, + username: "bot", + }); + let quoteMessage: Message | undefined; + bot.onQuoteRequest = (_session, request) => { + quoteMessage = request.quote; + }; + const ctx = createMockInboxContext(bot, "https://example.com", "bot"); + const session = new SessionImpl(bot, ctx); + const target = await session.publish(text`Quote me`); + ctx.sentActivities = []; + const actor = new Person({ + id: new URL("https://remote.example/users/alice"), + preferredUsername: "alice", + }); + + await bot.onQuoteRequested( + ctx, + new QuoteRequest({ + id: new URL("https://remote.example/quote-requests/1"), + actor, + object: target.id, + instrument: new Note({ + id: new URL("https://remote.example/notes/quote"), + quote: target.id, + content: "Quoted.", + to: PUBLIC_COLLECTION, + }), + }), + ); + + assert.deepStrictEqual(ctx.sentActivities.length, 1); + assert.ok(ctx.sentActivities[0].activity instanceof Accept); + assert.deepStrictEqual(quoteMessage?.actor.id, actor.id); + assert.deepStrictEqual(quoteMessage?.raw.attributionId, actor.id); +}); + +test("BotImpl.onQuoteRequested() keeps conflicting quote URLs", async () => { + const repository = new MemoryRepository(); + const bot = new BotImpl({ + kv: new MemoryKvStore(), + repository, + username: "bot", + }); + let quoteMessage: Message | undefined; + bot.onQuoteRequest = (_session, request) => { + quoteMessage = request.quote; + }; + const ctx = createMockInboxContext(bot, "https://example.com", "bot"); + const session = new SessionImpl(bot, ctx); + const target = await session.publish(text`Quote me`); + ctx.sentActivities = []; + const actor = new Person({ + id: new URL("https://remote.example/users/alice"), + preferredUsername: "alice", + }); + const otherUrl = new URL("https://remote.example/notes/other"); + + await bot.onQuoteRequested( + ctx, + new QuoteRequest({ + id: new URL("https://remote.example/quote-requests/1"), + actor, + object: target.id, + instrument: new Note({ + id: new URL("https://remote.example/notes/quote"), + attribution: actor.id, + quote: target.id, + quoteUrl: otherUrl, + content: "Quoted.", + to: PUBLIC_COLLECTION, + }), + }), + ); + + assert.deepStrictEqual(ctx.sentActivities.length, 1); + assert.ok(ctx.sentActivities[0].activity instanceof Accept); + assert.deepStrictEqual(quoteMessage?.raw.quoteUrl, otherUrl); +}); + +test("BotImpl.onQuoteRequested() uses the resolved requester ID", async () => { + const repository = new MemoryRepository(); + const bot = new BotImpl({ + kv: new MemoryKvStore(), + repository, + username: "bot", + }); + let quoteMessage: Message | undefined; + bot.onQuoteRequest = (_session, request) => { + quoteMessage = request.quote; + }; + const ctx = createMockInboxContext(bot, "https://example.com", "bot"); + const session = new SessionImpl(bot, ctx); + const target = await session.publish(text`Quote me`); + ctx.sentActivities = []; + const requestedActorId = new URL("https://remote.example/users/alice"); + const resolvedActorId = new URL("https://remote.example/users/alice2"); + const defaultDocumentLoader = ctx.documentLoader; + Object.defineProperty(ctx, "getDocumentLoader", { + value: () => + Promise.resolve( + (url: string, options?: Parameters[1]) => + url === requestedActorId.href + ? Promise.resolve({ + contextUrl: null, + documentUrl: url, + document: { + "@context": "https://www.w3.org/ns/activitystreams", + id: resolvedActorId.href, + type: "Person", + preferredUsername: "alice2", + }, + }) + : defaultDocumentLoader(url, options), + ), + }); + + await bot.onQuoteRequested( + ctx, + new QuoteRequest({ + id: new URL("https://remote.example/quote-requests/1"), + actor: requestedActorId, + object: target.id, + instrument: new Note({ + id: new URL("https://remote.example/notes/quote"), + attribution: resolvedActorId, + quote: target.id, + content: "Quoted.", + to: PUBLIC_COLLECTION, + }), + }), + ); + + assert.deepStrictEqual(ctx.sentActivities.length, 1); + assert.ok(ctx.sentActivities[0].activity instanceof Accept); + assert.deepStrictEqual(quoteMessage?.actor.id, resolvedActorId); +}); + +test("BotImpl.onQuoteRequested() propagates follower lookup failures", async () => { + const repository = new FailingFollowerRepository(); + const bot = new BotImpl({ + kv: new MemoryKvStore(), + repository, + username: "bot", + quotePolicy: "followers", + }); + const ctx = createMockInboxContext(bot, "https://example.com", "bot"); + const session = new SessionImpl(bot, ctx); + const target = await session.publish(text`Followers may quote me`); + const actor = new Person({ + id: new URL("https://remote.example/users/alice"), + preferredUsername: "alice", + }); + await repository.addFollower( + "bot", + new URL("https://remote.example/activities/follow"), + actor, + ); + const quote = new Note({ + id: new URL("https://remote.example/notes/quote"), + attribution: actor.id, + quote: target.id, + content: "Quoted.", + to: PUBLIC_COLLECTION, + }); + const createRequest = (id: string) => + new QuoteRequest({ + id: new URL(`https://remote.example/quote-requests/${id}`), + actor, + object: target.id, + instrument: quote, + }); + await bot.onQuoteRequested(ctx, createRequest("1")); + assert.ok(await repository.findQuoteAuthorization("bot", quote.id!) != null); + ctx.sentActivities = []; + repository.failHasFollower = true; + + await assert.rejects( + () => bot.onQuoteRequested(ctx, createRequest("2")), + TypeError, + ); + + assert.deepStrictEqual(ctx.sentActivities, []); + assert.ok(await repository.findQuoteAuthorization("bot", quote.id!) != null); +}); + +test("BotImpl.onQuoteRequested() skips follower lookups after public matches", async () => { + const repository = new FailingFollowerRepository(); + const bot = new BotImpl({ + kv: new MemoryKvStore(), + repository, + username: "bot", + }); + const ctx = createMockInboxContext(bot, "https://example.com", "bot"); + const target = await addQuoteTarget( + repository, + ctx, + new InteractionPolicy({ + canQuote: new InteractionRule({ + automaticApprovals: [ctx.getFollowersUri("bot"), PUBLIC_COLLECTION], + }), + }), + ); + const actor = new Person({ + id: new URL("https://remote.example/users/alice"), + preferredUsername: "alice", + }); + repository.failHasFollower = true; + + await bot.onQuoteRequested( + ctx, + new QuoteRequest({ + id: new URL("https://remote.example/quote-requests/1"), + actor, + object: target.id, + instrument: new Note({ + id: new URL("https://remote.example/notes/quote"), + attribution: actor.id, + quote: target.id, + content: "Quoted.", + to: PUBLIC_COLLECTION, + }), + }), + ); + + assert.deepStrictEqual(ctx.sentActivities.length, 1); + assert.ok(ctx.sentActivities[0].activity instanceof Accept); +}); + +async function prepareQuoteApproval(stampOrigin = "https://remote.example") { + 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 target = new Note({ + id: new URL("https://remote.example/notes/original"), + attribution: author, + content: "Original.", + to: PUBLIC_COLLECTION, + }); + const lookups: Record = { + [target.id!.href]: target, + }; + Object.defineProperty(ctx, "lookupObject", { + value: (id: URL) => Promise.resolve(lookups[id.href] ?? null), + }); + const documents: Record = {}; + const defaultDocumentLoader = ctx.documentLoader; + const documentLoader: typeof defaultDocumentLoader = (url, options) => + url in documents + ? Promise.resolve({ + contextUrl: null, + documentUrl: url, + document: documents[url], + }) + : defaultDocumentLoader(url, options); + Object.defineProperty(ctx, "documentLoader", { value: documentLoader }); + Object.defineProperty(ctx, "getDocumentLoader", { + value: () => Promise.resolve(documentLoader), + }); + documents[author.id!.href] = await author.toJsonLd({ format: "expand" }); + 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 requestId = ctx.getObjectUri(QuoteRequest, { + identifier: bot.identifier, + id: messageId, + }); + const createStamp = (id: string, interactingObject = quote.id) => + new QuoteAuthorization({ + id: new URL(id, stampOrigin), + attribution: author.id, + interactingObject, + interactionTarget: target.id, + }); + const getQuoteAuthorizationId = async () => { + const stored = await repository.getMessage("bot", messageId); + assert.ok(stored instanceof Create); + const object = await stored.getObject(ctx); + assert.ok(object instanceof Note); + return object.quoteAuthorizationId; + }; + ctx.sentActivities = []; + return { + bot, + ctx, + author, + requestId, + lookups, + documents, + createStamp, + getQuoteAuthorizationId, + }; +} + +test("BotImpl.onFollowAccepted() accepts same-origin embedded stamps", async () => { + const { bot, ctx, author, requestId, createStamp, getQuoteAuthorizationId } = + await prepareQuoteApproval(); + const stamp = createStamp("/stamps/1"); + const accept = await Accept.fromJsonLd( + await new Accept({ + id: new URL("https://remote.example/accepts/1"), + actor: author.id, + object: requestId, + result: stamp, + }).toJsonLd({ format: "expand" }), + { documentLoader: ctx.documentLoader, contextLoader: ctx.contextLoader }, + ); + + await bot.onFollowAccepted(ctx, accept); + + assert.deepStrictEqual(await getQuoteAuthorizationId(), stamp.id); +}); + +test("BotImpl.onFollowAccepted() ignores forged cross-origin embedded stamps", async () => { + const { + bot, + ctx, + author, + requestId, + createStamp, + documents, + getQuoteAuthorizationId, + } = await prepareQuoteApproval(); + const forged = createStamp("/stamps/1"); + documents[forged.id!.href] = await createStamp( + "/stamps/1", + new URL("https://example.com/notes/other"), + ).toJsonLd({ format: "expand" }); + const accept = await Accept.fromJsonLd( + await new Accept({ + id: new URL("https://relay.example/accepts/1"), + actor: author.id, + object: requestId, + result: forged, + }).toJsonLd({ format: "expand" }), + { documentLoader: ctx.documentLoader, contextLoader: ctx.contextLoader }, + ); + + await bot.onFollowAccepted(ctx, accept); + + assert.deepStrictEqual(await getQuoteAuthorizationId(), null); +}); + +test("BotImpl.onFollowAccepted() ignores dereferenced stamps with other IDs", async () => { + const { + bot, + ctx, + author, + requestId, + createStamp, + documents, + lookups, + getQuoteAuthorizationId, + } = await prepareQuoteApproval(); + const requested = new URL("https://remote.example/stamps/a"); + const other = createStamp("/stamps/b"); + documents[requested.href] = await other.toJsonLd({ format: "expand" }); + lookups[requested.href] = other; + + await bot.onFollowAccepted( + ctx, + new Accept({ + id: new URL("https://remote.example/accepts/1"), + actor: author.id, + object: requestId, + result: requested, + }), + ); + + assert.deepStrictEqual(await getQuoteAuthorizationId(), null); +}); + +test("BotImpl.onFollowAccepted() ignores undereferenceable stamps", async () => { + const { bot, ctx, author, requestId, getQuoteAuthorizationId } = + await prepareQuoteApproval(); + + await bot.onFollowAccepted( + ctx, + new Accept({ + id: new URL("https://remote.example/accepts/1"), + actor: author.id, + object: requestId, + result: new URL("https://remote.example/stamps/missing"), + }), + ); + + assert.deepStrictEqual(await getQuoteAuthorizationId(), null); +}); diff --git a/packages/botkit/src/bot-impl.ts b/packages/botkit/src/bot-impl.ts index cb13325..4238dec 100644 --- a/packages/botkit/src/bot-impl.ts +++ b/packages/botkit/src/bot-impl.ts @@ -25,6 +25,7 @@ import { type Software, type UnverifiedActivityReason, } from "@fedify/fedify"; +import { quoteInteraction } from "@fedify/interaction-controls"; import { type Accept, type Activity, @@ -40,6 +41,8 @@ import { Endpoints, Follow, Image, + InteractionPolicy, + InteractionRule, isActor, Like as RawLike, Link, @@ -53,7 +56,7 @@ import { QuoteRequest, type QuoteRequest as RawQuoteRequest, type Recipient, - Reject, + type Reject, Service, type Undo, Update, @@ -110,9 +113,9 @@ 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 { verifyQuoteAuthorization } from "./quote-authorization.ts"; +import { createQuoteReject, QuoteRequestImpl } from "./quote-impl.ts"; +import { type QuotePolicyOption, serializeQuotePolicy } from "./quote.ts"; import type { Like, Reaction } from "./reaction.ts"; import { ActorScopedRepository, @@ -595,7 +598,7 @@ export class BotImpl implements Bot { ) { return null; } - return new QuoteRequest({ + return quoteInteraction.createRequest({ id: ctx.getObjectUri(QuoteRequest, { identifier: this.identifier, id: values.id, @@ -967,7 +970,10 @@ export class BotImpl implements Bot { } | undefined > { - if (accept.actorId == null || accept.resultId == null) return undefined; + // Capture the stamp ID before dereferencing it, because getResult() + // replaces the property with whatever the fetched document claims to be: + const authorizationId = accept.resultId; + if (accept.actorId == null || authorizationId == null) return undefined; const actor = await accept.getActor({ contextLoader: ctx.contextLoader, documentLoader: ctx.documentLoader, @@ -982,22 +988,22 @@ export class BotImpl implements Bot { !isMessageObject(target) || target.attributionId?.href !== actor.id.href ) return undefined; - let authorization = await accept.getResult({ + // The result is either dereferenced from its ID or embedded in this + // authenticated Accept on the same origin; Fedify's accessors re-fetch + // cross-origin embedded objects instead of trusting them: + const result = await accept.getResult({ contextLoader: ctx.contextLoader, documentLoader: ctx.documentLoader, suppressError: true, + }) ?? await lookupObjectSafely(this, ctx, authorizationId); + const authorization = await verifyQuoteAuthorization(ctx, result, { + authorizationId, + quoteId: object.id!, + targetId: object.quoteId!, + targetActorId: actor.id, + source: "remote", }); - if (authorization == null) { - authorization = await lookupObjectSafely(this, ctx, accept.resultId); - } - if ( - !validateQuoteAuthorization(authorization, { - authorizationId: accept.resultId, - quoteId: object.id!, - targetId: object.quoteId!, - targetActorId: actor.id, - }) - ) return undefined; + if (authorization == null) return undefined; return { actor, authorization }; } @@ -1234,7 +1240,6 @@ export class BotImpl implements Bot { request: RawQuoteRequest, ): Promise { if (request.id == null || request.actorId == null) return; - const requestId = request.id; const parsedObj = parseLocalUri( ctx, request.objectId, @@ -1257,15 +1262,34 @@ export class BotImpl implements Bot { documentLoader, suppressError: true, }); - if (!isMessageObject(instrument) || instrument.id == null) return; - const quotedObjectId = instrument.quoteId ?? instrument.quoteUrl; - if (quotedObjectId?.href !== targetObject.id.href) return; + if (!isMessageObject(instrument)) return; const actor = await request.getActor({ contextLoader: ctx.contextLoader, documentLoader, suppressError: true, }); if (!isActor(actor) || actor.id == null) return; + const requesterId = actor.id; + // Fedify's verifier requires the quote's attribution to match the + // requester and its quote and quoteUrl to agree, whereas BotKit fills in + // a missing attribution and lets quote take precedence over quoteUrl. + // So verify a normalized copy, and keep the original objects for the bot: + const verification = await quoteInteraction.verifyRequest(ctx, { + request: request.clone({ + actor: actor.id, + object: targetObject, + instrument: instrument.clone({ + attribution: instrument.attributionId ?? actor.id, + quoteUrl: instrument.quoteId ?? instrument.quoteUrl, + }), + }), + }); + if ( + !verification.verified && + verification.failure.type !== "requesterMismatch" + ) { + return; + } const session = this.getSession(ctx); if (!await this.#canActorSeeObject(ctx, actor.id, targetObject)) { return; @@ -1274,19 +1298,11 @@ export class BotImpl implements Bot { await session.context.sendActivity( this, actor, - new Reject({ - id: new URL(`/#reject/${requestId.href}`, session.actorId), - actor: session.actorId, - to: actor.id, - object: request, - }), + createQuoteReject(session.actorId, request, requesterId), { excludeBaseUris: [new URL(session.context.origin)] }, ); }; - if ( - instrument.attributionId != null && - instrument.attributionId.href !== actor.id.href - ) { + if (!verification.verified) { await rejectRequest(); return; } @@ -1343,28 +1359,14 @@ export class BotImpl implements Bot { } await quoteRequest.reject(); }; - const rule = targetObject.interactionPolicy?.canQuote; - if (rule == null) { - const policy = normalizeQuotePolicy(this.quotePolicy); - if (await this.#matchesQuoteAcceptance(ctx, actor.id, policy.automatic)) { - await quoteRequest.accept(); - } else if ( - await this.#matchesQuoteAcceptance(ctx, actor.id, policy.manual) - ) { - if (existingAuthorization != null) { - await quoteRequest.accept(); - return; - } - } else { - await revokeAndRejectQuoteRequest(); - } - } else if ( - await this.#matchesQuoteApprovals(ctx, actor.id, rule.automaticApprovals) - ) { + const decision = await this.#evaluateQuotePolicy( + ctx, + targetObject, + actor.id, + ); + if (decision === "automatic") { await quoteRequest.accept(); - } else if ( - await this.#matchesQuoteApprovals(ctx, actor.id, rule.manualApprovals) - ) { + } else if (decision === "manual") { if (existingAuthorization != null) { await quoteRequest.accept(); return; @@ -1375,6 +1377,56 @@ export class BotImpl implements Bot { await this.onQuoteRequest?.(session, quoteRequest); } + async #evaluateQuotePolicy( + ctx: InboxContext, + target: MessageClass, + requester: URL, + ): Promise<"automatic" | "manual" | "denied"> { + const followersUri = ctx.getFollowersUri(this.identifier); + // Fedify denies requests when the target has no canQuote rule, whereas + // BotKit falls back to the bot's quote policy: + const rule = target.interactionPolicy?.canQuote ?? + serializeQuotePolicy( + this.quotePolicy, + ctx.getActorUri(this.identifier), + followersUri, + ).canQuote; + // Fedify turns collection lookup failures into denials, which would make + // a transient repository error revoke an existing authorization, so + // capture the error and rethrow it instead: + let lookupFailure: { readonly error: unknown } | undefined; + const matchesApprovalCollection = async (collection: URL, actor: URL) => { + if (collection.href !== followersUri.href) return false; + try { + return await this.repository.hasFollower(actor); + } catch (error) { + lookupFailure ??= { error }; + return false; + } + }; + // Fedify can prefer an explicit actor entry in manual approvals over + // a broader automatic entry, whereas BotKit checks automatic approvals + // first, so evaluate each axis on its own: + for (const axis of ["automatic", "manual"] as const) { + const decision = await quoteInteraction.evaluatePolicy(ctx, { + subject: target.clone({ + interactionPolicy: new InteractionPolicy({ + canQuote: new InteractionRule( + axis === "automatic" + ? { automaticApprovals: rule?.automaticApprovals ?? [] } + : { manualApprovals: rule?.manualApprovals ?? [] }, + ), + }), + }), + requester, + matchesApprovalCollection, + }); + if (lookupFailure != null) throw lookupFailure.error; + if (decision.result === axis) return axis; + } + return "denied"; + } + async #canActorSeeObject( ctx: InboxContext, actorId: URL, @@ -1478,41 +1530,6 @@ export class BotImpl implements Bot { ); } - async #matchesQuoteAcceptance( - ctx: InboxContext, - actorId: URL, - acceptance: ReturnType["automatic"], - ): Promise { - if (actorId.href === ctx.getActorUri(this.identifier).href) return true; - switch (acceptance) { - case "public": - return true; - case "followers": - return await this.repository.hasFollower(actorId); - case "nobody": - default: - return false; - } - } - - async #matchesQuoteApprovals( - ctx: InboxContext, - actorId: URL, - approvals: readonly URL[], - ): Promise { - if (actorId.href === ctx.getActorUri(this.identifier).href) return true; - const followerCollection = ctx.getFollowersUri(this.identifier).href; - for (const approval of approvals) { - if (approval.href === PUBLIC_COLLECTION.href) return true; - if (approval.href === actorId.href) return true; - if ( - approval.href === followerCollection && - await this.repository.hasFollower(actorId) - ) return true; - } - return false; - } - async #hasValidQuoteAuthorization( ctx: InboxContext, object: MessageClass, @@ -1533,19 +1550,18 @@ export class BotImpl implements Bot { parsed.class !== QuoteAuthorization || parsed.values.identifier !== this.identifier ) return false; - const authorization = await this.repository.getQuoteAuthorization( - parsed.values.id as Uuid, - ); - if ( - !validateQuoteAuthorization(authorization, { + const authorization = await verifyQuoteAuthorization( + ctx, + await this.repository.getQuoteAuthorization(parsed.values.id as Uuid), + { authorizationId: object.quoteAuthorizationId, quoteId: object.id, targetId, targetActorId: ctx.getActorUri(this.identifier), - }) - ) { - return false; - } + source: "repository", + }, + ); + if (authorization == null) return false; const parsedTarget = parseLocalUri( ctx, targetId, diff --git a/packages/botkit/src/message-impl.ts b/packages/botkit/src/message-impl.ts index 6bd99c1..56af8e3 100644 --- a/packages/botkit/src/message-impl.ts +++ b/packages/botkit/src/message-impl.ts @@ -14,6 +14,7 @@ // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . import "./temporal.ts"; +import { quoteInteraction } from "@fedify/interaction-controls"; import { LanguageString } from "@fedify/vocab-runtime"; import { type Actor, @@ -55,7 +56,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 { verifyQuoteAuthorization } from "./quote-authorization.ts"; import { parseQuotePolicy, type QuotePolicy, @@ -783,10 +784,10 @@ export class AuthorizedMessageImpl const followersUri = this.session.context.getFollowersUri( this.session.bot.identifier, ); - const del = new Delete({ + const del = quoteInteraction.createRevocation({ id: new URL("#delete", authorization.id), actor: this.session.actorId, - object: authorization.id, + authorization: authorization.id, to: quoteActor?.id ?? followersUri, cc: quoteActor?.id == null ? undefined : followersUri, }); @@ -1174,9 +1175,10 @@ async function verifyQuoteApproval( raw.quoteAuthorizationId, session.bot.legacyObjectUrisIdentifier, ); - const authorization = parsed?.type === "object" && - parsed.class === QuoteAuthorization && - parsed.values.identifier === session.bot.identifier + const local = parsed?.type === "object" && + parsed.class === QuoteAuthorization && + parsed.values.identifier === session.bot.identifier; + const authorization = local ? await session.bot.repository.getQuoteAuthorization( parsed.values.id as Uuid, ) @@ -1190,12 +1192,13 @@ async function verifyQuoteApproval( signal, }, ); - return validateQuoteAuthorization(authorization, { + return await verifyQuoteAuthorization(session.context, authorization, { authorizationId: raw.quoteAuthorizationId, quoteId: raw.id, targetId: quoteTarget.id, targetActorId: quoteTarget.actor.id, - }); + source: local ? "repository" : "remote", + }) != null; } catch (error) { if (signal?.aborted === true) throw error; return false; diff --git a/packages/botkit/src/quote-authorization.test.ts b/packages/botkit/src/quote-authorization.test.ts index 600fc1d..dc50f01 100644 --- a/packages/botkit/src/quote-authorization.test.ts +++ b/packages/botkit/src/quote-authorization.test.ts @@ -13,11 +13,17 @@ // // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . +import { createFederation, MemoryKvStore } from "@fedify/fedify/federation"; import { QuoteAuthorization } from "@fedify/vocab"; import assert from "node:assert/strict"; import { test } from "node:test"; -import { validateQuoteAuthorization } from "./quote-authorization.ts"; +import { + type QuoteAuthorizationVerificationOptions, + verifyQuoteAuthorization, +} from "./quote-authorization.ts"; +const context = createFederation({ kv: new MemoryKvStore() }) + .createContext(new URL("https://example.com/"), undefined); 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"); @@ -39,18 +45,41 @@ function createAuthorization( }); } -test("validateQuoteAuthorization() accepts matching authorization", () => { - assert.ok( - validateQuoteAuthorization(createAuthorization(), { +async function verify( + authorization: unknown, + options: Partial = {}, +): Promise { + return await verifyQuoteAuthorization(context, authorization, { + authorizationId, + quoteId, + targetId, + targetActorId, + source: "remote", + ...options, + }) != null; +} + +test("verifyQuoteAuthorization() accepts matching authorization", async () => { + const authorization = createAuthorization(); + assert.deepStrictEqual( + await verifyQuoteAuthorization(context, authorization, { authorizationId, quoteId, targetId, targetActorId, + source: "remote", + }), + authorization, + ); + assert.ok( + await verify(authorization, { + authorizationId: undefined, + source: "repository", }), ); }); -test("validateQuoteAuthorization() rejects mismatched authorizations", () => { +test("verifyQuoteAuthorization() rejects mismatched authorizations", async () => { const cases: readonly [ string, QuoteAuthorization, @@ -93,35 +122,55 @@ test("validateQuoteAuthorization() rejects mismatched authorizations", () => { for (const [name, authorization, expectedAuthorizationId] of cases) { assert.ok( - !validateQuoteAuthorization(authorization, { + !await verify(authorization, { authorizationId: expectedAuthorizationId, - quoteId, - targetId, - targetActorId, }), name, ); } }); -test("validateQuoteAuthorization() rejects non-authorization objects", () => { +test("verifyQuoteAuthorization() rejects non-authorization objects", async () => { + assert.ok(!await verify({})); +}); + +test("verifyQuoteAuthorization() rejects missing target actors", async () => { + assert.ok(!await verify(createAuthorization(), { targetActorId: null })); +}); + +test("verifyQuoteAuthorization() requires IDs for remote authorizations", async () => { assert.ok( - !validateQuoteAuthorization({}, { - authorizationId, - quoteId, - targetId, - targetActorId, - }), + !await verify(createAuthorization(), { authorizationId: undefined }), ); }); -test("validateQuoteAuthorization() rejects missing target actors", () => { +test("verifyQuoteAuthorization() compares FEP-fe34 origins", async () => { + const opaqueId = new URL("urn:example:stamp"); assert.ok( - !validateQuoteAuthorization(createAuthorization(), { - authorizationId, - quoteId, - targetId, - targetActorId: null, + !await verify( + createAuthorization({ id: opaqueId, attribution: opaqueId }), + { + authorizationId: opaqueId, + targetActorId: opaqueId, + }, + ), + "opaque IDs", + ); + const actor = new URL("did:example:alice"); + const stamp = new URL("did:example:alice#stamp"); + assert.ok( + await verify(createAuthorization({ id: stamp, attribution: actor }), { + authorizationId: stamp, + targetActorId: actor, + }), + "same DID", + ); + const other = new URL("did:example:bob#stamp"); + assert.ok( + !await verify(createAuthorization({ id: other, attribution: actor }), { + authorizationId: other, + targetActorId: actor, }), + "different DIDs", ); }); diff --git a/packages/botkit/src/quote-authorization.ts b/packages/botkit/src/quote-authorization.ts index fea650c..6d01490 100644 --- a/packages/botkit/src/quote-authorization.ts +++ b/packages/botkit/src/quote-authorization.ts @@ -13,16 +13,19 @@ // // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . +import type { Context } from "@fedify/fedify/federation"; +import { quoteInteraction } from "@fedify/interaction-controls"; import { QuoteAuthorization } from "@fedify/vocab"; /** - * Expected identifiers for validating a quote authorization stamp. + * Expected identifiers for verifying a quote authorization stamp. * - * @since 0.5.0 + * @since 0.6.0 */ -export interface QuoteAuthorizationValidationOptions { +export interface QuoteAuthorizationVerificationOptions { /** - * The expected quote authorization stamp ID. + * The expected quote authorization stamp ID. Required when + * {@link source} is `"remote"`. */ readonly authorizationId?: URL; @@ -40,27 +43,48 @@ export interface QuoteAuthorizationValidationOptions { * The actor that owns the quote target object. */ readonly targetActorId: URL | null; + + /** + * How the authorization was obtained: + * + * - `"repository"`: stored by the bot itself, so it is authentic. + * - `"remote"`: dereferenced from {@link authorizationId}, or embedded in + * an authenticated activity on the same origin (Fedify's vocabulary + * accessors re-fetch cross-origin embedded objects). It is authentic + * only when its ID is the one that was dereferenced. + */ + readonly source: "repository" | "remote"; } /** - * Checks whether an object is a matching FEP-044f quote authorization stamp. + * Verifies that an object is a matching FEP-044f quote authorization stamp. * - * @param authorization The fetched or stored object to validate. + * @param context The Fedify context. + * @param authorization The fetched or stored object to verify. * @param options The identifiers the authorization must match. - * @returns `true` if the object is a quote authorization for the quote. - * @since 0.5.0 + * @returns The authorization if it is valid for the quote, or `null`. + * @since 0.6.0 */ -export function validateQuoteAuthorization( +export async function verifyQuoteAuthorization( + context: Context, authorization: unknown, - options: QuoteAuthorizationValidationOptions, -): authorization is QuoteAuthorization { - return authorization instanceof QuoteAuthorization && - authorization.id != null && - options.targetActorId != null && - (options.authorizationId == null || - authorization.id.href === options.authorizationId.href) && - authorization.id.origin === options.targetActorId.origin && - authorization.attributionId?.href === options.targetActorId.href && - authorization.interactingObjectId?.href === options.quoteId.href && - authorization.interactionTargetId?.href === options.targetId.href; + options: QuoteAuthorizationVerificationOptions, +): Promise { + if ( + !(authorization instanceof QuoteAuthorization) || + options.targetActorId == null || + (options.authorizationId != null && + authorization.id?.href !== options.authorizationId.href) + ) { + return null; + } + const result = await quoteInteraction.verifyAuthorization(context, { + authorization, + interactingObject: options.quoteId, + interactionTarget: options.targetId, + attributedTo: options.targetActorId, + verifyAuthenticity: () => + options.source === "repository" || options.authorizationId != null, + }); + return result.verified ? result.authorization : null; } diff --git a/packages/botkit/src/quote-impl.ts b/packages/botkit/src/quote-impl.ts index cfabb3a..6d8e898 100644 --- a/packages/botkit/src/quote-impl.ts +++ b/packages/botkit/src/quote-impl.ts @@ -13,12 +13,12 @@ // // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . +import { quoteInteraction } from "@fedify/interaction-controls"; import { - Accept, type Actor, QuoteAuthorization, type QuoteRequest as RawQuoteRequest, - Reject, + type Reject, } from "@fedify/vocab"; import { v7 as uuidv7 } from "uuid"; import type { AuthorizedMessage, Message, MessageClass } from "./message.ts"; @@ -26,6 +26,31 @@ import type { QuoteRequest } from "./quote.ts"; import type { Uuid } from "./repository.ts"; import type { SessionImpl } from "./session-impl.ts"; +/** + * Creates a `Reject` activity for a quote request. + * @param actorId The URI of the bot rejecting the request. + * @param request The quote request to reject. + * @param requesterId The URI of the actor that sent the request. + * @returns The `Reject` activity. + * @throws {TypeError} The quote request ID is missing. + */ +export function createQuoteReject( + actorId: URL, + request: RawQuoteRequest, + requesterId: URL, +): Reject { + if (request.id == null) { + throw new TypeError("The quote request ID is missing."); + } + return quoteInteraction.createReject({ + mode: "polite", + id: new URL(`/#reject/${request.id.href}`, actorId), + actor: actorId, + request, + to: requesterId, + }); +} + export class QuoteRequestImpl implements QuoteRequest { readonly session: SessionImpl; @@ -34,6 +59,7 @@ export class QuoteRequestImpl readonly actor: Actor; readonly quote: Message; readonly target: AuthorizedMessage; + readonly #actorId: URL; #state: "pending" | "accepted" | "rejected"; get state(): "pending" | "accepted" | "rejected" { @@ -60,6 +86,7 @@ export class QuoteRequestImpl this.id = raw.id; this.raw = raw; this.actor = actor; + this.#actorId = actor.id; this.quote = quote; this.target = target; this.#state = "pending"; @@ -87,16 +114,19 @@ export class QuoteRequestImpl throw new TypeError( "The quote authorization does not belong to this message.", ); + } else if (authorization.id == null) { + throw new TypeError("The quote authorization ID is missing."); } await this.session.context.sendActivity( this.session.bot, this.actor, - new Accept({ + quoteInteraction.createAccept({ + mode: "polite", id: new URL(`/#accept/${this.id.href}`, this.session.actorId), actor: this.session.actorId, - to: this.actor.id, - object: this.raw, - result: authorization.id, + request: this.raw, + authorization: authorization.id, + to: this.#actorId, }), { excludeBaseUris: [new URL(this.session.context.origin)] }, ); @@ -112,12 +142,7 @@ export class QuoteRequestImpl await this.session.context.sendActivity( this.session.bot, this.actor, - new Reject({ - id: new URL(`/#reject/${this.id.href}`, this.session.actorId), - actor: this.session.actorId, - to: this.actor.id, - object: this.raw, - }), + createQuoteReject(this.session.actorId, this.raw, this.#actorId), { excludeBaseUris: [new URL(this.session.context.origin)] }, ); this.#state = "rejected"; @@ -125,12 +150,12 @@ export class QuoteRequestImpl async #createAuthorization(): Promise { const id = uuidv7() as Uuid; - const authorization = new QuoteAuthorization({ + const authorization = quoteInteraction.createAuthorization({ id: this.session.context.getObjectUri(QuoteAuthorization, { identifier: this.session.bot.identifier, id, }), - attribution: this.session.actorId, + attributedTo: this.session.actorId, interactingObject: this.quote.id, interactionTarget: this.target.id, }); diff --git a/packages/botkit/src/session-impl.ts b/packages/botkit/src/session-impl.ts index 781b5d3..07bc146 100644 --- a/packages/botkit/src/session-impl.ts +++ b/packages/botkit/src/session-impl.ts @@ -15,6 +15,7 @@ // along with this program. If not, see . import "./temporal.ts"; import type { Context } from "@fedify/fedify/federation"; +import { quoteInteraction } from "@fedify/interaction-controls"; import { LanguageString } from "@fedify/vocab-runtime"; import { type Actor, @@ -343,11 +344,12 @@ export class SessionImpl implements Session { voters = 0; endTime = options.poll.endTime; } + const msgId = this.context.getObjectUri(cls, { + identifier: this.bot.identifier, + id, + }); const msg = new cls({ - id: this.context.getObjectUri(cls, { - identifier: this.bot.identifier, - id, - }), + id: msgId, contents: options.language == null ? [contentHtml] : [new LanguageString(contentHtml, options.language), contentHtml], @@ -478,14 +480,14 @@ export class SessionImpl implements Session { options.quoteTarget.actor.id.href !== this.context.getActorUri(this.bot.identifier).href ) { - const request = new QuoteRequest({ + const request = quoteInteraction.createRequest({ id: this.context.getObjectUri(QuoteRequest, { identifier: this.bot.identifier, id, }), actor: this.context.getActorUri(this.bot.identifier), object: options.quoteTarget.id, - instrument: msg.id, + instrument: msgId, to: options.quoteTarget.actor.id, }); await this.context.sendActivity( diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e02eeb6..21eb2a9 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -12,6 +12,9 @@ catalogs: '@fedify/fedify': specifier: ~2.4.0 version: 2.4.0 + '@fedify/interaction-controls': + specifier: ~2.4.0 + version: 2.4.0 '@fedify/postgres': specifier: ~2.4.0 version: 2.4.0 @@ -152,6 +155,9 @@ importers: '@fedify/fedify': specifier: 'catalog:' version: 2.4.0 + '@fedify/interaction-controls': + specifier: 'catalog:' + version: 2.4.0(@fedify/fedify@2.4.0) '@fedify/markdown-it-hashtag': specifier: ^0.3.0 version: 0.3.0 @@ -511,6 +517,11 @@ packages: resolution: {integrity: sha512-XlR202zMU5+tiISyU48cxe23IU9xXHVPunO+apSSgBgl922JnMNErM/l23HmHEMAtiFiRgiXG+AqC75ZxppWtw==} engines: {bun: '>=1.1.0', deno: '>=2.0.0', node: '>=22.0.0'} + '@fedify/interaction-controls@2.4.0': + resolution: {integrity: sha512-lUZS1eOlFer33p1dgK7kH+cjxDFZLz1FP2JYXJr7/CRUw4h9fB4zk1/yBI0ws2xt2sPNwZh/dAFbtt4z1YPrug==} + peerDependencies: + '@fedify/fedify': ^2.4.0 + '@fedify/markdown-it-hashtag@0.3.0': resolution: {integrity: sha512-DCtfQ1OlFstob382d2iwEi77ezNqDQkUjS664q2r7ZOQQkBExvgvKDkL851tf1XRYp4A425/HO2yGRLZ1u1pSg==} @@ -2398,6 +2409,13 @@ snapshots: temporal-polyfill: 1.0.5 urlpattern-polyfill: 10.1.0 + '@fedify/interaction-controls@2.4.0(@fedify/fedify@2.4.0)': + dependencies: + '@fedify/fedify': 2.4.0 + '@fedify/vocab': 2.4.0 + '@fedify/vocab-runtime': 2.4.0 + temporal-polyfill: 1.0.5 + '@fedify/markdown-it-hashtag@0.3.0': dependencies: markdown-it: 14.3.2 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 47fb207..3d00e0f 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -16,6 +16,7 @@ minimumReleaseAgeExclude: catalog: "@fedify/denokv": "jsr:@fedify/denokv@^2.4.0" "@fedify/fedify": ~2.4.0 + "@fedify/interaction-controls": ~2.4.0 "@fedify/postgres": ~2.4.0 "@fedify/redis": ~2.4.0 "@fedify/sqlite": ~2.4.0 From 023a4f5cbb0cf87bc5d6a7986917de038307206b Mon Sep 17 00:00:00 2001 From: Hong Minhee Date: Fri, 2 Oct 2026 16:14:08 +0900 Subject: [PATCH 2/2] Let quote authorization checks be cancelled verifyQuoteAuthorization() is async but took no AbortSignal, so verifyQuoteApproval() could pass its signal to lookupObject() but not on to the verification step. It now accepts an optional signal and checks it before and after calling Fedify's verifyAuthorization(), which has no signal of its own. https://github.com/fedify-dev/botkit/pull/53#discussion_r4163326213 Assisted-by: Claude Code:claude-opus-5-5 --- packages/botkit/src/message-impl.ts | 2 +- packages/botkit/src/quote-authorization.test.ts | 16 ++++++++++++++++ packages/botkit/src/quote-authorization.ts | 5 +++++ 3 files changed, 22 insertions(+), 1 deletion(-) diff --git a/packages/botkit/src/message-impl.ts b/packages/botkit/src/message-impl.ts index 56af8e3..3586b50 100644 --- a/packages/botkit/src/message-impl.ts +++ b/packages/botkit/src/message-impl.ts @@ -1198,7 +1198,7 @@ async function verifyQuoteApproval( targetId: quoteTarget.id, targetActorId: quoteTarget.actor.id, source: local ? "repository" : "remote", - }) != null; + }, signal) != null; } catch (error) { if (signal?.aborted === true) throw error; return false; diff --git a/packages/botkit/src/quote-authorization.test.ts b/packages/botkit/src/quote-authorization.test.ts index dc50f01..6d08677 100644 --- a/packages/botkit/src/quote-authorization.test.ts +++ b/packages/botkit/src/quote-authorization.test.ts @@ -144,6 +144,22 @@ test("verifyQuoteAuthorization() requires IDs for remote authorizations", async ); }); +test("verifyQuoteAuthorization() throws when the signal is aborted", async () => { + const controller = new AbortController(); + controller.abort(); + await assert.rejects( + () => + verifyQuoteAuthorization(context, createAuthorization(), { + authorizationId, + quoteId, + targetId, + targetActorId, + source: "remote", + }, controller.signal), + { name: "AbortError" }, + ); +}); + test("verifyQuoteAuthorization() compares FEP-fe34 origins", async () => { const opaqueId = new URL("urn:example:stamp"); assert.ok( diff --git a/packages/botkit/src/quote-authorization.ts b/packages/botkit/src/quote-authorization.ts index 6d01490..6f568ab 100644 --- a/packages/botkit/src/quote-authorization.ts +++ b/packages/botkit/src/quote-authorization.ts @@ -62,14 +62,18 @@ export interface QuoteAuthorizationVerificationOptions { * @param context The Fedify context. * @param authorization The fetched or stored object to verify. * @param options The identifiers the authorization must match. + * @param signal An abort signal. * @returns The authorization if it is valid for the quote, or `null`. + * @throws {DOMException} The signal is aborted. * @since 0.6.0 */ export async function verifyQuoteAuthorization( context: Context, authorization: unknown, options: QuoteAuthorizationVerificationOptions, + signal?: AbortSignal, ): Promise { + signal?.throwIfAborted(); if ( !(authorization instanceof QuoteAuthorization) || options.targetActorId == null || @@ -86,5 +90,6 @@ export async function verifyQuoteAuthorization( verifyAuthenticity: () => options.source === "repository" || options.authorizationId != null, }); + signal?.throwIfAborted(); return result.verified ? result.authorization : null; }