Skip to content

Verify third-party quote approvals and handle revocation (FEP-044f) #30

Description

@dahlia

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.

Metadata

Metadata

Assignees

Labels

enhancementNew feature or request

Fields

Priority

None yet

Effort

None yet

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions