@sdxc/activitypub 2026.10.12 → 2026.10.13

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/README.md +353 -543
  2. package/dist/conformance.js +3 -2
  3. package/package.json +14 -16
package/README.md CHANGED
@@ -2,49 +2,64 @@
2
2
 
3
3
  ActivityPub protocol logic: vocabulary, actors, discovery, a verified inbox and signed delivery.
4
4
 
5
+ One `Federation` federates one local actor: `fetch` answers the actor's URLs and verifies its
6
+ inbox, `process` runs every queued step (inbox, fan-out, delivery), and `on` registers what the
7
+ app does with a reply, a like or a follow. The vocabulary (`parse*`, `stringify`) is plain
8
+ functions for anything the app reads or writes itself.
9
+
5
10
  ## Installation
6
11
 
7
12
  ```sh
8
13
  npm add @sdxc/activitypub
9
14
  ```
10
15
 
11
- Every fallible call answers an [`@sdxc/result`](https://www.npmjs.com/package/@sdxc/result)
12
- value, and stores and caches are the app's own: the remote-document cache is an
13
- [`@sdxc/cache`](https://www.npmjs.com/package/@sdxc/cache) `Cache`. Both install alongside
14
- this package. `vitest` is an optional peer, needed only for the conformance suites in
16
+ Fallible calls answer a `Result` from [`@sdxc/result`](https://www.npmjs.com/package/@sdxc/result),
17
+ and remote documents are cached in an [`@sdxc/cache`](https://www.npmjs.com/package/@sdxc/cache)
18
+ `Cache`; install both alongside. `vitest` is an optional peer, needed only by
15
19
  `@sdxc/activitypub/testing`.
16
20
 
17
- One `Federation` holds everything a local actor needs wired: its document and keys, the
18
- app's stores, the cache, and the queue. `fetch` answers the actor's own URLs (the inbox is
19
- verified there, inside the request), `process` runs every queued step, and `on` registers what
20
- the app does with a reply, a like or a follow. The vocabulary (`parse*`, `stringify`) stays as
21
- plain functions for anything an app reads or writes itself.
22
-
23
21
  ## Usage
24
22
 
25
- ### Set Up A Federation
23
+ ### Set up a federation
24
+
25
+ Generate the actor's key pair once and store `privateKeyPem` as a secret; the federation imports
26
+ it on every run. The stores are the app's own, described under Storage.
26
27
 
27
28
  ```typescript
28
- import { ActorKeys, Federation, PUBLIC } from "@sdxc/activitypub";
29
+ import { ActorKeys, CacheSeenActivities, Federation, parseActor } from "@sdxc/activitypub";
30
+ import { WorkerKVCache } from "@sdxc/cache/worker-kv";
29
31
  import { unwrap } from "@sdxc/result";
30
32
 
31
- // Once, from a script: store privateKeyPem as a secret.
33
+ // Once, from a script:
32
34
  let { privateKeyPem } = unwrap(await ActorKeys.generate());
33
35
 
34
- let keys = unwrap(await ActorKeys.import({ actor: ACTOR_ID, privateKeyPem: env.PRIVATE_KEY }));
36
+ let actor = unwrap(
37
+ parseActor({
38
+ id: "https://example.com/actor",
39
+ type: "Person",
40
+ preferredUsername: "hello",
41
+ inbox: "https://example.com/actor/inbox",
42
+ outbox: "https://example.com/actor/outbox",
43
+ followers: "https://example.com/actor/followers",
44
+ following: "https://example.com/actor/following",
45
+ endpoints: { sharedInbox: "https://example.com/inbox" },
46
+ }),
47
+ );
48
+
49
+ let keys = unwrap(await ActorKeys.import({ actor: actor.id, privateKeyPem: env.PRIVATE_KEY }));
50
+ let cache = new WorkerKVCache(env.CACHE);
35
51
 
36
52
  let federation = new Federation({
37
- actor: ACTOR, // the local actor document; its ids name the URLs `fetch` answers
53
+ actor,
38
54
  keys,
39
- stores: { followers, seen, objects }, // FollowerStore, SeenActivities, LocalObjects
40
- cache, // an @sdxc/cache Cache, such as one over Workers KV
55
+ stores: { followers, seen: new CacheSeenActivities(cache), objects },
56
+ cache,
41
57
  userAgent: "example.com/1.0 (+https://example.com)",
42
- blocked: (host) => blockedHosts.has(host),
43
- queue: { enqueue: (message) => queue.send(message) },
58
+ queue: { enqueue: (message) => env.QUEUE.send(message) },
44
59
  });
45
60
  ```
46
61
 
47
- ### Answer The Actor's URLs
62
+ ### Answer the actor's URLs
48
63
 
49
64
  ```typescript
50
65
  let response = await federation.fetch(request);
@@ -52,16 +67,15 @@ if (response) return response; // the actor, its inbox, outbox, followers or fol
52
67
  return router.fetch(request);
53
68
  ```
54
69
 
55
- An inbox `POST` is verified before it is queued and answers `202`, or the status of the check
56
- it failed, so a sender learns from a `401` to retry with the other signature scheme.
70
+ An inbox `POST` is verified inside the request and answers `202` once queued, or the status of
71
+ the check it failed.
57
72
 
58
- ### Process The Queue
73
+ ### Process the queue
59
74
 
60
- Every message the federation enqueues (an inbox activity, a fan-out, a delivery) is one shape,
61
- `Federation.MESSAGE`, so the app declares one job and hands each message back:
75
+ Every message the federation enqueues has one shape, `Federation.Message`, so one consumer hands
76
+ each back:
62
77
 
63
78
  ```typescript
64
- import { Federation } from "@sdxc/activitypub";
65
79
  import { isFailure } from "@sdxc/result";
66
80
 
67
81
  let processed = await federation.process(message, { attempts });
@@ -71,7 +85,10 @@ if (isFailure(processed)) {
71
85
  }
72
86
  ```
73
87
 
74
- ### Handle Replies, Likes And Follows
88
+ ### Handle replies, likes and follows
89
+
90
+ The package does the protocol's work first (stores followers, sends the `Accept`, checks
91
+ ownership and that the activity concerns local content), then calls the handler.
75
92
 
76
93
  ```typescript
77
94
  federation.on(["Create", "Update", "Like", "Announce"], async (ctx) => {
@@ -79,584 +96,247 @@ federation.on(["Create", "Update", "Like", "Announce"], async (ctx) => {
79
96
  if (summary) await responses.save(summary);
80
97
  });
81
98
 
82
- federation.on("Follow", async (ctx) => (isWelcome(ctx.actor) ? "accept" : "pending"));
99
+ federation.on("Follow", async (ctx) => (isWelcome(ctx.actor) ? "accept" : "reject"));
83
100
  ```
84
101
 
85
- ### Publish A Post
102
+ ### Publish an activity
86
103
 
87
104
  ```typescript
105
+ import { PUBLIC } from "@sdxc/activitypub";
106
+
88
107
  await federation.publish({
89
- id: `${post.url}#create`,
108
+ id: "https://example.com/articles/hello#create",
90
109
  type: "Create",
91
- actor: ACTOR_ID,
110
+ actor: actor.id,
92
111
  to: [PUBLIC],
93
- cc: [FOLLOWERS_ID],
112
+ cc: ["https://example.com/actor/followers"],
94
113
  object: article,
95
114
  });
96
115
  ```
97
116
 
98
- ### Serve A Page As HTML Or As ActivityStreams
99
-
100
- ```typescript
101
- let activity = await federation.respond(request, article);
102
- if (activity) return activity; // the client preferred ActivityStreams
103
- return renderHtml(article);
104
- ```
117
+ `publish` queues a fan-out: one delivery per distinct inbox (`sharedInbox ?? inbox`) of the
118
+ followers and every other addressed actor, each signed when sent.
105
119
 
106
120
  ## API
107
121
 
108
122
  ### `Federation`
109
123
 
110
- The runtime of one local actor. It holds no state beyond its options and handlers, so an app
111
- may build one per request or per job, wherever its stores are at hand.
124
+ The runtime of one local actor. It holds no state beyond its options and handlers, so build one
125
+ per request or per job wherever the stores are at hand.
112
126
 
113
127
  #### `new Federation(options: Federation.Options)`
114
128
 
115
- - `actor`: the local actor's `ActivityPub.Actor`. Its `id`, `inbox`,
116
- `endpoints.sharedInbox`, `outbox`, `followers` and `following` are the URLs `fetch`
117
- answers. `manuallyApprovesFollowers` makes a Follow `pending` by default.
118
- - `keys`: the actor's `ActorKeys`, or a `KeyProvider` for an app hosting several actors. An
119
- actor the provider has keys for counts as local.
120
- - `stores`: `{ followers, seen, objects }`, the app's `FollowerStore`, `SeenActivities` and
121
- `LocalObjects`.
122
- - `cache`: an `@sdxc/cache` `Cache` for remote documents, the signature scheme each origin
123
- accepted, and origins that keep failing. A failing cache costs a fetch or a knock, never
124
- correctness.
129
+ - `actor`: the local `ActivityPub.Actor`. Its `id`, `inbox`, `endpoints.sharedInbox`, `outbox`,
130
+ `followers` and `following` are the URLs `fetch` answers; `manuallyApprovesFollowers` makes a
131
+ Follow `pending` by default.
132
+ - `keys`: the actor's `ActorKeys`, or a `KeyProvider` for an app hosting several actors.
133
+ - `stores`: `{ followers, seen, objects }`, a `FollowerStore`, `SeenActivities` and `LocalObjects`.
134
+ - `cache`: an `@sdxc/cache` `Cache` for remote documents and per-origin delivery state.
125
135
  - `userAgent`: sent on every outbound request.
126
- - `queue`: `{ enqueue(message), enqueueMany?(messages) }`. Each may answer a `Result` or
127
- nothing; a failure or an exception fails the step that enqueued as retryable.
128
- - `blocked`: `(host) => boolean | Promise<boolean>`, nothing blocked by default. A blocked
129
- server can neither deliver, follow, nor receive.
130
- - `outbox`: `(cursor: string | null) => Promise<Result<OutboxPage, Error>>`, the pages of the
131
- outbox (`{ items, next, totalItems? }`). Without it the outbox is empty.
132
- - `authorizedFetch`: `true` requires a signed GET for the collections and for `respond`
133
- (secure mode). The actor document always answers unsigned.
134
- - `inbox`: `{ maxBytes?, maxAge? }`, `102400` bytes and `"1 hour"` of signature age by
135
- default.
136
- - `resolver`: `{ signed?, ttl?, timeout?, maxBytes? }`. GETs are signed with the actor's keys
137
- unless `signed` is `false`; actors are cached a day and objects an hour.
138
- - `delivery`: `{ timeout?, pageSize? }`, `"15 seconds"` per POST and 100 inboxes per store
139
- page and per `enqueueMany`.
140
- - `collections`: `{ pageSize? }`, 50 followers per page.
141
- - `backoff`: the retry schedule by attempt, `DELIVERY_BACKOFF` by default.
142
- - `seenTtl`: how long a processed activity id is remembered, `"1 day"` by default.
136
+ - `queue`: `{ enqueue(message), enqueueMany?(messages) }`, each answering a `Result` or nothing.
137
+ - `blocked`: `(host) => boolean | Promise<boolean>`; a blocked server can neither deliver,
138
+ follow nor receive.
139
+ - `outbox`: `(cursor) => Promise<Result<OutboxPage, Error>>`, pages of `{ items, next, totalItems? }`;
140
+ without it the outbox is empty.
141
+ - `authorizedFetch`: `true` requires a signed GET for the collections and `respond`.
142
+ - `inbox`: `{ maxBytes?, maxAge? }`, default `102400` and `"1 hour"`.
143
+ - `resolver`: `{ signed?, ttl?, timeout?, maxBytes? }`; GETs are signed unless `signed: false`.
144
+ - `delivery`: `{ timeout?, pageSize? }`, default `"15 seconds"` and 100.
145
+ - `collections`: `{ pageSize? }`, default 50 followers per page.
146
+ - `backoff`: the retry schedule, default `DELIVERY_BACKOFF`.
147
+ - `seenTtl`: how long a processed activity id is remembered, default `"1 day"`.
143
148
  - `now`: the clock, for tests.
144
149
 
145
150
  #### `federation.on(type, handler): Federation`
146
151
 
147
- Registers the handler for one activity type or a list of them, replacing an earlier one for
148
- the same type. The types are `Follow`, `Undo`, `Create`, `Update`, `Delete`, `Like` (which
149
- also receives Pleroma's `EmojiReact`), `Announce`, `Accept`, `Reject` and `Move`.
150
-
151
- A handler answers nothing or a `Result`, sync or async. A failure or an exception fails the
152
- message as retryable, so a handler tolerates seeing an activity twice. A `Follow` handler may
153
- answer `"accept"`, `"reject"` or `"pending"`; nothing keeps the default.
152
+ Registers a handler for `Follow`, `Undo`, `Create`, `Update`, `Delete`, `Like` (also Pleroma's
153
+ `EmojiReact`), `Announce`, `Accept`, `Reject` or `Move`, or a list of them; a later registration
154
+ replaces an earlier one. A handler answers nothing or a `Result`, and a failure or exception
155
+ retries the message, so handlers must be idempotent. A `Follow` handler may answer `"accept"`,
156
+ `"reject"` or `"pending"`.
154
157
 
155
- ```typescript
156
- // Federation.Context
157
- interface Context {
158
- activity: ActivityPub.Activity;
159
- actor: ActivityPub.Actor; // fetched from its own origin, never the copy an activity embeds
160
- origin: string;
161
- verification: "signature" | "proof" | "refetched";
162
- object: ActivityPub.Object | null; // the post, the local object, the undone activity, the Follow, the Move target
163
- target: string | null; // the local object or actor it concerns
164
- receivedAt: Date;
165
- summary(): Summary | null;
166
- }
167
- ```
158
+ The handler receives a `Federation.Context`: `activity`, `actor` (fetched from its own origin),
159
+ `origin`, `verification`, `object`, `target` (the local object or actor it concerns),
160
+ `receivedAt` and `summary()`. A `Delete` handler receives a `Federation.DeleteContext`, whose
161
+ `actor` is `null` when the account is gone and whose `deleted` is `{ kind: "object" | "actor", id }`.
168
162
 
169
- A `Delete` handler receives a `Federation.DeleteContext`, whose `actor` is `null` when a
170
- deleted account's document is already gone, whose `deleted` is
171
- `{ kind: "object" | "actor", id }`, and whose `summary()` is `null`.
172
-
173
- `summary()` is the shape a Webmention is stored in, for a reply, mention, like or boost of
174
- local content, and `null` for anything else:
175
-
176
- ```typescript
177
- interface Summary {
178
- kind: "reply" | "like" | "repost" | "mention"; // a quote reads as mention
179
- id: string; // the remote post, or the Like/Announce
180
- url: string; // its HTML page, else its id
181
- target: string; // the local object or actor
182
- author: { id: string; handle: string; name: string | null; url: string; photo: string | null };
183
- content: { html: string; text: string } | null; // sanitized against the object's id
184
- published: Date | null;
185
- }
186
- ```
187
-
188
- Misskey's emoji reactions (a `Like` with `content`) and Pleroma's `EmojiReact` read as `like`,
189
- with the emoji as content.
163
+ `summary()` answers a reply, mention, like or repost of local content as a `Summary`
164
+ (`kind`, `id`, `url`, `target`, `author`, sanitized `content`, `published`), the shape a
165
+ Webmention is stored in, and `null` for anything else.
190
166
 
191
167
  #### `federation.fetch(request: Request): Promise<Response | null>`
192
168
 
193
- Answers the actor's URLs, matched by origin and path, and `null` for any other URL:
194
-
195
- | URL | Method | Answer |
196
- | -------------------------------- | ------------- | ----------------------------------------------------------------------- |
197
- | `actor.id` | `GET`, `HEAD` | The actor, with `publicKey` from its keys |
198
- | `inbox`, `endpoints.sharedInbox` | `POST` | `202` once queued, the refusal's status, or `503` when the queue failed |
199
- | `outbox` | `GET`, `HEAD` | `totalItems` and `first`; `?page=true[&cursor=…]` answers a page |
200
- | `followers` | `GET`, `HEAD` | `totalItems` from `count`; pages of follower ids from `list` |
201
- | `following` | `GET`, `HEAD` | An empty collection |
202
- | Any of the above, another method | | `405` with `Allow` |
203
-
204
- Documents answer `application/activity+json; charset=utf-8` with an `ETag`, `304`
205
- revalidation and a public 5-minute cache. A failing store answers `503`. The inbox runs these
206
- checks in order and stops at the first failure, and fetches nothing before the blocked check:
207
-
208
- | Step | `InboxError.code` | Status |
209
- | -------------------------------------------------------------------------------------- | -------------------------------------- | ------ |
210
- | `Content-Type` is `application/activity+json` or `application/ld+json` | `unsupported-media-type` | 415 |
211
- | The body fits `inbox.maxBytes` | `too-large` | 413 |
212
- | The body is JSON that `parseActivity` accepts | `invalid-activity` | 400 |
213
- | Neither the `keyId` host nor the `actor` host is `blocked` | `blocked` | 403 |
214
- | A signature is present: `Signature-Input` (RFC 9421) or `Signature` (cavage) | `unsigned` | 401 |
215
- | The signature covers `Content-Digest` or `Digest`, and the body matches it | `digest-mismatch` | 401 |
216
- | `created` or `Date` is within `inbox.maxAge`, with 5 minutes of clock skew | `stale-signature` | 401 |
217
- | The signature verifies with the key; a failure refetches the key once, for a rotation | `invalid-signature`, `key-unavailable` | 401 |
218
- | The key's owner is `activity.actor`, or the activity refetched from its origin matches | `actor-mismatch` | 401 |
219
- | A `Delete` of an account whose key now answers `410` | `ignored` | 202 |
220
-
221
- A forwarded activity (Mastodon forwards replies to a thread's participants under the
222
- forwarder's signature) is accepted when its `id` has the actor's origin and a fresh copy from
223
- there is the same activity by the same actor; the queued message carries that copy. A verified
224
- activity clears its signer's origin from the failing-delivery record.
169
+ Answers the actor document, an inbox or shared-inbox `POST`, the outbox, followers and
170
+ following collections (paged with `?page=true&cursor=…`), and `405` for other methods on them;
171
+ `null` for any other URL. A failing store or queue answers `503`. A refused inbox `POST` answers
172
+ its `InboxError` status: `415`, `413`, `400`, `403` for a blocked host, or `401` for a missing,
173
+ stale or invalid signature, so the sender retries with its other signature scheme.
225
174
 
226
175
  #### `federation.respond(request, document, options?): Promise<Response | null>`
227
176
 
228
- `null` when the request prefers HTML, so the app renders its page; otherwise the document as
229
- ActivityStreams with `Vary: Accept`, an `ETag`, `304` revalidation and a public 5-minute cache.
230
- A `Tombstone` answers `410`, which is how a refetching server learns the object was deleted.
231
- `options` takes `cache` (an [`@sdxc/http`](https://www.npmjs.com/package/@sdxc/http)
232
- `policy()` input), `headers` (applied last) and `status`. With `authorizedFetch`, an unsigned
233
- or invalid request answers its refusal.
234
-
235
- An HTML page with an ActivityStreams representation should link it with
236
- `<link rel="alternate" type="application/activity+json" href="…">`, which Mastodon follows
237
- when someone pastes the page's URL into its search.
177
+ `null` when the request prefers HTML; otherwise the document as ActivityStreams with
178
+ `Vary: Accept`, an `ETag` and a public 5-minute cache. A `Tombstone` answers `410`. `options`
179
+ takes `cache` (an [`@sdxc/http`](https://www.npmjs.com/package/@sdxc/http) `policy()` input),
180
+ `headers` and `status`.
238
181
 
239
182
  #### `federation.verify(request: Request): Promise<Result<VerifiedFetch, InboxError>>`
240
183
 
241
- Verifies the signature on a GET, for documents an app serves on its own under authorized
242
- fetch: the key's blocked host, the signature's age, and the key, refetched once. The actor
243
- document passes unsigned with `{ signer: null, keyId: null }`. A refusal answers itself
244
- through `error.toResponse()`.
184
+ Verifies the signature on a GET, for documents the app serves itself under authorized fetch. A
185
+ refusal answers itself through `error.toResponse()`.
245
186
 
246
187
  #### `federation.process(message, options?): Promise<Result<Federation.Outcome, FederationError>>`
247
188
 
248
- Runs one queued message. `options.attempts` is the job's attempt, starting at 1: it picks the
249
- backoff step, and past the first the redelivery check is skipped so a retry is processed again.
250
-
251
- - `inbox`: claims the activity id in `seen` (a store failure is logged and processing goes
252
- on), fetches the actor, does the protocol's work for the type (below), then calls the
253
- handler. Answers `{ kind: "inbox", status: "processed" | "duplicate" | "ignored", type, reason }`.
254
- - `fanOut`: queues one `deliver` message per distinct inbox the activity addresses, and answers
255
- `{ kind: "fanOut", inboxes, skipped }`.
256
- - `deliver`: signs and POSTs the activity, and answers `{ kind: "deliver", status, scheme }`.
257
- An inbox answering `410` drops every follower reached through it.
258
-
259
- A `FederationError` carries the underlying `code`, `retryable`, `kind`, and `delay` in
260
- milliseconds: the backoff step for the attempt, or the inbox's `Retry-After` when that is
261
- longer, and `0` when the failure is not retryable. Its `cause` is the original error, such as
262
- a `DeliveryError`.
263
-
264
- | Activity | The package does |
265
- | -------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
266
- | `Follow` of the local actor | Stores the follower (`pending` when `manuallyApprovesFollowers`) and queues an `Accept`, embedding the Follow, to the follower's inbox; again on a repeated Follow. A blocked server, or the handler's `"reject"`, gets a `Reject` |
267
- | `Undo` of `Follow` | Removes the follower when the stored `followId` matches or the embedded Follow is the sender's |
268
- | `Create`, `Update` | Requires the object to be attributed to the actor and on its origin, and to reply to, quote, or mention something local |
269
- | `Update` of the actor | Evicts and refetches the actor, and refreshes a stored follower's `inbox` and `sharedInbox` |
270
- | `Delete` of an object | Requires a fresh refetch that answers `404`/`410`/`Tombstone`, or still names the sender as author |
271
- | `Delete` of the actor | Removes its follow of the local actor and evicts it, even when its document is gone |
272
- | `Like`, `EmojiReact`, `Announce` | Ignores objects that are not in `LocalObjects` |
273
- | `Undo` of anything else | Requires the undone activity, embedded from the sender's origin or fetched, to be the sender's |
274
- | `Accept`, `Reject` | Requires the Follow to be one a local actor sent: embedded on the local origin naming the sender, or a local IRI |
275
- | `Move` | Fetches the target fresh; it must list the mover in `alsoKnownAs`, and the mover, fetched fresh, must name it in `movedTo` |
276
- | Anything else | Logged and acknowledged |
277
-
278
- An `ignored` outcome's `reason` is one of `blocked`, `actor-unavailable`, `not-addressed`,
279
- `not-owner`, `unrelated`, `unverifiable` and `unhandled`; a processed Follow may carry
280
- `rejected` or `pending`.
281
-
282
- Fan-out reaches the followers when the actor's `followers` IRI is in `to`, `cc`, `bto` or
283
- `bcc` of the activity or its embedded object, and resolves every other addressed IRI except
284
- `PUBLIC` to its actor's `sharedInbox ?? inbox`; one that fails to resolve is skipped and
285
- counted. Targets are deduplicated as URLs, so one server with 400 followers gets one POST.
286
- Blocked hosts and origins failing for more than 7 days are skipped, and `bto`/`bcc` are
287
- stripped from what is delivered.
288
-
289
- Delivery double-knocks: the first POST to an origin is signed with RFC 9421
290
- (`Content-Digest`), and a `400`, `401` or `403` is retried at once with draft-cavage
291
- (`Digest`). The scheme that lands is remembered per origin for 30 days, and a `401` carrying
292
- `Accept-Signature` is honored once. Each POST is signed when sent, so `Date` is fresh on every
293
- retry, and goes out once with `redirect: "manual"` after the inbox passes
294
- [`@sdxc/outbound`](https://www.npmjs.com/package/@sdxc/outbound)'s checks.
295
-
296
- | Response | `code` | `retryable` |
297
- | ---------------------------------- | --------------------- | -------------------------------------- |
298
- | 2xx | (success) | |
299
- | `410` | `gone` | No; the inbox's followers are removed |
300
- | `401`/`403` after every scheme | `unauthorized` | No |
301
- | `429` | `rate-limited` | Yes, at least the `Retry-After` |
302
- | other 3xx and 4xx | `rejected` | No |
303
- | 5xx | `server` | Yes, at least a `Retry-After` it names |
304
- | deadline passed / `fetch` rejected | `timeout` / `network` | Yes |
305
- | the URL or its resolution refused | `refused-url` | No |
306
- | no keys for the actor | `missing-keys` | No |
307
- | the `KeyProvider` failed | `keys-unavailable` | Yes |
308
-
309
- A `Retry-After` is clamped to 12 hours, the longest `DELIVERY_BACKOFF` step.
189
+ Runs one queued message and answers what happened: `{ kind: "inbox", status, type, reason }`,
190
+ `{ kind: "fanOut", inboxes, skipped }` or `{ kind: "deliver", status, scheme }`. Pass the job's
191
+ `attempts` (from 1) so `FederationError.delay` follows the backoff; a non-retryable failure has
192
+ `delay` `0`. An inbox answering `410` drops every follower behind it.
310
193
 
311
194
  #### `federation.publish(activity): Promise<Result<void, ActivityPubError>>`
312
195
 
313
- Queues the fan-out of an activity by the local actor, given as a document or as JSON text
314
- already serialized. An activity over 120 KB (`MAX_ACTIVITY_BYTES`) fails `too-large`, since no
315
- delivery message could carry it, and a failing queue fails a retryable `enqueue`. A retried
316
- fan-out may queue some deliveries twice, which receivers absorb by activity id.
317
-
318
- #### `federation.approve(follower: string): Promise<Result<void, ActivityPubError>>`
196
+ Queues the fan-out of an activity by the local actor, given as a document or JSON text. Fails
197
+ `too-large` past `MAX_ACTIVITY_BYTES`, or with a retryable `enqueue` error.
319
198
 
320
- Accepts a follower a `"pending"` decision held back: it is stored as `accepted`, so it joins the
321
- followers collection and every later fan-out, and an `Accept` of its stored Follow is queued to
322
- its inbox. Approving an accepted follower sends the same Accept again, so a retry is safe.
199
+ #### `federation.approve(follower)` / `federation.reject(follower)`
323
200
 
324
- #### `federation.reject(follower: string): Promise<Result<void, ActivityPubError>>`
325
-
326
- Refuses a follower, pending or accepted: a `Reject` of its stored Follow is queued to its inbox,
327
- then the follower is removed. It stays stored until the Reject is queued, so retrying a failed
328
- call still sends it.
329
-
330
- Both answer `not-found` for an actor that does not follow the local actor, and a retryable
331
- `store` or `enqueue` failure when the store or the queue fails.
332
-
333
- ```typescript
334
- let approved = await federation.approve("https://mastodon.social/users/alice");
335
- ```
201
+ Answer `Promise<Result<void, ActivityPubError>>`. `approve` stores a pending follower as
202
+ `accepted` and queues an `Accept`; `reject` queues a `Reject` and removes the follower. Both are
203
+ safe to retry and fail `not-found` for an actor that does not follow the local actor.
336
204
 
337
205
  #### `federation.lookup(handle: string): Promise<Result<ActivityPub.Actor, ActivityPubFetchError>>`
338
206
 
339
- Resolves `@user@host`, `user@host` or `acct:user@host` the way Mastodon does: WebFinger on the
340
- handle's host, the `self` link typed as ActivityStreams, then the actor. A handle other than
341
- the actor's canonical `preferredUsername@<actor host>` resolves only when WebFinger on the
342
- actor's host confirms the canonical handle points at the same actor. Fails `refused-url`,
343
- `not-found` or `id-mismatch`, or with any code a fetch answers.
207
+ Resolves `@user@host`, `user@host` or `acct:user@host` through WebFinger to its actor.
344
208
 
345
- #### `federation.resolver: RemoteResolver`
209
+ #### `federation.resolver`
346
210
 
347
- The resolver the federation fetches with, sharing its cache and signer.
211
+ The `RemoteResolver` the federation fetches with, sharing its cache and keys.
348
212
 
349
213
  #### `Federation.MESSAGE`
350
214
 
351
- The Standard Schema (from `remix/data-schema`) of `Federation.Message`, the union of every
352
- message the federation enqueues, each plain JSON under 128 KB:
353
-
354
- ```typescript
355
- // Federation.Message
356
- type Message =
357
- | {
358
- kind: "inbox";
359
- activity: Record<string, unknown>;
360
- actor: string;
361
- signer: string;
362
- keyId: string;
363
- origin: string;
364
- verification: "signature" | "proof" | "refetched";
365
- receivedAt: string;
366
- }
367
- | { kind: "fanOut"; actor: string; activity: string }
368
- | { kind: "deliver"; actor: string; activity: string; inbox: string };
369
- ```
215
+ The Standard Schema of `Federation.Message`, for declaring the one job that carries every
216
+ message.
370
217
 
371
218
  ### `ActorKeys`
372
219
 
373
- The RSA keys a local actor signs with: RSASSA-PKCS1-v1_5 2048-bit with SHA-256, which is what
374
- Mastodon generates and verifies.
375
-
376
- - `ActorKeys.generate(): Promise<Result<{ privateKeyPem, publicKeyPem }, CryptoError>>`
377
- makes a PKCS#8 and SPKI pair. Run it once and store `privateKeyPem` as a secret; a new pair
378
- is an actor `Update` delivered to every follower.
379
- - `ActorKeys.import({ actor, privateKeyPem }): Promise<Result<ActorKeys, CryptoError>>`
380
- derives the public half from the private key, so one secret is all an app stores. A PKCS#1
381
- key (`BEGIN RSA PRIVATE KEY`) fails with an `InvalidKeyError`; convert it with
382
- `openssl pkcs8 -topk8 -nocrypt`.
383
- - `keys.id` is the key id every signature names, `<actor>#main-key`, and `keys.actor` the
384
- actor's id.
385
- - `keys.publicKey` is the actor document's `publicKey` member:
386
- `{ id: "<actor>#main-key", owner: actor, publicKeyPem }`.
387
- - `keys.rsa` holds `{ id, privateKey, publicKeyPem }`, the private key unextractable;
388
- `keys.ed25519` is `null` until object integrity proofs ship.
389
- - `new ActorKeys({ actor, rsa, ed25519? })` wraps keys already imported into Web Crypto.
390
-
391
- ### `RemoteResolver`
392
-
393
- Fetches remote actors, keys and objects, and works on its own as well as inside a federation.
220
+ The RSA keys (RSASSA-PKCS1-v1_5, 2048-bit, SHA-256) a local actor signs with.
394
221
 
395
- ```typescript
396
- import { RemoteResolver } from "@sdxc/activitypub";
222
+ - `ActorKeys.generate()`: a new `{ privateKeyPem, publicKeyPem }` pair, PKCS#8 and SPKI.
223
+ - `ActorKeys.import({ actor, privateKeyPem })`: the keys from a PKCS#8 private key alone. A
224
+ PKCS#1 key (`BEGIN RSA PRIVATE KEY`) converts with `openssl pkcs8 -topk8 -nocrypt`.
225
+ - `new ActorKeys({ actor, rsa, ed25519? })`: keys already imported into Web Crypto.
226
+ - `keys.id` (`<actor>#main-key`), `keys.actor`, `keys.publicKey` (the actor document's
227
+ `publicKey` member), `keys.rsa` and `keys.ed25519`.
397
228
 
