Part of #27. This issue covers the receiving side of FEP-044f: declaring who may quote the bot's messages, answering incoming QuoteRequest activities, issuing and serving QuoteAuthorization stamps, and revoking them afterwards. With this in place, Mastodon 4.5 users can quote a bot's posts and have the quotes verified, independently of whether BotKit itself can quote anyone yet (that direction is #29).
The quotePolicy option
Add a quotePolicy option to CreateBotOptions, next to followerPolicy, and the same option to SessionPublishOptions, with the per-message value taking precedence and "public" as the overall default:
export type QuoteAcceptance = "public" | "followers" | "nobody";
export interface QuotePolicy {
readonly automatic?: QuoteAcceptance;
readonly manual?: QuoteAcceptance;
}
// On both CreateBotOptions and SessionPublishOptions:
readonly quotePolicy?: QuoteAcceptance | "manual" | QuotePolicy;
The two forms exist because the ecosystem has two models. Mastodon offers a single three-way choice (everyone, followers, nobody), which maps onto the plain strings, while GoToSocial's interaction policies, which the FEP adopts, distinguish actors whose quotes are approved automatically from actors whose quotes await manual review. A string normalizes to an object as follows: "public" becomes { automatic: "public" }, "followers" becomes { automatic: "followers" }, "nobody" becomes { automatic: "nobody" }, and "manual" becomes { manual: "public" }. Put the types and the normalization function in a new src/quote.ts module and re-export the types from src/mod.ts.
AuthorizedMessage.update() currently takes only the new text; give it an optional second parameter accepting { quotePolicy } so the policy of an existing message can be changed. Mastodon allows editing the quote policy after publication, and since the FEP makes the policy purely advisory (it must never be used for verification), a change has no retroactive consequences to worry about.
Serializing the policy onto outgoing messages
Every message publish() creates gets an interactionPolicy containing a canQuote rule, built from Fedify's InteractionPolicy and InteractionRule classes. Map the normalized policy as follows: on the automatic axis, "public" produces automaticApprovals: [PUBLIC_COLLECTION], "followers" produces [<bot actor URI>, <bot followers collection URI>], and "nobody" produces [<bot actor URI>]; on the manual axis, "public" produces manualApprovals: [PUBLIC_COLLECTION], "followers" produces [<bot followers collection URI>], and "nobody" is treated the same as leaving the axis out, because an actor listed on neither axis is never approved anyway. The bot's own actor URI appears in the non-public automatic sets for two reasons: self-quotes are always legitimate, and the FEP requires the nobody case to be expressed as an automaticApproval array containing only the author, since an empty array is indistinguishable from a missing property after JSON-LD canonicalization.
Because the policy is serialized into the stored Create, evaluating a later QuoteRequest against the policy the message was published with is a matter of reading the stored object back. Per-message overrides therefore need no bookkeeping of their own.
Handling incoming QuoteRequest activities
Register a QuoteRequest listener in the inbox listener chain. The handler resolves the activity's object through parseLocalUri; when it is not a message class owned by one of the instance's bots, log and ignore the activity. The instrument is the quote post itself; the FEP says senders should inline it but allows a bare reference, so resolve it with getInstrument() using the bot's document loader and ignore the request when it does not resolve to a message object.
To evaluate the request, read the stored message's interactionPolicy.canQuote back. Messages published before this feature carry no policy; fall back to the bot's current quotePolicy for those. Then classify the requesting actor: any actor matches PUBLIC_COLLECTION, followers are checked with Repository.hasFollower(), and the bot's own actor always matches. An actor in the automatic set gets accept() called on its request right away; an actor in neither set gets reject(); an actor only in the manual set leaves the request pending. In every case fire the new onQuoteRequest event afterwards, mirroring how onFollow fires even when followerPolicy has already auto-accepted; the handler can distinguish the cases through state. A pending request with no handler installed stays unanswered, which remote servers already interpret as no decision yet.
The event handler receives a QuoteRequest object modeled on FollowRequest:
export interface QuoteRequest<TContextData> {
readonly id: URL;
readonly raw: vocab.QuoteRequest;
readonly actor: Actor;
readonly quote: Message<MessageClass, TContextData>;
readonly target: Message<MessageClass, TContextData>;
readonly state: "pending" | "accepted" | "rejected";
accept(): Promise<void>;
reject(): Promise<void>;
}
Unlike FollowRequest it is generic, because it carries Message objects. accept() and reject() throw TypeError when the request is not pending, matching FollowRequest. Add a QuoteRequestEventHandler type to src/events.ts, an onQuoteRequest property to the Bot interface and CreateBotOptions, and wire it through src/bot-impl.ts (the handler name list, the property declaration, and the getter/setter proxy) the same way the other handlers are wired.
When a QuoteRequest arrives for a quote post that already has a stored stamp (same interactingObject), do not mint a second stamp: resend the Accept carrying the existing one. Remote servers retry deliveries, and two stamps for one quote would make revocation ambiguous.
Issuing and serving QuoteAuthorization stamps
accept() builds a QuoteAuthorization with a fresh UUIDv7 under the object dispatcher URI described below, attributedTo set to the bot's actor URI, interactingObject set to the quote post's id, and interactionTarget set to the quoted message's id. It stores the stamp through the repository, then replies to the requester with an Accept whose object is the received QuoteRequest and whose result references the stamp by id, constructed the same way the follow-request accept() builds its Accept. reject() replies with a Reject whose object is the QuoteRequest.
Register an object dispatcher for QuoteAuthorization at /ap/actor/{identifier}/quote-authorization/{id}, following the Follow dispatcher pattern, serving stamps from the repository. Serve them publicly, without an authorize() callback: the FEP permits public dereferenceability, and a stamp discloses nothing beyond three URIs. The FEP forbids inlining the interactingObject or interactionTarget objects in the served document to avoid leaking their contents, so include both only as URI references.
Repository changes
Add four required methods to the Repository interface:
addQuoteAuthorization(identifier: string, id: Uuid, authorization: QuoteAuthorization): Promise<void>;
getQuoteAuthorization(identifier: string, id: Uuid): Promise<QuoteAuthorization | undefined>;
findQuoteAuthorization(identifier: string, interactingObject: URL): Promise<QuoteAuthorization | undefined>;
removeQuoteAuthorization(identifier: string, id: Uuid): Promise<QuoteAuthorization | undefined>;
findQuoteAuthorization looks a stamp up by the quote post it authorizes; both the duplicate-request case above and revocation below need that direction. MemoryRepository and KvRepository implement it with a secondary index from the interactingObject href to the stamp id, kept in step by add and remove, with the KV layout following the existing per-bot key prefixes. The @fedify/botkit-sqlite and @fedify/botkit-postgres packages each get a quote_authorizations table (identifier, UUID primary key, a unique index on the interacting object URI, and the stamp serialized as JSON-LD the way other objects are stored) plus the corresponding schema migration.
Make the methods required, not optional. Stamps must survive restarts, because remote servers re-fetch them whenever they verify a quote; a stamp that vanishes on redeploy silently unapproves every quote it covered. Third-party Repository implementations break as a result, which is acceptable before 1.0 and gets an explicit changelog entry.
Revoking an accepted quote
The FEP lets the quoted author moderate after the fact by deleting the stamp. Expose this as a method on Message:
unauthorizeQuote(): Promise<void>;
Calling it looks the stamp up through findQuoteAuthorization with the message's own id as the interacting object. When the message does not quote one of this bot's messages, or no stamp was ever issued for it, throw TypeError. Otherwise remove the stamp from the repository, so the object dispatcher starts answering 404 for it, and send a Delete whose object is the stamp's bare URI, addressed to the quote post's author with the bot's followers collection cc'd, which approximates the FEP's “the quote post's author and any recipient it has reasons to think has accessed the quote post”. Do not embed the stamp or either of its targets in the Delete; the FEP forbids that for the same information-leak reasons as above.
Documentation and tests
Write the tests first, per the TDD practice in AGENTS.md: policy normalization and serialization, request evaluation for each policy shape and actor relation (including the fallback for messages published without a policy and the duplicate-request path), the stamp dispatcher, the repository methods in all four implementations, and revocation. Document the new option, event, interface, and method under docs/, and record every user-facing change in CHANGES.md under version 0.5.0.
Part of #27. This issue covers the receiving side of FEP-044f: declaring who may quote the bot's messages, answering incoming
QuoteRequestactivities, issuing and servingQuoteAuthorizationstamps, and revoking them afterwards. With this in place, Mastodon 4.5 users can quote a bot's posts and have the quotes verified, independently of whether BotKit itself can quote anyone yet (that direction is #29).The
quotePolicyoptionAdd a
quotePolicyoption toCreateBotOptions, next tofollowerPolicy, and the same option toSessionPublishOptions, with the per-message value taking precedence and"public"as the overall default:The two forms exist because the ecosystem has two models. Mastodon offers a single three-way choice (everyone, followers, nobody), which maps onto the plain strings, while GoToSocial's interaction policies, which the FEP adopts, distinguish actors whose quotes are approved automatically from actors whose quotes await manual review. A string normalizes to an object as follows:
"public"becomes{ automatic: "public" },"followers"becomes{ automatic: "followers" },"nobody"becomes{ automatic: "nobody" }, and"manual"becomes{ manual: "public" }. Put the types and the normalization function in a new src/quote.ts module and re-export the types from src/mod.ts.AuthorizedMessage.update()currently takes only the new text; give it an optional second parameter accepting{ quotePolicy }so the policy of an existing message can be changed. Mastodon allows editing the quote policy after publication, and since the FEP makes the policy purely advisory (it must never be used for verification), a change has no retroactive consequences to worry about.Serializing the policy onto outgoing messages
Every message
publish()creates gets aninteractionPolicycontaining acanQuoterule, built from Fedify'sInteractionPolicyandInteractionRuleclasses. Map the normalized policy as follows: on theautomaticaxis,"public"producesautomaticApprovals: [PUBLIC_COLLECTION],"followers"produces[<bot actor URI>, <bot followers collection URI>], and"nobody"produces[<bot actor URI>]; on themanualaxis,"public"producesmanualApprovals: [PUBLIC_COLLECTION],"followers"produces[<bot followers collection URI>], and"nobody"is treated the same as leaving the axis out, because an actor listed on neither axis is never approved anyway. The bot's own actor URI appears in the non-public automatic sets for two reasons: self-quotes are always legitimate, and the FEP requires the nobody case to be expressed as anautomaticApprovalarray containing only the author, since an empty array is indistinguishable from a missing property after JSON-LD canonicalization.Because the policy is serialized into the stored
Create, evaluating a laterQuoteRequestagainst the policy the message was published with is a matter of reading the stored object back. Per-message overrides therefore need no bookkeeping of their own.Handling incoming
QuoteRequestactivitiesRegister a
QuoteRequestlistener in the inbox listener chain. The handler resolves the activity'sobjectthroughparseLocalUri; when it is not a message class owned by one of the instance's bots, log and ignore the activity. Theinstrumentis the quote post itself; the FEP says senders should inline it but allows a bare reference, so resolve it withgetInstrument()using the bot's document loader and ignore the request when it does not resolve to a message object.To evaluate the request, read the stored message's
interactionPolicy.canQuoteback. Messages published before this feature carry no policy; fall back to the bot's currentquotePolicyfor those. Then classify the requesting actor: any actor matchesPUBLIC_COLLECTION, followers are checked withRepository.hasFollower(), and the bot's own actor always matches. An actor in the automatic set getsaccept()called on its request right away; an actor in neither set getsreject(); an actor only in the manual set leaves the request pending. In every case fire the newonQuoteRequestevent afterwards, mirroring howonFollowfires even whenfollowerPolicyhas already auto-accepted; the handler can distinguish the cases throughstate. A pending request with no handler installed stays unanswered, which remote servers already interpret as no decision yet.The event handler receives a
QuoteRequestobject modeled onFollowRequest:Unlike
FollowRequestit is generic, because it carriesMessageobjects.accept()andreject()throwTypeErrorwhen the request is not pending, matchingFollowRequest. Add aQuoteRequestEventHandlertype to src/events.ts, anonQuoteRequestproperty to theBotinterface andCreateBotOptions, and wire it through src/bot-impl.ts (the handler name list, the property declaration, and the getter/setter proxy) the same way the other handlers are wired.When a
QuoteRequestarrives for a quote post that already has a stored stamp (sameinteractingObject), do not mint a second stamp: resend theAcceptcarrying the existing one. Remote servers retry deliveries, and two stamps for one quote would make revocation ambiguous.Issuing and serving
QuoteAuthorizationstampsaccept()builds aQuoteAuthorizationwith a fresh UUIDv7 under the object dispatcher URI described below,attributedToset to the bot's actor URI,interactingObjectset to the quote post's id, andinteractionTargetset to the quoted message's id. It stores the stamp through the repository, then replies to the requester with anAcceptwhoseobjectis the receivedQuoteRequestand whoseresultreferences the stamp by id, constructed the same way the follow-requestaccept()builds itsAccept.reject()replies with aRejectwhoseobjectis theQuoteRequest.Register an object dispatcher for
QuoteAuthorizationat/ap/actor/{identifier}/quote-authorization/{id}, following theFollowdispatcher pattern, serving stamps from the repository. Serve them publicly, without anauthorize()callback: the FEP permits public dereferenceability, and a stamp discloses nothing beyond three URIs. The FEP forbids inlining theinteractingObjectorinteractionTargetobjects in the served document to avoid leaking their contents, so include both only as URI references.Repository changes
Add four required methods to the
Repositoryinterface:findQuoteAuthorizationlooks a stamp up by the quote post it authorizes; both the duplicate-request case above and revocation below need that direction.MemoryRepositoryandKvRepositoryimplement it with a secondary index from theinteractingObjecthref to the stamp id, kept in step byaddandremove, with the KV layout following the existing per-bot key prefixes. The @fedify/botkit-sqlite and @fedify/botkit-postgres packages each get aquote_authorizationstable (identifier, UUID primary key, a unique index on the interacting object URI, and the stamp serialized as JSON-LD the way other objects are stored) plus the corresponding schema migration.Make the methods required, not optional. Stamps must survive restarts, because remote servers re-fetch them whenever they verify a quote; a stamp that vanishes on redeploy silently unapproves every quote it covered. Third-party
Repositoryimplementations break as a result, which is acceptable before 1.0 and gets an explicit changelog entry.Revoking an accepted quote
The FEP lets the quoted author moderate after the fact by deleting the stamp. Expose this as a method on
Message:Calling it looks the stamp up through
findQuoteAuthorizationwith the message's own id as the interacting object. When the message does not quote one of this bot's messages, or no stamp was ever issued for it, throwTypeError. Otherwise remove the stamp from the repository, so the object dispatcher starts answering 404 for it, and send aDeletewhoseobjectis the stamp's bare URI, addressed to the quote post's author with the bot's followers collection cc'd, which approximates the FEP's “the quote post's author and any recipient it has reasons to think has accessed the quote post”. Do not embed the stamp or either of its targets in theDelete; the FEP forbids that for the same information-leak reasons as above.Documentation and tests
Write the tests first, per the TDD practice in AGENTS.md: policy normalization and serialization, request evaluation for each policy shape and actor relation (including the fallback for messages published without a policy and the duplicate-request path), the stamp dispatcher, the repository methods in all four implementations, and revocation. Document the new option, event, interface, and method under docs/, and record every user-facing change in CHANGES.md under version 0.5.0.