Skip to content
Open
Show file tree
Hide file tree
Changes from 14 commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
6a919ba
Define interaction control types
dahlia Jul 9, 2026
b9d89e3
Add like interaction helpers
dahlia Jul 9, 2026
019063c
Add reply interaction helpers
dahlia Jul 9, 2026
a5648cd
Add announce interaction helpers
dahlia Jul 9, 2026
fb4b235
Add quote interaction helpers
dahlia Jul 9, 2026
dc1a770
Add feature interaction helpers
dahlia Jul 9, 2026
3359b92
Document interaction controls
dahlia Jul 9, 2026
38f0a9f
Fix interaction controls docs build
dahlia Jul 9, 2026
559fe08
Hide interaction controls Twoslash setup
dahlia Jul 9, 2026
3d87f7a
Remove duplicate interaction controls references
dahlia Jul 9, 2026
d63c483
Add interaction controls flow diagram
dahlia Jul 9, 2026
0a09d5c
Accept FEP-7aa9 feature approvals
dahlia Jul 9, 2026
c0eaa56
Fail closed on authorization grantors
dahlia Jul 9, 2026
49815c2
Validate interaction request instruments
dahlia Jul 9, 2026
8b5765f
Harden interaction request verification
dahlia Jul 9, 2026
e011a56
Harden interaction authorization checks
dahlia Jul 10, 2026
9bacb9e
Reject opaque authorization origins
dahlia Jul 10, 2026
e802dc1
Split opaque origin authorization tests
dahlia Jul 10, 2026
224a791
Harden interaction verification failures
dahlia Jul 10, 2026
2c2b5f2
Honor portable authorization origins
dahlia Jul 10, 2026
8e99bbb
Use context loaders for verification
dahlia Jul 10, 2026
563aaa7
Deny quotes without canQuote
dahlia Jul 10, 2026
4d93930
Approve reply participants implicitly
dahlia Jul 10, 2026
134dee8
Tighten interaction policy evaluation
dahlia Jul 10, 2026
d5b2d92
Fail closed on interaction verification gaps
dahlia Jul 10, 2026
0e10800
Harden interaction remote checks
dahlia Jul 10, 2026
aa58154
Fail closed after manual interaction checks
dahlia Jul 10, 2026
e8879db
Keep authorization origin fallback broad
dahlia Jul 10, 2026
f58bfde
Resolve interaction loader typing gaps
dahlia Jul 10, 2026
6ac44ef
List interaction controls in package indexes
dahlia Jul 10, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .github/CODEOWNERS
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,7 @@
# by Jiwon Kwon ("@fedify/relay", "@fedify/backfill").
/packages/relay/ @sij411 @dahlia
/packages/backfill/ @sij411 @dahlia
/packages/interaction-controls/ @dahlia

