@rmdes/indiekit-endpoint-activitypub 3.13.11 → 3.13.12

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/index.js CHANGED
@@ -4,27 +4,30 @@ import { ACTIVITYPUB_BLOCKS } from "./lib/blocks.js";
4
4
  import { resolveOptions } from "./lib/defaults.js";
5
5
  import { buildNavigationItems } from "./lib/navigation.js";
6
6
 
7
- import { setupFederation, buildPersonActor } from "./lib/federation-setup.js";
7
+ import { setupFederation } from "./lib/federation-setup.js";
8
8
  import { createMastodonRouter } from "./lib/mastodon/router.js";
9
9
  import { setLocalIdentity } from "./lib/mastodon/entities/status.js";
10
10
  import { initRedisCache } from "./lib/redis-cache.js";
11
11
  import { createIndexes } from "./lib/init-indexes.js";
12
12
  import { lookupWithSecurity } from "./lib/lookup-helpers.js";
13
- import {
14
- needsDirectFollow,
15
- sendDirectFollow,
16
- sendDirectUnfollow,
17
- } from "./lib/direct-follow.js";
18
13
  import {
19
14
  createFedifyMiddleware,
20
15
  } from "./lib/federation-bridge.js";
21
- import { jf2ToAS2Activity } from "./lib/jf2-to-as2.js";
22
16
  import { createSyndicator } from "./lib/syndicator.js";
17
+ import {
18
+ loadRsaPrivateKey,
19
+ followActor,
20
+ unfollowActor,
21
+ broadcastActorUpdate,
22
+ broadcastDelete,
23
+ broadcastPostUpdate,
24
+ deletePost,
25
+ updatePost,
26
+ getActorUrl,
27
+ } from "./lib/endpoint-federation.js";
23
28
  import { buildAdminRoutes } from "./lib/routes/admin-routes.js";
24
29
  import { buildRoutesPublic, buildContentNegotiationRoutes } from "./lib/routes/public-routes.js";
25
30
  import { startBatchRefollow } from "./lib/batch-refollow.js";
26
- import { logActivity } from "./lib/activity-log.js";
27
- import { batchBroadcast } from "./lib/batch-broadcast.js";
28
31
  import { scheduleCleanup } from "./lib/timeline-cleanup.js";
29
32
  import { runSeparateMentionsMigration } from "./lib/migrations/separate-mentions.js";
30
33
  import { loadBlockedServersToRedis } from "./lib/storage/server-blocks.js";
