Part of #27; depends on #28 and #29. This issue makes BotKit honor the verification and revocation rules of FEP-044f: treating third-party quotes as unapproved unless proven otherwise, and reacting when an author revokes an approval the bot's own quote post depends on.
Verifying incoming quote posts
The FEP requires recipients to consider a quote post unapproved unless it is a self-quote or its quoteAuthorization resolves to a valid stamp. BotKit materializes incoming messages in createMessage(), which already resolves quoteTarget; compute a new Message.quoteApproved?: boolean property there. It is undefined when the message quotes nothing, true when the quote's attributedTo equals the quoted message's attributedTo (a self-quote), and otherwise true exactly when quoteAuthorization dereferences through the bot's document loader to a stamp that passes the validateQuoteAuthorization() helper from #29: interactingObject matching the quoting message's id, interactionTarget matching the quoted message's id, attributedTo matching the quoted message's author, and the stamp's id sharing that author's origin. Every failure mode (missing property, fetch error, any mismatch) yields false rather than an exception: unapproved is the spec's default, and a network hiccup must not take down inbox processing.
Legacy quotes, meaning _misskey_quote tags or quoteUrl with no quoteAuthorization, come out as false under the same rule. Keep firing onQuote for them regardless of approval: a bot may specifically want to see unapproved quotes for moderation or statistics, and silently dropping the event would be a behavior change for every existing bot. Handlers that care check message.quoteApproved.
The cost is one extra dereference per incoming quote post, in line with the lookups createMessage() already performs for mentions and targets, and bounded by the Fedify document loader's caching.
Re-verification
The FEP suggests re-checking stamps opportunistically, because a revocation only reaches the audience if the quote poster's server cooperates in forwarding it. BotKit gets this behavior without extra machinery: messages are materialized from activities on demand and quoteApproved is computed at materialization time, so every time a quote resurfaces its stamp is fetched again, subject only to document-loader caching. Do not introduce a persistent verification cache; the absence of one is what makes this work.
Finding the bot's quote post when its stamp is deleted
When the author who approved a bot's quote revokes it, a Delete for the stamp arrives, and the bot must find which of its messages depended on that stamp. Scanning the whole outbox on every incoming Delete would let any stranger trigger unbounded work, so keep an index instead. Add three required methods to the Repository interface, next to the stamp store from #28:
addQuoteAuthorizationReference(identifier: string, authorization: URL, messageId: Uuid): Promise<void>;
findQuoteAuthorizationReference(identifier: string, authorization: URL): Promise<Uuid | undefined>;
removeQuoteAuthorizationReference(identifier: string, authorization: URL): Promise<void>;
These index received stamps attached to the bot's own quote posts, complementing the issued-stamp store from #28. MemoryRepository and KvRepository store the mapping under a per-bot key from the authorization href to the message UUID; @fedify/botkit-sqlite and @fedify/botkit-postgres get a quote_authorization_refs table (identifier, authorization URI as the key, message UUID) with a migration. The Accept handling from #29 writes the reference at the moment it records quoteAuthorization on the stored message, and the rejection and revocation paths remove it when they strip the quote. Since all three sub-issues ship together in 0.5.0, no message can gain a quoteAuthorization without the index entry existing.
Handling the revocation
There is currently no Delete listener in the inbox chain; add one. Take the deleted object's id and look it up with findQuoteAuthorizationReference for each of the instance's bots; when nothing matches, ignore the activity, which also preserves today's behavior for every other kind of Delete. When a message matches, check that the Delete's actor shares its origin with the stamp URI before honoring it: the stamp lived on the approving author's server, so only an actor of that server can legitimately delete it, and Fedify's inbox signature verification has already established that the actor sent this activity.
Handle a valid revocation in this order. Forward the Delete to the quote post's audience with ctx.forwardActivity() (the followers collection, skipIfUnsigned: true), because the FEP makes forwarding mandatory for the owner of the quote post: third parties can only learn the stamp is gone through us. Then apply the same default as a rejection in #29: strip the quote properties, the _misskey_quote tag, and the content fallback from the stored message, deliver an Update, and remove the index entry. Fire the new onQuoteRevoked event last:
export type QuoteRevokedEventHandler<TContextData> = (
session: Session<TContextData>,
message: AuthorizedMessage<MessageClass, TContextData>,
revoker: Actor,
) => void | Promise<void>;
As with onQuoteRejected in #29, the message the handler receives has already been stripped, so the revoking author is passed separately, and a bot that prefers deleting the whole post calls message.delete() from the handler. Wire the handler through src/events.ts, the Bot interface, CreateBotOptions, and src/bot-impl.ts like the existing ones.
Deletes aimed at stamps the bot itself issued need no handling here: only the bot deletes those, through unauthorizeQuote() from #28, and the reference index never matches them.
Documentation and tests
Write the tests first: the quoteApproved matrix (self-quote, valid stamp, wrong attributedTo, wrong interactingObject, wrong interactionTarget, cross-origin stamp id, fetch failure, legacy-only quote), the revocation flow (forwarding, stripping, index removal, event, and a Delete from the wrong origin being ignored), and the repository index in all four implementations. Document the verification semantics and the revocation behavior under docs/, and record the changes in CHANGES.md under version 0.5.0.
Part of #27; depends on #28 and #29. This issue makes BotKit honor the verification and revocation rules of FEP-044f: treating third-party quotes as unapproved unless proven otherwise, and reacting when an author revokes an approval the bot's own quote post depends on.
Verifying incoming quote posts
The FEP requires recipients to consider a quote post unapproved unless it is a self-quote or its
quoteAuthorizationresolves to a valid stamp. BotKit materializes incoming messages increateMessage(), which already resolvesquoteTarget; compute a newMessage.quoteApproved?: booleanproperty there. It isundefinedwhen the message quotes nothing,truewhen the quote'sattributedToequals the quoted message'sattributedTo(a self-quote), and otherwisetrueexactly whenquoteAuthorizationdereferences through the bot's document loader to a stamp that passes thevalidateQuoteAuthorization()helper from #29:interactingObjectmatching the quoting message's id,interactionTargetmatching the quoted message's id,attributedTomatching the quoted message's author, and the stamp's id sharing that author's origin. Every failure mode (missing property, fetch error, any mismatch) yieldsfalserather than an exception: unapproved is the spec's default, and a network hiccup must not take down inbox processing.Legacy quotes, meaning
_misskey_quotetags orquoteUrlwith noquoteAuthorization, come out asfalseunder the same rule. Keep firingonQuotefor them regardless of approval: a bot may specifically want to see unapproved quotes for moderation or statistics, and silently dropping the event would be a behavior change for every existing bot. Handlers that care checkmessage.quoteApproved.The cost is one extra dereference per incoming quote post, in line with the lookups
createMessage()already performs for mentions and targets, and bounded by the Fedify document loader's caching.Re-verification
The FEP suggests re-checking stamps opportunistically, because a revocation only reaches the audience if the quote poster's server cooperates in forwarding it. BotKit gets this behavior without extra machinery: messages are materialized from activities on demand and
quoteApprovedis computed at materialization time, so every time a quote resurfaces its stamp is fetched again, subject only to document-loader caching. Do not introduce a persistent verification cache; the absence of one is what makes this work.Finding the bot's quote post when its stamp is deleted
When the author who approved a bot's quote revokes it, a
Deletefor the stamp arrives, and the bot must find which of its messages depended on that stamp. Scanning the whole outbox on every incomingDeletewould let any stranger trigger unbounded work, so keep an index instead. Add three required methods to theRepositoryinterface, next to the stamp store from #28:These index received stamps attached to the bot's own quote posts, complementing the issued-stamp store from #28.
MemoryRepositoryandKvRepositorystore the mapping under a per-bot key from the authorization href to the message UUID; @fedify/botkit-sqlite and @fedify/botkit-postgres get aquote_authorization_refstable (identifier, authorization URI as the key, message UUID) with a migration. TheAccepthandling from #29 writes the reference at the moment it recordsquoteAuthorizationon the stored message, and the rejection and revocation paths remove it when they strip the quote. Since all three sub-issues ship together in 0.5.0, no message can gain aquoteAuthorizationwithout the index entry existing.Handling the revocation
There is currently no
Deletelistener in the inbox chain; add one. Take the deleted object's id and look it up withfindQuoteAuthorizationReferencefor each of the instance's bots; when nothing matches, ignore the activity, which also preserves today's behavior for every other kind ofDelete. When a message matches, check that theDelete's actor shares its origin with the stamp URI before honoring it: the stamp lived on the approving author's server, so only an actor of that server can legitimately delete it, and Fedify's inbox signature verification has already established that the actor sent this activity.Handle a valid revocation in this order. Forward the
Deleteto the quote post's audience withctx.forwardActivity()(the followers collection,skipIfUnsigned: true), because the FEP makes forwarding mandatory for the owner of the quote post: third parties can only learn the stamp is gone through us. Then apply the same default as a rejection in #29: strip the quote properties, the_misskey_quotetag, and the content fallback from the stored message, deliver anUpdate, and remove the index entry. Fire the newonQuoteRevokedevent last:As with
onQuoteRejectedin #29, the message the handler receives has already been stripped, so the revoking author is passed separately, and a bot that prefers deleting the whole post callsmessage.delete()from the handler. Wire the handler through src/events.ts, theBotinterface,CreateBotOptions, and src/bot-impl.ts like the existing ones.Deletes aimed at stamps the bot itself issued need no handling here: only the bot deletes those, through
unauthorizeQuote()from #28, and the reference index never matches them.Documentation and tests
Write the tests first: the
quoteApprovedmatrix (self-quote, valid stamp, wrongattributedTo, wronginteractingObject, wronginteractionTarget, cross-origin stamp id, fetch failure, legacy-only quote), the revocation flow (forwarding, stripping, index removal, event, and aDeletefrom the wrong origin being ignored), and the repository index in all four implementations. Document the verification semantics and the revocation behavior under docs/, and record the changes in CHANGES.md under version 0.5.0.