@fedify/relay 2.0.0-dev.12 → 2.0.0-dev.158

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