@@ -113,141 +116,11 @@ export default class ActivityPubEndpoint {
113
116
  * @returns {Promise<CryptoKey|null>}
114
117
  */
115
118
  async _loadRsaPrivateKey() {
116
- try {
117
- const keyDoc = await this._collections.ap_keys.findOne({
118
- privateKeyPem: { $exists: true },
119
- });
120
- if (!keyDoc?.privateKeyPem) return null;
121
- const pemBody = keyDoc.privateKeyPem
122
- .replace(/-----[^-]+-----/g, "")
123
- .replace(/\s/g, "");
124
- return await crypto.subtle.importKey(
125
- "pkcs8",
126
- Buffer.from(pemBody, "base64"),
127
- { name: "RSASSA-PKCS1-v1_5", hash: "SHA-256" },
128
- true,
129
- ["sign"],
130
- );
131
- } catch (error) {
132
- console.error("[ActivityPub] Failed to load RSA key:", error.message);
133
- return null;
134
- }
119
+ return loadRsaPrivateKey(this);
135
120
  }
136
121
 
137
122
  async followActor(actorUrl, actorInfo = {}) {
138
- if (!this._federation) {
139
- return { ok: false, error: "Federation not initialized" };
140
- }
141
-
142
- try {
143
- const { Follow } = await import("@fedify/fedify/vocab");
144
- const handle = this.options.actor.handle;
145
- const ctx = this._federation.createContext(
146
- new URL(this._publicationUrl),
147
- { handle, publicationUrl: this._publicationUrl },
148
- );
149
-
150
- // Resolve the remote actor to get their inbox
151
- // lookupWithSecurity handles signed→unsigned fallback automatically
152
- const documentLoader = await ctx.getDocumentLoader({
153
- identifier: handle,
154
- });
155
- const remoteActor = await lookupWithSecurity(ctx, actorUrl, {
156
- documentLoader,
157
- });
158
- if (!remoteActor) {
159
- return { ok: false, error: "Could not resolve remote actor" };
160
- }
161
-
162
- // Send Follow activity
163
- if (needsDirectFollow(actorUrl)) {
164
- // tags.pub rejects Fedify's LD Signature context (identity/v1).
165
- // Send a minimal signed Follow directly, bypassing the outbox pipeline.
166
- // See: https://github.com/social-web-foundation/tags.pub/issues/10
167
- const rsaKey = await this._loadRsaPrivateKey();
168
- if (!rsaKey) {
169
- return { ok: false, error: "No RSA key available for direct follow" };
170
- }
171
- const result = await sendDirectFollow({
172
- actorUri: ctx.getActorUri(handle).href,
173
- targetActorUrl: actorUrl,
174
- inboxUrl: remoteActor.inboxId?.href,
175
- keyId: `${ctx.getActorUri(handle).href}#main-key`,
176
- privateKey: rsaKey,
177
- });
178
- if (!result.ok) {
179
- return { ok: false, error: result.error };
180
- }
181
- } else {
182
- const follow = new Follow({
183
- actor: ctx.getActorUri(handle),
184
- object: new URL(actorUrl),
185
- });
186
- await ctx.sendActivity({ identifier: handle }, remoteActor, follow, {
187
- orderingKey: actorUrl,
188
- });
189
- }
190
-
191
- // Store in ap_following
192
- const name =
193
- actorInfo.name ||
194
- remoteActor.name?.toString() ||
195
- remoteActor.preferredUsername?.toString() ||
196
- actorUrl;
197
- const actorHandle =
198
- actorInfo.handle ||
199
- remoteActor.preferredUsername?.toString() ||
200
- "";
201
- const avatar =
202
- actorInfo.photo ||
203
- (remoteActor.icon
204
- ? (await remoteActor.icon)?.url?.href || ""
205
- : "");
206
- const inbox = remoteActor.inboxId?.href || "";
207
- const sharedInbox = remoteActor.endpoints?.sharedInbox?.href || "";
208
-
209
- await this._collections.ap_following.updateOne(
210
- { actorUrl },
211
- {
212
- $set: {
213
- actorUrl,
214
- handle: actorHandle,
215
- name,
216
- avatar,
217
- inbox,
218
- sharedInbox,
219
- followedAt: new Date().toISOString(),
220
- source: "reader",
221
- },
222
- },
223
- { upsert: true },
224
- );
225
-
226
- console.info(`[ActivityPub] Sent Follow to ${actorUrl}`);
227
-
228
- await logActivity(this._collections.ap_activities, {
229
- direction: "outbound",
230
- type: "Follow",
231
- actorUrl: this._publicationUrl,
232
- objectUrl: actorUrl,
233
- actorName: name,
234
- summary: `Sent Follow to ${name} (${actorUrl})`,
235
- });
236
-
237
- return { ok: true };
238
- } catch (error) {
239
- console.error(`[ActivityPub] Follow failed for ${actorUrl}:`, error.message);
240
-
241
- await logActivity(this._collections.ap_activities, {
242
- direction: "outbound",
243
- type: "Follow",
244
- actorUrl: this._publicationUrl,
245
- objectUrl: actorUrl,
246
- summary: `Follow failed for ${actorUrl}: ${error.message}`,
247
- }).catch(() => {});
248
-
249
- return { ok: false, error: error.message };
250
- }
123
+ return followActor(this, actorUrl, actorInfo);
251
124
  }
252
125
 
253
126
  /**
@@ -256,277 +129,31 @@ export default class ActivityPubEndpoint {
256
129
  * @returns {Promise<{ok: boolean, error?: string}>}
257
130
  */
258
131
  async unfollowActor(actorUrl) {
259
- if (!this._federation) {
260
- return { ok: false, error: "Federation not initialized" };
261
- }
262
-
263
- try {
264
- const { Follow, Undo } = await import("@fedify/fedify/vocab");
265
- const handle = this.options.actor.handle;
266
- const ctx = this._federation.createContext(
267
- new URL(this._publicationUrl),
268
- { handle, publicationUrl: this._publicationUrl },
269
- );
270
-
271
- // Use authenticated document loader for servers requiring Authorized Fetch
272
- const documentLoader = await ctx.getDocumentLoader({
273
- identifier: handle,
274
- });
275
- const remoteActor = await lookupWithSecurity(ctx,actorUrl, {
276
- documentLoader,
277
- });
278
- if (!remoteActor) {
279
- // Even if we can't resolve, remove locally
280
- await this._collections.ap_following.deleteOne({ actorUrl });
281
-
282
- await logActivity(this._collections.ap_activities, {
283
- direction: "outbound",
284
- type: "Undo(Follow)",
285
- actorUrl: this._publicationUrl,
286
- objectUrl: actorUrl,
287
- summary: `Removed ${actorUrl} locally (could not resolve remote actor)`,
288
- }).catch(() => {});
289
-
290
- return { ok: true };
291
- }
292
-
293
- if (needsDirectFollow(actorUrl)) {
294
- // tags.pub rejects Fedify's LD Signature context (identity/v1).
295
- // See: https://github.com/social-web-foundation/tags.pub/issues/10
296
- const rsaKey = await this._loadRsaPrivateKey();
297
- if (rsaKey) {
298
- const result = await sendDirectUnfollow({
299
- actorUri: ctx.getActorUri(handle).href,
300
- targetActorUrl: actorUrl,
301
- inboxUrl: remoteActor.inboxId?.href,
302
- keyId: `${ctx.getActorUri(handle).href}#main-key`,
303
- privateKey: rsaKey,
304
- });
305
- if (!result.ok) {
306
- console.warn(`[ActivityPub] Direct unfollow failed for ${actorUrl}: ${result.error}`);
307
- }
308
- }
309
- } else {
310
- const follow = new Follow({
311
- actor: ctx.getActorUri(handle),
312
- object: new URL(actorUrl),
313
- });
314
- const undo = new Undo({
315
- actor: ctx.getActorUri(handle),
316
- object: follow,
317
- });
318
- await ctx.sendActivity({ identifier: handle }, remoteActor, undo, {
319
- orderingKey: actorUrl,
320
- });
321
- }
322
- await this._collections.ap_following.deleteOne({ actorUrl });
323
-
324
- console.info(`[ActivityPub] Sent Undo(Follow) to ${actorUrl}`);
325
-
326
- await logActivity(this._collections.ap_activities, {
327
- direction: "outbound",
328
- type: "Undo(Follow)",
329
- actorUrl: this._publicationUrl,
330
- objectUrl: actorUrl,
331
- summary: `Sent Undo(Follow) to ${actorUrl}`,
332
- });
333
-
334
- return { ok: true };
335
- } catch (error) {
336
- console.error(`[ActivityPub] Unfollow failed for ${actorUrl}:`, error.message);
337
-
338
- await logActivity(this._collections.ap_activities, {
339
- direction: "outbound",
340
- type: "Undo(Follow)",
341
- actorUrl: this._publicationUrl,
342
- objectUrl: actorUrl,
343
- summary: `Unfollow failed for ${actorUrl}: ${error.message}`,
344
- }).catch(() => {});
345
-
346
- // Remove locally even if remote delivery fails
347
- await this._collections.ap_following.deleteOne({ actorUrl }).catch(() => {});
348
- return { ok: false, error: error.message };
349
- }
132
+ return unfollowActor(this, actorUrl);
350
133
  }
351
134
 
352
- /**
353
- * Send an Update(Person) activity to all followers so remote servers
354
- * re-fetch the actor object (picking up profile changes, new featured
355
- * collections, attachments, etc.).
356
- *
357
- * Delivery is batched to avoid a thundering herd: hundreds of remote
358
- * servers simultaneously re-fetching the actor, featured posts, and
359
- * featured tags after receiving the Update all at once.
360
- */
361
135
  async broadcastActorUpdate() {
362
- if (!this._federation) return;
363
-
364
- try {
365
- const { Update } = await import("@fedify/fedify/vocab");
366
- const handle = this.options.actor.handle;
367
- const ctx = this._federation.createContext(
368
- new URL(this._publicationUrl),
369
- { handle, publicationUrl: this._publicationUrl },
370
- );
371
-
372
- const actor = await buildPersonActor(
373
- ctx,
374
- handle,
375
- this._collections,
376
- this.options.actorType,
377
- );
378
- if (!actor) {
379
- console.warn("[ActivityPub] broadcastActorUpdate: could not build actor");
380
- return;
381
- }
382
-
383
- const update = new Update({
384
- actor: ctx.getActorUri(handle),
385
- object: actor,
386
- });
387
-
388
- await batchBroadcast({
389
- federation: this._federation,
390
- collections: this._collections,
391
- publicationUrl: this._publicationUrl,
392
- handle,
393
- activity: update,
394
- label: "Update(Person)",
395
- objectUrl: this._getActorUrl(),
396
- });
397
- } catch (error) {
398
- console.error("[ActivityPub] broadcastActorUpdate failed:", error.message);
399
- }
136
+ return broadcastActorUpdate(this);
400
137
  }
401
138
 
402
- /**
403
- * Send Delete activity to all followers for a removed post.
404
- * Mirrors broadcastActorUpdate() pattern: batch delivery with shared inbox dedup.
405
- * @param {string} postUrl - Full URL of the deleted post
406
- */
407
139
  async broadcastDelete(postUrl) {
408
- if (!this._federation) return;
409
-
410
- try {
411
- const { Delete } = await import("@fedify/fedify/vocab");
412
- const handle = this.options.actor.handle;
413
- const ctx = this._federation.createContext(
414
- new URL(this._publicationUrl),
415
- { handle, publicationUrl: this._publicationUrl },
416
- );
417
-
418
- const del = new Delete({
419
- actor: ctx.getActorUri(handle),
420
- object: new URL(postUrl),
421
- });
422
-
423
- await batchBroadcast({
424
- federation: this._federation,
425
- collections: this._collections,
426
- publicationUrl: this._publicationUrl,
427
- handle,
428
- activity: del,
429
- label: "Delete",
430
- objectUrl: postUrl,
431
- });
432
- } catch (error) {
433
- console.warn("[ActivityPub] broadcastDelete failed:", error.message);
434
- }
140
+ return broadcastDelete(this, postUrl);
435
141
  }
436
142
 
437
- /**
438
- * Called by post-content.js when a Micropub delete succeeds.
439
- * Broadcasts an ActivityPub Delete activity to all followers.
440
- * @param {string} url - Full URL of the deleted post
441
- */
442
143
  async delete(url) {
443
- // Record tombstone for FEP-4f05
444
- try {
445
- const { addTombstone } = await import("./lib/storage/tombstones.js");
446
- const postsCol = this._collections.posts;
447
- const post = postsCol ? await postsCol.findOne({ "properties.url": url }) : null;
448
- await addTombstone(this._collections, {
449
- url,
450
- formerType: post?.properties?.["post-type"] === "article" ? "Article" : "Note",
451
- published: post?.properties?.published || null,
452
- deleted: new Date().toISOString(),
453
- });
454
- } catch (error) {
455
- console.warn(`[ActivityPub] Tombstone creation failed for ${url}: ${error.message}`);
456
- }
457
-
458
- await this.broadcastDelete(url).catch((err) =>
459
- console.warn(`[ActivityPub] broadcastDelete failed for ${url}: ${err.message}`)
460
- );
144
+ return deletePost(this, url);
461
145
  }
462
146
 
463
- /**
464
- * Called by post-content.js when a Micropub update succeeds.
465
- * Broadcasts an ActivityPub Update activity for the post to all followers.
466
- * @param {object} properties - JF2 post properties (must include url)
467
- */
468
147
  async update(properties) {
469
- await this.broadcastPostUpdate(properties).catch((err) =>
470
- console.warn(`[ActivityPub] broadcastPostUpdate failed for ${properties?.url}: ${err.message}`)
471
- );
148
+ return updatePost(this, properties);
472
149
  }
473
150
 
474
- /**
475
- * Send an Update activity to all followers for a modified post.
476
- * Mirrors broadcastDelete() pattern: batch delivery with shared inbox dedup.
477
- * @param {object} properties - JF2 post properties
478
- */
479
151
  async broadcastPostUpdate(properties) {
480
- if (!this._federation) return;
481
-
482
- try {
483
- const { Update } = await import("@fedify/fedify/vocab");
484
- const actorUrl = this._getActorUrl();
485
- const handle = this.options.actor.handle;
486
- const ctx = this._federation.createContext(
487
- new URL(this._publicationUrl),
488
- { handle, publicationUrl: this._publicationUrl },
489
- );
490
-
491
- const createActivity = jf2ToAS2Activity(
492
- properties,
493
- actorUrl,
494
- this._publicationUrl,
495
- { visibility: this.options.defaultVisibility },
496
- );
497
-
498
- if (!createActivity) {
499
- console.warn(`[ActivityPub] broadcastPostUpdate: could not convert post to AS2 for ${properties?.url}`);
500
- return;
501
- }
502
-
503
- const noteObject = await createActivity.getObject();
504
- const activity = new Update({
505
- actor: ctx.getActorUri(handle),
506
- object: noteObject,
507
- });
508
-
509
- await batchBroadcast({
510
- federation: this._federation,
511
- collections: this._collections,
512
- publicationUrl: this._publicationUrl,
513
- handle,
514
- activity,
515
- label: "Update(Note)",
516
- objectUrl: properties.url,
517
- });
518
- } catch (error) {
519
- console.warn("[ActivityPub] broadcastPostUpdate failed:", error.message);
520
- }
152
+ return broadcastPostUpdate(this, properties);
521
153
  }
522
154
 
523
- /**
524
- * Build the full actor URL from config.
525
- * @returns {string}
526
- */
527
155
  _getActorUrl() {
528
- const base = this._publicationUrl.replace(/\/$/, "");
529
- return `${base}${this.options.mountPath}/users/${this.options.actor.handle}`;
156
+ return getActorUrl(this);
530
157
  }
531
158
 
532
159
  init(Indiekit) {
@@ -0,0 +1,407 @@
1
+ /**
2
+ * Federation send-path actions, extracted from the index.js god-entry
3
+ * (Phase 2). Each takes `self` (the ActivityPubEndpoint instance); the class
4
+ * keeps thin delegating methods so the public interface + the init() facade
5
+ * are preserved. Internal cross-calls go directly to the module functions.
6
+ */
7
+ import {
8
+ needsDirectFollow,
9
+ sendDirectFollow,
10
+ sendDirectUnfollow,
11
+ } from "./direct-follow.js";
12
+ import { lookupWithSecurity } from "./lookup-helpers.js";
13
+ import { logActivity } from "./activity-log.js";
14
+ import { batchBroadcast } from "./batch-broadcast.js";
15
+ import { buildPersonActor } from "./federation-setup.js";
16
+ import { jf2ToAS2Activity } from "./jf2-to-as2.js";
17
+
18
+ /**
19
+ * Load the RSA private key from ap_keys for direct HTTP Signature signing.
20
+ * @returns {Promise<CryptoKey|null>}
21
+ */
22
+ export async function loadRsaPrivateKey(self) {
23
+ try {
24
+ const keyDoc = await self._collections.ap_keys.findOne({
25
+ privateKeyPem: { $exists: true },
26
+ });
27
+ if (!keyDoc?.privateKeyPem) return null;
28
+ const pemBody = keyDoc.privateKeyPem
29
+ .replace(/-----[^-]+-----/g, "")
30
+ .replace(/\s/g, "");
31
+ return await crypto.subtle.importKey(
32
+ "pkcs8",
33
+ Buffer.from(pemBody, "base64"),
34
+ { name: "RSASSA-PKCS1-v1_5", hash: "SHA-256" },
35
+ true,
36
+ ["sign"],
37
+ );
38
+ } catch (error) {
39
+ console.error("[ActivityPub] Failed to load RSA key:", error.message);
40
+ return null;
41
+ }
42
+ }
43
+
44
+ /** Send a Follow activity to a remote actor and store in ap_following. */
45
+ export async function followActor(self, actorUrl, actorInfo = {}) {
46
+ if (!self._federation) {
47
+ return { ok: false, error: "Federation not initialized" };
48
+ }
49
+
50
+ try {
51
+ const { Follow } = await import("@fedify/fedify/vocab");
52
+ const handle = self.options.actor.handle;
53
+ const ctx = self._federation.createContext(
54
+ new URL(self._publicationUrl),
55
+ { handle, publicationUrl: self._publicationUrl },
56
+ );
57
+
58
+ // Resolve the remote actor to get their inbox
59
+ // lookupWithSecurity handles signed→unsigned fallback automatically
60
+ const documentLoader = await ctx.getDocumentLoader({
61
+ identifier: handle,
62
+ });
63
+ const remoteActor = await lookupWithSecurity(ctx, actorUrl, {
64
+ documentLoader,
65
+ });
66
+ if (!remoteActor) {
67
+ return { ok: false, error: "Could not resolve remote actor" };
68
+ }
69
+
70
+ // Send Follow activity
71
+ if (needsDirectFollow(actorUrl)) {
72
+ // tags.pub rejects Fedify's LD Signature context (identity/v1).
73
+ // Send a minimal signed Follow directly, bypassing the outbox pipeline.
74
+ // See: https://github.com/social-web-foundation/tags.pub/issues/10
75
+ const rsaKey = await loadRsaPrivateKey(self);
76
+ if (!rsaKey) {
77
+ return { ok: false, error: "No RSA key available for direct follow" };
78
+ }
79
+ const result = await sendDirectFollow({
80
+ actorUri: ctx.getActorUri(handle).href,
81
+ targetActorUrl: actorUrl,
82
+ inboxUrl: remoteActor.inboxId?.href,
83
+ keyId: `${ctx.getActorUri(handle).href}#main-key`,
84
+ privateKey: rsaKey,
85
+ });
86
+ if (!result.ok) {
87
+ return { ok: false, error: result.error };
88
+ }
89
+ } else {
90
+ const follow = new Follow({
91
+ actor: ctx.getActorUri(handle),
92
+ object: new URL(actorUrl),
93
+ });
94
+ await ctx.sendActivity({ identifier: handle }, remoteActor, follow, {
95
+ orderingKey: actorUrl,
96
+ });
97
+ }
98
+
99
+ // Store in ap_following
100
+ const name =
101
+ actorInfo.name ||
102
+ remoteActor.name?.toString() ||
103
+ remoteActor.preferredUsername?.toString() ||
104
+ actorUrl;
105
+ const actorHandle =
106
+ actorInfo.handle ||
107
+ remoteActor.preferredUsername?.toString() ||
108
+ "";
109
+ const avatar =
110
+ actorInfo.photo ||
111
+ (remoteActor.icon
112
+ ? (await remoteActor.icon)?.url?.href || ""
113
+ : "");
114
+ const inbox = remoteActor.inboxId?.href || "";
115
+ const sharedInbox = remoteActor.endpoints?.sharedInbox?.href || "";
116
+
117
+ await self._collections.ap_following.updateOne(
118
+ { actorUrl },
119
+ {
120
+ $set: {
121
+ actorUrl,
122
+ handle: actorHandle,
123
+ name,
124
+ avatar,
125
+ inbox,
126
+ sharedInbox,
127
+ followedAt: new Date().toISOString(),
128
+ source: "reader",
129
+ },
130
+ },
131
+ { upsert: true },
132
+ );
133
+
134
+ console.info(`[ActivityPub] Sent Follow to ${actorUrl}`);
135
+
136
+ await logActivity(self._collections.ap_activities, {
137
+ direction: "outbound",
138
+ type: "Follow",
139
+ actorUrl: self._publicationUrl,
140
+ objectUrl: actorUrl,
141
+ actorName: name,
142
+ summary: `Sent Follow to ${name} (${actorUrl})`,
143
+ });
144
+
145
+ return { ok: true };
146
+ } catch (error) {
147
+ console.error(`[ActivityPub] Follow failed for ${actorUrl}:`, error.message);
148
+
149
+ await logActivity(self._collections.ap_activities, {
150
+ direction: "outbound",
151
+ type: "Follow",
152
+ actorUrl: self._publicationUrl,
153
+ objectUrl: actorUrl,
154
+ summary: `Follow failed for ${actorUrl}: ${error.message}`,
155
+ }).catch(() => {});
156
+
157
+ return { ok: false, error: error.message };
158
+ }
159
+ }
160
+
161
+ /** Send an Undo(Follow) activity and remove from ap_following. */
162
+ export async function unfollowActor(self, actorUrl) {
163
+ if (!self._federation) {
164
+ return { ok: false, error: "Federation not initialized" };
165
+ }
166
+
167
+ try {
168
+ const { Follow, Undo } = await import("@fedify/fedify/vocab");
169
+ const handle = self.options.actor.handle;
170
+ const ctx = self._federation.createContext(
171
+ new URL(self._publicationUrl),
172
+ { handle, publicationUrl: self._publicationUrl },
173
+ );
174
+
175
+ // Use authenticated document loader for servers requiring Authorized Fetch
176
+ const documentLoader = await ctx.getDocumentLoader({
177
+ identifier: handle,
178
+ });
179
+ const remoteActor = await lookupWithSecurity(ctx, actorUrl, {
180
+ documentLoader,
181
+ });
182
+ if (!remoteActor) {
183
+ // Even if we can't resolve, remove locally
184
+ await self._collections.ap_following.deleteOne({ actorUrl });
185
+
186
+ await logActivity(self._collections.ap_activities, {
187
+ direction: "outbound",
188
+ type: "Undo(Follow)",
189
+ actorUrl: self._publicationUrl,
190
+ objectUrl: actorUrl,
191
+ summary: `Removed ${actorUrl} locally (could not resolve remote actor)`,
192
+ }).catch(() => {});
193
+
194
+ return { ok: true };
195
+ }
196
+
197
+ if (needsDirectFollow(actorUrl)) {
198
+ // tags.pub rejects Fedify's LD Signature context (identity/v1).
199
+ // See: https://github.com/social-web-foundation/tags.pub/issues/10
200
+ const rsaKey = await loadRsaPrivateKey(self);
201
+ if (rsaKey) {
202
+ const result = await sendDirectUnfollow({
203
+ actorUri: ctx.getActorUri(handle).href,
204
+ targetActorUrl: actorUrl,
205
+ inboxUrl: remoteActor.inboxId?.href,
206
+ keyId: `${ctx.getActorUri(handle).href}#main-key`,
207
+ privateKey: rsaKey,
208
+ });
209
+ if (!result.ok) {
210
+ console.warn(`[ActivityPub] Direct unfollow failed for ${actorUrl}: ${result.error}`);
211
+ }
212
+ }
213
+ } else {
214
+ const follow = new Follow({
215
+ actor: ctx.getActorUri(handle),
216
+ object: new URL(actorUrl),
217
+ });
218
+ const undo = new Undo({
219
+ actor: ctx.getActorUri(handle),
220
+ object: follow,
221
+ });
222
+ await ctx.sendActivity({ identifier: handle }, remoteActor, undo, {
223
+ orderingKey: actorUrl,
224
+ });
225
+ }
226
+ await self._collections.ap_following.deleteOne({ actorUrl });
227
+
228
+ console.info(`[ActivityPub] Sent Undo(Follow) to ${actorUrl}`);
229
+
230
+ await logActivity(self._collections.ap_activities, {
231
+ direction: "outbound",
232
+ type: "Undo(Follow)",
233
+ actorUrl: self._publicationUrl,
234
+ objectUrl: actorUrl,
235
+ summary: `Sent Undo(Follow) to ${actorUrl}`,
236
+ });
237
+
238
+ return { ok: true };
239
+ } catch (error) {
240
+ console.error(`[ActivityPub] Unfollow failed for ${actorUrl}:`, error.message);
241
+
242
+ await logActivity(self._collections.ap_activities, {
243
+ direction: "outbound",
244
+ type: "Undo(Follow)",
245
+ actorUrl: self._publicationUrl,
246
+ objectUrl: actorUrl,
247
+ summary: `Unfollow failed for ${actorUrl}: ${error.message}`,
248
+ }).catch(() => {});
249
+
250
+ // Remove locally even if remote delivery fails
251
+ await self._collections.ap_following.deleteOne({ actorUrl }).catch(() => {});
252
+ return { ok: false, error: error.message };
253
+ }
254
+ }
255
+
256
+ /** Send an Update(Person) to all followers so they re-fetch the actor. */
257
+ export async function broadcastActorUpdate(self) {
258
+ if (!self._federation) return;
259
+
260
+ try {
261
+ const { Update } = await import("@fedify/fedify/vocab");
262
+ const handle = self.options.actor.handle;
263
+ const ctx = self._federation.createContext(
264
+ new URL(self._publicationUrl),
265
+ { handle, publicationUrl: self._publicationUrl },
266
+ );
267
+
268
+ const actor = await buildPersonActor(
269
+ ctx,
270
+ handle,
271
+ self._collections,
272
+ self.options.actorType,
273
+ );
274
+ if (!actor) {
275
+ console.warn("[ActivityPub] broadcastActorUpdate: could not build actor");
276
+ return;
277
+ }
278
+
279
+ const update = new Update({
280
+ actor: ctx.getActorUri(handle),
281
+ object: actor,
282
+ });
283
+
284
+ await batchBroadcast({
285
+ federation: self._federation,
286
+ collections: self._collections,
287
+ publicationUrl: self._publicationUrl,
288
+ handle,
289
+ activity: update,
290
+ label: "Update(Person)",
291
+ objectUrl: getActorUrl(self),
292
+ });
293
+ } catch (error) {
294
+ console.error("[ActivityPub] broadcastActorUpdate failed:", error.message);
295
+ }
296
+ }
297
+
298
+ /** Send a Delete activity to all followers for a removed post. */
299
+ export async function broadcastDelete(self, postUrl) {
300
+ if (!self._federation) return;
301
+
302
+ try {
303
+ const { Delete } = await import("@fedify/fedify/vocab");
304
+ const handle = self.options.actor.handle;
305
+ const ctx = self._federation.createContext(
306
+ new URL(self._publicationUrl),
307
+ { handle, publicationUrl: self._publicationUrl },
308
+ );
309
+
310
+ const del = new Delete({
311
+ actor: ctx.getActorUri(handle),
312
+ object: new URL(postUrl),
313
+ });
314
+
315
+ await batchBroadcast({
316
+ federation: self._federation,
317
+ collections: self._collections,
318
+ publicationUrl: self._publicationUrl,
319
+ handle,
320
+ activity: del,
321
+ label: "Delete",
322
+ objectUrl: postUrl,
323
+ });
324
+ } catch (error) {
325
+ console.warn("[ActivityPub] broadcastDelete failed:", error.message);
326
+ }
327
+ }
328
+
329
+ /** Micropub delete hook: record a tombstone (FEP-4f05) + broadcast Delete. */
330
+ export async function deletePost(self, url) {
331
+ // Record tombstone for FEP-4f05
332
+ try {
333
+ const { addTombstone } = await import("./storage/tombstones.js");
334
+ const postsCol = self._collections.posts;
335
+ const post = postsCol ? await postsCol.findOne({ "properties.url": url }) : null;
336
+ await addTombstone(self._collections, {
337
+ url,
338
+ formerType: post?.properties?.["post-type"] === "article" ? "Article" : "Note",
339
+ published: post?.properties?.published || null,
340
+ deleted: new Date().toISOString(),
341
+ });
342
+ } catch (error) {
343
+ console.warn(`[ActivityPub] Tombstone creation failed for ${url}: ${error.message}`);
344
+ }
345
+
346
+ await broadcastDelete(self, url).catch((err) =>
347
+ console.warn(`[ActivityPub] broadcastDelete failed for ${url}: ${err.message}`)
348
+ );
349
+ }
350
+
351
+ /** Micropub update hook: broadcast an Update for the modified post. */
352
+ export async function updatePost(self, properties) {
353
+ await broadcastPostUpdate(self, properties).catch((err) =>
354
+ console.warn(`[ActivityPub] broadcastPostUpdate failed for ${properties?.url}: ${err.message}`)
355
+ );
356
+ }
357
+
358
+ /** Send an Update activity to all followers for a modified post. */
359
+ export async function broadcastPostUpdate(self, properties) {
360
+ if (!self._federation) return;
361
+
362
+ try {
363
+ const { Update } = await import("@fedify/fedify/vocab");
364
+ const actorUrl = getActorUrl(self);
365
+ const handle = self.options.actor.handle;
366
+ const ctx = self._federation.createContext(
367
+ new URL(self._publicationUrl),
368
+ { handle, publicationUrl: self._publicationUrl },
369
+ );
370
+
371
+ const createActivity = jf2ToAS2Activity(
372
+ properties,
373
+ actorUrl,
374
+ self._publicationUrl,
375
+ { visibility: self.options.defaultVisibility },
376
+ );
377
+
378
+ if (!createActivity) {
379
+ console.warn(`[ActivityPub] broadcastPostUpdate: could not convert post to AS2 for ${properties?.url}`);
380
+ return;
381
+ }
382
+
383
+ const noteObject = await createActivity.getObject();
384
+ const activity = new Update({
385
+ actor: ctx.getActorUri(handle),
386
+ object: noteObject,
387
+ });
388
+
389
+ await batchBroadcast({
390
+ federation: self._federation,
391
+ collections: self._collections,
392
+ publicationUrl: self._publicationUrl,
393
+ handle,
394
+ activity,
395
+ label: "Update(Note)",
396
+ objectUrl: properties.url,
397
+ });
398
+ } catch (error) {
399
+ console.warn("[ActivityPub] broadcastPostUpdate failed:", error.message);
400
+ }
401
+ }
402
+
403
+ /** Build the full actor URL from config. */
404
+ export function getActorUrl(self) {
405
+ const base = self._publicationUrl.replace(/\/$/, "");
406
+ return `${base}${self.options.mountPath}/users/${self.options.actor.handle}`;
407
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rmdes/indiekit-endpoint-activitypub",
3
- "version": "3.13.11",
3
+ "version": "3.13.12",
4
4
  "description": "ActivityPub federation endpoint for Indiekit via Fedify. Adds full fediverse support: actor, inbox, outbox, followers, following, syndication, and Mastodon migration.",
5
5
  "keywords": [
6
6
  "indiekit",