Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
64 commits
Select commit Hold shift + click to select a range
7cf788b
Add client outbox listener hooks
dahlia Apr 17, 2026
7391f00
Warn on unfederated outbox listeners
dahlia Apr 17, 2026
9954d00
Document client outbox posting
dahlia Apr 17, 2026
bb53bd4
Harden outbox ownership checks
dahlia Apr 17, 2026
56de5cc
Align outbox middleware tests with owner checks
dahlia Apr 17, 2026
4de226b
Harden outbox listener follow-up checks
dahlia Apr 17, 2026
730a7f7
Refresh the outbox changelog PR reference
dahlia Apr 17, 2026
a623a94
Preserve signed outbox payloads in forwarding
dahlia Apr 17, 2026
32f515a
Clarify outbox delivery checks and guidance
dahlia Apr 17, 2026
41fb3b9
Tighten outbox listener request handling
dahlia Apr 17, 2026
47f08a6
Match outbox lint and mock behavior to runtime
dahlia Apr 17, 2026
d388244
Clarify outbox listener examples in the manual
dahlia Apr 17, 2026
7c0f541
Tighten outbox forwarding runtime behavior
dahlia Apr 17, 2026
f70f73e
Match outbox lint and mock edge cases to runtime
dahlia Apr 17, 2026
4024030
Use the existing outbox docs link reference
dahlia Apr 17, 2026
b722c52
Fix the outbox queue auto-start test helper
dahlia Apr 17, 2026
7fcd6f9
Polish outbox forwarding batch metadata
dahlia Apr 17, 2026
624da56
Keep forwarded outbox queue state consistent
dahlia Apr 17, 2026
88d085b
Cover template literal lint regressions
dahlia Apr 17, 2026
1e559ed
Bring the outbox testing helpers closer to runtime
dahlia Apr 17, 2026
efa55e0
Broaden lint coverage for delivery edge cases
dahlia Apr 17, 2026
9742169
Refine outbox validation error reporting
dahlia Apr 17, 2026
ca68606
Match the mock outbox flow to runtime auth checks
dahlia Apr 17, 2026
931589c
Handle delivery calls in string-like syntax safely
dahlia Apr 17, 2026
ca468c8
Match the mock outbox flow to runtime edge cases
dahlia Apr 17, 2026
7e76af1
Use ActivityListenerSet directly
dahlia Apr 17, 2026
d2fe608
Validate unsupported mock outbox posts first
dahlia Apr 17, 2026
0366aa3
Refine mock outbox signature and URI fidelity
dahlia Apr 17, 2026
948e430
Clarify mock signature-shape narrowing
dahlia Apr 17, 2026
85fbefc
Reformat example assets after the Deno upgrade
dahlia Apr 17, 2026
41385c3
Share signature-shape checks across mocks
dahlia Apr 17, 2026
d3efeec
Polish outbox listener runtime edge cases
dahlia Apr 18, 2026
a78cba5
Align mock outbox registration with runtime
dahlia Apr 18, 2026
1119422
Decouple outbox auth from body parsing
dahlia Apr 18, 2026
7064a7c
Tighten listener matching edge cases in lint and mocks
dahlia Apr 18, 2026
f94a6aa
Broaden signature-shape checks for forwarding
dahlia Apr 18, 2026
cf733b3
Feed posted JSON through mock outbox auth
dahlia Apr 18, 2026
44c4cb7
Avoid re-parsing forwarded proofs for warnings
dahlia Apr 18, 2026
aaea97e
Validate outbox listener paths before routing
dahlia Apr 18, 2026
68a4b95
Finish mock outbox path and body parity
dahlia Apr 18, 2026
b9ba95a
Keep forwarded delivery checks conservative
dahlia Apr 18, 2026
f62779a
Align mock outbox error contexts with runtime
dahlia Apr 18, 2026
c5fb7e0
Harden forwarding delivery detection and signatures
dahlia Apr 18, 2026
6de0472
Match outbox path checks to RFC6570 inputs
dahlia Apr 18, 2026
173dfb4
Correct outbox telemetry metadata
dahlia Apr 18, 2026
a65a6c3
Support full RFC6570 identifier operators in mocks
dahlia Apr 18, 2026
01f582f
Keep auth and telemetry handling accurate
dahlia Apr 18, 2026
6c649cf
Reject unroutable outbox identifier operators
dahlia Apr 18, 2026
cf9afc9
Resolve hoisted outbox listener declarations in lint
dahlia Apr 18, 2026
edbf4a4
Encode path-segment identifiers like runtime
dahlia Apr 18, 2026
5647358
Tighten outbox path and auth request handling
dahlia Apr 18, 2026
7a75df5
Correct mock URI-template expectation swaps
dahlia Apr 18, 2026
bf2ab15
Return consistent 202 responses from handleOutbox
dahlia Apr 18, 2026
5ab2976
Recognize expanded proof keys in quick checks
dahlia Apr 18, 2026
4940097
Clarify mock outbox helper semantics
dahlia Apr 18, 2026
15c5ff2
Defer outbox request clones until they matter
dahlia Apr 18, 2026
0020046
Recognize expanded proof fields in quick checks
dahlia Apr 18, 2026
9b0ea19
Make outbox delivery tracking part of the contract
dahlia Apr 18, 2026
87cfb98
Accept array-typed signature kinds in quick checks
dahlia Apr 18, 2026
7a98a33
Match outbox dispatcher path validation to listeners
dahlia Apr 18, 2026
a6e3de8
Tighten mock outbox path and delivery parity
dahlia Apr 18, 2026
238154d
Authorize outbox posts before actor lookup
dahlia Apr 18, 2026
d1dff22
Use raw proof shapes in mock outbox forwarding
dahlia Apr 18, 2026
d4cb2d8
Fix stale outbox test expectations
dahlia Apr 18, 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
25 changes: 25 additions & 0 deletions CHANGES.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,15 @@ To be released.

