@fedify/relay 2.0.0-dev.1908 → 2.0.0-dev.206
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/LICENSE +1 -1
- package/README.md +159 -53
- package/dist/litepub.test.d.ts +3 -0
- package/dist/litepub.test.js +701 -0
- package/dist/mastodon.test.d.ts +3 -0
- package/dist/mastodon.test.js +664 -0
- package/dist/mod.cjs +403 -179
- package/dist/mod.d.cts +123 -31
- package/dist/mod.d.ts +124 -31
- package/dist/mod.js +403 -179
- package/dist/types-ZeHCmEIv.js +28333 -0
- package/package.json +13 -11
|
@@ -0,0 +1,701 @@
|
|
|
1
|
+
|
|
2
|
+
import { Temporal } from "@js-temporal/polyfill";
|
|
3
|
+
import { URLPattern } from "urlpattern-polyfill";
|
|
4
|
+
globalThis.addEventListener = () => {};
|
|
5
|
+
|
|
6
|
+
import { exportSpki, getDocumentLoader, isRelayFollowerData } from "./types-ZeHCmEIv.js";
|
|
7
|
+
import { MemoryKvStore, signRequest } from "@fedify/fedify";
|
|
8
|
+
import { createRelay } from "@fedify/relay";
|
|
9
|
+
import { Accept, Announce, Create, Delete, Follow, Move, Note, Person, Undo, Update } from "@fedify/vocab";
|
|
10
|
+
import { ok, strictEqual } from "node:assert";
|
|
11
|
+
import test, { describe } from "node:test";
|
|
12
|
+
|
|
13
|
+
//#region src/litepub.test.ts
|
|
14
|
+
const mockDocumentLoader = async (url) => {
|
|
15
|
+
if (url === "https://remote.example.com/users/alice" || url === "https://remote.example.com/users/alice#main-key") return {
|
|
16
|
+
contextUrl: null,
|
|
17
|
+
documentUrl: url.replace(/#main-key$/, ""),
|
|
18
|
+
document: {
|
|
19
|
+
"@context": ["https://www.w3.org/ns/activitystreams", "https://w3id.org/security/v1"],
|
|
20
|
+
id: url,
|
|
21
|
+
type: "Person",
|
|
22
|
+
preferredUsername: "alice",
|
|
23
|
+
inbox: "https://remote.example.com/users/alice/inbox",
|
|
24
|
+
publicKey: {
|
|
25
|
+
id: "https://remote.example.com/users/alice#main-key",
|
|
26
|
+
owner: url.replace(/#main-key$/, ""),
|
|
27
|
+
publicKeyPem: await exportSpki(rsaKeyPair.publicKey)
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
};
|
|
31
|
+
else if (url === "https://remote.example.com/notes/1") return {
|
|
32
|
+
contextUrl: null,
|
|
33
|
+
documentUrl: url,
|
|
34
|
+
document: {
|
|
35
|
+
"@context": "https://www.w3.org/ns/activitystreams",
|
|
36
|
+
id: url,
|
|
37
|
+
type: "Note",
|
|
38
|
+
content: "Hello world"
|
|
39
|
+
}
|
|
40
|
+
};
|
|
41
|
+
else if (url.startsWith("https://remote.example.com/")) throw new Error(`Document not found: ${url}`);
|
|
42
|
+
return await getDocumentLoader()(url);
|
|
43
|
+
};
|
|
44
|
+
const rsaKeyPair = await crypto.subtle.generateKey({
|
|
45
|
+
name: "RSASSA-PKCS1-v1_5",
|
|
46
|
+
modulusLength: 2048,
|
|
47
|
+
publicExponent: new Uint8Array([
|
|
48
|
+
1,
|
|
49
|
+
0,
|
|
50
|
+
1
|
|
51
|
+
]),
|
|
52
|
+
hash: "SHA-256"
|
|
53
|
+
}, true, ["sign", "verify"]);
|
|
54
|
+
const rsaPublicKey = {
|
|
55
|
+
id: new URL("https://remote.example.com/users/alice#main-key"),
|
|
56
|
+
...rsaKeyPair.publicKey
|
|
57
|
+
};
|
|
58
|
+
describe("LitePubRelay", () => {
|
|
59
|
+
test("constructor with required options", () => {
|
|
60
|
+
const options = {
|
|
61
|
+
kv: new MemoryKvStore(),
|
|
62
|
+
origin: "https://relay.example.com",
|
|
63
|
+
documentLoaderFactory: () => mockDocumentLoader,
|
|
64
|
+
subscriptionHandler: async (_ctx, _actor) => {
|
|
65
|
+
return await Promise.resolve(true);
|
|
66
|
+
}
|
|
67
|
+
};
|
|
68
|
+
const relay = createRelay("litepub", options);
|
|
69
|
+
ok(relay);
|
|
70
|
+
});
|
|
71
|
+
test("fetch method returns Response", async () => {
|
|
72
|
+
const kv = new MemoryKvStore();
|
|
73
|
+
const relay = createRelay("litepub", {
|
|
74
|
+
kv,
|
|
75
|
+
origin: "https://relay.example.com",
|
|
76
|
+
documentLoaderFactory: () => mockDocumentLoader,
|
|
77
|
+
subscriptionHandler: () => Promise.resolve(true)
|
|
78
|
+
});
|
|
79
|
+
const request = new Request("https://relay.example.com/users/relay", { headers: { "Accept": "application/activity+json" } });
|
|
80
|
+
const response = await relay.fetch(request);
|
|
81
|
+
ok(response instanceof Response);
|
|
82
|
+
});
|
|
83
|
+
test("fetching relay actor returns Application", async () => {
|
|
84
|
+
const kv = new MemoryKvStore();
|
|
85
|
+
const relay = createRelay("litepub", {
|
|
86
|
+
kv,
|
|
87
|
+
origin: "https://relay.example.com",
|
|
88
|
+
documentLoaderFactory: () => mockDocumentLoader,
|
|
89
|
+
subscriptionHandler: () => Promise.resolve(true)
|
|
90
|
+
});
|
|
91
|
+
const request = new Request("https://relay.example.com/users/relay", { headers: { "Accept": "application/activity+json" } });
|
|
92
|
+
const response = await relay.fetch(request);
|
|
93
|
+
strictEqual(response.status, 200);
|
|
94
|
+
const json = await response.json();
|
|
95
|
+
strictEqual(json.type, "Application");
|
|
96
|
+
strictEqual(json.preferredUsername, "relay");
|
|
97
|
+
strictEqual(json.name, "ActivityPub Relay");
|
|
98
|
+
});
|
|
99
|
+
test("fetching non-relay actor returns 404", async () => {
|
|
100
|
+
const kv = new MemoryKvStore();
|
|
101
|
+
const relay = createRelay("litepub", {
|
|
102
|
+
kv,
|
|
103
|
+
origin: "https://relay.example.com",
|
|
104
|
+
documentLoaderFactory: () => mockDocumentLoader,
|
|
105
|
+
subscriptionHandler: () => Promise.resolve(true)
|
|
106
|
+
});
|
|
107
|
+
const request = new Request("https://relay.example.com/users/non-existent", { headers: { "Accept": "application/activity+json" } });
|
|
108
|
+
const response = await relay.fetch(request);
|
|
109
|
+
strictEqual(response.status, 404);
|
|
110
|
+
});
|
|
111
|
+
test("followers collection returns empty list initially", async () => {
|
|
112
|
+
const kv = new MemoryKvStore();
|
|
113
|
+
const relay = createRelay("litepub", {
|
|
114
|
+
kv,
|
|
115
|
+
origin: "https://relay.example.com",
|
|
116
|
+
documentLoaderFactory: () => mockDocumentLoader,
|
|
117
|
+
subscriptionHandler: () => Promise.resolve(true)
|
|
118
|
+
});
|
|
119
|
+
const request = new Request("https://relay.example.com/users/relay/followers", { headers: { "Accept": "application/activity+json" } });
|
|
120
|
+
const response = await relay.fetch(request);
|
|
121
|
+
strictEqual(response.status, 200);
|
|
122
|
+
const json = await response.json();
|
|
123
|
+
ok(json);
|
|
124
|
+
ok(json.type === "Collection" || json.type === "OrderedCollection");
|
|
125
|
+
});
|
|
126
|
+
test("followers collection returns populated list", async () => {
|
|
127
|
+
const kv = new MemoryKvStore();
|
|
128
|
+
const follower1 = new Person({
|
|
129
|
+
id: new URL("https://remote1.example.com/users/alice"),
|
|
130
|
+
preferredUsername: "alice",
|
|
131
|
+
inbox: new URL("https://remote1.example.com/users/alice/inbox")
|
|
132
|
+
});
|
|
133
|
+
const follower2 = new Person({
|
|
134
|
+
id: new URL("https://remote2.example.com/users/bob"),
|
|
135
|
+
preferredUsername: "bob",
|
|
136
|
+
inbox: new URL("https://remote2.example.com/users/bob/inbox")
|
|
137
|
+
});
|
|
138
|
+
const follower1Id = "https://remote1.example.com/users/alice";
|
|
139
|
+
const follower2Id = "https://remote2.example.com/users/bob";
|
|
140
|
+
await kv.set(["follower", follower1Id], {
|
|
141
|
+
actor: await follower1.toJsonLd(),
|
|
142
|
+
state: "accepted"
|
|
143
|
+
});
|
|
144
|
+
await kv.set(["follower", follower2Id], {
|
|
145
|
+
actor: await follower2.toJsonLd(),
|
|
146
|
+
state: "accepted"
|
|
147
|
+
});
|
|
148
|
+
const relay = createRelay("litepub", {
|
|
149
|
+
kv,
|
|
150
|
+
origin: "https://relay.example.com",
|
|
151
|
+
documentLoaderFactory: () => mockDocumentLoader,
|
|
152
|
+
subscriptionHandler: () => Promise.resolve(true)
|
|
153
|
+
});
|
|
154
|
+
const request = new Request("https://relay.example.com/users/relay/followers", { headers: { "Accept": "application/activity+json" } });
|
|
155
|
+
const response = await relay.fetch(request);
|
|
156
|
+
strictEqual(response.status, 200);
|
|
157
|
+
const json = await response.json();
|
|
158
|
+
ok(json);
|
|
159
|
+
ok(json.type === "Collection" || json.type === "OrderedCollection");
|
|
160
|
+
if (json.totalItems !== void 0) strictEqual(json.totalItems, 2);
|
|
161
|
+
});
|
|
162
|
+
test("relay actor has correct properties", async () => {
|
|
163
|
+
const kv = new MemoryKvStore();
|
|
164
|
+
const relay = createRelay("litepub", {
|
|
165
|
+
kv,
|
|
166
|
+
origin: "https://relay.example.com",
|
|
167
|
+
documentLoaderFactory: () => mockDocumentLoader,
|
|
168
|
+
subscriptionHandler: () => Promise.resolve(true)
|
|
169
|
+
});
|
|
170
|
+
const request = new Request("https://relay.example.com/users/relay", { headers: { "Accept": "application/activity+json" } });
|
|
171
|
+
const response = await relay.fetch(request);
|
|
172
|
+
strictEqual(response.status, 200);
|
|
173
|
+
const json = await response.json();
|
|
174
|
+
strictEqual(json.type, "Application");
|
|
175
|
+
strictEqual(json.preferredUsername, "relay");
|
|
176
|
+
strictEqual(json.name, "ActivityPub Relay");
|
|
177
|
+
strictEqual(json.id, "https://relay.example.com/users/relay");
|
|
178
|
+
strictEqual(json.inbox, "https://relay.example.com/inbox");
|
|
179
|
+
strictEqual(json.followers, "https://relay.example.com/users/relay/followers");
|
|
180
|
+
strictEqual(json.following, "https://relay.example.com/users/relay/following");
|
|
181
|
+
});
|
|
182
|
+
test("handles Follow activity with subscription approval", async () => {
|
|
183
|
+
const kv = new MemoryKvStore();
|
|
184
|
+
let handlerCalled = false;
|
|
185
|
+
let handlerActor = null;
|
|
186
|
+
const relay = createRelay("litepub", {
|
|
187
|
+
kv,
|
|
188
|
+
origin: "https://relay.example.com",
|
|
189
|
+
documentLoaderFactory: () => mockDocumentLoader,
|
|
190
|
+
authenticatedDocumentLoaderFactory: () => mockDocumentLoader,
|
|
191
|
+
subscriptionHandler: async (_ctx, actor) => {
|
|
192
|
+
handlerCalled = true;
|
|
193
|
+
handlerActor = actor;
|
|
194
|
+
return await Promise.resolve(true);
|
|
195
|
+
}
|
|
196
|
+
});
|
|
197
|
+
const follower = new Person({
|
|
198
|
+
id: new URL("https://remote.example.com/users/alice"),
|
|
199
|
+
preferredUsername: "alice",
|
|
200
|
+
inbox: new URL("https://remote.example.com/users/alice/inbox")
|
|
201
|
+
});
|
|
202
|
+
const followActivity = new Follow({
|
|
203
|
+
id: new URL("https://remote.example.com/activities/follow/1"),
|
|
204
|
+
actor: follower.id,
|
|
205
|
+
object: new URL("https://relay.example.com/users/relay")
|
|
206
|
+
});
|
|
207
|
+
let request = new Request("https://relay.example.com/inbox", {
|
|
208
|
+
method: "POST",
|
|
209
|
+
headers: { "Content-Type": "application/activity+json" },
|
|
210
|
+
body: JSON.stringify(await followActivity.toJsonLd({ contextLoader: mockDocumentLoader }))
|
|
211
|
+
});
|
|
212
|
+
request = await signRequest(request, rsaKeyPair.privateKey, rsaPublicKey.id);
|
|
213
|
+
await relay.fetch(request);
|
|
214
|
+
strictEqual(handlerCalled, true);
|
|
215
|
+
ok(handlerActor);
|
|
216
|
+
const followerData = await kv.get(["follower", "https://remote.example.com/users/alice"]);
|
|
217
|
+
ok(isRelayFollowerData(followerData));
|
|
218
|
+
strictEqual(followerData.state, "pending");
|
|
219
|
+
});
|
|
220
|
+
test("handles Follow activity with subscription rejection", async () => {
|
|
221
|
+
const kv = new MemoryKvStore();
|
|
222
|
+
const relay = createRelay("litepub", {
|
|
223
|
+
kv,
|
|
224
|
+
origin: "https://relay.example.com",
|
|
225
|
+
documentLoaderFactory: () => mockDocumentLoader,
|
|
226
|
+
authenticatedDocumentLoaderFactory: () => mockDocumentLoader,
|
|
227
|
+
subscriptionHandler: async (_ctx, _actor) => {
|
|
228
|
+
return await Promise.resolve(false);
|
|
229
|
+
}
|
|
230
|
+
});
|
|
231
|
+
const follower = new Person({
|
|
232
|
+
id: new URL("https://remote.example.com/users/alice"),
|
|
233
|
+
preferredUsername: "alice",
|
|
234
|
+
inbox: new URL("https://remote.example.com/users/alice/inbox")
|
|
235
|
+
});
|
|
236
|
+
const followActivity = new Follow({
|
|
237
|
+
id: new URL("https://remote.example.com/activities/follow/1"),
|
|
238
|
+
actor: follower.id,
|
|
239
|
+
object: new URL("https://relay.example.com/users/relay")
|
|
240
|
+
});
|
|
241
|
+
let request = new Request("https://relay.example.com/inbox", {
|
|
242
|
+
method: "POST",
|
|
243
|
+
headers: { "Content-Type": "application/activity+json" },
|
|
244
|
+
body: JSON.stringify(await followActivity.toJsonLd({ contextLoader: mockDocumentLoader }))
|
|
245
|
+
});
|
|
246
|
+
request = await signRequest(request, rsaKeyPair.privateKey, rsaPublicKey.id);
|
|
247
|
+
await relay.fetch(request);
|
|
248
|
+
const followerData = await kv.get(["follower", "https://remote.example.com/users/alice"]);
|
|
249
|
+
strictEqual(followerData, void 0);
|
|
250
|
+
});
|
|
251
|
+
test("handles public Follow activity", async () => {
|
|
252
|
+
const kv = new MemoryKvStore();
|
|
253
|
+
const relay = createRelay("litepub", {
|
|
254
|
+
kv,
|
|
255
|
+
origin: "https://relay.example.com",
|
|
256
|
+
documentLoaderFactory: () => mockDocumentLoader,
|
|
257
|
+
authenticatedDocumentLoaderFactory: () => mockDocumentLoader,
|
|
258
|
+
subscriptionHandler: async (_ctx, _actor) => await Promise.resolve(true)
|
|
259
|
+
});
|
|
260
|
+
const follower = new Person({
|
|
261
|
+
id: new URL("https://remote.example.com/users/alice"),
|
|
262
|
+
preferredUsername: "alice",
|
|
263
|
+
inbox: new URL("https://remote.example.com/users/alice/inbox")
|
|
264
|
+
});
|
|
265
|
+
const followActivity = new Follow({
|
|
266
|
+
id: new URL("https://remote.example.com/activities/follow/1"),
|
|
267
|
+
actor: follower.id,
|
|
268
|
+
object: new URL("https://www.w3.org/ns/activitystreams#Public")
|
|
269
|
+
});
|
|
270
|
+
let request = new Request("https://relay.example.com/inbox", {
|
|
271
|
+
method: "POST",
|
|
272
|
+
headers: { "Content-Type": "application/activity+json" },
|
|
273
|
+
body: JSON.stringify(await followActivity.toJsonLd({ contextLoader: mockDocumentLoader }))
|
|
274
|
+
});
|
|
275
|
+
request = await signRequest(request, rsaKeyPair.privateKey, rsaPublicKey.id);
|
|
276
|
+
await relay.fetch(request);
|
|
277
|
+
const followerData = await kv.get(["follower", "https://remote.example.com/users/alice"]);
|
|
278
|
+
ok(isRelayFollowerData(followerData));
|
|
279
|
+
strictEqual(followerData.state, "pending");
|
|
280
|
+
});
|
|
281
|
+
test("ignores Follow activity without required fields", async () => {
|
|
282
|
+
const kv = new MemoryKvStore();
|
|
283
|
+
const relay = createRelay("litepub", {
|
|
284
|
+
kv,
|
|
285
|
+
origin: "https://relay.example.com",
|
|
286
|
+
documentLoaderFactory: () => mockDocumentLoader,
|
|
287
|
+
authenticatedDocumentLoaderFactory: () => mockDocumentLoader,
|
|
288
|
+
subscriptionHandler: async (_ctx, _actor) => await Promise.resolve(true)
|
|
289
|
+
});
|
|
290
|
+
const followActivity = new Follow({
|
|
291
|
+
actor: new URL("https://remote.example.com/users/alice"),
|
|
292
|
+
object: new URL("https://relay.example.com/users/relay")
|
|
293
|
+
});
|
|
294
|
+
let request = new Request("https://relay.example.com/inbox", {
|
|
295
|
+
method: "POST",
|
|
296
|
+
headers: { "Content-Type": "application/activity+json" },
|
|
297
|
+
body: JSON.stringify(await followActivity.toJsonLd({ contextLoader: mockDocumentLoader }))
|
|
298
|
+
});
|
|
299
|
+
request = await signRequest(request, rsaKeyPair.privateKey, rsaPublicKey.id);
|
|
300
|
+
await relay.fetch(request);
|
|
301
|
+
const followerData = await kv.get(["follower", "https://remote.example.com/users/alice"]);
|
|
302
|
+
strictEqual(followerData, void 0);
|
|
303
|
+
});
|
|
304
|
+
test("ignores duplicate Follow activity from pending follower", async () => {
|
|
305
|
+
const kv = new MemoryKvStore();
|
|
306
|
+
let handlerCallCount = 0;
|
|
307
|
+
const relay = createRelay("litepub", {
|
|
308
|
+
kv,
|
|
309
|
+
origin: "https://relay.example.com",
|
|
310
|
+
documentLoaderFactory: () => mockDocumentLoader,
|
|
311
|
+
authenticatedDocumentLoaderFactory: () => mockDocumentLoader,
|
|
312
|
+
subscriptionHandler: async (_ctx, _actor) => {
|
|
313
|
+
handlerCallCount++;
|
|
314
|
+
return await Promise.resolve(true);
|
|
315
|
+
}
|
|
316
|
+
});
|
|
317
|
+
const follower = new Person({
|
|
318
|
+
id: new URL("https://remote.example.com/users/alice"),
|
|
319
|
+
preferredUsername: "alice",
|
|
320
|
+
inbox: new URL("https://remote.example.com/users/alice/inbox")
|
|
321
|
+
});
|
|
322
|
+
await kv.set(["follower", "https://remote.example.com/users/alice"], {
|
|
323
|
+
actor: await follower.toJsonLd(),
|
|
324
|
+
state: "pending"
|
|
325
|
+
});
|
|
326
|
+
const followActivity = new Follow({
|
|
327
|
+
id: new URL("https://remote.example.com/activities/follow/1"),
|
|
328
|
+
actor: follower.id,
|
|
329
|
+
object: new URL("https://relay.example.com/users/relay")
|
|
330
|
+
});
|
|
331
|
+
let request = new Request("https://relay.example.com/inbox", {
|
|
332
|
+
method: "POST",
|
|
333
|
+
headers: { "Content-Type": "application/activity+json" },
|
|
334
|
+
body: JSON.stringify(await followActivity.toJsonLd({ contextLoader: mockDocumentLoader }))
|
|
335
|
+
});
|
|
336
|
+
request = await signRequest(request, rsaKeyPair.privateKey, rsaPublicKey.id);
|
|
337
|
+
await relay.fetch(request);
|
|
338
|
+
strictEqual(handlerCallCount, 0);
|
|
339
|
+
});
|
|
340
|
+
test("handles Accept activity completing reciprocal follow", async () => {
|
|
341
|
+
const kv = new MemoryKvStore();
|
|
342
|
+
const follower = new Person({
|
|
343
|
+
id: new URL("https://remote.example.com/users/alice"),
|
|
344
|
+
preferredUsername: "alice",
|
|
345
|
+
inbox: new URL("https://remote.example.com/users/alice/inbox")
|
|
346
|
+
});
|
|
347
|
+
await kv.set(["follower", "https://remote.example.com/users/alice"], {
|
|
348
|
+
actor: await follower.toJsonLd(),
|
|
349
|
+
state: "pending"
|
|
350
|
+
});
|
|
351
|
+
const relay = createRelay("litepub", {
|
|
352
|
+
kv,
|
|
353
|
+
origin: "https://relay.example.com",
|
|
354
|
+
documentLoaderFactory: () => mockDocumentLoader,
|
|
355
|
+
authenticatedDocumentLoaderFactory: () => mockDocumentLoader,
|
|
356
|
+
subscriptionHandler: () => Promise.resolve(true)
|
|
357
|
+
});
|
|
358
|
+
const relayFollow = new Follow({
|
|
359
|
+
id: new URL("https://relay.example.com/activities/follow/1"),
|
|
360
|
+
actor: new URL("https://relay.example.com/users/relay"),
|
|
361
|
+
object: new URL("https://remote.example.com/users/alice")
|
|
362
|
+
});
|
|
363
|
+
const acceptActivity = new Accept({
|
|
364
|
+
id: new URL("https://remote.example.com/activities/accept/1"),
|
|
365
|
+
actor: new URL("https://remote.example.com/users/alice"),
|
|
366
|
+
object: relayFollow
|
|
367
|
+
});
|
|
368
|
+
let request = new Request("https://relay.example.com/inbox", {
|
|
369
|
+
method: "POST",
|
|
370
|
+
headers: { "Content-Type": "application/activity+json" },
|
|
371
|
+
body: JSON.stringify(await acceptActivity.toJsonLd({ contextLoader: mockDocumentLoader }))
|
|
372
|
+
});
|
|
373
|
+
request = await signRequest(request, rsaKeyPair.privateKey, rsaPublicKey.id);
|
|
374
|
+
await relay.fetch(request);
|
|
375
|
+
const followerData = await kv.get(["follower", "https://remote.example.com/users/alice"]);
|
|
376
|
+
ok(isRelayFollowerData(followerData));
|
|
377
|
+
strictEqual(followerData.state, "accepted");
|
|
378
|
+
});
|
|
379
|
+
test("handles Undo Follow activity", async () => {
|
|
380
|
+
const kv = new MemoryKvStore();
|
|
381
|
+
const followerId = "https://remote.example.com/users/alice";
|
|
382
|
+
const follower = new Person({
|
|
383
|
+
id: new URL(followerId),
|
|
384
|
+
preferredUsername: "alice",
|
|
385
|
+
inbox: new URL("https://remote.example.com/users/alice/inbox")
|
|
386
|
+
});
|
|
387
|
+
await kv.set(["follower", followerId], {
|
|
388
|
+
actor: await follower.toJsonLd(),
|
|
389
|
+
state: "accepted"
|
|
390
|
+
});
|
|
391
|
+
const relay = createRelay("litepub", {
|
|
392
|
+
kv,
|
|
393
|
+
origin: "https://relay.example.com",
|
|
394
|
+
documentLoaderFactory: () => mockDocumentLoader,
|
|
395
|
+
authenticatedDocumentLoaderFactory: () => mockDocumentLoader,
|
|
396
|
+
subscriptionHandler: () => Promise.resolve(true)
|
|
397
|
+
});
|
|
398
|
+
const originalFollow = new Follow({
|
|
399
|
+
id: new URL("https://remote.example.com/activities/follow/1"),
|
|
400
|
+
actor: new URL(followerId),
|
|
401
|
+
object: new URL("https://relay.example.com/users/relay")
|
|
402
|
+
});
|
|
403
|
+
const undoActivity = new Undo({
|
|
404
|
+
id: new URL("https://remote.example.com/activities/undo/1"),
|
|
405
|
+
actor: new URL(followerId),
|
|
406
|
+
object: originalFollow
|
|
407
|
+
});
|
|
408
|
+
let request = new Request("https://relay.example.com/inbox", {
|
|
409
|
+
method: "POST",
|
|
410
|
+
headers: { "Content-Type": "application/activity+json" },
|
|
411
|
+
body: JSON.stringify(await undoActivity.toJsonLd({ contextLoader: mockDocumentLoader }))
|
|
412
|
+
});
|
|
413
|
+
request = await signRequest(request, rsaKeyPair.privateKey, rsaPublicKey.id);
|
|
414
|
+
await relay.fetch(request);
|
|
415
|
+
const followerData = await kv.get(["follower", followerId]);
|
|
416
|
+
strictEqual(followerData, void 0);
|
|
417
|
+
});
|
|
418
|
+
test("handles Create activity with Announce forwarding", async () => {
|
|
419
|
+
const kv = new MemoryKvStore();
|
|
420
|
+
const followerId = "https://remote.example.com/users/alice";
|
|
421
|
+
const follower = new Person({
|
|
422
|
+
id: new URL(followerId),
|
|
423
|
+
preferredUsername: "alice",
|
|
424
|
+
inbox: new URL("https://remote.example.com/users/alice/inbox")
|
|
425
|
+
});
|
|
426
|
+
await kv.set(["follower", followerId], {
|
|
427
|
+
actor: await follower.toJsonLd(),
|
|
428
|
+
state: "accepted"
|
|
429
|
+
});
|
|
430
|
+
const relay = createRelay("litepub", {
|
|
431
|
+
kv,
|
|
432
|
+
origin: "https://relay.example.com",
|
|
433
|
+
documentLoaderFactory: () => mockDocumentLoader,
|
|
434
|
+
authenticatedDocumentLoaderFactory: () => mockDocumentLoader,
|
|
435
|
+
subscriptionHandler: () => Promise.resolve(true)
|
|
436
|
+
});
|
|
437
|
+
const note = new Note({
|
|
438
|
+
id: new URL("https://remote.example.com/notes/1"),
|
|
439
|
+
content: "Hello world"
|
|
440
|
+
});
|
|
441
|
+
const createActivity = new Create({
|
|
442
|
+
id: new URL("https://remote.example.com/activities/create/1"),
|
|
443
|
+
actor: new URL(followerId),
|
|
444
|
+
object: note
|
|
445
|
+
});
|
|
446
|
+
let request = new Request("https://relay.example.com/inbox", {
|
|
447
|
+
method: "POST",
|
|
448
|
+
headers: { "Content-Type": "application/activity+json" },
|
|
449
|
+
body: JSON.stringify(await createActivity.toJsonLd({ contextLoader: mockDocumentLoader }))
|
|
450
|
+
});
|
|
451
|
+
request = await signRequest(request, rsaKeyPair.privateKey, rsaPublicKey.id);
|
|
452
|
+
const response = await relay.fetch(request);
|
|
453
|
+
ok(response.status === 200 || response.status === 202);
|
|
454
|
+
});
|
|
455
|
+
test("handles Update activity with Announce forwarding", async () => {
|
|
456
|
+
const kv = new MemoryKvStore();
|
|
457
|
+
const relay = createRelay("litepub", {
|
|
458
|
+
kv,
|
|
459
|
+
origin: "https://relay.example.com",
|
|
460
|
+
documentLoaderFactory: () => mockDocumentLoader,
|
|
461
|
+
authenticatedDocumentLoaderFactory: () => mockDocumentLoader,
|
|
462
|
+
subscriptionHandler: () => Promise.resolve(true)
|
|
463
|
+
});
|
|
464
|
+
const note = new Note({
|
|
465
|
+
id: new URL("https://remote.example.com/notes/1"),
|
|
466
|
+
content: "Updated content"
|
|
467
|
+
});
|
|
468
|
+
const updateActivity = new Update({
|
|
469
|
+
id: new URL("https://remote.example.com/activities/update/1"),
|
|
470
|
+
actor: new URL("https://remote.example.com/users/alice"),
|
|
471
|
+
object: note
|
|
472
|
+
});
|
|
473
|
+
let request = new Request("https://relay.example.com/inbox", {
|
|
474
|
+
method: "POST",
|
|
475
|
+
headers: { "Content-Type": "application/activity+json" },
|
|
476
|
+
body: JSON.stringify(await updateActivity.toJsonLd({ contextLoader: mockDocumentLoader }))
|
|
477
|
+
});
|
|
478
|
+
request = await signRequest(request, rsaKeyPair.privateKey, rsaPublicKey.id);
|
|
479
|
+
const response = await relay.fetch(request);
|
|
480
|
+
ok(response.status === 200 || response.status === 202);
|
|
481
|
+
});
|
|
482
|
+
test("handles Move activity with Announce forwarding", async () => {
|
|
483
|
+
const kv = new MemoryKvStore();
|
|
484
|
+
const relay = createRelay("litepub", {
|
|
485
|
+
kv,
|
|
486
|
+
origin: "https://relay.example.com",
|
|
487
|
+
documentLoaderFactory: () => mockDocumentLoader,
|
|
488
|
+
authenticatedDocumentLoaderFactory: () => mockDocumentLoader,
|
|
489
|
+
subscriptionHandler: () => Promise.resolve(true)
|
|
490
|
+
});
|
|
491
|
+
const moveActivity = new Move({
|
|
492
|
+
id: new URL("https://remote.example.com/activities/move/1"),
|
|
493
|
+
actor: new URL("https://remote.example.com/users/alice"),
|
|
494
|
+
object: new URL("https://remote.example.com/users/alice"),
|
|
495
|
+
target: new URL("https://other.example.com/users/alice")
|
|
496
|
+
});
|
|
497
|
+
let request = new Request("https://relay.example.com/inbox", {
|
|
498
|
+
method: "POST",
|
|
499
|
+
headers: { "Content-Type": "application/activity+json" },
|
|
500
|
+
body: JSON.stringify(await moveActivity.toJsonLd({ contextLoader: mockDocumentLoader }))
|
|
501
|
+
});
|
|
502
|
+
request = await signRequest(request, rsaKeyPair.privateKey, rsaPublicKey.id);
|
|
503
|
+
const response = await relay.fetch(request);
|
|
504
|
+
ok(response.status === 200 || response.status === 202);
|
|
505
|
+
});
|
|
506
|
+
test("handles Delete activity with Announce forwarding", async () => {
|
|
507
|
+
const kv = new MemoryKvStore();
|
|
508
|
+
const relay = createRelay("litepub", {
|
|
509
|
+
kv,
|
|
510
|
+
origin: "https://relay.example.com",
|
|
511
|
+
documentLoaderFactory: () => mockDocumentLoader,
|
|
512
|
+
authenticatedDocumentLoaderFactory: () => mockDocumentLoader,
|
|
513
|
+
subscriptionHandler: () => Promise.resolve(true)
|
|
514
|
+
});
|
|
515
|
+
const deleteActivity = new Delete({
|
|
516
|
+
id: new URL("https://remote.example.com/activities/delete/1"),
|
|
517
|
+
actor: new URL("https://remote.example.com/users/alice"),
|
|
518
|
+
object: new URL("https://remote.example.com/notes/1")
|
|
519
|
+
});
|
|
520
|
+
let request = new Request("https://relay.example.com/inbox", {
|
|
521
|
+
method: "POST",
|
|
522
|
+
headers: { "Content-Type": "application/activity+json" },
|
|
523
|
+
body: JSON.stringify(await deleteActivity.toJsonLd({ contextLoader: mockDocumentLoader }))
|
|
524
|
+
});
|
|
525
|
+
request = await signRequest(request, rsaKeyPair.privateKey, rsaPublicKey.id);
|
|
526
|
+
const response = await relay.fetch(request);
|
|
527
|
+
ok(response.status === 200 || response.status === 202);
|
|
528
|
+
});
|
|
529
|
+
test("handles Announce activity forwarding", async () => {
|
|
530
|
+
const kv = new MemoryKvStore();
|
|
531
|
+
const relay = createRelay("litepub", {
|
|
532
|
+
kv,
|
|
533
|
+
origin: "https://relay.example.com",
|
|
534
|
+
documentLoaderFactory: () => mockDocumentLoader,
|
|
535
|
+
authenticatedDocumentLoaderFactory: () => mockDocumentLoader,
|
|
536
|
+
subscriptionHandler: () => Promise.resolve(true)
|
|
537
|
+
});
|
|
538
|
+
const announceActivity = new Announce({
|
|
539
|
+
id: new URL("https://remote.example.com/activities/announce/1"),
|
|
540
|
+
actor: new URL("https://remote.example.com/users/alice"),
|
|
541
|
+
object: new URL("https://remote.example.com/notes/1")
|
|
542
|
+
});
|
|
543
|
+
let request = new Request("https://relay.example.com/inbox", {
|
|
544
|
+
method: "POST",
|
|
545
|
+
headers: { "Content-Type": "application/activity+json" },
|
|
546
|
+
body: JSON.stringify(await announceActivity.toJsonLd({ contextLoader: mockDocumentLoader }))
|
|
547
|
+
});
|
|
548
|
+
request = await signRequest(request, rsaKeyPair.privateKey, rsaPublicKey.id);
|
|
549
|
+
const response = await relay.fetch(request);
|
|
550
|
+
ok(response.status === 200 || response.status === 202);
|
|
551
|
+
});
|
|
552
|
+
test("multiple followers can be stored", async () => {
|
|
553
|
+
const kv = new MemoryKvStore();
|
|
554
|
+
const followerIds = [
|
|
555
|
+
"https://remote1.example.com/users/user1",
|
|
556
|
+
"https://remote2.example.com/users/user2",
|
|
557
|
+
"https://remote3.example.com/users/user3"
|
|
558
|
+
];
|
|
559
|
+
for (let i = 0; i < followerIds.length; i++) {
|
|
560
|
+
const followerId = followerIds[i];
|
|
561
|
+
const actor = new Person({
|
|
562
|
+
id: new URL(followerId),
|
|
563
|
+
preferredUsername: `user${i + 1}`,
|
|
564
|
+
inbox: new URL(`${followerId}/inbox`)
|
|
565
|
+
});
|
|
566
|
+
await kv.set(["follower", followerId], {
|
|
567
|
+
actor: await actor.toJsonLd(),
|
|
568
|
+
state: "accepted"
|
|
569
|
+
});
|
|
570
|
+
}
|
|
571
|
+
let count = 0;
|
|
572
|
+
for await (const _ of kv.list(["follower"])) count++;
|
|
573
|
+
strictEqual(count, 3);
|
|
574
|
+
});
|
|
575
|
+
test("list() returns empty when no followers exist", async () => {
|
|
576
|
+
const kv = new MemoryKvStore();
|
|
577
|
+
let count = 0;
|
|
578
|
+
for await (const _ of kv.list(["follower"])) count++;
|
|
579
|
+
strictEqual(count, 0);
|
|
580
|
+
});
|
|
581
|
+
test("list() returns all followers after additions", async () => {
|
|
582
|
+
const kv = new MemoryKvStore();
|
|
583
|
+
const followerIds = [
|
|
584
|
+
"https://server1.example.com/users/alice",
|
|
585
|
+
"https://server2.example.com/users/bob",
|
|
586
|
+
"https://server3.example.com/users/carol"
|
|
587
|
+
];
|
|
588
|
+
for (const followerId of followerIds) {
|
|
589
|
+
const actor = new Person({
|
|
590
|
+
id: new URL(followerId),
|
|
591
|
+
preferredUsername: followerId.split("/").pop(),
|
|
592
|
+
inbox: new URL(`${followerId}/inbox`)
|
|
593
|
+
});
|
|
594
|
+
await kv.set(["follower", followerId], {
|
|
595
|
+
actor: await actor.toJsonLd(),
|
|
596
|
+
state: "accepted"
|
|
597
|
+
});
|
|
598
|
+
}
|
|
599
|
+
const retrievedIds = [];
|
|
600
|
+
for await (const { key, value } of kv.list(["follower"])) {
|
|
601
|
+
strictEqual(key.length, 2);
|
|
602
|
+
strictEqual(key[0], "follower");
|
|
603
|
+
retrievedIds.push(key[1]);
|
|
604
|
+
ok(isRelayFollowerData(value));
|
|
605
|
+
strictEqual(value.state, "accepted");
|
|
606
|
+
}
|
|
607
|
+
strictEqual(retrievedIds.length, 3);
|
|
608
|
+
for (const id of followerIds) ok(retrievedIds.includes(id));
|
|
609
|
+
});
|
|
610
|
+
test("list() excludes followers after deletion", async () => {
|
|
611
|
+
const kv = new MemoryKvStore();
|
|
612
|
+
const follower1Id = "https://server1.example.com/users/alice";
|
|
613
|
+
const follower2Id = "https://server2.example.com/users/bob";
|
|
614
|
+
const follower3Id = "https://server3.example.com/users/carol";
|
|
615
|
+
for (const followerId of [
|
|
616
|
+
follower1Id,
|
|
617
|
+
follower2Id,
|
|
618
|
+
follower3Id
|
|
619
|
+
]) {
|
|
620
|
+
const actor = new Person({
|
|
621
|
+
id: new URL(followerId),
|
|
622
|
+
preferredUsername: followerId.split("/").pop(),
|
|
623
|
+
inbox: new URL(`${followerId}/inbox`)
|
|
624
|
+
});
|
|
625
|
+
await kv.set(["follower", followerId], {
|
|
626
|
+
actor: await actor.toJsonLd(),
|
|
627
|
+
state: "accepted"
|
|
628
|
+
});
|
|
629
|
+
}
|
|
630
|
+
await kv.delete(["follower", follower2Id]);
|
|
631
|
+
const retrievedIds = [];
|
|
632
|
+
for await (const { key } of kv.list(["follower"])) retrievedIds.push(key[1]);
|
|
633
|
+
strictEqual(retrievedIds.length, 2);
|
|
634
|
+
ok(retrievedIds.includes(follower1Id));
|
|
635
|
+
ok(!retrievedIds.includes(follower2Id));
|
|
636
|
+
ok(retrievedIds.includes(follower3Id));
|
|
637
|
+
});
|
|
638
|
+
test("list() distinguishes between pending and accepted followers", async () => {
|
|
639
|
+
const kv = new MemoryKvStore();
|
|
640
|
+
const pendingFollowerId = "https://server1.example.com/users/alice";
|
|
641
|
+
const acceptedFollowerId = "https://server2.example.com/users/bob";
|
|
642
|
+
const pendingFollower = new Person({
|
|
643
|
+
id: new URL(pendingFollowerId),
|
|
644
|
+
preferredUsername: "alice",
|
|
645
|
+
inbox: new URL("https://server1.example.com/users/alice/inbox")
|
|
646
|
+
});
|
|
647
|
+
const acceptedFollower = new Person({
|
|
648
|
+
id: new URL(acceptedFollowerId),
|
|
649
|
+
preferredUsername: "bob",
|
|
650
|
+
inbox: new URL("https://server2.example.com/users/bob/inbox")
|
|
651
|
+
});
|
|
652
|
+
await kv.set(["follower", pendingFollowerId], {
|
|
653
|
+
actor: await pendingFollower.toJsonLd(),
|
|
654
|
+
state: "pending"
|
|
655
|
+
});
|
|
656
|
+
await kv.set(["follower", acceptedFollowerId], {
|
|
657
|
+
actor: await acceptedFollower.toJsonLd(),
|
|
658
|
+
state: "accepted"
|
|
659
|
+
});
|
|
660
|
+
const followers = [];
|
|
661
|
+
for await (const { key, value } of kv.list(["follower"])) {
|
|
662
|
+
if (!isRelayFollowerData(value)) continue;
|
|
663
|
+
followers.push({
|
|
664
|
+
id: key[1],
|
|
665
|
+
state: value.state
|
|
666
|
+
});
|
|
667
|
+
}
|
|
668
|
+
strictEqual(followers.length, 2);
|
|
669
|
+
const pendingEntry = followers.find((f) => f.id === pendingFollowerId);
|
|
670
|
+
ok(pendingEntry);
|
|
671
|
+
strictEqual(pendingEntry.state, "pending");
|
|
672
|
+
const acceptedEntry = followers.find((f) => f.id === acceptedFollowerId);
|
|
673
|
+
ok(acceptedEntry);
|
|
674
|
+
strictEqual(acceptedEntry.state, "accepted");
|
|
675
|
+
});
|
|
676
|
+
test("list() returns correct actor data", async () => {
|
|
677
|
+
const kv = new MemoryKvStore();
|
|
678
|
+
const followerId = "https://remote.example.com/users/alice";
|
|
679
|
+
const follower = new Person({
|
|
680
|
+
id: new URL(followerId),
|
|
681
|
+
preferredUsername: "alice",
|
|
682
|
+
name: "Alice Wonderland",
|
|
683
|
+
inbox: new URL("https://remote.example.com/users/alice/inbox")
|
|
684
|
+
});
|
|
685
|
+
await kv.set(["follower", followerId], {
|
|
686
|
+
actor: await follower.toJsonLd(),
|
|
687
|
+
state: "accepted"
|
|
688
|
+
});
|
|
689
|
+
for await (const { key, value } of kv.list(["follower"])) {
|
|
690
|
+
strictEqual(key[1], followerId);
|
|
691
|
+
ok(isRelayFollowerData(value));
|
|
692
|
+
strictEqual(value.state, "accepted");
|
|
693
|
+
ok(value.actor && typeof value.actor === "object");
|
|
694
|
+
const actor = value.actor;
|
|
695
|
+
strictEqual(actor.preferredUsername, "alice");
|
|
696
|
+
strictEqual(actor.name, "Alice Wonderland");
|
|
697
|
+
}
|
|
698
|
+
});
|
|
699
|
+
});
|
|
700
|
+
|
|
701
|
+
//#endregion
|