Skip to content

Commit b6b0996

Browse files
authored
Merge pull request #688 from dahlia/issue-430-outbox-listeners
Add outbox listeners for client-to-server posting
2 parents e5c3f1f + d4cb2d8 commit b6b0996

45 files changed

Lines changed: 5781 additions & 479 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

CHANGES.md

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,15 @@ To be released.
1010

1111
### @fedify/fedify
1212

13+
- Added `setOutboxListeners()` and `OutboxContext` for handling
14+
client-to-server `POST` requests to actor outboxes. Outbox listeners use
15+
application-defined authorization through `.authorize()`, catch activity
16+
types with `.on()`, and require explicit delivery through
17+
`ctx.sendActivity()` or `ctx.forwardActivity()`. Fedify now also logs a
18+
runtime warning when an outbox listener returns without delivering the
19+
posted activity.
20+
[[#430], [#688]]
21+
1322
- Allowed actor dispatchers to return `Tombstone` for deleted accounts.
1423
Fedify now serves those actor URIs as `410 Gone` with the serialized
1524
tombstone body, and the corresponding WebFinger lookups also return
@@ -24,8 +33,24 @@ To be released.
2433
`getAuthenticatedDocumentLoader()` now also respects
2534
`GetAuthenticatedDocumentLoaderOptions.maxRedirection`.
2635

36+
[#430]: https://github.com/fedify-dev/fedify/issues/430
2737
[#644]: https://github.com/fedify-dev/fedify/issues/644
2838
[#680]: https://github.com/fedify-dev/fedify/pull/680
39+
[#688]: https://github.com/fedify-dev/fedify/pull/688
40+
41+
### @fedify/lint
42+
43+
- Added the `outbox-listener-delivery-required` rule. It warns when an
44+
outbox listener registered through `setOutboxListeners()` returns without an
45+
explicit delivery call, which would otherwise leave a posted client
46+
activity unfederated. [[#430], [#688]]
47+
48+
### @fedify/testing
49+
50+
- Added `createOutboxContext()` plus `postOutboxActivity()` and mock
51+
`setOutboxListeners()` support so outbox listeners using either
52+
`sendActivity()` or `forwardActivity()` can be tested without spinning up
53+
a live federation server. [[#430], [#688]]
2954

3055
### @fedify/vocab-runtime
3156

docs/.vitepress/config.mts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -108,6 +108,7 @@ const MANUAL = {
108108
{ text: "Vocabulary", link: "/manual/vocab.md" },
109109
{ text: "Actor dispatcher", link: "/manual/actor.md" },
110110
{ text: "Inbox listeners", link: "/manual/inbox.md" },
111+
{ text: "Outbox listeners", link: "/manual/outbox.md" },
111112
{ text: "Sending activities", link: "/manual/send.md" },
112113
{ text: "Collections", link: "/manual/collections.md" },
113114
{ text: "Object dispatcher", link: "/manual/object.md" },

docs/manual/access-control.md

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,32 @@ federation
102102
If the predicate returns `false`, the request is rejected with a
103103
`401 Unauthorized` response.
104104

105+
Outbox listeners can use a similar hook for client-to-server `POST /outbox`
106+
requests:
107+
108+
~~~~ typescript twoslash
109+
import { type Federation } from "@fedify/fedify";
110+
const federation = null as unknown as Federation<void>;
111+
async function verifyAccessToken(
112+
authorization: string | null,
113+
): Promise<{ identifier: string } | null> {
114+
authorization;
115+
return null;
116+
}
117+
// ---cut-before---
118+
federation
119+
.setOutboxListeners("/users/{identifier}/outbox")
120+
.authorize(async (ctx, identifier) => {
121+
const session = await verifyAccessToken(
122+
ctx.request.headers.get("authorization"),
123+
);
124+
return session?.identifier === identifier;
125+
});
126+
~~~~
127+
128+
Unlike authorized fetch, this hook is purely local application logic for
129+
incoming client requests. It does not verify HTTP Signatures by itself.
130+
105131

106132
Fine-grained access control
107133
---------------------------

docs/manual/collections.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,10 @@ federation
4343
});
4444
~~~~
4545

46+
> [!TIP]
47+
> Use `~Federatable.setOutboxListeners()` to handle `POST` requests to the same
48+
> outbox path. See the [*Outbox listeners*](./outbox.md) guide.
49+
4650
Each actor has its own outbox collection, so the URI pattern of the outbox
4751
dispatcher should include the actor's `{identifier}`. The URI pattern syntax
4852
follows the [URI Template] specification.

docs/manual/context.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ callbacks that take a `Context` object as the first parameter:
3838

3939
- [Actor dispatcher](./actor.md)
4040
- [Inbox listeners](./inbox.md)
41+
- [Outbox listeners](./outbox.md)
4142
- [Outbox collection dispatcher](./collections.md#outbox)
4243
- [Inbox collection dispatcher](./collections.md#inbox)
4344
- [Following collection dispatcher](./collections.md#following)

docs/manual/lint.md

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -597,6 +597,55 @@ federation.setActorDispatcher("/users/{identifier}", (ctx, identifier) => {
597597
});
598598
~~~~
599599

600+
### `outbox-listener-delivery-required`
601+
602+
Warns when an outbox listener body does not deliver the posted activity with
603+
`ctx.sendActivity()` or `ctx.forwardActivity()`.
604+
605+
**When this rule applies:**
606+
You've registered an outbox listener with `setOutboxListeners()`, but the
607+
listener body never calls either delivery method.
608+
609+
**Why it matters:**
610+
Fedify does not federate client-to-server outbox posts automatically. If your
611+
application intends to deliver a posted activity, the listener must choose an
612+
explicit delivery path.
613+
614+
~~~~ typescript twoslash
615+
// @noErrors: 2345
616+
import { createFederation } from "@fedify/fedify";
617+
import { Activity } from "@fedify/vocab";
618+
const federation = createFederation<void>({ kv: null as any });
619+
// ---cut-before---
620+
// ❌ Bad: Listener stores the activity locally but never federates it
621+
federation
622+
.setOutboxListeners("/users/{identifier}/outbox")
623+
.on(Activity, async (ctx, activity) => {
624+
console.log(ctx.identifier, activity.id?.href);
625+
});
626+
627+
// ✅ Good: Listener federates explicitly
628+
federation
629+
.setOutboxListeners("/users/{identifier}/outbox")
630+
.on(Activity, async (ctx, activity) => {
631+
await ctx.sendActivity(
632+
{ identifier: ctx.identifier },
633+
"followers",
634+
activity,
635+
);
636+
});
637+
638+
// ✅ Good: Listener forwards the original posted payload explicitly
639+
federation
640+
.setOutboxListeners("/users/{identifier}/outbox")
641+
.on(Activity, async (ctx) => {
642+
await ctx.forwardActivity(
643+
{ identifier: ctx.identifier },
644+
"followers",
645+
);
646+
});
647+
~~~~
648+
600649
### `actor-followers-property-required`
601650

602651
Ensures `followers` is defined when `setFollowersDispatcher()` is configured.

docs/manual/outbox.md

Lines changed: 203 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,203 @@
1+
---
2+
description: >-
3+
Fedify provides a way to register outbox listeners so that you can handle
4+
client-to-server `POST` requests to actor outboxes. This section explains
5+
how to register an outbox listener and how to federate posted activities.
6+
---
7+
8+
Outbox listeners
9+
================
10+
11+
Fedify can route `POST` requests to an actor's outbox through typed listeners.
12+
This is useful when you want to accept ActivityPub client-to-server activities
13+
from your own clients without exposing a separate non-standard API.
14+
15+
This guide covers `POST /outbox`. To serve `GET /outbox`, use the
16+
[*Collections*][collections-outbox] guide.
17+
18+
[collections-outbox]: ./collections.md#outbox
19+
20+
21+
Registering an outbox listener
22+
------------------------------
23+
24+
With Fedify, you can register outbox listeners per activity type, just like
25+
inbox listeners. The following shows how to register a listener for `Create`
26+
activities:
27+
28+
~~~~ typescript twoslash
29+
import { type Federation } from "@fedify/fedify";
30+
import { Activity, Create, Person } from "@fedify/vocab";
31+
const federation = null as unknown as Federation<void>;
32+
const myKnownRecipients: Person[] = [];
33+
async function verifyAccessToken(
34+
authorization: string | null,
35+
): Promise<{ identifier: string } | null> {
36+
authorization;
37+
return null;
38+
}
39+
async function savePostedActivity(
40+
identifier: string,
41+
activity: Activity,
42+
): Promise<void> {
43+
identifier;
44+
activity;
45+
}
46+
// ---cut-before---
47+
federation
48+
.setOutboxListeners("/users/{identifier}/outbox")
49+
.on(Create, async (ctx, activity) => {
50+
await savePostedActivity(ctx.identifier, activity);
51+
await ctx.sendActivity(
52+
{ identifier: ctx.identifier },
53+
myKnownRecipients,
54+
activity,
55+
);
56+
})
57+
.authorize(async (ctx, identifier) => {
58+
const session = await verifyAccessToken(
59+
ctx.request.headers.get("authorization"),
60+
);
61+
return session?.identifier === identifier;
62+
});
63+
~~~~
64+
65+
The `~Federatable.setOutboxListeners()` method registers the outbox path, and
66+
the `~OutboxListenerSetters.on()` method registers a listener for a specific
67+
activity type. The `~OutboxListenerSetters.authorize()` hook runs before the
68+
listener and can reject unauthorized requests with `401 Unauthorized`.
69+
70+
Fedify also rejects a posted activity if its `actor` does not match the local
71+
actor who owns the addressed outbox.
72+
73+
> [!TIP]
74+
> If you need to handle every activity type, register a listener for the
75+
> `Activity` class. Unsupported activity types can also be left unhandled,
76+
> in which case Fedify responds with `202 Accepted` without dispatching a
77+
> listener.
78+
79+
> [!NOTE]
80+
> The URI Template syntax supports different expansion types like
81+
> `{identifier}` (simple expansion) and `{+identifier}` (reserved expansion).
82+
> If your identifiers contain URIs or special characters, you may need to use
83+
> `{+identifier}` to avoid double-encoding issues. See the
84+
> [*URI Template* guide][uri-template-guide] for details.
85+
86+
[uri-template-guide]: ./uri-template.md
87+
88+
89+
Looking at `OutboxContext.identifier`
90+
-------------------------------------
91+
92+
The `~OutboxContext.identifier` property contains the identifier from the
93+
matched outbox route. Fedify does not infer anything more specific than that.
94+
95+
~~~~ typescript twoslash
96+
import { type OutboxListenerSetters } from "@fedify/fedify";
97+
import { Create } from "@fedify/vocab";
98+
(0 as unknown as OutboxListenerSetters<void>)
99+
// ---cut-before---
100+
.on(Create, async (ctx, activity) => {
101+
console.log(ctx.identifier);
102+
console.log(activity.id?.href);
103+
});
104+
~~~~
105+
106+
107+
Federating posted activities
108+
----------------------------
109+
110+
Fedify does not federate client-posted activities automatically. If you want
111+
to deliver a posted activity, call `~Context.sendActivity()` or
112+
`~OutboxContext.forwardActivity()` explicitly inside your outbox listener.
113+
114+
~~~~ typescript twoslash
115+
import { type Federation } from "@fedify/fedify";
116+
import { Create, Person } from "@fedify/vocab";
117+
const federation = null as unknown as Federation<void>;
118+
const recipients: Person[] = [];
119+
// ---cut-before---
120+
federation
121+
.setOutboxListeners("/users/{identifier}/outbox")
122+
.on(Create, async (ctx, activity) => {
123+
await ctx.sendActivity(
124+
{ identifier: ctx.identifier },
125+
recipients,
126+
activity,
127+
);
128+
});
129+
~~~~
130+
131+
If the client already signed the posted JSON-LD with Linked Data Signatures or
132+
Object Integrity Proofs and you want to preserve that payload verbatim, use
133+
`~OutboxContext.forwardActivity()` instead of round-tripping through Fedify's
134+
vocabulary objects:
135+
136+
~~~~ typescript twoslash
137+
import { type Federation } from "@fedify/fedify";
138+
import { Activity, Person } from "@fedify/vocab";
139+
const federation = null as unknown as Federation<void>;
140+
const recipients: Person[] = [];
141+
// ---cut-before---
142+
federation
143+
.setOutboxListeners("/users/{identifier}/outbox")
144+
.on(Activity, async (ctx) => {
145+
await ctx.forwardActivity(
146+
{ identifier: ctx.identifier },
147+
recipients,
148+
{ skipIfUnsigned: true },
149+
);
150+
});
151+
~~~~
152+
153+
If a listener returns without calling one of these delivery methods, Fedify
154+
logs a runtime warning. The `@fedify/lint` package also provides a lint rule
155+
for the same mistake; see [*Linting*][linting-guide] for details.
156+
157+
> [!TIP]
158+
> Explicit delivery keeps outbox listeners symmetric with inbox listeners:
159+
> Fedify never guesses the recipient list for you, so applications can reuse
160+
> their own caches and delivery policies.
161+
162+
[linting-guide]: ./lint.md
163+
164+
165+
Handling errors
166+
---------------
167+
168+
You can attach an error handler to outbox listeners. It receives the outbox
169+
context along with the thrown error:
170+
171+
~~~~ typescript twoslash
172+
import { type Federation } from "@fedify/fedify";
173+
import { Activity } from "@fedify/vocab";
174+
const federation = null as unknown as Federation<void>;
175+
// ---cut-before---
176+
federation
177+
.setOutboxListeners("/users/{identifier}/outbox")
178+
.on(Activity, async () => {
179+
throw new Error("Something went wrong.");
180+
})
181+
.onError(async (ctx, error) => {
182+
console.error(ctx.identifier, error);
183+
});
184+
~~~~
185+
186+
187+
Current scope
188+
-------------
189+
190+
Outbox listeners currently provide the routing and authorization surface for
191+
client-to-server posting, but the rest of the server-side behavior remains
192+
application-defined.
193+
194+
In particular, Fedify does not currently do the following for you:
195+
196+
- Persist the posted activity in your outbox collection
197+
- Generate IDs or `Location` headers for newly posted activities
198+
- Wrap non-`Activity` objects in `Create` automatically
199+
- Federate anything unless your listener calls `ctx.sendActivity()` or
200+
`ctx.forwardActivity()`
201+
202+
If you need full `GET /outbox` support as well, combine this guide with the
203+
[*Collections*][collections-outbox] guide.

0 commit comments

Comments
 (0)