@fedify/relay 2.4.0-dev.1832 → 2.4.0-dev.1848
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 +74 -41
- package/dist/factory.test.d.ts +2 -0
- package/dist/factory.test.js +21 -0
- package/dist/litepub.test.js +81 -3
- package/dist/mastodon.test.js +2 -2
- package/package.json +4 -4
package/README.md
CHANGED
|
@@ -40,33 +40,38 @@ subscribed instances, creating a shared pool of federated content.
|
|
|
40
40
|
Relay protocols
|
|
41
41
|
---------------
|
|
42
42
|
|
|
43
|
-
This package
|
|
43
|
+
This package implements the relay-server side of the two relay protocols
|
|
44
|
+
described by [FEP-ae0c]. It does not subscribe an existing ActivityPub
|
|
45
|
+
application to remote relays.
|
|
46
|
+
|
|
47
|
+
[FEP-ae0c]: https://w3id.org/fep/ae0c
|
|
44
48
|
|
|
45
49
|
### Mastodon-style relay
|
|
46
50
|
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
51
|
+
Mastodon-style clients subscribe by sending a `Follow` whose object is the
|
|
52
|
+
ActivityStreams Public collection to the relay's shared inbox. The relay
|
|
53
|
+
accepts or rejects the subscription and forwards signed activities directly to
|
|
54
|
+
accepted subscribers.
|
|
50
55
|
|
|
51
|
-
Key features
|
|
56
|
+
#### Key features
|
|
52
57
|
|
|
53
|
-
- Direct
|
|
54
|
-
|
|
55
|
-
-
|
|
56
|
-
-
|
|
57
|
-
- Simple subscription mechanism via `Follow` activities
|
|
58
|
+
- Direct forwarding of `Create`, `Update`, `Delete`, `Move`, and `Announce`
|
|
59
|
+
activities
|
|
60
|
+
- Immediate subscription state after approval
|
|
61
|
+
- Subscription through the shared inbox URI
|
|
58
62
|
|
|
59
63
|
### LitePub-style relay
|
|
60
64
|
|
|
61
|
-
|
|
62
|
-
|
|
65
|
+
LitePub-style clients subscribe by following the relay actor. After approving
|
|
66
|
+
the request, the relay follows the client actor back and waits for an `Accept`.
|
|
67
|
+
It wraps forwarded objects in `Announce` activities.
|
|
63
68
|
|
|
64
|
-
Key features
|
|
69
|
+
#### Key features
|
|
65
70
|
|
|
66
71
|
- Reciprocal following between relay and subscribers
|
|
67
|
-
- Activities
|
|
72
|
+
- Activities distributed through `Announce`
|
|
68
73
|
- Two-phase subscription (pending → accepted)
|
|
69
|
-
-
|
|
74
|
+
- Subscription through the relay actor URI
|
|
70
75
|
|
|
71
76
|
|
|
72
77
|
Installation
|
|
@@ -102,7 +107,7 @@ Usage
|
|
|
102
107
|
|
|
103
108
|
### Creating a relay
|
|
104
109
|
|
|
105
|
-
Here's a simple example of creating a relay server using the factory function
|
|
110
|
+
Here's a simple example of creating a relay server using the factory function.
|
|
106
111
|
|
|
107
112
|
~~~~ typescript
|
|
108
113
|
import { createRelay } from "@fedify/relay";
|
|
@@ -128,7 +133,7 @@ const relay = createRelay("mastodon", {
|
|
|
128
133
|
Deno.serve((request) => relay.fetch(request));
|
|
129
134
|
~~~~
|
|
130
135
|
|
|
131
|
-
You can also create a LitePub-style relay by changing the type
|
|
136
|
+
You can also create a LitePub-style relay by changing the type.
|
|
132
137
|
|
|
133
138
|
~~~~ typescript
|
|
134
139
|
const relay = createRelay("litepub", {
|
|
@@ -138,10 +143,25 @@ const relay = createRelay("litepub", {
|
|
|
138
143
|
});
|
|
139
144
|
~~~~
|
|
140
145
|
|
|
146
|
+
The relay actor and shared inbox use fixed internal routes. Retrieve their
|
|
147
|
+
public URIs from the relay instead of constructing paths yourself.
|
|
148
|
+
|
|
149
|
+
~~~~ typescript
|
|
150
|
+
const actorUri = await relay.getActorUri();
|
|
151
|
+
// https://relay.example.com/users/relay
|
|
152
|
+
|
|
153
|
+
const inboxUri = await relay.getSharedInboxUri();
|
|
154
|
+
// https://relay.example.com/inbox
|
|
155
|
+
~~~~
|
|
156
|
+
|
|
157
|
+
Give Mastodon-style clients the shared inbox URI and LitePub-style clients the
|
|
158
|
+
actor URI.
|
|
159
|
+
|
|
141
160
|
### Subscription handling
|
|
142
161
|
|
|
143
162
|
The `subscriptionHandler` is required and determines whether to approve or
|
|
144
|
-
reject subscription requests.
|
|
163
|
+
reject subscription requests. The following example creates an open relay that
|
|
164
|
+
accepts all subscriptions.
|
|
145
165
|
|
|
146
166
|
~~~~ typescript
|
|
147
167
|
const relay = createRelay("mastodon", {
|
|
@@ -151,7 +171,7 @@ const relay = createRelay("mastodon", {
|
|
|
151
171
|
});
|
|
152
172
|
~~~~
|
|
153
173
|
|
|
154
|
-
You can also implement custom approval logic
|
|
174
|
+
You can also implement custom approval logic.
|
|
155
175
|
|
|
156
176
|
~~~~ typescript
|
|
157
177
|
const relay = createRelay("mastodon", {
|
|
@@ -199,7 +219,7 @@ if (follower) {
|
|
|
199
219
|
|
|
200
220
|
The relay's `fetch()` method returns a standard `Response` object, making it
|
|
201
221
|
compatible with any web framework that supports the Fetch API. Here's an
|
|
202
|
-
example with Hono
|
|
222
|
+
example with Hono.
|
|
203
223
|
|
|
204
224
|
~~~~ typescript
|
|
205
225
|
import { Hono } from "hono";
|
|
@@ -224,31 +244,44 @@ export default app;
|
|
|
224
244
|
How it works
|
|
225
245
|
------------
|
|
226
246
|
|
|
227
|
-
|
|
247
|
+
1. Actor registration—the relay presents itself as an `Application` actor at
|
|
248
|
+
`/users/relay`.
|
|
249
|
+
2. Subscription—Mastodon-style clients follow the Public collection;
|
|
250
|
+
LitePub-style clients follow the relay actor.
|
|
251
|
+
3. Approval—the relay's subscription handler determines whether to approve
|
|
252
|
+
the subscription and responds with `Accept` or `Reject`.
|
|
253
|
+
4. Forwarding—the relay handles `Create`, `Update`, `Delete`, `Move`, and
|
|
254
|
+
`Announce` activities delivered to its inbox. Mastodon-style relays forward
|
|
255
|
+
them directly; LitePub-style relays wrap their objects in `Announce`.
|
|
256
|
+
5. Unsubscription—instances can unsubscribe by sending an `Undo` activity
|
|
257
|
+
wrapping their original `Follow` activity.
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
Application responsibilities
|
|
261
|
+
----------------------------
|
|
262
|
+
|
|
263
|
+
`createRelay()` provides the relay-specific ActivityPub routes and behavior.
|
|
264
|
+
The surrounding application remains responsible for HTTPS, persistent storage,
|
|
265
|
+
a durable production queue, subscription policy, rate limiting, monitoring,
|
|
266
|
+
and moderation. WebFinger and NodeInfo discovery endpoints are not configured
|
|
267
|
+
by this package.
|
|
228
268
|
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
approve the subscription (responds with `Accept` or `Reject`)
|
|
235
|
-
4. **Forwarding**: When a subscribed instance sends activities (`Create`,
|
|
236
|
-
`Update`, `Delete`, `Move`) to the relay's inbox, the relay forwards them
|
|
237
|
-
to all other subscribed instances
|
|
238
|
-
5. **Unsubscription**: Instances can unsubscribe by sending an `Undo` activity
|
|
239
|
-
wrapping their original `Follow` activity
|
|
269
|
+
The `subscriptionHandler` controls which actors become delivery recipients; it
|
|
270
|
+
does not authorize publishing to the relay. The relay does not require an
|
|
271
|
+
activity sender to be a stored follower or inspect its audience for the Public
|
|
272
|
+
collection, so deployments need to account for that behavior in their access
|
|
273
|
+
and moderation policies.
|
|
240
274
|
|
|
241
275
|
|
|
242
276
|
Storage requirements
|
|
243
277
|
--------------------
|
|
244
278
|
|
|
245
|
-
The relay requires a key–value store to persist
|
|
279
|
+
The relay requires a key–value store to persist the following data.
|
|
246
280
|
|
|
247
|
-
- Subscriber
|
|
248
|
-
-
|
|
249
|
-
- Relay's cryptographic key pairs (RSA and Ed25519)
|
|
281
|
+
- Subscriber actor information and subscription state
|
|
282
|
+
- The relay's cryptographic key pairs (RSA and Ed25519)
|
|
250
283
|
|
|
251
|
-
Any `KvStore` implementation from Fedify can be used, including
|
|
284
|
+
Any `KvStore` implementation from Fedify can be used, including the following.
|
|
252
285
|
|
|
253
286
|
- `MemoryKvStore` (for development/testing)
|
|
254
287
|
- `DenoKvStore` (Deno KV)
|
|
@@ -301,7 +334,7 @@ Public interface for ActivityPub relay implementations.
|
|
|
301
334
|
|
|
302
335
|
#### Relay types
|
|
303
336
|
|
|
304
|
-
The relay type is specified when calling `createRelay()
|
|
337
|
+
The relay type is specified when calling `createRelay()`.
|
|
305
338
|
|
|
306
339
|
- `"mastodon"`: Mastodon-compatible relay using direct activity forwarding,
|
|
307
340
|
immediate subscription approval, and LD signatures
|
|
@@ -310,7 +343,7 @@ The relay type is specified when calling `createRelay()`:
|
|
|
310
343
|
|
|
311
344
|
### `RelayOptions`
|
|
312
345
|
|
|
313
|
-
Configuration options for the relay
|
|
346
|
+
Configuration options for the relay.
|
|
314
347
|
|
|
315
348
|
- `kv: KvStore` (required): Key–value store for persisting relay data
|
|
316
349
|
- `origin: string` (required): Relay's origin URL (e.g.,
|
|
@@ -326,7 +359,7 @@ Configuration options for the relay:
|
|
|
326
359
|
|
|
327
360
|
### `SubscriptionRequestHandler`
|
|
328
361
|
|
|
329
|
-
A function that determines whether to approve a subscription request
|
|
362
|
+
A function that determines whether to approve a subscription request.
|
|
330
363
|
|
|
331
364
|
~~~~ typescript
|
|
332
365
|
type SubscriptionRequestHandler = (
|
|
@@ -347,7 +380,7 @@ type SubscriptionRequestHandler = (
|
|
|
347
380
|
|
|
348
381
|
### `RelayFollower`
|
|
349
382
|
|
|
350
|
-
A follower of the relay with validated Actor instance
|
|
383
|
+
A follower of the relay with validated Actor instance.
|
|
351
384
|
|
|
352
385
|
~~~~ typescript
|
|
353
386
|
interface RelayFollower {
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import "temporal-polyfill";
|
|
2
|
+
import "urlpattern-polyfill";
|
|
3
|
+
globalThis.addEventListener = () => {};
|
|
4
|
+
import { MemoryKvStore } from "@fedify/fedify";
|
|
5
|
+
import { createRelay } from "@fedify/relay";
|
|
6
|
+
import { strictEqual } from "node:assert";
|
|
7
|
+
import test, { describe } from "node:test";
|
|
8
|
+
//#region src/factory.test.ts
|
|
9
|
+
describe("createRelay", () => {
|
|
10
|
+
for (const type of ["mastodon", "litepub"]) test(`${type} exposes the canonical relay URIs`, async () => {
|
|
11
|
+
const relay = createRelay(type, {
|
|
12
|
+
kv: new MemoryKvStore(),
|
|
13
|
+
origin: "https://relay.example.com",
|
|
14
|
+
subscriptionHandler: () => Promise.resolve(true)
|
|
15
|
+
});
|
|
16
|
+
strictEqual((await relay.getActorUri()).href, "https://relay.example.com/users/relay");
|
|
17
|
+
strictEqual((await relay.getSharedInboxUri()).href, "https://relay.example.com/inbox");
|
|
18
|
+
});
|
|
19
|
+
});
|
|
20
|
+
//#endregion
|
|
21
|
+
export {};
|
package/dist/litepub.test.js
CHANGED
|
@@ -4,10 +4,10 @@ globalThis.addEventListener = () => {};
|
|
|
4
4
|
import { t as isRelayFollowerData } from "./types-BDWJ2_j0.js";
|
|
5
5
|
import { MemoryKvStore, signRequest } from "@fedify/fedify";
|
|
6
6
|
import { createRelay } from "@fedify/relay";
|
|
7
|
-
import { Accept, Announce, Create, Delete, Follow, Move, Note, Person, Undo, Update } from "@fedify/vocab";
|
|
8
|
-
import { exportSpki, getDocumentLoader } from "@fedify/vocab-runtime";
|
|
9
7
|
import { deepStrictEqual, ok, strictEqual } from "node:assert";
|
|
10
8
|
import test, { describe } from "node:test";
|
|
9
|
+
import { Accept, Announce, Create, Delete, Follow, Move, Note, Person, Undo, Update } from "@fedify/vocab";
|
|
10
|
+
import { exportSpki, getDocumentLoader } from "@fedify/vocab-runtime";
|
|
11
11
|
//#region src/litepub.test.ts
|
|
12
12
|
const mockDocumentLoader = async (url) => {
|
|
13
13
|
if (url === "https://remote.example.com/users/alice" || url === "https://remote.example.com/users/alice#main-key") return {
|
|
@@ -199,12 +199,31 @@ describe("LitePubRelay", () => {
|
|
|
199
199
|
body: JSON.stringify(await followActivity.toJsonLd({ contextLoader: mockDocumentLoader }))
|
|
200
200
|
});
|
|
201
201
|
request = await signRequest(request, rsaKeyPair.privateKey, rsaPublicKey.id);
|
|
202
|
-
|
|
202
|
+
const originalFetch = globalThis.fetch;
|
|
203
|
+
const deliveredActivities = [];
|
|
204
|
+
globalThis.fetch = (async (input, init) => {
|
|
205
|
+
const outboundRequest = input instanceof Request ? input : new Request(input, init);
|
|
206
|
+
if (outboundRequest.url === "https://remote.example.com/users/alice/inbox") {
|
|
207
|
+
deliveredActivities.push(await outboundRequest.json());
|
|
208
|
+
return new Response(null, { status: 202 });
|
|
209
|
+
}
|
|
210
|
+
return originalFetch(input, init);
|
|
211
|
+
});
|
|
212
|
+
try {
|
|
213
|
+
await relay.fetch(request);
|
|
214
|
+
} finally {
|
|
215
|
+
globalThis.fetch = originalFetch;
|
|
216
|
+
}
|
|
203
217
|
strictEqual(handlerCalled, true);
|
|
204
218
|
ok(handlerActor);
|
|
205
219
|
const followerData = await kv.get(["follower", "https://remote.example.com/users/alice"]);
|
|
206
220
|
ok(isRelayFollowerData(followerData));
|
|
207
221
|
strictEqual(followerData.state, "pending");
|
|
222
|
+
const reciprocalFollow = deliveredActivities.find((activity) => activity.type === "Follow");
|
|
223
|
+
ok(reciprocalFollow, "Expected a reciprocal Follow activity");
|
|
224
|
+
strictEqual(reciprocalFollow.actor, "https://relay.example.com/users/relay");
|
|
225
|
+
strictEqual(reciprocalFollow.object, "https://remote.example.com/users/alice");
|
|
226
|
+
strictEqual(reciprocalFollow.to, "https://remote.example.com/users/alice");
|
|
208
227
|
});
|
|
209
228
|
test("handles Follow activity with subscription rejection", async () => {
|
|
210
229
|
const kv = new MemoryKvStore();
|
|
@@ -539,6 +558,65 @@ describe("LitePubRelay", () => {
|
|
|
539
558
|
const response = await relay.fetch(request);
|
|
540
559
|
ok(response.status === 200 || response.status === 202);
|
|
541
560
|
});
|
|
561
|
+
test("forwards activities only to accepted followers", async () => {
|
|
562
|
+
const kv = new MemoryKvStore();
|
|
563
|
+
const pendingFollower = new Person({
|
|
564
|
+
id: new URL("https://pending.example.com/users/bob"),
|
|
565
|
+
preferredUsername: "bob",
|
|
566
|
+
inbox: new URL("https://pending.example.com/users/bob/inbox")
|
|
567
|
+
});
|
|
568
|
+
const acceptedFollower = new Person({
|
|
569
|
+
id: new URL("https://accepted.example.com/users/carol"),
|
|
570
|
+
preferredUsername: "carol",
|
|
571
|
+
inbox: new URL("https://accepted.example.com/users/carol/inbox")
|
|
572
|
+
});
|
|
573
|
+
await kv.set(["follower", pendingFollower.id.href], {
|
|
574
|
+
actor: await pendingFollower.toJsonLd(),
|
|
575
|
+
state: "pending"
|
|
576
|
+
});
|
|
577
|
+
await kv.set(["follower", acceptedFollower.id.href], {
|
|
578
|
+
actor: await acceptedFollower.toJsonLd(),
|
|
579
|
+
state: "accepted"
|
|
580
|
+
});
|
|
581
|
+
const relay = createRelay("litepub", {
|
|
582
|
+
kv,
|
|
583
|
+
origin: "https://relay.example.com",
|
|
584
|
+
documentLoaderFactory: () => mockDocumentLoader,
|
|
585
|
+
authenticatedDocumentLoaderFactory: () => mockDocumentLoader,
|
|
586
|
+
subscriptionHandler: () => Promise.resolve(true)
|
|
587
|
+
});
|
|
588
|
+
const createActivity = new Create({
|
|
589
|
+
id: new URL("https://remote.example.com/activities/create/1"),
|
|
590
|
+
actor: new URL("https://remote.example.com/users/alice"),
|
|
591
|
+
object: new Note({
|
|
592
|
+
id: new URL("https://remote.example.com/notes/1"),
|
|
593
|
+
content: "Hello world"
|
|
594
|
+
})
|
|
595
|
+
});
|
|
596
|
+
let request = new Request("https://relay.example.com/inbox", {
|
|
597
|
+
method: "POST",
|
|
598
|
+
headers: { "Content-Type": "application/activity+json" },
|
|
599
|
+
body: JSON.stringify(await createActivity.toJsonLd({ contextLoader: mockDocumentLoader }))
|
|
600
|
+
});
|
|
601
|
+
request = await signRequest(request, rsaKeyPair.privateKey, rsaPublicKey.id);
|
|
602
|
+
const originalFetch = globalThis.fetch;
|
|
603
|
+
const deliveredInboxUrls = [];
|
|
604
|
+
globalThis.fetch = (async (input, init) => {
|
|
605
|
+
const outboundRequest = input instanceof Request ? input : new Request(input, init);
|
|
606
|
+
if (outboundRequest.url.endsWith("/inbox")) {
|
|
607
|
+
deliveredInboxUrls.push(outboundRequest.url);
|
|
608
|
+
return new Response(null, { status: 202 });
|
|
609
|
+
}
|
|
610
|
+
return await originalFetch(input, init);
|
|
611
|
+
});
|
|
612
|
+
try {
|
|
613
|
+
const response = await relay.fetch(request);
|
|
614
|
+
ok(response.status === 200 || response.status === 202);
|
|
615
|
+
} finally {
|
|
616
|
+
globalThis.fetch = originalFetch;
|
|
617
|
+
}
|
|
618
|
+
deepStrictEqual(deliveredInboxUrls, ["https://accepted.example.com/users/carol/inbox"]);
|
|
619
|
+
});
|
|
542
620
|
test("handles Update activity with Announce forwarding", async () => {
|
|
543
621
|
const relay = createRelay("litepub", {
|
|
544
622
|
kv: new MemoryKvStore(),
|
package/dist/mastodon.test.js
CHANGED
|
@@ -4,10 +4,10 @@ globalThis.addEventListener = () => {};
|
|
|
4
4
|
import { t as isRelayFollowerData } from "./types-BDWJ2_j0.js";
|
|
5
5
|
import { MemoryKvStore, signJsonLd, signRequest } from "@fedify/fedify";
|
|
6
6
|
import { createRelay } from "@fedify/relay";
|
|
7
|
-
import { Announce, Create, Delete, Follow, Move, Note, Person, Undo, Update } from "@fedify/vocab";
|
|
8
|
-
import { exportSpki, getDocumentLoader } from "@fedify/vocab-runtime";
|
|
9
7
|
import { deepStrictEqual, ok, strictEqual } from "node:assert";
|
|
10
8
|
import test, { describe } from "node:test";
|
|
9
|
+
import { Announce, Create, Delete, Follow, Move, Note, Person, Undo, Update } from "@fedify/vocab";
|
|
10
|
+
import { exportSpki, getDocumentLoader } from "@fedify/vocab-runtime";
|
|
11
11
|
//#region src/mastodon.test.ts
|
|
12
12
|
const mockDocumentLoader = async (url) => {
|
|
13
13
|
if (url === "https://remote.example.com/users/alice" || url === "https://remote.example.com/users/alice#main-key") return {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@fedify/relay",
|
|
3
|
-
"version": "2.4.0-dev.
|
|
3
|
+
"version": "2.4.0-dev.1848+1443269f",
|
|
4
4
|
"description": "ActivityPub relay support for Fedify",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"Fedify",
|
|
@@ -47,14 +47,14 @@
|
|
|
47
47
|
"dependencies": {
|
|
48
48
|
"@logtape/logtape": "^2.2.0",
|
|
49
49
|
"temporal-polyfill": "^1.0.1",
|
|
50
|
-
"@fedify/fedify": "^2.4.0-dev.
|
|
51
|
-
"@fedify/vocab": "2.4.0-dev.
|
|
50
|
+
"@fedify/fedify": "^2.4.0-dev.1848+1443269f",
|
|
51
|
+
"@fedify/vocab": "2.4.0-dev.1848+1443269f"
|
|
52
52
|
},
|
|
53
53
|
"devDependencies": {
|
|
54
54
|
"tsdown": "^0.22.0",
|
|
55
55
|
"typescript": "^6.0.0",
|
|
56
56
|
"urlpattern-polyfill": "^10.1.0",
|
|
57
|
-
"@fedify/vocab-runtime": "^2.4.0-dev.
|
|
57
|
+
"@fedify/vocab-runtime": "^2.4.0-dev.1848+1443269f"
|
|
58
58
|
},
|
|
59
59
|
"scripts": {
|
|
60
60
|
"build:self": "tsdown",
|