398
- let resolver = new RemoteResolver({
399
- cache,
400
- userAgent: "example.com/1.0 (+https://example.com)",
401
- signer: { actor: ACTOR_ID, keys }, // signs every GET; omit for unsigned fetches
402
- ttl: { actor: "1 day", object: "1 hour" },
403
- });
404
-
405
- let actor = await resolver.actor("https://mastodon.social/users/someone");
406
- let key = await resolver.key("https://mastodon.social/users/someone#main-key"); // { owner, publicKey, actor }
407
- let note = await resolver.object(iri, { fresh: true }); // skips the cached copy
408
- ```
409
-
410
- - Options: `cache` (documents as JSON under `activitypub:doc:<iri>`; a failing store reads as
411
- a miss), `userAgent`, `signer` (`{ actor, keys }` with `ActorKeys` or a `KeyProvider`, which
412
- signs each GET with draft-cavage over `(request-target) host date`; a provider holding no
413
- keys for the actor leaves GETs unsigned), `ttl`, `timeout` (`"10 seconds"` for the redirect
414
- chain and the body) and `maxBytes` (1 MB).
415
- - `actor(iri)`, `object(iri)` (an activity when the document has an `actor`), and
416
- `document(iri)` (the decoded JSON, for a shape no reader models), each with `{ fresh }`.
417
- - `key(keyId)` resolves a signature's key to the actor that publishes it: the actor must list
418
- the key under exactly `keyId` (else `not-found`, which is also how a rotated key shows) and
419
- the key's `owner` must be that actor (else `id-mismatch`).
420
- - `evict(iri)` forgets a cached document, as after an actor's `Update` or `Delete`.
421
-
422
- Every request goes through `@sdxc/outbound`: each hop is checked and its host resolved over
423
- DNS-over-HTTPS, so a document pointing inside a network is refused, never fetched. A document
424
- is accepted only when its `id` has the origin the redirect chain ended at. When a signed
425
- request is redirected and the final URL answers `401` or `403`, it is signed for that URL and
426
- asked once more. `Resolver` is the interface it implements.
427
-
428
- | Outcome | `ActivityPubFetchError.code` |
429
- | ---------------------------------------------------- | ---------------------------- |
430
- | `410`, or a `Tombstone` (the cache entry is evicted) | `gone` |
431
- | `404` | `not-found` |
432
- | `401`, `403` | `unauthorized` |
433
- | Any other non-2xx (`5xx` and `429` are retryable) | `http` |
434
- | A URL, redirect or address `@sdxc/outbound` refuses | `refused-url` |
435
- | Body past `maxBytes` | `too-large` |
436
- | Deadline passed, connection failed (retryable) | `timeout`, `network` |
437
- | Not JSON, no `id`, or not the shape asked for | `invalid-document` |
438
- | `id` on another origin, key owned by another actor | `id-mismatch` |
229
+ ### `RemoteResolver`
439
230
 
440
- ### Vocabulary
231
+ Fetches remote documents through [`@sdxc/outbound`](https://www.npmjs.com/package/@sdxc/outbound)
232
+ and the cache, accepting a document only when its `id` has the origin it was served from.
441
233
 
442
- #### `parseActivity(json: unknown): Result<ActivityPub.Activity, ActivityPubParseError>`
234
+ #### `new RemoteResolver(options: ResolverOptions)`
443
235
 
444
- Reads an activity. It needs an absolute `id`, a `type` and an `actor`. Any type name is
445
- accepted, so a type the app does not handle can still be acknowledged. An embedded `object` is
446
- kept; trust it only when its `id` has the actor's origin.
236
+ - `cache`, `userAgent`: as for `Federation`.
237
+ - `signer`: `{ actor, keys }` to sign every GET; omit for unsigned fetches.
238
+ - `ttl`: `{ actor?, object? }`, default `"1 day"` and `"1 hour"`.
239
+ - `timeout`, `maxBytes`: default `"10 seconds"` and 1 MB.
447
240
 
448
- #### `parseActor(json: unknown): Result<ActivityPub.Actor, ActivityPubParseError>`
241
+ `actor(iri)`, `object(iri)`, `document(iri)` (the decoded JSON) and `key(keyId)` (the key and the
242
+ actor that publishes it) each take `{ fresh: true }` to skip the cache, and fail with an
243
+ `ActivityPubFetchError`. `evict(iri)` forgets a cached document. `Resolver` is the interface it
244
+ implements.
449
245
 
450
- Reads an actor. It needs an actor type (`Person`, `Service`, `Application`, `Group`,
451
- `Organization`), an absolute `id`, a `preferredUsername` and an `inbox`. A `publicKey` that is
452
- present but incomplete fails, since no signature could verify against it.
246
+ ### `CacheSeenActivities`
453
247
 
454
- #### `parseObject(json: unknown): Result<ActivityPub.Object, ActivityPubParseError>`
248
+ A `SeenActivities` over any `@sdxc/cache` `Cache`, keyed `activitypub:seen:<id>`, with a TTL of at
249
+ least 60 seconds.
455
250
 
456
- Reads any object and keeps its `type`. A deleted one reads with `type: "Tombstone"`.
251
+ ### Vocabulary
457
252
 
458
- #### `parseCollection(json: unknown): Result<ActivityPub.AnyCollection, ActivityPubParseError>`
253
+ #### `parseActivity`, `parseActor`, `parseObject`, `parseCollection`
459
254
 
460
- Reads a `Collection`, `OrderedCollection`, `CollectionPage` or `OrderedCollectionPage`. Its
461
- `first` page may be an IRI or embedded, and each embedded item reads as an activity, an actor
462
- or an object.
255
+ `(json: unknown) => Result<T, ActivityPubParseError>` for an activity, an actor, any object, or
256
+ any collection or collection page. Each normalizes the variants servers send (single values or
257
+ arrays, IRIs or embedded objects, language maps, quote properties), and every member of the
258
+ result is present, with `null`, `[]`, `{}` or `false` when the sender left it out.
463
259
 
464
260
  #### `stringify(document: ActivityPub.Draft<ActivityPub.Document>): string`
465
261
 
466
- Writes a document with the ActivityStreams and security contexts, the data-integrity and
467
- Multikey contexts when the document uses them, and an embedded map of the `toot:`, `schema:`
468
- and `as:` extension terms. It omits `null`, empty arrays and empty maps, never writes `bto` or
469
- `bcc`, writes dates as ISO 8601, and writes `quote` also as `_misskey_quote` and `quoteUri`.
470
-
471
- ```typescript
472
- import type { ActivityPub } from "@sdxc/activitypub";
473
-
474
- import { PUBLIC, stringify } from "@sdxc/activitypub";
475
-
476
- let article = {
477
- id: "https://example.com/articles/hello",
478
- type: "Article",
479
- attributedTo: ["https://example.com/actor"],
480
- to: [PUBLIC],
481
- cc: ["https://example.com/followers"],
482
- name: "Hello",
483
- content: "<p>Hello, fediverse.</p>",
484
- published: new Date("2026-10-01T12:00:00Z"),
485
- } satisfies ActivityPub.Draft<ActivityPub.Object>;
262
+ The document as JSON with the `@context` it needs, omitting empty members and `bto`/`bcc`, and
263
+ dates as ISO 8601.
486
264
 
