@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 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 supports two popular relay protocols used in the fediverse:
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
- The Mastodon-style relay protocol uses LD signatures for activity
48
- verification and follows the Public collection. This protocol is widely
49
- supported by Mastodon and many other ActivityPub implementations.
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 activity relaying with proper content types (`Create`, `Update`,
54
- `Delete`, `Move`)
55
- - LD signature verification and generation
56
- - Follows the ActivityPub Public collection
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
- The LitePub-style relay protocol uses bidirectional following relationships
62
- and wraps activities in `Announce` activities for distribution.
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 wrapped in `Announce` for distribution
72
+ - Activities distributed through `Announce`
68
73
  - Two-phase subscription (pending → accepted)
69
- - Enhanced federation capabilities
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. For an open relay that accepts all subscriptions:
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
- The relay operates by:
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
- 1. **Actor registration**: The relay presents itself as a Service actor at
230
- `/users/relay`
231
- 2. **Subscription**: Instances subscribe to the relay by sending a `Follow`
232
- activity
233
- 3. **Approval**: The relay's subscription handler determines whether to
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 list and their Follow activity IDs
248
- - Subscriber actor information
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,2 @@
1
+ import { Temporal } from "temporal-polyfill";
2
+ import { URLPattern } from "urlpattern-polyfill";
@@ -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 {};
@@ -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
- await relay.fetch(request);
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(),
@@ -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.1832+1af9355c",
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.1832+1af9355c",
51
- "@fedify/vocab": "2.4.0-dev.1832+1af9355c"
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.1832+1af9355c"
57
+ "@fedify/vocab-runtime": "^2.4.0-dev.1848+1443269f"
58
58
  },
59
59
  "scripts": {
60
60
  "build:self": "tsdown",