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..3586b50 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", + }, 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 600fc1d..6d08677 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,71 @@ 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("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("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..6f568ab 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,53 @@ 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 + * @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 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, + signal?: AbortSignal, +): Promise { + signal?.throwIfAborted(); + 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, + }); + signal?.throwIfAborted(); + 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