487
- stringify(article); // JSON text with @context, ready to serve or deliver
488
- ```
265
+ #### `tombstone({ id, formerType?, deleted? }): ActivityPub.Tombstone`
489
266
 
490
- #### `tombstone(init: TombstoneInit): ActivityPub.Tombstone`
491
-
492
- The document a deleted object is served as: `{ id, formerType?, deleted? }`.
267
+ The document a deleted object is served as.
493
268
 
494
269
  #### `wantsActivity(request: Request): boolean`
495
270
 
496
- `true` when `application/activity+json` or `application/ld+json` is preferred over
497
- `text/html`. A wildcard and a missing `Accept` read as HTML, so browsers and crawlers get the
498
- page. `federation.respond` asks it first.
499
-
500
- #### Collections
501
-
502
- `orderedCollection`, `orderedCollectionPage`, `collection` and `collectionPage` build the
503
- documents a collection serves, such as an object's `replies`. Give a collection `totalItems`
504
- alone to publish a count without its members.
271
+ `true` when the request prefers ActivityStreams over HTML; a wildcard or missing `Accept` reads
272
+ as HTML.
505
273
 
506
- ```typescript
507
- import { orderedCollection, orderedCollectionPage } from "@sdxc/activitypub";
274
+ #### `orderedCollection`, `orderedCollectionPage`, `collection`, `collectionPage`
508
275
 