# Testing utilities and fixtures, owned by Hong Minhee with
# ChanHaeng Lee contributing.
Expand Down
14 changes: 13 additions & 1 deletion CHANGES.md
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,19 @@ To be released.
[#850]: https://github.com/fedify-dev/fedify/pull/850
[#914]: https://github.com/fedify-dev/fedify/pull/914

### @fedify/interaction-controls

- Added the new `@fedify/interaction-controls` package for implementing
[GoToSocial interaction controls], [FEP-044f], and [FEP-7aa9]. It provides
immutable TypeScript APIs for creating and verifying interaction requests
and authorizations, evaluating `InteractionPolicy`, recognizing bare
interactions, and formatting stable storage keys for like, reply, announce,
quote, and feature interactions. [[#811]]

[GoToSocial interaction controls]: https://docs.gotosocial.org/en/v0.21.1/federation/interaction_controls/
[FEP-044f]: https://w3id.org/fep/044f
[#811]: https://github.com/fedify-dev/fedify/issues/811

### @fedify/vocab-runtime

- Added `getFe34Origin()` and `haveSameFe34Origin()` for comparing ordinary
Expand Down Expand Up @@ -1079,7 +1092,6 @@ Released on April 28, 2026.
- Added `Measure` class for representing quantities with units of
measure, with `unit` and `numericalValue` properties.

[FEP-044f]: https://w3id.org/fep/044f
[#452]: https://github.com/fedify-dev/fedify/issues/452
[#578]: https://github.com/fedify-dev/fedify/issues/578
[#645]: https://github.com/fedify-dev/fedify/issues/645
Expand Down
1 change: 1 addition & 0 deletions deno.json
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
"./packages/fastify",
"./packages/fedify",
"./packages/fixture",
"./packages/interaction-controls",
"./packages/fresh",
"./packages/h3",
"./packages/init",
Expand Down
1 change: 1 addition & 0 deletions docs/.vitepress/config.mts
Original file line number Diff line number Diff line change
Expand Up @@ -146,6 +146,7 @@ const MANUAL = {
{ text: "Conversation backfill", link: "/manual/backfill.md" },
{ text: "Object dispatcher", link: "/manual/object.md" },
{ text: "Access control", link: "/manual/access-control.md" },
{ text: "Interaction controls", link: "/manual/interaction-controls.md" },
{ text: "WebFinger", link: "/manual/webfinger.md" },
{ text: "NodeInfo", link: "/manual/nodeinfo.md" },
{ text: "URI Template", link: "/manual/uri-template.md" },
Expand Down
319 changes: 319 additions & 0 deletions docs/manual/interaction-controls.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,319 @@
---
description: >-
Interaction controls let servers ask for, grant, deny, and revoke
permission for likes, replies, announces, quotes, and featured actors.
---

Interaction controls
====================

Interaction controls let an object owner publish policy and exchange explicit
request and authorization objects before another actor interacts with that
object. They are useful when an application wants automatic or manual approval
for interactions such as likes, replies, announces, quotes, and featuring an
actor in a public collection.

Fedify provides the `@fedify/interaction-controls` package for the helper logic
around the vocabulary terms defined by [GoToSocial interaction controls],
[FEP-044f], and [FEP-7aa9]. The package does not install inbox listeners for
you. It gives you typed helpers that you can call from your own outbox,
inbox, storage, moderation, and UI code.

[GoToSocial interaction controls]: https://docs.gotosocial.org/en/v0.21.1/federation/interaction_controls/
[FEP-044f]: https://codeberg.org/fediverse/fep/src/branch/main/fep/044f/fep-044f.md
[FEP-7aa9]: https://codeberg.org/fediverse/fep/src/branch/main/fep/7aa9/fep-7aa9.md


Supported interactions
----------------------

The package exports one helper per interaction:

`likeInteraction`
: Works with `LikeRequest`, `LikeAuthorization`, and `Like`.
It evaluates `canLike`.

`replyInteraction`
: Works with `ReplyRequest`, `ReplyAuthorization`, and reply objects such as
`Note`, `Article`, `Question`, and `ChatMessage`.
It evaluates `canReply`.

`announceInteraction`
: Works with `AnnounceRequest`, `AnnounceAuthorization`, and `Announce`.
It evaluates `canAnnounce`.

`quoteInteraction`
: Works with `QuoteRequest`, `QuoteAuthorization`, and quote objects.
It evaluates `canQuote` and accepts both the FEP-044f `quote` property and
compatible `quoteUrl` values.

`featureInteraction`
: Works with `FeatureRequest`, `FeatureAuthorization`, `FeaturedCollection`,
and ActivityPub actor objects.
It evaluates `canFeature` on the actor being featured.


Policy evaluation
-----------------

Each helper can evaluate an object's `InteractionPolicy` and report whether
the interaction should be accepted automatically, queued for manual approval,
or denied:

~~~~ typescript twoslash
import { likeInteraction } from "@fedify/interaction-controls";
import { InteractionPolicy, InteractionRule, Note, PUBLIC_COLLECTION } from "@fedify/vocab";
import type { Context } from "@fedify/fedify";

const context = {} as Context<void>;
// ---cut-before---
const target = new Note({
id: new URL("https://example.com/notes/1"),
attribution: new URL("https://example.com/users/alice"),
interactionPolicy: new InteractionPolicy({
canLike: new InteractionRule({
automaticApproval: PUBLIC_COLLECTION,
}),
}),
});

const decision = await likeInteraction.evaluatePolicy(context, {
subject: target,
requester: new URL("https://remote.example/users/bob"),
});
~~~~

A decision with `result: "automatic"` means the interaction can be accepted
without a moderation step. A decision with `result: "manual"` means the
application should store the request for review and create the authorization
only after approval. A decision with `result: "denied"` means the request
does not match the policy.

Missing policy is handled conservatively for feature requests and permissively
for the other interactions:

- Like, reply, announce, and quote helpers treat missing policy as automatic
approval for compatibility with existing ActivityPub objects.
- The feature helper treats missing `canFeature` policy as denied, because
featuring another actor is a profile/discovery action.


End-to-end flow
---------------

The helpers cover the request, authorization, and final interaction checks.
Your application still owns delivery, persistence, and moderation:

~~~~ mermaid
sequenceDiagram
participant Requester
participant Owner as Object owner
participant Moderator

Requester->>Owner: createRequest() activity
Owner->>Owner: verifyRequest() and evaluatePolicy()

alt denied
Owner-->>Requester: Reject or ignore
else manual approval
Owner->>Moderator: Queue request
Moderator-->>Owner: Approve
Owner-->>Requester: Accept with createAuthorization()
else automatic approval
Owner-->>Requester: Accept with createAuthorization()
end

opt accepted authorization
Requester->>Owner: Interaction with authorization
Owner->>Owner: verifyAuthorization()
alt authorization valid
Owner->>Owner: Accept interaction
else invalid authorization
Owner-->>Requester: Reject or ignore
end
end
~~~~


Request flow
------------

When your actor wants to perform an interaction, create a request activity and
send it to the object owner. The request `actor` is the actor asking for
permission, the `object` is the interaction target, and the `instrument` is the
object or collection that would perform the interaction:

~~~~ typescript twoslash
import { likeInteraction } from "@fedify/interaction-controls";
import { Like, Note } from "@fedify/vocab";

const actor = new URL("https://remote.example/users/bob");
const target = new Note({
id: new URL("https://example.com/notes/1"),
attribution: new URL("https://example.com/users/alice"),
});
const like = new Like({
id: new URL("https://remote.example/likes/1"),
actor,
object: target.id,
});

const request = likeInteraction.createRequest({
id: new URL("https://remote.example/requests/1"),
actor,
object: target,
instrument: like,
});
~~~~

On the receiving side, verify that the request is dereferenceable, has the
expected type, and that the instrument matches both the requester and target:

~~~~ typescript twoslash
import type { Context } from "@fedify/fedify";
import { likeInteraction } from "@fedify/interaction-controls";
import { LikeRequest } from "@fedify/vocab";

const context = {} as Context<void>;
const request = null as unknown as LikeRequest;
// ---cut-before---

const verified = await likeInteraction.verifyRequest(context, { request });
if (!verified.verified) {
throw new Error(`Invalid interaction request: ${verified.failure.type}`);
}
~~~~

For `replyInteraction`, `quoteInteraction`, and `featureInteraction`, the
request `instrument` has this meaning:

- Reply: the reply object whose `inReplyTo` target is being requested.
- Quote: the post object whose FEP-044f `quote` or compatible `quoteUrl`
target is being requested.
- Feature: the `FeaturedCollection` owned by the requester. The request
`object` is the actor being featured.


Authorization flow
------------------

After a policy decision is automatic or a moderator approves a manual request,
create an authorization object and include it with the resulting interaction:

~~~~ typescript twoslash
import { likeInteraction } from "@fedify/interaction-controls";
import { Like, Note } from "@fedify/vocab";

const owner = new URL("https://example.com/users/alice");
const target = new Note({
id: new URL("https://example.com/notes/1"),
attribution: owner,
});
const like = new Like({
id: new URL("https://remote.example/likes/1"),
actor: new URL("https://remote.example/users/bob"),
object: target.id,
});

const authorization = likeInteraction.createAuthorization({
id: new URL("https://example.com/authorizations/1"),
attributedTo: owner,
interactingObject: like,
interactionTarget: target,
});
~~~~

When a signed interaction arrives with an authorization, verify that the
authorization still refers to the same interaction object and target, and that
the grant came from the target owner:

~~~~ typescript twoslash
import type { Context } from "@fedify/fedify";
import { likeInteraction } from "@fedify/interaction-controls";
import { Like, LikeAuthorization, Note } from "@fedify/vocab";

const context = {} as Context<void>;
const authorization = null as unknown as LikeAuthorization;
const like = null as unknown as Like;
const target = null as unknown as Note;
// ---cut-before---

const verified = await likeInteraction.verifyAuthorization(context, {
authorization,
interactingObject: like,
interactionTarget: target,
attributedTo: target.attributionId ?? undefined,
});
if (!verified.verified) {
throw new Error(`Invalid authorization: ${verified.failure.type}`);
}
~~~~

You can also create `Accept`, `Reject`, and revocation activities from the same
helper. Store authorization IDs with the interaction object so that later
revocation checks can reject stale approvals.


Recognizing unrequested interactions
------------------------------------

Some remote servers will send a bare `Like`, reply, announce, or quote without
first sending a request. The helpers expose `recognizeImpolite()` for
best-effort detection:

~~~~ typescript twoslash
import { likeInteraction } from "@fedify/interaction-controls";
import { Like } from "@fedify/vocab";

const activity = new Like({
id: new URL("https://remote.example/likes/1"),
actor: new URL("https://remote.example/users/bob"),
object: new URL("https://example.com/notes/1"),
});

const recognized = likeInteraction.recognizeImpolite(activity);
~~~~

This method is synchronous and only recognizes objects whose interaction target
can be read without dereferencing. In particular, `Create` activities wrapping
reply or quote objects are not recognized by this method; unwrap and verify the
created object in your inbox listener before passing it to the helper.


Stable storage keys
-------------------

Each helper provides stable keys for persistence:

~~~~ typescript twoslash
import {
formatAuthorizationKey,
formatInteractionKey,
likeInteraction,
} from "@fedify/interaction-controls";
import { Like, LikeAuthorization, Note } from "@fedify/vocab";

const actor = new URL("https://remote.example/users/bob");
const target = new URL("https://example.com/notes/1");
const like = new Like({
id: new URL("https://remote.example/likes/1"),
actor,
object: target,
});
const authorization = new URL("https://example.com/authorizations/1");

const interactionKey = formatInteractionKey(likeInteraction.getInteractionKey({
requester: actor,
interactingObject: like,
interactionTarget: new Note({ id: target }),
}));
const authorizationKey = formatAuthorizationKey(
likeInteraction.getAuthorizationKey({
authorization: new LikeAuthorization({ id: authorization }),
}),
);
~~~~

The keys are plain strings. Use them with your existing database or key–value
store to record pending requests, accepted authorizations, and revoked
authorizations.
3 changes: 2 additions & 1 deletion docs/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
"@fedify/fedify": "workspace:^",
"@fedify/h3": "workspace:^",
"@fedify/hono": "workspace:^",
"@fedify/interaction-controls": "workspace:^",
"@fedify/koa": "workspace:^",
"@fedify/lint": "workspace:^",
"@fedify/mysql": "workspace:^",
Expand Down Expand Up @@ -76,7 +77,7 @@
},
"scripts": {
"dev": "cd ../ && pnpm run --filter '@fedify/*' -r build:self && cd docs/ && vitepress dev --host",
"build": "cd ../ && pnpm run --filter '@fedify/*' -r build:self && cd docs/ && NODE_OPTIONS='--max-old-space-size=8192' vitepress build",
"build": "cd ../ && pnpm run --filter '@fedify/*' -r build:self && cd docs/ && NODE_OPTIONS='--max-old-space-size=12288' vitepress build",
"preview": "cd ../ && pnpm run --filter '@fedify/*' -r build:self && cd docs/ && vitepress preview"
}
}
2 changes: 2 additions & 0 deletions packages/interaction-controls/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
dist/
node_modules/
4 changes: 4 additions & 0 deletions packages/interaction-controls/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
@fedify/interaction-controls
============================

Interaction control helpers for Fedify applications.
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Loading