@sdxc/activitypub 0.0.0-pre.1 → 0.0.0-stage
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.
- package/README.md +2 -710
- package/package.json +5 -53
- package/LICENSE.md +0 -21
- package/dist/collections.d.ts +0 -67
- package/dist/collections.js +0 -38
- package/dist/conformance.d.ts +0 -80
- package/dist/conformance.js +0 -313
- package/dist/discovery.d.ts +0 -46
- package/dist/discovery.js +0 -105
- package/dist/errors.d.ts +0 -115
- package/dist/errors.js +0 -124
- package/dist/federation.d.ts +0 -281
- package/dist/federation.js +0 -513
- package/dist/fixtures/gotosocial.d.ts +0 -114
- package/dist/fixtures/gotosocial.js +0 -115
- package/dist/fixtures/index.d.ts +0 -15
- package/dist/fixtures/index.js +0 -50
- package/dist/fixtures/mastodon.d.ts +0 -489
- package/dist/fixtures/mastodon.js +0 -334
- package/dist/fixtures/misskey.d.ts +0 -254
- package/dist/fixtures/misskey.js +0 -163
- package/dist/fixtures/pixelfed.d.ts +0 -118
- package/dist/fixtures/pixelfed.js +0 -126
- package/dist/fixtures/shared.d.ts +0 -14
- package/dist/fixtures/shared.js +0 -14
- package/dist/fixtures/threads.d.ts +0 -82
- package/dist/fixtures/threads.js +0 -88
- package/dist/index.d.ts +0 -33
- package/dist/index.js +0 -22
- package/dist/keys.d.ts +0 -100
- package/dist/keys.js +0 -147
- package/dist/lib/availability.d.ts +0 -37
- package/dist/lib/availability.js +0 -52
- package/dist/lib/compact.d.ts +0 -145
- package/dist/lib/compact.js +0 -514
- package/dist/lib/constants.d.ts +0 -35
- package/dist/lib/constants.js +0 -65
- package/dist/lib/delivery-fan-out.d.ts +0 -72
- package/dist/lib/delivery-fan-out.js +0 -202
- package/dist/lib/delivery-post.d.ts +0 -108
- package/dist/lib/delivery-post.js +0 -318
- package/dist/lib/fetch.d.ts +0 -64
- package/dist/lib/fetch.js +0 -103
- package/dist/lib/inbox-error.d.ts +0 -45
- package/dist/lib/inbox-error.js +0 -74
- package/dist/lib/inbox-handle.d.ts +0 -119
- package/dist/lib/inbox-handle.js +0 -587
- package/dist/lib/inbox-receive.d.ts +0 -117
- package/dist/lib/inbox-receive.js +0 -233
- package/dist/lib/inbox-signature.d.ts +0 -61
- package/dist/lib/inbox-signature.js +0 -156
- package/dist/lib/inbox-summarize.d.ts +0 -41
- package/dist/lib/inbox-summarize.js +0 -97
- package/dist/lib/messages.d.ts +0 -36
- package/dist/lib/messages.js +0 -35
- package/dist/lib/parse.d.ts +0 -42
- package/dist/lib/parse.js +0 -348
- package/dist/lib/response.d.ts +0 -45
- package/dist/lib/response.js +0 -59
- package/dist/lib/stringify.d.ts +0 -51
- package/dist/lib/stringify.js +0 -122
- package/dist/lib/tombstone.d.ts +0 -24
- package/dist/lib/tombstone.js +0 -15
- package/dist/lib/types.d.ts +0 -249
- package/dist/lib/types.js +0 -0
- package/dist/memory.d.ts +0 -77
- package/dist/memory.js +0 -146
- package/dist/remote.d.ts +0 -105
- package/dist/remote.js +0 -262
- package/dist/store.d.ts +0 -78
- package/dist/store.js +0 -8
- package/dist/testing.d.ts +0 -12
- package/dist/testing.js +0 -10
package/README.md
CHANGED
|
@@ -1,711 +1,3 @@
|
|
|
1
|
-
#
|
|
1
|
+
# Temporary Holding Version
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
## Installation
|
|
6
|
-
|
|
7
|
-
```sh
|
|
8
|
-
npm add @sdxc/activitypub
|
|
9
|
-
```
|
|
10
|
-
|
|
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
|
|
15
|
-
`@sdxc/activitypub/testing`.
|
|
16
|
-
|
|
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
|
-
## Usage
|
|
24
|
-
|
|
25
|
-
### Set Up A Federation
|
|
26
|
-
|
|
27
|
-
```typescript
|
|
28
|
-
import { ActorKeys, Federation, PUBLIC } from "@sdxc/activitypub";
|
|
29
|
-
import { unwrap } from "@sdxc/result";
|
|
30
|
-
|
|
31
|
-
// Once, from a script: store privateKeyPem as a secret.
|
|
32
|
-
let { privateKeyPem } = unwrap(await ActorKeys.generate());
|
|
33
|
-
|
|
34
|
-
let keys = unwrap(await ActorKeys.import({ actor: ACTOR_ID, privateKeyPem: env.PRIVATE_KEY }));
|
|
35
|
-
|
|
36
|
-
let federation = new Federation({
|
|
37
|
-
actor: ACTOR, // the local actor document; its ids name the URLs `fetch` answers
|
|
38
|
-
keys,
|
|
39
|
-
stores: { followers, seen, objects }, // FollowerStore, SeenActivities, LocalObjects
|
|
40
|
-
cache, // an @sdxc/cache Cache, such as one over Workers KV
|
|
41
|
-
userAgent: "example.com/1.0 (+https://example.com)",
|
|
42
|
-
blocked: (host) => blockedHosts.has(host),
|
|
43
|
-
queue: { enqueue: (message) => queue.send(message) },
|
|
44
|
-
});
|
|
45
|
-
```
|
|
46
|
-
|
|
47
|
-
### Answer The Actor's URLs
|
|
48
|
-
|
|
49
|
-
```typescript
|
|
50
|
-
let response = await federation.fetch(request);
|
|
51
|
-
if (response) return response; // the actor, its inbox, outbox, followers or following
|
|
52
|
-
return router.fetch(request);
|
|
53
|
-
```
|
|
54
|
-
|
|
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.
|
|
57
|
-
|
|
58
|
-
### Process The Queue
|
|
59
|
-
|
|
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:
|
|
62
|
-
|
|
63
|
-
```typescript
|
|
64
|
-
import { Federation } from "@sdxc/activitypub";
|
|
65
|
-
import { isFailure } from "@sdxc/result";
|
|
66
|
-
|
|
67
|
-
let processed = await federation.process(message, { attempts });
|
|
68
|
-
if (isFailure(processed)) {
|
|
69
|
-
if (processed.error.retryable) return retry({ delay: processed.error.delay });
|
|
70
|
-
return acknowledge(processed.error.message);
|
|
71
|
-
}
|
|
72
|
-
```
|
|
73
|
-
|
|
74
|
-
### Handle Replies, Likes And Follows
|
|
75
|
-
|
|
76
|
-
```typescript
|
|
77
|
-
federation.on(["Create", "Update", "Like", "Announce"], async (ctx) => {
|
|
78
|
-
let summary = ctx.summary(); // a reply, mention, like or repost of local content
|
|
79
|
-
if (summary) await responses.save(summary);
|
|
80
|
-
});
|
|
81
|
-
|
|
82
|
-
federation.on("Follow", async (ctx) => (isWelcome(ctx.actor) ? "accept" : "pending"));
|
|
83
|
-
```
|
|
84
|
-
|
|
85
|
-
### Publish A Post
|
|
86
|
-
|
|
87
|
-
```typescript
|
|
88
|
-
await federation.publish({
|
|
89
|
-
id: `${post.url}#create`,
|
|
90
|
-
type: "Create",
|
|
91
|
-
actor: ACTOR_ID,
|
|
92
|
-
to: [PUBLIC],
|
|
93
|
-
cc: [FOLLOWERS_ID],
|
|
94
|
-
object: article,
|
|
95
|
-
});
|
|
96
|
-
```
|
|
97
|
-
|
|
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
|
-
```
|
|
105
|
-
|
|
106
|
-
## API
|
|
107
|
-
|
|
108
|
-
### `Federation`
|
|
109
|
-
|
|
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.
|
|
112
|
-
|
|
113
|
-
#### `new Federation(options: Federation.Options)`
|
|
114
|
-
|
|
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.
|
|
125
|
-
- `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.
|
|
143
|
-
- `now`: the clock, for tests.
|
|
144
|
-
|
|
145
|
-
#### `federation.on(type, handler): Federation`
|
|
146
|
-
|
|
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.
|
|
154
|
-
|
|
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
|
-
```
|
|
168
|
-
|
|
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.
|
|
190
|
-
|
|
191
|
-
#### `federation.fetch(request: Request): Promise<Response | null>`
|
|
192
|
-
|
|
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.
|
|
225
|
-
|
|
226
|
-
#### `federation.respond(request, document, options?): Promise<Response | null>`
|
|
227
|
-
|
|
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.
|
|
238
|
-
|
|
239
|
-
#### `federation.verify(request: Request): Promise<Result<VerifiedFetch, InboxError>>`
|
|
240
|
-
|
|
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()`.
|
|
245
|
-
|
|
246
|
-
#### `federation.process(message, options?): Promise<Result<Federation.Outcome, FederationError>>`
|
|
247
|
-
|
|
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.
|
|
310
|
-
|
|
311
|
-
#### `federation.publish(activity): Promise<Result<void, ActivityPubError>>`
|
|
312
|
-
|
|
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.lookup(handle: string): Promise<Result<ActivityPub.Actor, ActivityPubFetchError>>`
|
|
319
|
-
|
|
320
|
-
Resolves `@user@host`, `user@host` or `acct:user@host` the way Mastodon does: WebFinger on the
|
|
321
|
-
handle's host, the `self` link typed as ActivityStreams, then the actor. A handle other than
|
|
322
|
-
the actor's canonical `preferredUsername@<actor host>` resolves only when WebFinger on the
|
|
323
|
-
actor's host confirms the canonical handle points at the same actor. Fails `refused-url`,
|
|
324
|
-
`not-found` or `id-mismatch`, or with any code a fetch answers.
|
|
325
|
-
|
|
326
|
-
#### `federation.resolver: RemoteResolver`
|
|
327
|
-
|
|
328
|
-
The resolver the federation fetches with, sharing its cache and signer.
|
|
329
|
-
|
|
330
|
-
#### `Federation.MESSAGE`
|
|
331
|
-
|
|
332
|
-
The Standard Schema (from `remix/data-schema`) of `Federation.Message`, the union of every
|
|
333
|
-
message the federation enqueues, each plain JSON under 128 KB:
|
|
334
|
-
|
|
335
|
-
```typescript
|
|
336
|
-
// Federation.Message
|
|
337
|
-
type Message =
|
|
338
|
-
| {
|
|
339
|
-
kind: "inbox";
|
|
340
|
-
activity: Record<string, unknown>;
|
|
341
|
-
actor: string;
|
|
342
|
-
signer: string;
|
|
343
|
-
keyId: string;
|
|
344
|
-
origin: string;
|
|
345
|
-
verification: "signature" | "proof" | "refetched";
|
|
346
|
-
receivedAt: string;
|
|
347
|
-
}
|
|
348
|
-
| { kind: "fanOut"; actor: string; activity: string }
|
|
349
|
-
| { kind: "deliver"; actor: string; activity: string; inbox: string };
|
|
350
|
-
```
|
|
351
|
-
|
|
352
|
-
### `ActorKeys`
|
|
353
|
-
|
|
354
|
-
The RSA keys a local actor signs with: RSASSA-PKCS1-v1_5 2048-bit with SHA-256, which is what
|
|
355
|
-
Mastodon generates and verifies.
|
|
356
|
-
|
|
357
|
-
- `ActorKeys.generate(): Promise<Result<{ privateKeyPem, publicKeyPem }, CryptoError>>`
|
|
358
|
-
makes a PKCS#8 and SPKI pair. Run it once and store `privateKeyPem` as a secret; a new pair
|
|
359
|
-
is an actor `Update` delivered to every follower.
|
|
360
|
-
- `ActorKeys.import({ actor, privateKeyPem }): Promise<Result<ActorKeys, CryptoError>>`
|
|
361
|
-
derives the public half from the private key, so one secret is all an app stores. A PKCS#1
|
|
362
|
-
key (`BEGIN RSA PRIVATE KEY`) fails with an `InvalidKeyError`; convert it with
|
|
363
|
-
`openssl pkcs8 -topk8 -nocrypt`.
|
|
364
|
-
- `keys.id` is the key id every signature names, `<actor>#main-key`, and `keys.actor` the
|
|
365
|
-
actor's id.
|
|
366
|
-
- `keys.publicKey` is the actor document's `publicKey` member:
|
|
367
|
-
`{ id: "<actor>#main-key", owner: actor, publicKeyPem }`.
|
|
368
|
-
- `keys.rsa` holds `{ id, privateKey, publicKeyPem }`, the private key unextractable;
|
|
369
|
-
`keys.ed25519` is `null` until object integrity proofs ship.
|
|
370
|
-
- `new ActorKeys({ actor, rsa, ed25519? })` wraps keys already imported into Web Crypto.
|
|
371
|
-
|
|
372
|
-
### `RemoteResolver`
|
|
373
|
-
|
|
374
|
-
Fetches remote actors, keys and objects, and works on its own as well as inside a federation.
|
|
375
|
-
|
|
376
|
-
```typescript
|
|
377
|
-
import { RemoteResolver } from "@sdxc/activitypub";
|
|
378
|
-
|
|
379
|
-
let resolver = new RemoteResolver({
|
|
380
|
-
cache,
|
|
381
|
-
userAgent: "example.com/1.0 (+https://example.com)",
|
|
382
|
-
signer: { actor: ACTOR_ID, keys }, // signs every GET; omit for unsigned fetches
|
|
383
|
-
ttl: { actor: "1 day", object: "1 hour" },
|
|
384
|
-
});
|
|
385
|
-
|
|
386
|
-
let actor = await resolver.actor("https://mastodon.social/users/someone");
|
|
387
|
-
let key = await resolver.key("https://mastodon.social/users/someone#main-key"); // { owner, publicKey, actor }
|
|
388
|
-
let note = await resolver.object(iri, { fresh: true }); // skips the cached copy
|
|
389
|
-
```
|
|
390
|
-
|
|
391
|
-
- Options: `cache` (documents as JSON under `activitypub:doc:<iri>`; a failing store reads as
|
|
392
|
-
a miss), `userAgent`, `signer` (`{ actor, keys }` with `ActorKeys` or a `KeyProvider`, which
|
|
393
|
-
signs each GET with draft-cavage over `(request-target) host date`; a provider holding no
|
|
394
|
-
keys for the actor leaves GETs unsigned), `ttl`, `timeout` (`"10 seconds"` for the redirect
|
|
395
|
-
chain and the body) and `maxBytes` (1 MB).
|
|
396
|
-
- `actor(iri)`, `object(iri)` (an activity when the document has an `actor`), and
|
|
397
|
-
`document(iri)` (the decoded JSON, for a shape no reader models), each with `{ fresh }`.
|
|
398
|
-
- `key(keyId)` resolves a signature's key to the actor that publishes it: the actor must list
|
|
399
|
-
the key under exactly `keyId` (else `not-found`, which is also how a rotated key shows) and
|
|
400
|
-
the key's `owner` must be that actor (else `id-mismatch`).
|
|
401
|
-
- `evict(iri)` forgets a cached document, as after an actor's `Update` or `Delete`.
|
|
402
|
-
|
|
403
|
-
Every request goes through `@sdxc/outbound`: each hop is checked and its host resolved over
|
|
404
|
-
DNS-over-HTTPS, so a document pointing inside a network is refused, never fetched. A document
|
|
405
|
-
is accepted only when its `id` has the origin the redirect chain ended at. When a signed
|
|
406
|
-
request is redirected and the final URL answers `401` or `403`, it is signed for that URL and
|
|
407
|
-
asked once more. `Resolver` is the interface it implements.
|
|
408
|
-
|
|
409
|
-
| Outcome | `ActivityPubFetchError.code` |
|
|
410
|
-
| ---------------------------------------------------- | ---------------------------- |
|
|
411
|
-
| `410`, or a `Tombstone` (the cache entry is evicted) | `gone` |
|
|
412
|
-
| `404` | `not-found` |
|
|
413
|
-
| `401`, `403` | `unauthorized` |
|
|
414
|
-
| Any other non-2xx (`5xx` and `429` are retryable) | `http` |
|
|
415
|
-
| A URL, redirect or address `@sdxc/outbound` refuses | `refused-url` |
|
|
416
|
-
| Body past `maxBytes` | `too-large` |
|
|
417
|
-
| Deadline passed, connection failed (retryable) | `timeout`, `network` |
|
|
418
|
-
| Not JSON, no `id`, or not the shape asked for | `invalid-document` |
|
|
419
|
-
| `id` on another origin, key owned by another actor | `id-mismatch` |
|
|
420
|
-
|
|
421
|
-
### Vocabulary
|
|
422
|
-
|
|
423
|
-
#### `parseActivity(json: unknown): Result<ActivityPub.Activity, ActivityPubParseError>`
|
|
424
|
-
|
|
425
|
-
Reads an activity. It needs an absolute `id`, a `type` and an `actor`. Any type name is
|
|
426
|
-
accepted, so a type the app does not handle can still be acknowledged. An embedded `object` is
|
|
427
|
-
kept; trust it only when its `id` has the actor's origin.
|
|
428
|
-
|
|
429
|
-
#### `parseActor(json: unknown): Result<ActivityPub.Actor, ActivityPubParseError>`
|
|
430
|
-
|
|
431
|
-
Reads an actor. It needs an actor type (`Person`, `Service`, `Application`, `Group`,
|
|
432
|
-
`Organization`), an absolute `id`, a `preferredUsername` and an `inbox`. A `publicKey` that is
|
|
433
|
-
present but incomplete fails, since no signature could verify against it.
|
|
434
|
-
|
|
435
|
-
#### `parseObject(json: unknown): Result<ActivityPub.Object, ActivityPubParseError>`
|
|
436
|
-
|
|
437
|
-
Reads any object and keeps its `type`. A deleted one reads with `type: "Tombstone"`.
|
|
438
|
-
|
|
439
|
-
#### `parseCollection(json: unknown): Result<ActivityPub.AnyCollection, ActivityPubParseError>`
|
|
440
|
-
|
|
441
|
-
Reads a `Collection`, `OrderedCollection`, `CollectionPage` or `OrderedCollectionPage`. Its
|
|
442
|
-
`first` page may be an IRI or embedded, and each embedded item reads as an activity, an actor
|
|
443
|
-
or an object.
|
|
444
|
-
|
|
445
|
-
#### `stringify(document: ActivityPub.Draft<ActivityPub.Document>): string`
|
|
446
|
-
|
|
447
|
-
Writes a document with the ActivityStreams and security contexts, the data-integrity and
|
|
448
|
-
Multikey contexts when the document uses them, and an embedded map of the `toot:`, `schema:`
|
|
449
|
-
and `as:` extension terms. It omits `null`, empty arrays and empty maps, never writes `bto` or
|
|
450
|
-
`bcc`, writes dates as ISO 8601, and writes `quote` also as `_misskey_quote` and `quoteUri`.
|
|
451
|
-
|
|
452
|
-
```typescript
|
|
453
|
-
import type { ActivityPub } from "@sdxc/activitypub";
|
|
454
|
-
|
|
455
|
-
import { PUBLIC, stringify } from "@sdxc/activitypub";
|
|
456
|
-
|
|
457
|
-
let article = {
|
|
458
|
-
id: "https://example.com/articles/hello",
|
|
459
|
-
type: "Article",
|
|
460
|
-
attributedTo: ["https://example.com/actor"],
|
|
461
|
-
to: [PUBLIC],
|
|
462
|
-
cc: ["https://example.com/followers"],
|
|
463
|
-
name: "Hello",
|
|
464
|
-
content: "<p>Hello, fediverse.</p>",
|
|
465
|
-
published: new Date("2026-10-01T12:00:00Z"),
|
|
466
|
-
} satisfies ActivityPub.Draft<ActivityPub.Object>;
|
|
467
|
-
|
|
468
|
-
stringify(article); // JSON text with @context, ready to serve or deliver
|
|
469
|
-
```
|
|
470
|
-
|
|
471
|
-
#### `tombstone(init: TombstoneInit): ActivityPub.Tombstone`
|
|
472
|
-
|
|
473
|
-
The document a deleted object is served as: `{ id, formerType?, deleted? }`.
|
|
474
|
-
|
|
475
|
-
#### `wantsActivity(request: Request): boolean`
|
|
476
|
-
|
|
477
|
-
`true` when `application/activity+json` or `application/ld+json` is preferred over
|
|
478
|
-
`text/html`. A wildcard and a missing `Accept` read as HTML, so browsers and crawlers get the
|
|
479
|
-
page. `federation.respond` asks it first.
|
|
480
|
-
|
|
481
|
-
#### Collections
|
|
482
|
-
|
|
483
|
-
`orderedCollection`, `orderedCollectionPage`, `collection` and `collectionPage` build the
|
|
484
|
-
documents a collection serves, such as an object's `replies`. Give a collection `totalItems`
|
|
485
|
-
alone to publish a count without its members.
|
|
486
|
-
|
|
487
|
-
```typescript
|
|
488
|
-
import { orderedCollection, orderedCollectionPage } from "@sdxc/activitypub";
|
|
489
|
-
|
|
490
|
-
orderedCollection({ id: REPLIES, totalItems: 42, first: `${REPLIES}?page=true` });
|
|
491
|
-
orderedCollectionPage({ id: pageUrl, partOf: REPLIES, orderedItems: replies, next: nextUrl });
|
|
492
|
-
```
|
|
493
|
-
|
|
494
|
-
#### `actorLink(actorId: string): JrdLink`
|
|
495
|
-
|
|
496
|
-
The WebFinger `self` link typed `application/activity+json`, which Mastodon follows to
|
|
497
|
-
resolve a handle. WebFinger for `preferredUsername@<actor host>` must answer this link,
|
|
498
|
-
because Mastodon checks the reverse direction.
|
|
499
|
-
|
|
500
|
-
```typescript
|
|
501
|
-
import { actorLink } from "@sdxc/activitypub";
|
|
502
|
-
|
|
503
|
-
let jrd = {
|
|
504
|
-
subject: "acct:hello@example.com",
|
|
505
|
-
aliases: [],
|
|
506
|
-
properties: {},
|
|
507
|
-
links: [actorLink(ACTOR_ID)],
|
|
508
|
-
};
|
|
509
|
-
```
|
|
510
|
-
|
|
511
|
-
#### Constants
|
|
512
|
-
|
|
513
|
-
| Constant | Value |
|
|
514
|
-
| ------------------------ | ------------------------------------------------------ |
|
|
515
|
-
| `PUBLIC` | `https://www.w3.org/ns/activitystreams#Public` |
|
|
516
|
-
| `ACTIVITY_JSON` | `application/activity+json` |
|
|
517
|
-
| `LD_JSON` | `application/ld+json` |
|
|
518
|
-
| `LD_JSON_ACTIVITY` | `application/ld+json; profile="…activitystreams"` |
|
|
519
|
-
| `ACTIVITY_CONTENT_TYPE` | `application/activity+json; charset=utf-8` |
|
|
520
|
-
| `ACTIVITY_ACCEPT` | The `Accept` a client sends for the AS2 representation |
|
|
521
|
-
| `AS2_CONTEXT` | `https://www.w3.org/ns/activitystreams` |
|
|
522
|
-
| `SECURITY_CONTEXT` | `https://w3id.org/security/v1` |
|
|
523
|
-
| `DATA_INTEGRITY_CONTEXT` | `https://w3id.org/security/data-integrity/v1` |
|
|
524
|
-
| `MULTIKEY_CONTEXT` | `https://w3id.org/security/multikey/v1` |
|
|
525
|
-
| `EXTENSION_CONTEXT` | The embedded term map `stringify` writes |
|
|
526
|
-
| `ACTIVITY_TYPES` | The ActivityStreams activity types, plus `EmojiReact` |
|
|
527
|
-
| `ACTOR_TYPES` | The ActivityStreams actor types |
|
|
528
|
-
| `MAX_ACTIVITY_BYTES` | `122880`, the largest activity a delivery carries |
|
|
529
|
-
| `DELIVERY_BACKOFF` | The default retry schedule |
|
|
530
|
-
|
|
531
|
-
`DELIVERY_BACKOFF` is `createBackoff({ steps: ["5 minutes", "30 minutes", "2 hours",
|
|
532
|
-
"6 hours", "12 hours"], jitter: 0.2 })` from
|
|
533
|
-
[`@sdxc/backoff`](https://www.npmjs.com/package/@sdxc/backoff): about 21 hours over five
|
|
534
|
-
retries. An app whose queue retries more passes its own as `backoff`.
|
|
535
|
-
|
|
536
|
-
### Errors
|
|
537
|
-
|
|
538
|
-
Every error extends `ActivityPubError`, which carries a `code` and a `retryable` flag.
|
|
539
|
-
|
|
540
|
-
- `ActivityPubParseError`: `code: "invalid-document"`, never retryable. `kind` names the
|
|
541
|
-
reader, and `issues` lists every problem as `{ at, message }`, where `at` is a JSON Pointer.
|
|
542
|
-
- `ActivityPubFetchError`: a remote document that could not be used, with its `url` and the
|
|
543
|
-
response `status` (codes in the `RemoteResolver` table).
|
|
544
|
-
- `InboxError`: a refused request, with the `status` it is answered with. `toResponse()`
|
|
545
|
-
answers that status with a fixed `text/plain` sentence per code, so a sender learns which
|
|
546
|
-
rule failed and never what the verifier saw; `ignored` answers an empty `202`.
|
|
547
|
-
- `DeliveryError`: a delivery that did not land, with `inbox`, `status` and `retryAfter`.
|
|
548
|
-
- `FederationError`: how `process` fails, with `kind`, `delay` and the error underneath as
|
|
549
|
-
`cause`.
|
|
550
|
-
|
|
551
|
-
### Storage
|
|
552
|
-
|
|
553
|
-
The package keeps no state of its own. It calls four interfaces, exported as types, and the
|
|
554
|
-
app implements them over its own database:
|
|
555
|
-
|
|
556
|
-
- `FollowerStore`: followers keyed by `(actor, id)`. `put` inserts or replaces, so a repeated
|
|
557
|
-
Follow refreshes the inbox and `followId`. `list`, `count` and `inboxes` see accepted
|
|
558
|
-
followers only; `inboxes` answers each `sharedInbox ?? inbox` once. `removeInbox` drops
|
|
559
|
-
every follower whose inbox or shared inbox is the URL. Listings page through
|
|
560
|
-
`StorePage<T>`, whose `next` is the store's own opaque cursor.
|
|
561
|
-
- `SeenActivities`: `claim(id, ttl)` answers `true` the first time an id is claimed within
|
|
562
|
-
`ttl`, a `DurationInput` such as `"1 day"`.
|
|
563
|
-
- `LocalObjects`: `find(id)` answers the object the app serves under `id`, or `null`.
|
|
564
|
-
- `KeyProvider`: `keysOf(actor)` answers a hosted actor's `ActorKeys`, or `null`.
|
|
565
|
-
|
|
566
|
-
### `@sdxc/activitypub/testing`
|
|
567
|
-
|
|
568
|
-
In-memory implementations of every storage interface, and Vitest suites an app registers
|
|
569
|
-
against its own storage so it is held to the contracts the package relies on.
|
|
570
|
-
|
|
571
|
-
- `new MemoryFollowerStore(followers?)` lists in insertion order, and its cursors are offsets.
|
|
572
|
-
- `new MemorySeenActivities({ now? })` reads the time from `now`, so a test passes a TTL by
|
|
573
|
-
moving its own clock.
|
|
574
|
-
- `new MemoryLocalObjects(objects?)` serves a fixed list of objects.
|
|
575
|
-
- `new MemoryKeyProvider(keys?)` hosts a fixed list of `ActorKeys`.
|
|
576
|
-
|
|
577
|
-
```typescript
|
|
578
|
-
import {
|
|
579
|
-
followerStoreConformance,
|
|
580
|
-
keyProviderConformance,
|
|
581
|
-
localObjectsConformance,
|
|
582
|
-
seenActivitiesConformance,
|
|
583
|
-
} from "@sdxc/activitypub/testing";
|
|
584
|
-
|
|
585
|
-
followerStoreConformance({ name: "D1FollowerStore", create: () => new D1FollowerStore(db) });
|
|
586
|
-
seenActivitiesConformance({ name: "KvSeenActivities", create: () => new KvSeenActivities(kv) });
|
|
587
|
-
localObjectsConformance({ name: "Posts", create: () => new Posts(db), served: [ARTICLE_ID] });
|
|
588
|
-
keyProviderConformance({ name: "Keys", create: () => new Keys(env), hosted: [ACTOR_ID] });
|
|
589
|
-
```
|
|
590
|
-
|
|
591
|
-
Each suite takes a `name` that labels it and a `create` that builds a fresh implementation
|
|
592
|
-
for every test. `seenActivitiesConformance` takes an optional `advance(ms)` that moves the
|
|
593
|
-
store's clock, `localObjectsConformance` takes `served` ids, and `keyProviderConformance`
|
|
594
|
-
takes `hosted` actors.
|
|
595
|
-
|
|
596
|
-
### Types
|
|
597
|
-
|
|
598
|
-
`ActivityPub` holds the vocabulary as types: `Object`, `Activity`, `Actor`, `PublicKey`,
|
|
599
|
-
`Multikey`, `Proof`, `Tag` (`Hashtag`, `Mention`, `Emoji`), `Attachment` (`Media`,
|
|
600
|
-
`PropertyValue`), `Image`, `Tombstone`, `Collection`, `CollectionPage`, `OrderedCollection`,
|
|
601
|
-
`OrderedCollectionPage`, and `Ref<T>` for a member that may be an IRI or the object it names.
|
|
602
|
-
Every member a reader returns is present, with `null`, `[]`, `{}` or `false` when the sender
|
|
603
|
-
left it out. `ActivityPub.Draft<T>` is the shape an app authors: `id` and `type` required,
|
|
604
|
-
every other member optional.
|
|
605
|
-
|
|
606
|
-
`Federation` carries its own types: `Options`, `Stores`, `Queue`, `OutboxPage`, `Message`,
|
|
607
|
-
`Context`, `DeleteContext`, `HandledType`, `Outcome`, `ProcessOptions` and `RespondOptions`.
|
|
608
|
-
|
|
609
|
-
## Pattern: One Job With `@sdxc/jobs`
|
|
610
|
-
|
|
611
|
-
The package has no dependency on a job runner. With
|
|
612
|
-
[`@sdxc/jobs`](https://www.npmjs.com/package/@sdxc/jobs), one declared job carries every
|
|
613
|
-
message, and its handler builds the federation from the job's context:
|
|
614
|
-
|
|
615
|
-
```typescript
|
|
616
|
-
import { Federation } from "@sdxc/activitypub";
|
|
617
|
-
import { createJobHandler, job, jobs } from "@sdxc/jobs";
|
|
618
|
-
import { isFailure } from "@sdxc/result";
|
|
619
|
-
|
|
620
|
-
export let definitions = jobs({ federation: job({ input: Federation.MESSAGE }) });
|
|
621
|
-
|
|
622
|
-
export function federationFor(stores: Federation.Stores): Federation {
|
|
623
|
-
return new Federation({
|
|
624
|
-
actor: ACTOR,
|
|
625
|
-
keys,
|
|
626
|
-
stores,
|
|
627
|
-
cache,
|
|
628
|
-
userAgent: USER_AGENT,
|
|
629
|
-
queue: {
|
|
630
|
-
enqueue: (message) => dispatcher.enqueue(definitions.federation, message),
|
|
631
|
-
enqueueMany: (messages) => dispatcher.enqueueMany(definitions.federation, messages),
|
|
632
|
-
},
|
|
633
|
-
}).on(["Create", "Like", "Announce"], (ctx) => saveResponse(ctx.summary()));
|
|
634
|
-
}
|
|
635
|
-
|
|
636
|
-
export default createJobHandler(definitions.federation, async (ctx) => {
|
|
637
|
-
let processed = await federationFor(storesOf(ctx)).process(ctx.input, { attempts: ctx.attempts });
|
|
638
|
-
if (isFailure(processed)) {
|
|
639
|
-
if (processed.error.retryable)
|
|
640
|
-
ctx.retry({ delay: processed.error.delay, cause: processed.error });
|
|
641
|
-
ctx.ack(processed.error.message);
|
|
642
|
-
}
|
|
643
|
-
});
|
|
644
|
-
```
|
|
645
|
-
|
|
646
|
-
## Quirks the package absorbs
|
|
647
|
-
|
|
648
|
-
| Quirk | Handled by |
|
|
649
|
-
| ----------------------------------------------------------------------------------- | ---------------------------------------------------- |
|
|
650
|
-
| `as:Public`, `Public` and the full IRI all address the public | `parse*` |
|
|
651
|
-
| One value or an array for `to`, `cc`, `tag`, `attachment` and `type` | `parse*` |
|
|
652
|
-
| An IRI or an embedded object for `actor`, `attributedTo`, `inReplyTo` and `object` | `parse*` |
|
|
653
|
-
| `contentMap`, `nameMap` or `summaryMap` without the plain value | `parse*` |
|
|
654
|
-
| `url` as a string, a `Link`, or a list of `Link`s with the HTML page anywhere in it | `parse*` |
|
|
655
|
-
| Quotes as `quote` (Mastodon 4.5), `_misskey_quote`, `quoteUrl` or `quoteUri` | `parse*`, `stringify` |
|
|
656
|
-
| Misskey emoji reactions as a `Like` with `content` or only `_misskey_reaction` | `parseActivity` |
|
|
657
|
-
| Mastodon embeds the first page of `replies` without an `id` | `parseCollection` |
|
|
658
|
-
| WebFinger `self` must be typed `application/activity+json` | `actorLink` |
|
|
659
|
-
| A handle resolves back through `preferredUsername@<actor host>` | `federation.lookup` |
|
|
660
|
-
| `publicKey.id` is `<actor>#main-key`, with `owner` | `ActorKeys#publicKey`, `federation.fetch` |
|
|
661
|
-
| Secure mode and GoToSocial refuse unsigned GETs | `RemoteResolver` signs GETs |
|
|
662
|
-
| A deleted account's key answers `410` | `RemoteResolver` → `gone` |
|
|
663
|
-
| A refetch of a deleted object must answer `410` | `federation.respond` |
|
|
664
|
-
| A sender falls back from RFC 9421 to cavage only on a synchronous `401` | `federation.fetch` verifies in the request |
|
|
665
|
-
| A rotated key fails against the cached one | The inbox refetches the key once |
|
|
666
|
-
| Mastodon forwards replies to a thread's participants under its own signature | The inbox refetches the activity from its origin |
|
|
667
|
-
| A deleted account sends unverifiable `Delete`s to every server it knew | The inbox answers `202` (`ignored`) |
|
|
668
|
-
| An embedded object from another origin can say anything | `process` fetches it from its own origin (FEP-c7d3) |
|
|
669
|
-
| `Accept` should embed the Follow, since some servers match on it | `process` |
|
|
670
|
-
| A repeated Follow means the sender never saw the Accept | `process` re-sends it |
|
|
671
|
-
| Misskey reactions are a `Like` with `content`; Pleroma sends `EmojiReact` | `Like` handlers and `summary()` read both as a like |
|
|
672
|
-
| Every inbox POST must be signed and cover the body digest | `process` (`deliver`) |
|
|
673
|
-
| Mastodon before 4.5 rejects RFC 9421 and newer servers may prefer it | Double-knocking, scheme cached per origin |
|
|
674
|
-
| `Date` must be an IMF-fixdate inside the receiver's window | Deliveries are signed at send time, never at enqueue |
|
|
675
|
-
| Some instances refuse requests without a `User-Agent` | `userAgent` is a required option |
|
|
676
|
-
| A server can ask for specific components through `Accept-Signature` | Delivery honors it once |
|
|
677
|
-
| An inbox answering `410` is gone for good | `process` removes its followers |
|
|
678
|
-
| Servers down for days should stop costing a delivery per post | Fan-out skips origins failing for 7 days |
|
|
679
|
-
| One server hosting many followers wants one POST | Fan-out dedupes `sharedInbox ?? inbox` |
|
|
680
|
-
| `bto` and `bcc` must not reach recipients | Fan-out strips them before delivery |
|
|
681
|
-
| A queue message is at most 128 KB | `federation.publish` → `too-large` past 120 KB |
|
|
682
|
-
|
|
683
|
-
## Versioning
|
|
684
|
-
|
|
685
|
-
Releases are dated rather than semantic. A version is the UTC date it was published,
|
|
686
|
-
written `YYYY.M.D`, so `2026.9.4` is the release from 4 September 2026. At most one
|
|
687
|
-
release goes out per day.
|
|
688
|
-
|
|
689
|
-
Those numbers say when, not what: a later date means a later release and carries no
|
|
690
|
-
compatibility promise. Any release may change or remove an export.
|
|
691
|
-
|
|
692
|
-
Depend on one exact date, and move it when you are ready to take the change:
|
|
693
|
-
|
|
694
|
-
```json
|
|
695
|
-
{
|
|
696
|
-
"dependencies": {
|
|
697
|
-
"@sdxc/activitypub": "2026.9.4"
|
|
698
|
-
}
|
|
699
|
-
}
|
|
700
|
-
```
|
|
701
|
-
|
|
702
|
-
A caret or tilde range reads the date as major, minor and patch, so it accepts every
|
|
703
|
-
later release in the same year. An exact version keeps the upgrade yours to schedule.
|
|
704
|
-
|
|
705
|
-
## License
|
|
706
|
-
|
|
707
|
-
MIT
|
|
708
|
-
|
|
709
|
-
## Author
|
|
710
|
-
|
|
711
|
-
[Sergio Xalambrí](https://sergiodxa.com)
|
|
3
|
+
This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
|