509
- orderedCollection({ id: REPLIES, totalItems: 42, first: `${REPLIES}?page=true` });
510
- orderedCollectionPage({ id: pageUrl, partOf: REPLIES, orderedItems: replies, next: nextUrl });
511
- ```
276
+ Build collection documents. A collection with `totalItems` and no items publishes a count alone.
512
277
 
513
278
  #### `actorLink(actorId: string): JrdLink`
514
279
 
515
- The WebFinger `self` link typed `application/activity+json`, which Mastodon follows to
516
- resolve a handle. WebFinger for `preferredUsername@<actor host>` must answer this link,
517
- because Mastodon checks the reverse direction.
518
-
519
- ```typescript
520
- import { actorLink } from "@sdxc/activitypub";
521
-
522
- let jrd = {
523
- subject: "acct:hello@example.com",
524
- aliases: [],
525
- properties: {},
526
- links: [actorLink(ACTOR_ID)],
527
- };
528
- ```
280
+ The WebFinger `self` link typed `application/activity+json` that resolves a handle to the actor.
529
281
 
530
282
  #### Constants
531
283
 
532
- | Constant | Value |
533
- | ------------------------ | ------------------------------------------------------ |
534
- | `PUBLIC` | `https://www.w3.org/ns/activitystreams#Public` |
535
- | `ACTIVITY_JSON` | `application/activity+json` |
536
- | `LD_JSON` | `application/ld+json` |
537
- | `LD_JSON_ACTIVITY` | `application/ld+json; profile="…activitystreams"` |
538
- | `ACTIVITY_CONTENT_TYPE` | `application/activity+json; charset=utf-8` |
539
- | `ACTIVITY_ACCEPT` | The `Accept` a client sends for the AS2 representation |
540
- | `AS2_CONTEXT` | `https://www.w3.org/ns/activitystreams` |
541
- | `SECURITY_CONTEXT` | `https://w3id.org/security/v1` |
542
- | `DATA_INTEGRITY_CONTEXT` | `https://w3id.org/security/data-integrity/v1` |
543
- | `MULTIKEY_CONTEXT` | `https://w3id.org/security/multikey/v1` |
544
- | `EXTENSION_CONTEXT` | The embedded term map `stringify` writes |
545
- | `ACTIVITY_TYPES` | The ActivityStreams activity types, plus `EmojiReact` |
546
- | `ACTOR_TYPES` | The ActivityStreams actor types |
547
- | `MAX_ACTIVITY_BYTES` | `122880`, the largest activity a delivery carries |
548
- | `DELIVERY_BACKOFF` | The default retry schedule |
549
-
550
- `DELIVERY_BACKOFF` is `createBackoff({ steps: ["5 minutes", "30 minutes", "2 hours",
551
- "6 hours", "12 hours"], jitter: 0.2 })` from
552
- [`@sdxc/backoff`](https://www.npmjs.com/package/@sdxc/backoff): about 21 hours over five
553
- retries. An app whose queue retries more passes its own as `backoff`.
284
+ `PUBLIC`, `ACTIVITY_JSON`, `LD_JSON`, `LD_JSON_ACTIVITY`, `ACTIVITY_CONTENT_TYPE`,
285
+ `ACTIVITY_ACCEPT`, `AS2_CONTEXT`, `SECURITY_CONTEXT`, `DATA_INTEGRITY_CONTEXT`,
286
+ `MULTIKEY_CONTEXT`, `EXTENSION_CONTEXT`, `ACTIVITY_TYPES` and `ACTOR_TYPES`.
287
+ `MAX_ACTIVITY_BYTES` is `122880`. `DELIVERY_BACKOFF` is an
288
+ [`@sdxc/backoff`](https://www.npmjs.com/package/@sdxc/backoff) schedule of five retries over about
289
+ 21 hours.
554
290
 
555
291
  ### Errors
556
292
 
557
- Every error extends `ActivityPubError`, which carries a `code` and a `retryable` flag.
293
+ Every error extends `ActivityPubError`, with a `code` and a `retryable` flag.
558
294
 
559
- - `ActivityPubParseError`: `code: "invalid-document"`, never retryable. `kind` names the
560
- reader, and `issues` lists every problem as `{ at, message }`, where `at` is a JSON Pointer.
561
- - `ActivityPubFetchError`: a remote document that could not be used, with its `url` and the
562
- response `status` (codes in the `RemoteResolver` table).
563
- - `InboxError`: a refused request, with the `status` it is answered with. `toResponse()`
564
- answers that status with a fixed `text/plain` sentence per code, so a sender learns which
565
- rule failed and never what the verifier saw; `ignored` answers an empty `202`.
295
+ - `ActivityPubParseError`: `invalid-document`, with `kind` and `issues` as `{ at, message }`.
296
+ - `ActivityPubFetchError`: a remote document that could not be used, with `url` and `status`;
297
+ codes `gone`, `not-found`, `unauthorized`, `http`, `refused-url`, `too-large`, `timeout`,
298
+ `network`, `invalid-document` and `id-mismatch`.
299
+ - `InboxError`: a refused request, with `status` and `toResponse()`.
566
300
  - `DeliveryError`: a delivery that did not land, with `inbox`, `status` and `retryAfter`.
567
- - `FederationError`: how `process` fails, with `kind`, `delay` and the error underneath as
568
- `cause`.
301
+ - `FederationError`: how `process` fails, with `kind`, `delay` in milliseconds, and the error
302
+ underneath as `cause`.
569
303
 
570
304
  ### Storage
571
305
 
572
- The package keeps no state of its own. It calls four interfaces, exported as types, and the
573
- app implements them over its own database, or uses `CacheSeenActivities` for `SeenActivities`:
306
+ The app implements these over its own database:
574
307
 
575
- - `FollowerStore`: followers keyed by `(actor, id)`. `put` inserts or replaces, so a repeated
576
- Follow refreshes the inbox and `followId`. `list`, `count` and `inboxes` see accepted
577
- followers only; `inboxes` answers each `sharedInbox ?? inbox` once. `removeInbox` drops
578
- every follower whose inbox or shared inbox is the URL. Listings page through
579
- `StorePage<T>`, whose `next` is the store's own opaque cursor.
580
- - `SeenActivities`: `claim(id, ttl)` answers `true` the first time an id is claimed within
581
- `ttl`, a `DurationInput` such as `"1 day"`.
308
+ - `FollowerStore`: `put`, `get`, `remove`, `removeInbox`, and `list`, `count` and `inboxes`
309
+ over accepted followers. `put` inserts or replaces by `(actor, id)`; listings page through
310
+ `StorePage<T>` with the store's own cursor.
311
+ - `SeenActivities`: `claim(id, ttl)` answers `true` the first time an id is claimed within `ttl`.
582
312
  - `LocalObjects`: `find(id)` answers the object the app serves under `id`, or `null`.
583
313
  - `KeyProvider`: `keysOf(actor)` answers a hosted actor's `ActorKeys`, or `null`.
584
314
 
585
- ### `CacheSeenActivities`
586
-
587
- A shipped `SeenActivities` over any `@sdxc/cache` `Cache`, so an app that already caches remote
588
- documents in Workers KV can hold its claims there too.
589
-
590
- ```typescript
591
- import { CacheSeenActivities, Federation } from "@sdxc/activitypub";
592
- import { WorkerKVCache } from "@sdxc/cache/worker-kv";
593
-
594
- let cache = new WorkerKVCache(env.CACHE);
595
-
596
- let federation = new Federation({
597
- // ...
598
- stores: { followers, seen: new CacheSeenActivities(cache), objects },
599
- cache,
600
- });
601
- ```
602
-
603
- - Claims live under `activitypub:seen:<id>`, apart from anything else the cache holds.
604
- - A claim's TTL is rounded up to whole seconds and held for at least 60, the shortest
605
- expiration Workers KV accepts, so a TTL under a minute is stretched rather than refused.
606
- - `claim` fails with a `RangeError` for a TTL that is not a positive duration, and with the
607
- cache's error when the key cannot be read. A refused write still answers `true`: the
608
- activity is processed, and a redelivery is processed again.
609
- - A claim reads the key and writes it when missing, so two deliveries racing through different
610
- isolates can both win. Every handler is idempotent, which makes that harmless.
611
-
612
315
  ### `@sdxc/activitypub/testing`
613
316
 
614
- In-memory implementations of every storage interface, and Vitest suites an app registers
615
- against its own storage so it is held to the contracts the package relies on.
616
-
617
- - `new MemoryFollowerStore(followers?)` lists in insertion order, and its cursors are offsets.
618
- - `new MemorySeenActivities({ now? })` reads the time from `now`, so a test passes a TTL by
619
- moving its own clock.
620
- - `new MemoryLocalObjects(objects?)` serves a fixed list of objects.
621
- - `new MemoryKeyProvider(keys?)` hosts a fixed list of `ActorKeys`.
622
-
623
- ```typescript
624
- import {
625
- followerStoreConformance,
626
- keyProviderConformance,
627
- localObjectsConformance,
628
- seenActivitiesConformance,
629
- } from "@sdxc/activitypub/testing";
630
-
631
- followerStoreConformance({ name: "D1FollowerStore", create: () => new D1FollowerStore(db) });
632
- seenActivitiesConformance({ name: "KvSeenActivities", create: () => new KvSeenActivities(kv) });
633
- localObjectsConformance({ name: "Posts", create: () => new Posts(db), served: [ARTICLE_ID] });
634
- keyProviderConformance({ name: "Keys", create: () => new Keys(env), hosted: [ACTOR_ID] });
635
- ```
636
-
637
- Each suite takes a `name` that labels it and a `create` that builds a fresh implementation
638
- for every test. `seenActivitiesConformance` takes an optional `advance(ms)` that moves the
639
- store's clock, `localObjectsConformance` takes `served` ids, and `keyProviderConformance`
640
- takes `hosted` actors.
317
+ - `MemoryFollowerStore`, `MemorySeenActivities` (`{ now? }`), `MemoryLocalObjects` and
318
+ `MemoryKeyProvider`: in-memory stores.
319
+ - `followerStoreConformance`, `seenActivitiesConformance`, `localObjectsConformance` and
320
+ `keyProviderConformance`: Vitest suites holding the app's own stores to the contracts the
321
+ package relies on.
641
322
 
642
323
  ### Types
643
324
 
644
- `ActivityPub` holds the vocabulary as types: `Object`, `Activity`, `Actor`, `PublicKey`,
645
- `Multikey`, `Proof`, `Tag` (`Hashtag`, `Mention`, `Emoji`), `Attachment` (`Media`,
646
- `PropertyValue`), `Image`, `Tombstone`, `Collection`, `CollectionPage`, `OrderedCollection`,
647
- `OrderedCollectionPage`, and `Ref<T>` for a member that may be an IRI or the object it names.
648
- Every member a reader returns is present, with `null`, `[]`, `{}` or `false` when the sender
649
- left it out. `ActivityPub.Draft<T>` is the shape an app authors: `id` and `type` required,
650
- every other member optional.
651
-
652
- `Federation` carries its own types: `Options`, `Stores`, `Queue`, `OutboxPage`, `Message`,
325
+ `ActivityPub` holds the vocabulary (`Object`, `Activity`, `Actor`, `Collection`, `Tombstone`,
326
+ `Ref<T>` and the rest); `ActivityPub.Draft<T>` is the shape an app authors, with only `id` and
327
+ `type` required. `Federation` carries `Options`, `Stores`, `Queue`, `OutboxPage`, `Message`,
653
328
  `Context`, `DeleteContext`, `HandledType`, `Outcome`, `ProcessOptions` and `RespondOptions`.
329
+ Also exported: `Follower`, `StorePage`, `Summary`, `SummaryAuthor`, `FollowDecision`,
330
+ `HandleReason`, `BlockedCheck`, `Verification`, `VerifiedFetch`, `ResolverOptions`,
331
+ `ResolverSigner`, `ResolverTtl`, `ResolveOptions`, `ResolvedKey`, `CollectionInit`, `PageInit`,
332
+ `CollectionItem`, `TombstoneInit`, and the `ActivityPubFetchErrorCode`, `InboxErrorCode` and
333
+ `DeliveryErrorCode` unions.
654
334
 
655
335
  ## Pattern: One Job With `@sdxc/jobs`
656
336
 
657
- The package has no dependency on a job runner. With
658
- [`@sdxc/jobs`](https://www.npmjs.com/package/@sdxc/jobs), one declared job carries every
659
- message, and its handler builds the federation from the job's context:
337
+ With [`@sdxc/jobs`](https://www.npmjs.com/package/@sdxc/jobs), one job declared with
338
+ `Federation.MESSAGE` carries every message, and its handler builds the federation from the job's
339
+ context.
660
340
 
661
341
  ```typescript
662
342
  import { Federation } from "@sdxc/activitypub";
@@ -667,11 +347,11 @@ export let definitions = jobs({ federation: job({ input: Federation.MESSAGE }) }
667
347
 
668
348
  export function federationFor(stores: Federation.Stores): Federation {
669
349
  return new Federation({
670
- actor: ACTOR,
350
+ actor,
671
351
  keys,
672
352
  stores,
673
353
  cache,
674
- userAgent: USER_AGENT,
354
+ userAgent: "example.com/1.0 (+https://example.com)",
675
355
  queue: {
676
356
  enqueue: (message) => dispatcher.enqueue(definitions.federation, message),
677
357
  enqueueMany: (messages) => dispatcher.enqueueMany(definitions.federation, messages),
@@ -682,49 +362,179 @@ export function federationFor(stores: Federation.Stores): Federation {
682
362
  export default createJobHandler(definitions.federation, async (ctx) => {
683
363
  let processed = await federationFor(storesOf(ctx)).process(ctx.input, { attempts: ctx.attempts });
684
364
  if (isFailure(processed)) {
685
- if (processed.error.retryable)
686
- return ctx.retry({ delay: processed.error.delay, cause: processed.error });
365
+ if (processed.error.retryable) return ctx.retry({ delay: processed.error.delay });
687
366
  return ctx.ack(processed.error.message);
688
367
  }
689
368
  });
690
369
  ```
691
370
 
692
- ## Quirks the package absorbs
693
-
694
- | Quirk | Handled by |
695
- | ----------------------------------------------------------------------------------- | ---------------------------------------------------- |
696
- | `as:Public`, `Public` and the full IRI all address the public | `parse*` |
697
- | One value or an array for `to`, `cc`, `tag`, `attachment` and `type` | `parse*` |
698
- | An IRI or an embedded object for `actor`, `attributedTo`, `inReplyTo` and `object` | `parse*` |
699
- | `contentMap`, `nameMap` or `summaryMap` without the plain value | `parse*` |
700
- | `url` as a string, a `Link`, or a list of `Link`s with the HTML page anywhere in it | `parse*` |
701
- | Quotes as `quote` (Mastodon 4.5), `_misskey_quote`, `quoteUrl` or `quoteUri` | `parse*`, `stringify` |
702
- | Misskey emoji reactions as a `Like` with `content` or only `_misskey_reaction` | `parseActivity` |
703
- | Mastodon embeds the first page of `replies` without an `id` | `parseCollection` |
704
- | WebFinger `self` must be typed `application/activity+json` | `actorLink` |
705
- | A handle resolves back through `preferredUsername@<actor host>` | `federation.lookup` |
706
- | `publicKey.id` is `<actor>#main-key`, with `owner` | `ActorKeys#publicKey`, `federation.fetch` |
707
- | Secure mode and GoToSocial refuse unsigned GETs | `RemoteResolver` signs GETs |
708
- | A deleted account's key answers `410` | `RemoteResolver` → `gone` |
709
- | A refetch of a deleted object must answer `410` | `federation.respond` |
710
- | A sender falls back from RFC 9421 to cavage only on a synchronous `401` | `federation.fetch` verifies in the request |
711
- | A rotated key fails against the cached one | The inbox refetches the key once |
712
- | Mastodon forwards replies to a thread's participants under its own signature | The inbox refetches the activity from its origin |
713
- | A deleted account sends unverifiable `Delete`s to every server it knew | The inbox answers `202` (`ignored`) |
714
- | An embedded object from another origin can say anything | `process` fetches it from its own origin (FEP-c7d3) |
715
- | `Accept` should embed the Follow, since some servers match on it | `process` |
716
- | A repeated Follow means the sender never saw the Accept | `process` re-sends it |
717
- | Misskey reactions are a `Like` with `content`; Pleroma sends `EmojiReact` | `Like` handlers and `summary()` read both as a like |
718
- | Every inbox POST must be signed and cover the body digest | `process` (`deliver`) |
719
- | Mastodon before 4.5 rejects RFC 9421 and newer servers may prefer it | Double-knocking, scheme cached per origin |
720
- | `Date` must be an IMF-fixdate inside the receiver's window | Deliveries are signed at send time, never at enqueue |
721
- | Some instances refuse requests without a `User-Agent` | `userAgent` is a required option |
722
- | A server can ask for specific components through `Accept-Signature` | Delivery honors it once |
723
- | An inbox answering `410` is gone for good | `process` removes its followers |
724
- | Servers down for days should stop costing a delivery per post | Fan-out skips origins failing for 7 days |
725
- | One server hosting many followers wants one POST | Fan-out dedupes `sharedInbox ?? inbox` |
726
- | `bto` and `bcc` must not reach recipients | Fan-out strips them before delivery |
727
- | A queue message is at most 128 KB | `federation.publish` → `too-large` past 120 KB |
371
+ ## Pattern: Serve A Post As HTML Or ActivityStreams
372
+
373
+ One URL serves both representations; a deleted post is served as a tombstone, which answers
374
+ `410` so remote servers drop their copy.
375
+
376
+ ```typescript
377
+ import type { ActivityPub } from "@sdxc/activitypub";
378
+
379
+ import { PUBLIC, stringify, tombstone } from "@sdxc/activitypub";
380
+
381
+ let post = await posts.find(slug);
382
+ if (post.deletedAt) {
383
+ let gone = await federation.respond(
384
+ request,
385
+ tombstone({ id: post.url, formerType: "Article", deleted: post.deletedAt }),
386
+ );
387
+ return gone ?? new Response("Gone", { status: 410 });
388
+ }
389
+
390
+ let article = {
391
+ id: post.url,
392
+ type: "Article",
393
+ attributedTo: ["https://example.com/actor"],
394
+ to: [PUBLIC],
395
+ cc: ["https://example.com/actor/followers"],
396
+ name: post.title,
397
+ content: post.html,
398
+ published: post.publishedAt,
399
+ } satisfies ActivityPub.Draft<ActivityPub.Object>;
400
+
401
+ let activity = await federation.respond(request, article);
402
+ if (activity) return activity; // the client asked for ActivityStreams
403
+ return renderPage(post); // the HTML, with <link rel="alternate" type="application/activity+json">
404
+ ```
405
+
406
+ The `alternate` link lets a Mastodon user paste the page URL into search and find the post.
407
+ `stringify(article)` gives the same JSON for storage or a static file.
408
+
409
+ ## Pattern: WebFinger And Handle Lookup
410
+
411
+ Mastodon resolves `@hello@example.com` through WebFinger, then checks the reverse direction, so
412
+ the app answers WebFinger with `actorLink`. `lookup` does the same resolution for remote handles.
413
+
414
+ ```typescript
415
+ import { actorLink } from "@sdxc/activitypub";
416
+ import { isFailure } from "@sdxc/result";
417
+
418
+ if (url.pathname === "/.well-known/webfinger") {
419
+ if (url.searchParams.get("resource") !== "acct:hello@example.com") {
420
+ return new Response(null, { status: 404 });
421
+ }
422
+ return Response.json(
423
+ {
424
+ subject: "acct:hello@example.com",
425
+ aliases: ["https://example.com/actor"],
426
+ links: [actorLink("https://example.com/actor")],
427
+ },
428
+ { headers: { "content-type": "application/jrd+json" } },
429
+ );
430
+ }
431
+
432
+ let remote = await federation.lookup("@someone@mastodon.social");
433
+ if (isFailure(remote)) return Response.json({ error: remote.error.code }, { status: 404 });
434
+ return Response.json({ id: remote.data.id, name: remote.data.preferredUsername });
435
+ ```
436
+
437
+ ## Pattern: Approve Followers By Hand
438
+
439
+ With `manuallyApprovesFollowers` on the actor, every Follow is stored `pending` unless the
440
+ handler decides; a pending follower is outside the followers collection and every fan-out until
441
+ approved.
442
+
443
+ ```typescript
444
+ federation.on("Follow", async (ctx) => {
445
+ await notifications.add({ kind: "follow-request", from: ctx.actor.id });
446
+ return ctx.actor.movedTo ? "reject" : null; // null keeps the default: pending
447
+ });
448
+
449
+ // Later, from the app's own UI:
450
+ let approved = await federation.approve("https://mastodon.social/users/alice");
451
+ let rejected = await federation.reject("https://spam.example.com/users/bot");
452
+ ```
453
+
454
+ ## Pattern: An Outbox And A Replies Collection
455
+
456
+ The `outbox` option pages the actor's outbox from the app's own posts; the collection builders
457
+ serve any other collection, such as a post's replies.
458
+
459
+ ```typescript
460
+ import { orderedCollection, orderedCollectionPage } from "@sdxc/activitypub";
461
+ import { success } from "@sdxc/result";
462
+
463
+ let federation = new Federation({
464
+ // ...actor, keys, stores, cache, userAgent, queue
465
+ outbox: async (cursor) => {
466
+ let page = await posts.page({ after: cursor, limit: 20 });
467
+ return success({
468
+ items: page.items.map((post) => `${post.url}#create`),
469
+ next: page.next,
470
+ totalItems: page.total,
471
+ });
472
+ },
473
+ });
474
+
475
+ let replies = `${post.url}/replies`;
476
+ let document = url.searchParams.has("page")
477
+ ? orderedCollectionPage({ id: url.href, partOf: replies, orderedItems: replyIds, next: null })
478
+ : orderedCollection({ id: replies, totalItems: replyIds.length, first: `${replies}?page=true` });
479
+ return (await federation.respond(request, document)) ?? new Response(null, { status: 406 });
480
+ ```
481
+
482
+ ## Pattern: Read Remote Documents Without A Federation
483
+
484
+ `RemoteResolver` works alone, for an app that only reads the fediverse, and `parse*` reads
485
+ documents it got elsewhere.
486
+
487
+ ```typescript
488
+ import { parseObject, RemoteResolver } from "@sdxc/activitypub";
489
+ import { MemoryCache } from "@sdxc/cache/memory";
490
+ import { isFailure } from "@sdxc/result";
491
+
492
+ let resolver = new RemoteResolver({
493
+ cache: new MemoryCache(),
494
+ userAgent: "example.com/1.0 (+https://example.com)",
495
+ });
496
+
497
+ let actor = await resolver.actor("https://mastodon.social/users/someone");
498
+ if (isFailure(actor) && actor.error.code === "gone") await profiles.remove(actor.error.url);
499
+
500
+ let note = await resolver.object("https://mastodon.social/@someone/1", { fresh: true }); // skips the cache
501
+
502
+ let parsed = parseObject(JSON.parse(stored));
503
+ if (isFailure(parsed)) return Response.json(parsed.error.issues, { status: 422 }); // [{ at, message }]
504
+ ```
505
+
506
+ ## Pattern: Test Against In-Memory Stores And Hold Real Ones To The Contract
507
+
508
+ ```typescript
509
+ import { Federation } from "@sdxc/activitypub";
510
+ import {
511
+ followerStoreConformance,
512
+ MemoryFollowerStore,
513
+ MemoryLocalObjects,
514
+ MemorySeenActivities,
515
+ seenActivitiesConformance,
516
+ } from "@sdxc/activitypub/testing";
517
+ import { MemoryCache } from "@sdxc/cache/memory";
518
+
519
+ let federation = new Federation({
520
+ actor,
521
+ keys,
522
+ stores: {
523
+ followers: new MemoryFollowerStore(),
524
+ seen: new MemorySeenActivities(),
525
+ objects: new MemoryLocalObjects([article]),
526
+ },
527
+ cache: new MemoryCache(),
528
+ userAgent: "test",
529
+ queue: { enqueue: async (message) => void queued.push(message) },
530
+ });
531
+
532
+ followerStoreConformance({ name: "SqlFollowerStore", create: () => new SqlFollowerStore(db) });
533
+ seenActivitiesConformance({ name: "KvSeenActivities", create: () => new KvSeenActivities(kv) });
534
+ ```
535
+
536
+ `localObjectsConformance` also takes the `served` ids, `keyProviderConformance` the `hosted`
537
+ actors, and `seenActivitiesConformance` an optional `advance(ms)` that moves the store's clock.
728
538
 
729
539
  ## Versioning
730
540
 
@@ -7,6 +7,7 @@
7
7
  * @copyright Sergio Xalambrí 2026
8
8
  */
9
9
  import { unwrap } from "@sdxc/result";
10
+ import { generateUUID } from "@sdxc/uuid/v4";
10
11
  import { describe, expect, test } from "vitest";
11
12
  /** The local actor most tests follow, and a second one that proves followers are per actor. */
12
13
  const ACTOR = "https://local.com/users/author";
@@ -282,7 +283,7 @@ export function localObjectsConformance({ name, create, served, }) {
282
283
  });
283
284
  test("answers null for an IRI it serves nothing under", async () => {
284
285
  let objects = await create();
285
- expect(unwrap(await objects.find(`https://remote.com/${crypto.randomUUID()}`))).toBeNull();
286
+ expect(unwrap(await objects.find(`https://remote.com/${generateUUID()}`))).toBeNull();
286
287
  });
