@fedify/relay 2.0.0-dev.100

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/dist/mod.js ADDED
@@ -0,0 +1,414 @@
1
+
2
+ import { Temporal } from "@js-temporal/polyfill";
3
+
4
+ import { Accept, Announce, Create, Delete, Follow, Move, PUBLIC_COLLECTION, Reject, Undo, Update, createFederationBuilder, exportJwk, generateCryptoKeyPair, importJwk, isActor } from "@fedify/fedify";
5
+ import { Application, Object as Object$1, isActor as isActor$1 } from "@fedify/fedify/vocab";
6
+ import { getLogger } from "@logtape/logtape";
7
+
8
+ //#region src/types.ts
9
+ const RELAY_SERVER_ACTOR = "relay";
10
+ /**
11
+ * Type predicate to check if a value is valid RelayFollowerData from KV store.
12
+ * Validates the storage format (JSON-LD), not the deserialized Actor instance.
13
+ *
14
+ * @param value The value to check
15
+ * @returns true if the value is a RelayFollowerData
16
+ * @internal
17
+ */
18
+ function isRelayFollowerData(value) {
19
+ if (!value || typeof value !== "object") return false;
20
+ const obj = value;
21
+ return "actor" in obj && "state" in obj && typeof obj.state === "string" && (obj.state === "pending" || obj.state === "accepted");
22
+ }
23
+
24
+ //#endregion
25
+ //#region src/builder.ts
26
+ const relayBuilder = createFederationBuilder();
27
+ relayBuilder.setActorDispatcher("/users/{identifier}", async (ctx, identifier) => {
28
+ if (identifier !== RELAY_SERVER_ACTOR) return null;
29
+ const keys = await ctx.getActorKeyPairs(identifier);
30
+ return new Application({
31
+ id: ctx.getActorUri(identifier),
32
+ preferredUsername: identifier,
33
+ name: ctx.data.name ?? "ActivityPub Relay",
34
+ inbox: ctx.getInboxUri(),
35
+ followers: ctx.getFollowersUri(identifier),
36
+ following: ctx.getFollowingUri(identifier),
37
+ url: ctx.getActorUri(identifier),
38
+ publicKey: keys[0].cryptographicKey,
39
+ assertionMethods: keys.map((k) => k.multikey)
40
+ });
41
+ }).setKeyPairsDispatcher(async (ctx, identifier) => {
42
+ if (identifier !== RELAY_SERVER_ACTOR) return [];
43
+ const rsaPairJson = await ctx.data.kv.get([
44
+ "keypair",
45
+ "rsa",
46
+ identifier
47
+ ]);
48
+ const ed25519PairJson = await ctx.data.kv.get([
49
+ "keypair",
50
+ "ed25519",
51
+ identifier
52
+ ]);
53
+ if (rsaPairJson == null || ed25519PairJson == null) {
54
+ const rsaPair$1 = await generateCryptoKeyPair("RSASSA-PKCS1-v1_5");
55
+ const ed25519Pair$1 = await generateCryptoKeyPair("Ed25519");
56
+ await ctx.data.kv.set([
57
+ "keypair",
58
+ "rsa",
59
+ identifier
60
+ ], {
61
+ privateKey: await exportJwk(rsaPair$1.privateKey),
62
+ publicKey: await exportJwk(rsaPair$1.publicKey)
63
+ });
64
+ await ctx.data.kv.set([
65
+ "keypair",
66
+ "ed25519",
67
+ identifier
68
+ ], {
69
+ privateKey: await exportJwk(ed25519Pair$1.privateKey),
70
+ publicKey: await exportJwk(ed25519Pair$1.publicKey)
71
+ });
72
+ return [rsaPair$1, ed25519Pair$1];
73
+ }
74
+ const rsaPair = {
75
+ privateKey: await importJwk(rsaPairJson.privateKey, "private"),
76
+ publicKey: await importJwk(rsaPairJson.publicKey, "public")
77
+ };
78
+ const ed25519Pair = {
79
+ privateKey: await importJwk(ed25519PairJson.privateKey, "private"),
80
+ publicKey: await importJwk(ed25519PairJson.publicKey, "public")
81
+ };
82
+ return [rsaPair, ed25519Pair];
83
+ });
84
+ async function getFollowerActors(ctx) {
85
+ const actors = [];
86
+ for await (const { value } of ctx.data.kv.list(["follower"])) {
87
+ if (!isRelayFollowerData(value)) continue;
88
+ if (value.state !== "accepted") continue;
89
+ const actor = await Object$1.fromJsonLd(value.actor);
90
+ if (!isActor$1(actor)) continue;
91
+ actors.push(actor);
92
+ }
93
+ return actors;
94
+ }
95
+ async function dispatchRelayActors(ctx, identifier) {
96
+ if (identifier !== RELAY_SERVER_ACTOR) return null;
97
+ const actors = await getFollowerActors(ctx);
98
+ return { items: actors };
99
+ }
100
+ relayBuilder.setFollowersDispatcher("/users/{identifier}/followers", dispatchRelayActors);
101
+ relayBuilder.setFollowingDispatcher("/users/{identifier}/following", dispatchRelayActors);
102
+
103
+ //#endregion
104
+ //#region src/base.ts
105
+ /**
106
+ * Abstract base class for relay implementations.
107
+ * Provides common infrastructure for both Mastodon and LitePub relays.
108
+ *
109
+ * @internal
110
+ */
111
+ var BaseRelay = class {
112
+ federationBuilder;
113
+ options;
114
+ federation;
115
+ constructor(options, relayBuilder$1) {
116
+ this.options = options;
117
+ this.federationBuilder = relayBuilder$1;
118
+ }
119
+ async fetch(request) {
120
+ if (this.federation == null) {
121
+ this.federation = await this.federationBuilder.build(this.options);
122
+ this.setupInboxListeners();
123
+ }
124
+ return await this.federation.fetch(request, { contextData: this.options });
125
+ }
126
+ /**
127
+ * Helper method to parse and validate follower data from storage.
128
+ * Deserializes JSON-LD actor data and validates it.
129
+ *
130
+ * @param actorId The actor ID of the follower
131
+ * @param data Raw data from KV store
132
+ * @returns RelayFollower object if valid, null otherwise
133
+ * @internal
134
+ */
135
+ async parseFollowerData(actorId, data) {
136
+ if (!isRelayFollowerData(data)) return null;
137
+ const actor = await Object$1.fromJsonLd(data.actor);
138
+ if (!isActor$1(actor)) return null;
139
+ return {
140
+ actorId,
141
+ actor,
142
+ state: data.state
143
+ };
144
+ }
145
+ /**
146
+ * Lists all followers of the relay.
147
+ *
148
+ * @returns An async iterator of follower entries
149
+ *
150
+ * @example
151
+ * ```ts
152
+ * import { createRelay } from "@fedify/relay";
153
+ * import { MemoryKvStore } from "@fedify/fedify";
154
+ *
155
+ * const relay = createRelay("mastodon", {
156
+ * kv: new MemoryKvStore(),
157
+ * domain: "relay.example.com",
158
+ * subscriptionHandler: async (ctx, actor) => true,
159
+ * });
160
+ *
161
+ * for await (const follower of relay.listFollowers()) {
162
+ * console.log(`Follower: ${follower.actorId}`);
163
+ * console.log(`State: ${follower.state}`);
164
+ * console.log(`Actor: ${follower.actor.name}`);
165
+ * }
166
+ * ```
167
+ *
168
+ * @since 2.0.0
169
+ */
170
+ async *listFollowers() {
171
+ for await (const entry of this.options.kv.list(["follower"])) {
172
+ const actorId = entry.key[1];
173
+ if (typeof actorId !== "string") continue;
174
+ const follower = await this.parseFollowerData(actorId, entry.value);
175
+ if (follower) yield follower;
176
+ }
177
+ }
178
+ /**
179
+ * Gets a specific follower by actor ID.
180
+ *
181
+ * @param actorId The actor ID (URL) of the follower to retrieve
182
+ * @returns The follower entry if found, null otherwise
183
+ *
184
+ * @example
185
+ * ```ts
186
+ * import { createRelay } from "@fedify/relay";
187
+ * import { MemoryKvStore } from "@fedify/fedify";
188
+ *
189
+ * const relay = createRelay("mastodon", {
190
+ * kv: new MemoryKvStore(),
191
+ * domain: "relay.example.com",
192
+ * subscriptionHandler: async (ctx, actor) => true,
193
+ * });
194
+ *
195
+ * const follower = await relay.getFollower(
196
+ * "https://mastodon.example.com/users/alice"
197
+ * );
198
+ * if (follower) {
199
+ * console.log(`State: ${follower.state}`);
200
+ * console.log(`Actor: ${follower.actor.preferredUsername}`);
201
+ * }
202
+ * ```
203
+ *
204
+ * @since 2.0.0
205
+ */
206
+ async getFollower(actorId) {
207
+ const followerData = await this.options.kv.get(["follower", actorId]);
208
+ return await this.parseFollowerData(actorId, followerData);
209
+ }
210
+ };
211
+
212
+ //#endregion
213
+ //#region src/follow.ts
214
+ /**
215
+ * Validate Follow activity and return follower actor if valid.
216
+ * This validation is common to both Mastodon and LitePub relay protocols.
217
+ *
218
+ * @param ctx The federation context
219
+ * @param follow The Follow activity to validate
220
+ * @returns The follower Actor if valid, null otherwise
221
+ */
222
+ async function validateFollowActivity(ctx, follow) {
223
+ if (follow.id == null || follow.objectId == null) return null;
224
+ const parsed = ctx.parseUri(follow.objectId);
225
+ const isPublicFollow = follow.objectId.href === "https://www.w3.org/ns/activitystreams#Public";
226
+ if (!isPublicFollow && parsed?.type !== "actor") return null;
227
+ const follower = await follow.getActor(ctx);
228
+ if (follower == null || follower.id == null || follower.preferredUsername == null || follower.inboxId == null) return null;
229
+ return follower;
230
+ }
231
+ /**
232
+ * Send Accept or Reject response for a Follow activity.
233
+ * This is common to both Mastodon and LitePub relay protocols.
234
+ *
235
+ * @param ctx The federation context
236
+ * @param follow The Follow activity being responded to
237
+ * @param follower The actor who sent the Follow
238
+ * @param approved Whether the follow was approved
239
+ */
240
+ async function sendFollowResponse(ctx, follow, follower, approved) {
241
+ const relayActorUri = ctx.getActorUri(RELAY_SERVER_ACTOR);
242
+ const Activity = approved ? Accept : Reject;
243
+ const action = approved ? "accepts" : "rejects";
244
+ await ctx.sendActivity({ identifier: RELAY_SERVER_ACTOR }, follower, new Activity({
245
+ id: new URL(`#${action}`, relayActorUri),
246
+ actor: relayActorUri,
247
+ object: follow
248
+ }));
249
+ }
250
+ /**
251
+ * Handle Undo activity for Follow.
252
+ * This logic is identical for both Mastodon and LitePub relay protocols.
253
+ *
254
+ * @param ctx The federation context
255
+ * @param undo The Undo activity to handle
256
+ * @param logger The logger instance to use for warnings
257
+ */
258
+ async function handleUndoFollow(ctx, undo, logger$2) {
259
+ const activity = await undo.getObject({
260
+ crossOrigin: "trust",
261
+ ...ctx
262
+ });
263
+ if (activity instanceof Follow) {
264
+ if (activity.id == null || activity.actorId == null) return;
265
+ await ctx.data.kv.delete(["follower", activity.actorId.href]);
266
+ } else logger$2.warn("Unsupported object type ({type}) for Undo activity: {object}", {
267
+ type: activity?.constructor.name,
268
+ object: activity
269
+ });
270
+ }
271
+
272
+ //#endregion
273
+ //#region src/litepub.ts
274
+ const logger$1 = getLogger([
275
+ "fedify",
276
+ "relay",
277
+ "litepub"
278
+ ]);
279
+ /**
280
+ * A LitePub-compatible ActivityPub relay implementation.
281
+ * This relay follows LitePub's relay protocol and extensions for
282
+ * enhanced federation capabilities.
283
+ *
284
+ * @since 2.0.0
285
+ */
286
+ var LitePubRelay = class extends BaseRelay {
287
+ async #announceToFollowers(ctx, activity) {
288
+ const sender = await activity.getActor(ctx);
289
+ const excludeBaseUris = sender?.id ? [new URL(sender.id)] : [];
290
+ const announce = new Announce({
291
+ id: new URL(`/announce#${crypto.randomUUID()}`, ctx.origin),
292
+ actor: ctx.getActorUri(RELAY_SERVER_ACTOR),
293
+ object: activity.objectId,
294
+ to: PUBLIC_COLLECTION,
295
+ published: Temporal.Now.instant()
296
+ });
297
+ await ctx.sendActivity({ identifier: RELAY_SERVER_ACTOR }, "followers", announce, {
298
+ excludeBaseUris,
299
+ preferSharedInbox: true
300
+ });
301
+ }
302
+ setupInboxListeners() {
303
+ if (this.federation != null) this.federation.setInboxListeners("/users/{identifier}/inbox", "/inbox").on(Follow, async (ctx, follow) => {
304
+ const follower = await validateFollowActivity(ctx, follow);
305
+ if (!follower || !follower.id) return;
306
+ const existingFollow = await ctx.data.kv.get(["follower", follower.id.href]);
307
+ if (existingFollow?.state === "pending") return;
308
+ const approved = await this.options.subscriptionHandler(ctx, follower);
309
+ if (approved) {
310
+ await ctx.data.kv.set(["follower", follower.id.href], {
311
+ actor: await follower.toJsonLd(),
312
+ state: "pending"
313
+ });
314
+ await sendFollowResponse(ctx, follow, follower, approved);
315
+ const relayActorUri = ctx.getActorUri(RELAY_SERVER_ACTOR);
316
+ await ctx.sendActivity({ identifier: RELAY_SERVER_ACTOR }, follower, new Follow({
317
+ actor: relayActorUri,
318
+ object: follower.id,
319
+ to: follower.id
320
+ }));
321
+ } else await sendFollowResponse(ctx, follow, follower, approved);
322
+ }).on(Accept, async (ctx, accept) => {
323
+ const follow = await accept.getObject({
324
+ crossOrigin: "trust",
325
+ ...ctx
326
+ });
327
+ if (!(follow instanceof Follow)) return;
328
+ const relayActorId = follow.actorId;
329
+ if (relayActorId == null) return;
330
+ const followerActor = await accept.getActor();
331
+ if (!isActor(followerActor) || !followerActor.id) return;
332
+ const parsed = ctx.parseUri(relayActorId);
333
+ if (parsed == null || parsed.type !== "actor") return;
334
+ const followerData = await ctx.data.kv.get(["follower", followerActor.id.href]);
335
+ if (followerData == null) return;
336
+ const updatedFollowerData = {
337
+ ...followerData,
338
+ state: "accepted"
339
+ };
340
+ await ctx.data.kv.set(["follower", followerActor.id.href], updatedFollowerData);
341
+ }).on(Undo, async (ctx, undo) => await handleUndoFollow(ctx, undo, logger$1)).on(Create, async (ctx, create) => await this.#announceToFollowers(ctx, create)).on(Update, async (ctx, update) => await this.#announceToFollowers(ctx, update)).on(Move, async (ctx, move) => await this.#announceToFollowers(ctx, move)).on(Delete, async (ctx, deleteActivity) => await this.#announceToFollowers(ctx, deleteActivity)).on(Announce, async (ctx, announce) => await this.#announceToFollowers(ctx, announce));
342
+ }
343
+ };
344
+
345
+ //#endregion
346
+ //#region src/mastodon.ts
347
+ const logger = getLogger([
348
+ "fedify",
349
+ "relay",
350
+ "mastodon"
351
+ ]);
352
+ /**
353
+ * A Mastodon-compatible ActivityPub relay implementation.
354
+ * This relay follows Mastodon's relay protocol for compatibility
355
+ * with Mastodon instances.
356
+ *
357
+ * @since 2.0.0
358
+ */
359
+ var MastodonRelay = class extends BaseRelay {
360
+ async #forwardToFollowers(ctx, activity) {
361
+ const sender = await activity.getActor(ctx);
362
+ const excludeBaseUris = sender?.id ? [new URL(sender.id)] : [];
363
+ await ctx.forwardActivity({ identifier: RELAY_SERVER_ACTOR }, "followers", {
364
+ skipIfUnsigned: true,
365
+ excludeBaseUris,
366
+ preferSharedInbox: true
367
+ });
368
+ }
369
+ setupInboxListeners() {
370
+ if (this.federation != null) this.federation.setInboxListeners("/users/{identifier}/inbox", "/inbox").on(Follow, async (ctx, follow) => {
371
+ const follower = await validateFollowActivity(ctx, follow);
372
+ if (!follower || !follower.id) return;
373
+ const approved = await this.options.subscriptionHandler(ctx, follower);
374
+ if (approved) await ctx.data.kv.set(["follower", follower.id.href], {
375
+ actor: await follower.toJsonLd(),
376
+ state: "accepted"
377
+ });
378
+ await sendFollowResponse(ctx, follow, follower, approved);
379
+ }).on(Undo, async (ctx, undo) => await handleUndoFollow(ctx, undo, logger)).on(Create, async (ctx, create) => await this.#forwardToFollowers(ctx, create)).on(Delete, async (ctx, deleteActivity) => await this.#forwardToFollowers(ctx, deleteActivity)).on(Move, async (ctx, move) => await this.#forwardToFollowers(ctx, move)).on(Update, async (ctx, update) => await this.#forwardToFollowers(ctx, update)).on(Announce, async (ctx, announce) => await this.#forwardToFollowers(ctx, announce));
380
+ }
381
+ };
382
+
383
+ //#endregion
384
+ //#region src/factory.ts
385
+ /**
386
+ * Factory function to create a relay instance.
387
+ *
388
+ * @param type The type of relay to create ("mastodon" or "litepub")
389
+ * @param options Configuration options for the relay
390
+ * @returns A relay instance
391
+ *
392
+ * @example
393
+ * ```ts
394
+ * import { createRelay } from "@fedify/relay";
395
+ * import { MemoryKvStore } from "@fedify/fedify";
396
+ *
397
+ * const relay = createRelay("mastodon", {
398
+ * kv: new MemoryKvStore(),
399
+ * domain: "relay.example.com",
400
+ * subscriptionHandler: async (ctx, actor) => true,
401
+ * });
402
+ * ```
403
+ *
404
+ * @since 2.0.0
405
+ */
406
+ function createRelay(type, options) {
407
+ switch (type) {
408
+ case "mastodon": return new MastodonRelay(options, relayBuilder);
409
+ case "litepub": return new LitePubRelay(options, relayBuilder);
410
+ }
411
+ }
412
+
413
+ //#endregion
414
+ export { RELAY_SERVER_ACTOR, createRelay };
package/package.json ADDED
@@ -0,0 +1,69 @@
1
+ {
2
+ "name": "@fedify/relay",
3
+ "version": "2.0.0-dev.100+a9d733cc",
4
+ "description": "ActivityPub relay support for Fedify",
5
+ "keywords": [
6
+ "Fedify",
7
+ "ActivityPub",
8
+ "Fediverse",
9
+ "Relay"
10
+ ],
11
+ "author": {
12
+ "name": "Jiwon Kwon",
13
+ "email": "work@kwonjiwon.org"
14
+ },
15
+ "homepage": "https://fedify.dev/",
16
+ "repository": {
17
+ "type": "git",
18
+ "url": "git+https://github.com/fedify-dev/fedify.git",
19
+ "directory": "packages/relay"
20
+ },
21
+ "license": "MIT",
22
+ "bugs": {
23
+ "url": "https://github.com/fedify-dev/fedify/issues"
24
+ },
25
+ "funding": [
26
+ "https://opencollective.com/fedify",
27
+ "https://github.com/sponsors/dahlia"
28
+ ],
29
+ "type": "module",
30
+ "main": "./dist/mod.cjs",
31
+ "module": "./dist/mod.js",
32
+ "types": "./dist/mod.d.ts",
33
+ "exports": {
34
+ ".": {
35
+ "types": {
36
+ "import": "./dist/mod.d.ts",
37
+ "require": "./dist/mod.d.cts",
38
+ "default": "./dist/mod.d.ts"
39
+ },
40
+ "import": "./dist/mod.js",
41
+ "require": "./dist/mod.cjs",
42
+ "default": "./dist/mod.js"
43
+ },
44
+ "./package.json": "./package.json"
45
+ },
46
+ "files": [
47
+ "dist/",
48
+ "package.json"
49
+ ],
50
+ "dependencies": {
51
+ "@js-temporal/polyfill": "^0.5.1",
52
+ "@logtape/logtape": "^1.3.5"
53
+ },
54
+ "peerDependencies": {
55
+ "@fedify/fedify": "^2.0.0-dev.100+a9d733cc"
56
+ },
57
+ "devDependencies": {
58
+ "tsdown": "^0.12.9",
59
+ "typescript": "^5.9.3",
60
+ "@fedify/testing": "^2.0.0-dev.100+a9d733cc",
61
+ "@fedify/vocab-runtime": "^2.0.0-dev.100+a9d733cc"
62
+ },
63
+ "scripts": {
64
+ "build": "deno task codegen && tsdown",
65
+ "prepublish": "deno task codegen && tsdown",
66
+ "test": "deno task codegen && tsdown && node --test",
67
+ "test:bun": "deno task codegen && tsdown && bun test --timeout 60000"
68
+ }
69
+ }