@fedify/relay 2.4.0-pr.936.41 → 2.4.0
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 +238 -29
- package/dist/mastodon.test.js +94 -25
- package/dist/mod.cjs +249 -228
- package/dist/mod.d.cts +38 -2
- package/dist/mod.d.ts +38 -2
- package/dist/mod.js +141 -126
- package/dist/{types-B1cI0tFL.js → types-Bs0cVLk6.js} +22292 -22072
- 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
|
@@ -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-
|
|
4
|
+
import { n as exportSpki, r as getDocumentLoader, t as isRelayFollowerData } from "./types-Bs0cVLk6.js";
|
|
5
5
|
import { MemoryKvStore, signRequest } from "@fedify/fedify";
|
|
6
6
|
import { createRelay } from "@fedify/relay";
|
|
7
|
-
import {
|
|
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:
|
|
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/
|
|
187
|
-
preferredUsername: "
|
|
188
|
-
inbox: new URL(
|
|
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(
|
|
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,
|
|
201
|
-
|
|
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/
|
|
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(
|
|
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(
|
|
308
|
+
inbox: new URL(aliceInbox)
|
|
251
309
|
});
|
|
252
310
|
const followActivity = new Follow({
|
|
253
|
-
id: new URL(
|
|
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("
|
|
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(
|
|
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
|
|
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(
|
|
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("
|
|
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(
|
|
532
|
+
inbox: new URL(aliceInbox)
|
|
372
533
|
});
|
|
373
534
|
await kv.set(["follower", followerId], {
|
|
374
535
|
actor: await follower.toJsonLd(),
|
|
375
|
-
state
|
|
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(
|
|
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(
|
|
873
|
+
inbox: new URL(aliceInbox)
|
|
665
874
|
});
|
|
666
875
|
await kv.set(["follower", followerId], {
|
|
667
876
|
actor: await follower.toJsonLd(),
|