287
288
  });
288
289
  }
@@ -307,7 +308,7 @@ export function keyProviderConformance({ name, create, hosted, }) {
307
308
  });
308
309
  test("answers null for an actor it does not host", async () => {
309
310
  let provider = await create();
310
- expect(unwrap(await provider.keysOf(`https://remote.com/users/${crypto.randomUUID()}`))).toBeNull();
311
+ expect(unwrap(await provider.keysOf(`https://remote.com/users/${generateUUID()}`))).toBeNull();
311
312
  });
312
313
  });
313
314
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sdxc/activitypub",
3
- "version": "2026.10.12",
3
+ "version": "2026.10.13",
4
4
  "description": "ActivityPub protocol logic: vocabulary, actors, discovery, a verified inbox and signed delivery",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -9,20 +9,18 @@
9
9
  "./testing": "./dist/testing.js"
10
10
  },
11
11
  "dependencies": {
12
- "@sdxc/backoff": "2026.10.6",
13
- "@sdxc/cache": "2026.10.12",
14
- "@sdxc/crypto": "2026.10.10",
15
- "@sdxc/digest-fields": "2026.10.10",
16
- "@sdxc/duration": "2026.10.6",
17
- "@sdxc/html": "2026.10.6",
18
- "@sdxc/http": "2026.10.10",
19
- "@sdxc/http-signatures": "2026.10.10",
20
- "@sdxc/logger": "2026.10.12",
21
- "@sdxc/outbound": "2026.10.10",
22
- "@sdxc/result": "2026.10.6",
23
- "@sdxc/validate": "2026.10.6",
24
- "@sdxc/well-known": "2026.10.10",
25
- "@standard-schema/spec": "^1.1.0",
12
+ "@sdxc/backoff": "2026.10.13",
13
+ "@sdxc/cache": "2026.10.13",
14
+ "@sdxc/crypto": "2026.10.13",
15
+ "@sdxc/duration": "2026.10.13",
16
+ "@sdxc/html": "2026.10.13",
17
+ "@sdxc/http": "2026.10.13",
18
+ "@sdxc/http-signatures": "2026.10.13",
19
+ "@sdxc/logger": "2026.10.13",
20
+ "@sdxc/outbound": "2026.10.13",
21
+ "@sdxc/result": "2026.10.13",
22
+ "@sdxc/uuid": "2026.10.13",
23
+ "@sdxc/well-known": "2026.10.13",
26
24
  "remix": "3.0.0"
27
25
  },
28
26
  "peerDependencies": {
@@ -33,7 +31,7 @@
33
31
  "optional": true
34
32
  }
35
33
  },
36
- "gitHead": "857ef87ddb0b2484e28b46431d0740fcc80e5f33",
34
+ "gitHead": "5ff758a57e35480a8db7abd24f1d9527b81bfb51",
37
35
  "publishConfig": {
38
36
  "access": "public"
39
37
  },