@sdxc/activitypub 2026.10.12 → 2026.10.14
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 +353 -543
- package/dist/conformance.js +3 -2
- package/dist/lib/parse.js +7 -4
- package/package.json +15 -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
|
-
|
|
12
|
-
|
|
13
|
-
|
|
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
|
|
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,
|
|
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:
|
|
33
|
+
// Once, from a script:
|
|
32
34
|
let { privateKeyPem } = unwrap(await ActorKeys.generate());
|
|
33
35
|
|
|
34
|
-
let
|
|
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
|
|
53
|
+
actor,
|
|
38
54
|
keys,
|
|
39
|
-
stores: { followers, seen, objects },
|
|
40
|
-
cache,
|
|
55
|
+
stores: { followers, seen: new CacheSeenActivities(cache), objects },
|
|
56
|
+
cache,
|
|
41
57
|
userAgent: "example.com/1.0 (+https://example.com)",
|
|
42
|
-
|
|
43
|
-
queue: { enqueue: (message) => queue.send(message) },
|
|
58
|
+
queue: { enqueue: (message) => env.QUEUE.send(message) },
|
|
44
59
|
});
|
|
45
60
|
```
|
|
46
61
|
|
|
47
|
-
### Answer
|
|
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
|
|
56
|
-
|
|
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
|
|
73
|
+
### Process the queue
|
|
59
74
|
|
|
60
|
-
Every message the federation enqueues
|
|
61
|
-
|
|
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
|
|
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" : "
|
|
99
|
+
federation.on("Follow", async (ctx) => (isWelcome(ctx.actor) ? "accept" : "reject"));
|
|
83
100
|
```
|
|
84
101
|
|
|
85
|
-
### Publish
|
|
102
|
+
### Publish an activity
|
|
86
103
|
|
|
87
104
|
```typescript
|
|
105
|
+
import { PUBLIC } from "@sdxc/activitypub";
|
|
106
|
+
|
|
88
107
|
await federation.publish({
|
|
89
|
-
id:
|
|
108
|
+
id: "https://example.com/articles/hello#create",
|
|
90
109
|
type: "Create",
|
|
91
|
-
actor:
|
|
110
|
+
actor: actor.id,
|
|
92
111
|
to: [PUBLIC],
|
|
93
|
-
cc: [
|
|
112
|
+
cc: ["https://example.com/actor/followers"],
|
|
94
113
|
object: article,
|
|
95
114
|
});
|
|
96
115
|
```
|
|
97
116
|
|
|
98
|
-
|
|
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
|
|
111
|
-
|
|
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
|
|
116
|
-
`
|
|
117
|
-
|
|
118
|
-
- `keys`: the actor's `ActorKeys`, or a `KeyProvider` for an app hosting several actors.
|
|
119
|
-
|
|
120
|
-
- `
|
|
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) }
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
- `
|
|
133
|
-
|
|
134
|
-
- `
|
|
135
|
-
|
|
136
|
-
- `
|
|
137
|
-
|
|
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
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
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
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
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
|
-
|
|
170
|
-
|
|
171
|
-
|
|
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
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
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
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
`
|
|
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
|
|
242
|
-
|
|
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
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
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
|
|
314
|
-
|
|
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
|
-
|
|
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
|
-
|
|
325
|
-
|
|
326
|
-
|
|
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`
|
|
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
|
|
209
|
+
#### `federation.resolver`
|
|
346
210
|
|
|
347
|
-
The
|
|
211
|
+
The `RemoteResolver` the federation fetches with, sharing its cache and keys.
|
|
348
212
|
|
|
349
213
|
#### `Federation.MESSAGE`
|
|
350
214
|
|
|
351
|
-
The Standard Schema
|
|
352
|
-
message
|
|
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
|
|
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
|
-
|
|
396
|
-
import
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
#### `
|
|
234
|
+
#### `new RemoteResolver(options: ResolverOptions)`
|
|
443
235
|
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
248
|
+
A `SeenActivities` over any `@sdxc/cache` `Cache`, keyed `activitypub:seen:<id>`, with a TTL of at
|
|
249
|
+
least 60 seconds.
|
|
455
250
|
|
|
456
|
-
|
|
251
|
+
### Vocabulary
|
|
457
252
|
|
|
458
|
-
#### `
|
|
253
|
+
#### `parseActivity`, `parseActor`, `parseObject`, `parseCollection`
|
|
459
254
|
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
or
|
|
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
|
-
|
|
467
|
-
|
|
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
|
-
|
|
488
|
-
```
|
|
265
|
+
#### `tombstone({ id, formerType?, deleted? }): ActivityPub.Tombstone`
|
|
489
266
|
|
|
490
|
-
|
|
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
|
|
497
|
-
|
|
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
|
-
|
|
507
|
-
import { orderedCollection, orderedCollectionPage } from "@sdxc/activitypub";
|
|
274
|
+
#### `orderedCollection`, `orderedCollectionPage`, `collection`, `collectionPage`
|
|
508
275
|
|
|
509
|
-
|
|
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
|
|
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
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
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`,
|
|
293
|
+
Every error extends `ActivityPubError`, with a `code` and a `retryable` flag.
|
|
558
294
|
|
|
559
|
-
- `ActivityPubParseError`: `
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
- `InboxError`: a refused request, with
|
|
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
|
|
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
|
|
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`:
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
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
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
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
|
|
645
|
-
`
|
|
646
|
-
`
|
|
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
|
-
|
|
658
|
-
|
|
659
|
-
|
|
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
|
|
350
|
+
actor,
|
|
671
351
|
keys,
|
|
672
352
|
stores,
|
|
673
353
|
cache,
|
|
674
|
-
userAgent:
|
|
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
|
-
##
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
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
|
|
package/dist/conformance.js
CHANGED
|
@@ -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/${
|
|
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/${
|
|
311
|
+
expect(unwrap(await provider.keysOf(`https://remote.com/users/${generateUUID()}`))).toBeNull();
|
|
311
312
|
});
|
|
312
313
|
});
|
|
313
314
|
}
|
package/dist/lib/parse.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { stringify } from "@sdxc/json-pointer";
|
|
1
2
|
import { failure, success } from "@sdxc/result";
|
|
2
3
|
import { createSchema, fail, object, parseSafe } from "remix/data-schema";
|
|
3
4
|
import { lazy } from "remix/data-schema/lazy";
|
|
@@ -285,10 +286,12 @@ const COLLECTION_SCHEMA = createSchema((value, context) => {
|
|
|
285
286
|
* @param path - The issue's path, outermost first.
|
|
286
287
|
*/
|
|
287
288
|
function pointerOf(path) {
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
.
|
|
289
|
+
let segments = [];
|
|
290
|
+
for (let segment of path ?? []) {
|
|
291
|
+
let key = typeof segment === "object" ? segment.key : segment;
|
|
292
|
+
segments.push(typeof key === "number" ? key : String(key));
|
|
293
|
+
}
|
|
294
|
+
return stringify(segments);
|
|
292
295
|
}
|
|
293
296
|
/**
|
|
294
297
|
* Validates a decoded document, answering every issue with a pointer into it.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sdxc/activitypub",
|
|
3
|
-
"version": "2026.10.
|
|
3
|
+
"version": "2026.10.14",
|
|
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,19 @@
|
|
|
9
9
|
"./testing": "./dist/testing.js"
|
|
10
10
|
},
|
|
11
11
|
"dependencies": {
|
|
12
|
-
"@sdxc/backoff": "2026.10.
|
|
13
|
-
"@sdxc/cache": "2026.10.
|
|
14
|
-
"@sdxc/crypto": "2026.10.
|
|
15
|
-
"@sdxc/
|
|
16
|
-
"@sdxc/
|
|
17
|
-
"@sdxc/
|
|
18
|
-
"@sdxc/http": "2026.10.
|
|
19
|
-
"@sdxc/
|
|
20
|
-
"@sdxc/logger": "2026.10.
|
|
21
|
-
"@sdxc/outbound": "2026.10.
|
|
22
|
-
"@sdxc/result": "2026.10.
|
|
23
|
-
"@sdxc/
|
|
24
|
-
"@sdxc/well-known": "2026.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/json-pointer": "2026.10.14",
|
|
20
|
+
"@sdxc/logger": "2026.10.13",
|
|
21
|
+
"@sdxc/outbound": "2026.10.13",
|
|
22
|
+
"@sdxc/result": "2026.10.13",
|
|
23
|
+
"@sdxc/uuid": "2026.10.13",
|
|
24
|
+
"@sdxc/well-known": "2026.10.14",
|
|
26
25
|
"remix": "3.0.0"
|
|
27
26
|
},
|
|
28
27
|
"peerDependencies": {
|
|
@@ -33,7 +32,7 @@
|
|
|
33
32
|
"optional": true
|
|
34
33
|
}
|
|
35
34
|
},
|
|
36
|
-
"gitHead": "
|
|
35
|
+
"gitHead": "2981b3f8283031e5e2d347864fa40c6a48d21247",
|
|
37
36
|
"publishConfig": {
|
|
38
37
|
"access": "public"
|
|
39
38
|
},
|