@fedify/relay 2.4.0-pr.936.41 → 2.5.0-dev.2271

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 {};
@@ -1,12 +1,12 @@
1
1
  import "temporal-polyfill";
2
2
  import "urlpattern-polyfill";
3
3
  globalThis.addEventListener = () => {};
4
- import { n as exportSpki, r as getDocumentLoader, t as isRelayFollowerData } from "./types-B1cI0tFL.js";
4
+ import { n as exportSpki, r as getDocumentLoader, t as isRelayFollowerData } from "./types-D-OFsRKO.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 { ok, strictEqual } from "node:assert";
7
+ import { deepStrictEqual, ok, strictEqual } from "node:assert";
9
8
  import test, { describe } from "node:test";
9
+ import { Accept, Announce, Create, Delete, Follow, Move, Note, Person, Undo, Update } from "@fedify/vocab";
10
10
  //#region src/litepub.test.ts
11
11
  const mockDocumentLoader = async (url) => {
12
12
  if (url === "https://remote.example.com/users/alice" || url === "https://remote.example.com/users/alice#main-key") return {
@@ -17,7 +17,7 @@ const mockDocumentLoader = async (url) => {
17
17
  id: url,
18
18
  type: "Person",
19
19
  preferredUsername: "alice",
20
- inbox: "https://remote.example.com/users/alice/inbox",
20
+ inbox: aliceInbox,
21
21
  publicKey: {
22
22
  id: "https://remote.example.com/users/alice#main-key",
23
23
  owner: url.replace(/#main-key$/, ""),
@@ -25,6 +25,22 @@ const mockDocumentLoader = async (url) => {
25
25
  }
26
26
  }
27
27
  };
28
+ else if (url === "https://remote.example.com/users/dave" || url === "https://remote.example.com/users/dave#main-key") return {
29
+ contextUrl: null,
30
+ documentUrl: url.replace(/#main-key$/, ""),
31
+ document: {
32
+ "@context": ["https://www.w3.org/ns/activitystreams", "https://w3id.org/security/v1"],
33
+ id: url,
34
+ type: "Person",
35
+ preferredUsername: "dave",
36
+ inbox: daveInbox,
37
+ publicKey: {
38
+ id: "https://remote.example.com/users/dave#main-key",
39
+ owner: url.replace(/#main-key$/, ""),
40
+ publicKeyPem: await exportSpki(rsaKeyPair.publicKey)
41
+ }
42
+ }
43
+ };
28
44
  else if (url === "https://remote.example.com/notes/1") return {
29
45
  contextUrl: null,
30
46
  documentUrl: url,
@@ -52,6 +68,41 @@ const rsaPublicKey = {
52
68
  id: new URL("https://remote.example.com/users/alice#main-key"),
53
69
  ...rsaKeyPair.publicKey
54
70
  };
71
+ const davePublicKey = {
72
+ id: new URL("https://remote.example.com/users/dave#main-key"),
73
+ ...rsaKeyPair.publicKey
74
+ };
75
+ const INBOX_PREFIX = "https://[2001:db8:1::";
76
+ const aliceInbox = `${INBOX_PREFIX}4]/users/alice/inbox`;
77
+ const daveInbox = `${INBOX_PREFIX}1]/users/dave/inbox`;
78
+ const pendingInbox = `${INBOX_PREFIX}2]/users/bob/inbox`;
79
+ const acceptedInbox = `${INBOX_PREFIX}3]/users/carol/inbox`;
80
+ const recorders = /* @__PURE__ */ new Map();
81
+ function recordInbox(inbox) {
82
+ const recorded = [];
83
+ recorders.set(inbox, recorded);
84
+ return recorded;
85
+ }
86
+ const aliceDeliveries = recordInbox(aliceInbox);
87
+ function assertAcceptDelivered(follow, deliveries = aliceDeliveries) {
88
+ const accepts = deliveries.filter(({ body }) => body.type === "Accept" && body.object?.id === follow.id?.href);
89
+ strictEqual(accepts.length, 1, "Expected exactly one Accept for this Follow");
90
+ strictEqual(accepts[0].method, "POST");
91
+ strictEqual(accepts[0].body.actor, "https://relay.example.com/users/relay");
92
+ strictEqual(accepts[0].body.object.type, "Follow");
93
+ strictEqual(accepts[0].body.object.actor.id, follow.actorId?.href);
94
+ }
95
+ const nextFetch = globalThis.fetch;
96
+ globalThis.fetch = (async (input, init) => {
97
+ const request = input instanceof Request ? input : new Request(input, init);
98
+ if (!request.url.startsWith(INBOX_PREFIX)) return await nextFetch(input, init);
99
+ const recorded = recorders.get(request.url);
100
+ if (recorded != null) recorded.push({
101
+ method: request.method,
102
+ body: await request.json()
103
+ });
104
+ return new Response(null, { status: 202 });
105
+ });
55
106
  describe("LitePubRelay", () => {
56
107
  test("constructor with required options", () => {
57
108
  ok(createRelay("litepub", {
@@ -183,12 +234,12 @@ describe("LitePubRelay", () => {
183
234
  }
184
235
  });
185
236
  const follower = new Person({
186
- id: new URL("https://remote.example.com/users/alice"),
187
- preferredUsername: "alice",
188
- inbox: new URL("https://remote.example.com/users/alice/inbox")
237
+ id: new URL("https://remote.example.com/users/dave"),
238
+ preferredUsername: "dave",
239
+ inbox: new URL(daveInbox)
189
240
  });
190
241
  const followActivity = new Follow({
191
- id: new URL("https://remote.example.com/activities/follow/1"),
242
+ id: new URL(`https://remote.example.com/activities/follow/${crypto.randomUUID()}`),
192
243
  actor: follower.id,
193
244
  object: new URL("https://relay.example.com/users/relay")
194
245
  });
@@ -197,13 +248,20 @@ describe("LitePubRelay", () => {
197
248
  headers: { "Content-Type": "application/activity+json" },
198
249
  body: JSON.stringify(await followActivity.toJsonLd({ contextLoader: mockDocumentLoader }))
199
250
  });
200
- request = await signRequest(request, rsaKeyPair.privateKey, rsaPublicKey.id);
201
- await relay.fetch(request);
251
+ request = await signRequest(request, rsaKeyPair.privateKey, davePublicKey.id);
252
+ const deliveredActivities = recordInbox(daveInbox);
253
+ strictEqual((await relay.fetch(request)).status, 202);
254
+ assertAcceptDelivered(followActivity, deliveredActivities);
202
255
  strictEqual(handlerCalled, true);
203
256
  ok(handlerActor);
204
- const followerData = await kv.get(["follower", "https://remote.example.com/users/alice"]);
257
+ const followerData = await kv.get(["follower", "https://remote.example.com/users/dave"]);
205
258
  ok(isRelayFollowerData(followerData));
206
259
  strictEqual(followerData.state, "pending");
260
+ const reciprocalFollow = deliveredActivities.map(({ body }) => body).find((activity) => activity.type === "Follow");
261
+ ok(reciprocalFollow, "Expected a reciprocal Follow activity");
262
+ strictEqual(reciprocalFollow.actor, "https://relay.example.com/users/relay");
263
+ strictEqual(reciprocalFollow.object, "https://remote.example.com/users/dave");
264
+ strictEqual(reciprocalFollow.to, "https://remote.example.com/users/dave");
207
265
  });
208
266
  test("handles Follow activity with subscription rejection", async () => {
209
267
  const kv = new MemoryKvStore();
@@ -219,7 +277,7 @@ describe("LitePubRelay", () => {
219
277
  const follower = new Person({
220
278
  id: new URL("https://remote.example.com/users/alice"),
221
279
  preferredUsername: "alice",
222
- inbox: new URL("https://remote.example.com/users/alice/inbox")
280
+ inbox: new URL(aliceInbox)
223
281
  });
224
282
  const followActivity = new Follow({
225
283
  id: new URL("https://remote.example.com/activities/follow/1"),
@@ -232,7 +290,7 @@ describe("LitePubRelay", () => {
232
290
  body: JSON.stringify(await followActivity.toJsonLd({ contextLoader: mockDocumentLoader }))
233
291
  });
234
292
  request = await signRequest(request, rsaKeyPair.privateKey, rsaPublicKey.id);
235
- await relay.fetch(request);
293
+ strictEqual((await relay.fetch(request)).status, 202);
236
294
  strictEqual(await kv.get(["follower", "https://remote.example.com/users/alice"]), void 0);
237
295
  });
238
296
  test("handles public Follow activity", async () => {
@@ -247,10 +305,10 @@ describe("LitePubRelay", () => {
247
305
  const follower = new Person({
248
306
  id: new URL("https://remote.example.com/users/alice"),
249
307
  preferredUsername: "alice",
250
- inbox: new URL("https://remote.example.com/users/alice/inbox")
308
+ inbox: new URL(aliceInbox)
251
309
  });
252
310
  const followActivity = new Follow({
253
- id: new URL("https://remote.example.com/activities/follow/1"),
311
+ id: new URL(`https://remote.example.com/activities/follow/${crypto.randomUUID()}`),
254
312
  actor: follower.id,
255
313
  object: new URL("https://www.w3.org/ns/activitystreams#Public")
256
314
  });
@@ -260,7 +318,8 @@ describe("LitePubRelay", () => {
260
318
  body: JSON.stringify(await followActivity.toJsonLd({ contextLoader: mockDocumentLoader }))
261
319
  });
262
320
  request = await signRequest(request, rsaKeyPair.privateKey, rsaPublicKey.id);
263
- await relay.fetch(request);
321
+ strictEqual((await relay.fetch(request)).status, 202);
322
+ assertAcceptDelivered(followActivity);
264
323
  const followerData = await kv.get(["follower", "https://remote.example.com/users/alice"]);
265
324
  ok(isRelayFollowerData(followerData));
266
325
  strictEqual(followerData.state, "pending");
@@ -284,10 +343,62 @@ describe("LitePubRelay", () => {
284
343
  body: JSON.stringify(await followActivity.toJsonLd({ contextLoader: mockDocumentLoader }))
285
344
  });
286
345
  request = await signRequest(request, rsaKeyPair.privateKey, rsaPublicKey.id);
287
- await relay.fetch(request);
346
+ strictEqual((await relay.fetch(request)).status, 202);
288
347
  strictEqual(await kv.get(["follower", "https://remote.example.com/users/alice"]), void 0);
289
348
  });
290
- test("ignores duplicate Follow activity from pending follower", async () => {
349
+ test("replaces malformed follower data on Follow", async () => {
350
+ const followerId = "https://remote.example.com/users/alice";
351
+ const malformedRows = [
352
+ { state: "pending" },
353
+ {
354
+ actor: null,
355
+ state: "pending"
356
+ },
357
+ {
358
+ actor: {},
359
+ state: "pending"
360
+ },
361
+ {
362
+ actor: await new Person({ id: new URL("https://remote.example.com/users/bob") }).toJsonLd(),
363
+ state: "pending"
364
+ }
365
+ ];
366
+ for (const malformedRow of malformedRows) {
367
+ const kv = new MemoryKvStore();
368
+ await kv.set(["follower", followerId], malformedRow);
369
+ let handlerCallCount = 0;
370
+ const relay = createRelay("litepub", {
371
+ kv,
372
+ origin: "https://relay.example.com",
373
+ documentLoaderFactory: () => mockDocumentLoader,
374
+ authenticatedDocumentLoaderFactory: () => mockDocumentLoader,
375
+ subscriptionHandler: () => {
376
+ handlerCallCount++;
377
+ return Promise.resolve(true);
378
+ }
379
+ });
380
+ strictEqual(await relay.getFollower(followerId), null);
381
+ const followActivity = new Follow({
382
+ id: new URL(`https://remote.example.com/activities/follow/${crypto.randomUUID()}`),
383
+ actor: new URL(followerId),
384
+ object: new URL("https://relay.example.com/users/relay")
385
+ });
386
+ let request = new Request("https://relay.example.com/inbox", {
387
+ method: "POST",
388
+ headers: { "Content-Type": "application/activity+json" },
389
+ body: JSON.stringify(await followActivity.toJsonLd({ contextLoader: mockDocumentLoader }))
390
+ });
391
+ request = await signRequest(request, rsaKeyPair.privateKey, rsaPublicKey.id);
392
+ strictEqual((await relay.fetch(request)).status, 202);
393
+ assertAcceptDelivered(followActivity);
394
+ strictEqual(handlerCallCount, 1);
395
+ const follower = await relay.getFollower(followerId);
396
+ ok(follower);
397
+ strictEqual(follower.state, "pending");
398
+ strictEqual(follower.actor.id?.href, followerId);
399
+ }
400
+ });
401
+ for (const state of ["pending", "accepted"]) test(`ignores duplicate Follow activity from ${state} follower`, async () => {
291
402
  const kv = new MemoryKvStore();
292
403
  let handlerCallCount = 0;
293
404
  const relay = createRelay("litepub", {
@@ -303,11 +414,11 @@ describe("LitePubRelay", () => {
303
414
  const follower = new Person({
304
415
  id: new URL("https://remote.example.com/users/alice"),
305
416
  preferredUsername: "alice",
306
- inbox: new URL("https://remote.example.com/users/alice/inbox")
417
+ inbox: new URL(aliceInbox)
307
418
  });
308
419
  await kv.set(["follower", "https://remote.example.com/users/alice"], {
309
420
  actor: await follower.toJsonLd(),
310
- state: "pending"
421
+ state
311
422
  });
312
423
  const followActivity = new Follow({
313
424
  id: new URL("https://remote.example.com/activities/follow/1"),
@@ -320,15 +431,18 @@ describe("LitePubRelay", () => {
320
431
  body: JSON.stringify(await followActivity.toJsonLd({ contextLoader: mockDocumentLoader }))
321
432
  });
322
433
  request = await signRequest(request, rsaKeyPair.privateKey, rsaPublicKey.id);
323
- await relay.fetch(request);
434
+ strictEqual((await relay.fetch(request)).status, 202);
324
435
  strictEqual(handlerCallCount, 0);
436
+ const followerData = await kv.get(["follower", "https://remote.example.com/users/alice"]);
437
+ ok(isRelayFollowerData(followerData));
438
+ strictEqual(followerData.state, state);
325
439
  });
326
440
  test("handles Accept activity completing reciprocal follow", async () => {
327
441
  const kv = new MemoryKvStore();
328
442
  const follower = new Person({
329
443
  id: new URL("https://remote.example.com/users/alice"),
330
444
  preferredUsername: "alice",
331
- inbox: new URL("https://remote.example.com/users/alice/inbox")
445
+ inbox: new URL(aliceInbox)
332
446
  });
333
447
  await kv.set(["follower", "https://remote.example.com/users/alice"], {
334
448
  actor: await follower.toJsonLd(),
@@ -357,22 +471,69 @@ describe("LitePubRelay", () => {
357
471
  body: JSON.stringify(await acceptActivity.toJsonLd({ contextLoader: mockDocumentLoader }))
358
472
  });
359
473
  request = await signRequest(request, rsaKeyPair.privateKey, rsaPublicKey.id);
360
- await relay.fetch(request);
474
+ strictEqual((await relay.fetch(request)).status, 202);
361
475
  const followerData = await kv.get(["follower", "https://remote.example.com/users/alice"]);
362
476
  ok(isRelayFollowerData(followerData));
363
477
  strictEqual(followerData.state, "accepted");
364
478
  });
365
- test("handles Undo Follow activity", async () => {
479
+ test("ignores Accept activity for invalid follower data", async () => {
480
+ const followerId = "https://remote.example.com/users/alice";
481
+ const invalidRows = [
482
+ { state: "pending" },
483
+ {
484
+ actor: null,
485
+ state: "pending"
486
+ },
487
+ {
488
+ actor: {},
489
+ state: "pending"
490
+ },
491
+ {
492
+ actor: await new Person({ id: new URL("https://remote.example.com/users/bob") }).toJsonLd(),
493
+ state: "pending"
494
+ }
495
+ ];
496
+ for (const invalidRow of invalidRows) {
497
+ const kv = new MemoryKvStore();
498
+ await kv.set(["follower", followerId], invalidRow);
499
+ const relay = createRelay("litepub", {
500
+ kv,
501
+ origin: "https://relay.example.com",
502
+ documentLoaderFactory: () => mockDocumentLoader,
503
+ authenticatedDocumentLoaderFactory: () => mockDocumentLoader,
504
+ subscriptionHandler: () => Promise.resolve(true)
505
+ });
506
+ const relayFollow = new Follow({
507
+ id: new URL("https://relay.example.com/activities/follow/1"),
508
+ actor: new URL("https://relay.example.com/users/relay"),
509
+ object: new URL(followerId)
510
+ });
511
+ const acceptActivity = new Accept({
512
+ id: new URL("https://remote.example.com/activities/accept/1"),
513
+ actor: new URL(followerId),
514
+ object: relayFollow
515
+ });
516
+ let request = new Request("https://relay.example.com/inbox", {
517
+ method: "POST",
518
+ headers: { "Content-Type": "application/activity+json" },
519
+ body: JSON.stringify(await acceptActivity.toJsonLd({ contextLoader: mockDocumentLoader }))
520
+ });
521
+ request = await signRequest(request, rsaKeyPair.privateKey, rsaPublicKey.id);
522
+ strictEqual((await relay.fetch(request)).status, 202);
523
+ deepStrictEqual(await kv.get(["follower", followerId]), invalidRow);
524
+ }
525
+ });
526
+ for (const state of ["pending", "accepted"]) test(`handles Undo Follow activity for ${state} follower`, async () => {
366
527
  const kv = new MemoryKvStore();
367
528
  const followerId = "https://remote.example.com/users/alice";
368
529
  const follower = new Person({
369
530
  id: new URL(followerId),
370
531
  preferredUsername: "alice",
371
- inbox: new URL("https://remote.example.com/users/alice/inbox")
532
+ inbox: new URL(aliceInbox)
372
533
  });
373
534
  await kv.set(["follower", followerId], {
374
535
  actor: await follower.toJsonLd(),
375
- state: "accepted"
536
+ state
376
537
  });
377
538
  const relay = createRelay("litepub", {
378
539
  kv,
@@ -397,7 +558,7 @@ describe("LitePubRelay", () => {
397
558
  body: JSON.stringify(await undoActivity.toJsonLd({ contextLoader: mockDocumentLoader }))
398
559
  });
399
560
  request = await signRequest(request, rsaKeyPair.privateKey, rsaPublicKey.id);
400
- await relay.fetch(request);
561
+ strictEqual((await relay.fetch(request)).status, 202);
401
562
  strictEqual(await kv.get(["follower", followerId]), void 0);
402
563
  });
403
564
  test("handles Create activity with Announce forwarding", async () => {
@@ -406,7 +567,7 @@ describe("LitePubRelay", () => {
406
567
  const follower = new Person({
407
568
  id: new URL(followerId),
408
569
  preferredUsername: "alice",
409
- inbox: new URL("https://remote.example.com/users/alice/inbox")
570
+ inbox: new URL(aliceInbox)
410
571
  });
411
572
  await kv.set(["follower", followerId], {
412
573
  actor: await follower.toJsonLd(),
@@ -437,6 +598,54 @@ describe("LitePubRelay", () => {
437
598
  const response = await relay.fetch(request);
438
599
  ok(response.status === 200 || response.status === 202);
439
600
  });
601
+ test("forwards activities only to accepted followers", async () => {
602
+ const kv = new MemoryKvStore();
603
+ const pendingFollower = new Person({
604
+ id: new URL("https://pending.example.com/users/bob"),
605
+ preferredUsername: "bob",
606
+ inbox: new URL(pendingInbox)
607
+ });
608
+ const acceptedFollower = new Person({
609
+ id: new URL("https://accepted.example.com/users/carol"),
610
+ preferredUsername: "carol",
611
+ inbox: new URL(acceptedInbox)
612
+ });
613
+ await kv.set(["follower", pendingFollower.id.href], {
614
+ actor: await pendingFollower.toJsonLd(),
615
+ state: "pending"
616
+ });
617
+ await kv.set(["follower", acceptedFollower.id.href], {
618
+ actor: await acceptedFollower.toJsonLd(),
619
+ state: "accepted"
620
+ });
621
+ const relay = createRelay("litepub", {
622
+ kv,
623
+ origin: "https://relay.example.com",
624
+ documentLoaderFactory: () => mockDocumentLoader,
625
+ authenticatedDocumentLoaderFactory: () => mockDocumentLoader,
626
+ subscriptionHandler: () => Promise.resolve(true)
627
+ });
628
+ const createActivity = new Create({
629
+ id: new URL("https://remote.example.com/activities/create/1"),
630
+ actor: new URL("https://remote.example.com/users/alice"),
631
+ object: new Note({
632
+ id: new URL("https://remote.example.com/notes/1"),
633
+ content: "Hello world"
634
+ })
635
+ });
636
+ let request = new Request("https://relay.example.com/inbox", {
637
+ method: "POST",
638
+ headers: { "Content-Type": "application/activity+json" },
639
+ body: JSON.stringify(await createActivity.toJsonLd({ contextLoader: mockDocumentLoader }))
640
+ });
641
+ request = await signRequest(request, rsaKeyPair.privateKey, rsaPublicKey.id);
642
+ const deliveredToPending = recordInbox(pendingInbox);
643
+ const deliveredToAccepted = recordInbox(acceptedInbox);
644
+ const response = await relay.fetch(request);
645
+ ok(response.status === 200 || response.status === 202, `Unexpected inbox response status: ${response.status}`);
646
+ strictEqual(deliveredToAccepted.length, 1);
647
+ deepStrictEqual(deliveredToPending, []);
648
+ });
440
649
  test("handles Update activity with Announce forwarding", async () => {
441
650
  const relay = createRelay("litepub", {
442
651
  kv: new MemoryKvStore(),
@@ -661,7 +870,7 @@ describe("LitePubRelay", () => {
661
870
  id: new URL(followerId),
662
871
  preferredUsername: "alice",
663
872
  name: "Alice Wonderland",
664
- inbox: new URL("https://remote.example.com/users/alice/inbox")
873
+ inbox: new URL(aliceInbox)
665
874
  });
666
875
  await kv.set(["follower", followerId], {
667
876
  actor: await follower.toJsonLd(),