### @fedify/fedify

- Added `setOutboxListeners()` and `OutboxContext` for handling
client-to-server `POST` requests to actor outboxes. Outbox listeners use
application-defined authorization through `.authorize()`, catch activity
types with `.on()`, and require explicit delivery through
`ctx.sendActivity()` or `ctx.forwardActivity()`. Fedify now also logs a
runtime warning when an outbox listener returns without delivering the
posted activity.
[[#430], [#688]]

- Allowed actor dispatchers to return `Tombstone` for deleted accounts.
Fedify now serves those actor URIs as `410 Gone` with the serialized
tombstone body, and the corresponding WebFinger lookups also return
Expand All @@ -24,8 +33,24 @@ To be released.
`getAuthenticatedDocumentLoader()` now also respects
`GetAuthenticatedDocumentLoaderOptions.maxRedirection`.

[#430]: https://github.com/fedify-dev/fedify/issues/430
[#644]: https://github.com/fedify-dev/fedify/issues/644
[#680]: https://github.com/fedify-dev/fedify/pull/680
[#688]: https://github.com/fedify-dev/fedify/pull/688

### @fedify/lint

- Added the `outbox-listener-delivery-required` rule. It warns when an
outbox listener registered through `setOutboxListeners()` returns without an
explicit delivery call, which would otherwise leave a posted client
activity unfederated. [[#430], [#688]]

### @fedify/testing

- Added `createOutboxContext()` plus `postOutboxActivity()` and mock
`setOutboxListeners()` support so outbox listeners using either
`sendActivity()` or `forwardActivity()` can be tested without spinning up
a live federation server. [[#430], [#688]]

### @fedify/vocab-runtime

Expand Down
1 change: 1 addition & 0 deletions docs/.vitepress/config.mts
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,7 @@ const MANUAL = {
{ text: "Vocabulary", link: "/manual/vocab.md" },
{ text: "Actor dispatcher", link: "/manual/actor.md" },
{ text: "Inbox listeners", link: "/manual/inbox.md" },
{ text: "Outbox listeners", link: "/manual/outbox.md" },
{ text: "Sending activities", link: "/manual/send.md" },
{ text: "Collections", link: "/manual/collections.md" },
{ text: "Object dispatcher", link: "/manual/object.md" },
Expand Down
26 changes: 26 additions & 0 deletions docs/manual/access-control.md
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,32 @@ federation
If the predicate returns `false`, the request is rejected with a
`401 Unauthorized` response.

Outbox listeners can use a similar hook for client-to-server `POST /outbox`
requests:

~~~~ typescript twoslash
import { type Federation } from "@fedify/fedify";
const federation = null as unknown as Federation<void>;
async function verifyAccessToken(
authorization: string | null,
): Promise<{ identifier: string } | null> {
authorization;
return null;
}
// ---cut-before---
federation
.setOutboxListeners("/users/{identifier}/outbox")
.authorize(async (ctx, identifier) => {
const session = await verifyAccessToken(
ctx.request.headers.get("authorization"),
);
return session?.identifier === identifier;
});
~~~~

Unlike authorized fetch, this hook is purely local application logic for
incoming client requests. It does not verify HTTP Signatures by itself.


Fine-grained access control
---------------------------
Expand Down
4 changes: 4 additions & 0 deletions docs/manual/collections.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,10 @@ federation
});
~~~~

> [!TIP]
> Use `~Federatable.setOutboxListeners()` to handle `POST` requests to the same
> outbox path. See the [*Outbox listeners*](./outbox.md) guide.

Each actor has its own outbox collection, so the URI pattern of the outbox
dispatcher should include the actor's `{identifier}`. The URI pattern syntax
follows the [URI Template] specification.
Expand Down
1 change: 1 addition & 0 deletions docs/manual/context.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ callbacks that take a `Context` object as the first parameter:

- [Actor dispatcher](./actor.md)
- [Inbox listeners](./inbox.md)
- [Outbox listeners](./outbox.md)
- [Outbox collection dispatcher](./collections.md#outbox)
- [Inbox collection dispatcher](./collections.md#inbox)
- [Following collection dispatcher](./collections.md#following)
Expand Down
49 changes: 49 additions & 0 deletions docs/manual/lint.md
Original file line number Diff line number Diff line change
Expand Up @@ -597,6 +597,55 @@ federation.setActorDispatcher("/users/{identifier}", (ctx, identifier) => {
});
~~~~

### `outbox-listener-delivery-required`

Warns when an outbox listener body does not deliver the posted activity with
`ctx.sendActivity()` or `ctx.forwardActivity()`.

**When this rule applies:**
You've registered an outbox listener with `setOutboxListeners()`, but the
listener body never calls either delivery method.

**Why it matters:**
Fedify does not federate client-to-server outbox posts automatically. If your
application intends to deliver a posted activity, the listener must choose an
explicit delivery path.

~~~~ typescript twoslash
// @noErrors: 2345
import { createFederation } from "@fedify/fedify";
import { Activity } from "@fedify/vocab";
const federation = createFederation<void>({ kv: null as any });
// ---cut-before---
// ❌ Bad: Listener stores the activity locally but never federates it
federation
.setOutboxListeners("/users/{identifier}/outbox")
.on(Activity, async (ctx, activity) => {
console.log(ctx.identifier, activity.id?.href);
});

// ✅ Good: Listener federates explicitly
federation
.setOutboxListeners("/users/{identifier}/outbox")
.on(Activity, async (ctx, activity) => {
await ctx.sendActivity(
{ identifier: ctx.identifier },
"followers",
activity,
);
});

// ✅ Good: Listener forwards the original posted payload explicitly
federation
.setOutboxListeners("/users/{identifier}/outbox")
.on(Activity, async (ctx) => {
await ctx.forwardActivity(
{ identifier: ctx.identifier },
"followers",
);
});
~~~~

### `actor-followers-property-required`

Ensures `followers` is defined when `setFollowersDispatcher()` is configured.
Expand Down
203 changes: 203 additions & 0 deletions docs/manual/outbox.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,203 @@
---
description: >-
Fedify provides a way to register outbox listeners so that you can handle
client-to-server `POST` requests to actor outboxes. This section explains
how to register an outbox listener and how to federate posted activities.
---

Outbox listeners
================

Fedify can route `POST` requests to an actor's outbox through typed listeners.
This is useful when you want to accept ActivityPub client-to-server activities
from your own clients without exposing a separate non-standard API.

This guide covers `POST /outbox`. To serve `GET /outbox`, use the
[*Collections*][collections-outbox] guide.

[collections-outbox]: ./collections.md#outbox


Registering an outbox listener
------------------------------

With Fedify, you can register outbox listeners per activity type, just like
inbox listeners. The following shows how to register a listener for `Create`
activities:

~~~~ typescript twoslash
import { type Federation } from "@fedify/fedify";
import { Activity, Create, Person } from "@fedify/vocab";
const federation = null as unknown as Federation<void>;
const myKnownRecipients: Person[] = [];
async function verifyAccessToken(
authorization: string | null,
): Promise<{ identifier: string } | null> {
authorization;
return null;
}
async function savePostedActivity(
identifier: string,
activity: Activity,
): Promise<void> {
identifier;
activity;
}
// ---cut-before---
federation
.setOutboxListeners("/users/{identifier}/outbox")
.on(Create, async (ctx, activity) => {
await savePostedActivity(ctx.identifier, activity);
await ctx.sendActivity(
{ identifier: ctx.identifier },
myKnownRecipients,
activity,
);
})
.authorize(async (ctx, identifier) => {
const session = await verifyAccessToken(
ctx.request.headers.get("authorization"),
);
return session?.identifier === identifier;
});
~~~~

The `~Federatable.setOutboxListeners()` method registers the outbox path, and
the `~OutboxListenerSetters.on()` method registers a listener for a specific
activity type. The `~OutboxListenerSetters.authorize()` hook runs before the
listener and can reject unauthorized requests with `401 Unauthorized`.

Fedify also rejects a posted activity if its `actor` does not match the local
actor who owns the addressed outbox.

> [!TIP]
> If you need to handle every activity type, register a listener for the
> `Activity` class. Unsupported activity types can also be left unhandled,
> in which case Fedify responds with `202 Accepted` without dispatching a
> listener.

> [!NOTE]
> The URI Template syntax supports different expansion types like
> `{identifier}` (simple expansion) and `{+identifier}` (reserved expansion).
> If your identifiers contain URIs or special characters, you may need to use
> `{+identifier}` to avoid double-encoding issues. See the
> [*URI Template* guide][uri-template-guide] for details.

[uri-template-guide]: ./uri-template.md


Looking at `OutboxContext.identifier`
-------------------------------------

The `~OutboxContext.identifier` property contains the identifier from the
matched outbox route. Fedify does not infer anything more specific than that.

~~~~ typescript twoslash
import { type OutboxListenerSetters } from "@fedify/fedify";
import { Create } from "@fedify/vocab";
(0 as unknown as OutboxListenerSetters<void>)
// ---cut-before---
.on(Create, async (ctx, activity) => {
console.log(ctx.identifier);
console.log(activity.id?.href);
});
~~~~


Federating posted activities
----------------------------

Fedify does not federate client-posted activities automatically. If you want
to deliver a posted activity, call `~Context.sendActivity()` or
`~OutboxContext.forwardActivity()` explicitly inside your outbox listener.

~~~~ typescript twoslash
import { type Federation } from "@fedify/fedify";
import { Create, Person } from "@fedify/vocab";
const federation = null as unknown as Federation<void>;
const recipients: Person[] = [];
// ---cut-before---
federation
.setOutboxListeners("/users/{identifier}/outbox")
.on(Create, async (ctx, activity) => {
await ctx.sendActivity(
{ identifier: ctx.identifier },
recipients,
activity,
);
});
~~~~

If the client already signed the posted JSON-LD with Linked Data Signatures or
Object Integrity Proofs and you want to preserve that payload verbatim, use
`~OutboxContext.forwardActivity()` instead of round-tripping through Fedify's
vocabulary objects:

~~~~ typescript twoslash
import { type Federation } from "@fedify/fedify";
import { Activity, Person } from "@fedify/vocab";
const federation = null as unknown as Federation<void>;
const recipients: Person[] = [];
// ---cut-before---
federation
.setOutboxListeners("/users/{identifier}/outbox")
.on(Activity, async (ctx) => {
await ctx.forwardActivity(
{ identifier: ctx.identifier },
recipients,
{ skipIfUnsigned: true },
);
});
~~~~

If a listener returns without calling one of these delivery methods, Fedify
logs a runtime warning. The `@fedify/lint` package also provides a lint rule
for the same mistake; see [*Linting*][linting-guide] for details.

> [!TIP]
> Explicit delivery keeps outbox listeners symmetric with inbox listeners:
> Fedify never guesses the recipient list for you, so applications can reuse
> their own caches and delivery policies.

[linting-guide]: ./lint.md


Handling errors
---------------

You can attach an error handler to outbox listeners. It receives the outbox
context along with the thrown error:

~~~~ typescript twoslash
import { type Federation } from "@fedify/fedify";
import { Activity } from "@fedify/vocab";
const federation = null as unknown as Federation<void>;
// ---cut-before---
federation
.setOutboxListeners("/users/{identifier}/outbox")
.on(Activity, async () => {
throw new Error("Something went wrong.");
})
.onError(async (ctx, error) => {
console.error(ctx.identifier, error);
});
~~~~


Current scope
-------------

Outbox listeners currently provide the routing and authorization surface for
client-to-server posting, but the rest of the server-side behavior remains
application-defined.

In particular, Fedify does not currently do the following for you:

- Persist the posted activity in your outbox collection
- Generate IDs or `Location` headers for newly posted activities
- Wrap non-`Activity` objects in `Create` automatically
- Federate anything unless your listener calls `ctx.sendActivity()` or
`ctx.forwardActivity()`

If you need full `GET /outbox` support as well, combine this guide with the
[*Collections*][collections-outbox] guide.
Loading
Loading