@rmdes/indiekit-endpoint-activitypub 2.14.0 → 2.15.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -41,6 +41,7 @@ import { MongoKvStore } from "./kv-store.js";
41
41
  import { registerInboxListeners } from "./inbox-listeners.js";
42
42
  import { jf2ToAS2Activity, resolvePostUrl } from "./jf2-to-as2.js";
43
43
  import { cachedQuery } from "./redis-cache.js";
44
+ import { onOutboxPermanentFailure } from "./outbox-failure.js";
44
45
 
45
46
  const COLLECTION_CACHE_TTL = 300; // 5 minutes
46
47
 
@@ -342,25 +343,15 @@ export function setupFederation(options) {
342
343
  });
343
344
 
344
345
  // Handle permanent delivery failures (Fedify 2.0).
345
- // Fires when a remote inbox returns 404/410 — the server is gone.
346
- // Log it and let the admin see which followers are unreachable.
346
+ // Fires when a remote inbox returns 404/410.
347
+ // 410: immediate full cleanup. 404: strike system (3 strikes over 7 days).
347
348
  federation.setOutboxPermanentFailureHandler(async (_ctx, values) => {
348
- const { inbox, error, actorIds } = values;
349
- const inboxUrl = inbox?.href || String(inbox);
350
- const actors = actorIds?.map((id) => id?.href || String(id)) || [];
351
- console.warn(
352
- `[ActivityPub] Permanent delivery failure to ${inboxUrl}: ${error?.message || "unknown"}` +
353
- (actors.length ? ` (actors: ${actors.join(", ")})` : ""),
349
+ await onOutboxPermanentFailure(
350
+ values.statusCode,
351
+ values.actorIds,
352
+ values.inbox,
353
+ collections,
354
354
  );
355
- collections.ap_activities.insertOne({
356
- direction: "outbound",
357
- type: "DeliveryFailed",
358
- actorUrl: publicationUrl,
359
- objectUrl: inboxUrl,
360
- summary: `Permanent delivery failure to ${inboxUrl}: ${error?.message || "unknown"}`,
361
- affectedActors: actors,
362
- receivedAt: new Date().toISOString(),
363
- }).catch(() => {});
364
355
  });
365
356
 
366
357
  // Wrap with debug dashboard if enabled. The debugger proxies the
@@ -152,6 +152,73 @@ function isDirectMessage(object, ourActorUrl, followersUrl) {
152
152
  return true;
153
153
  }
154
154
 
155
+ /**
156
+ * Compute post visibility from to/cc addressing fields.
157
+ * Matches Hollo's write-time visibility classification.
158
+ *
159
+ * @param {object} object - Fedify object (Note, Article, etc.)
160
+ * @returns {"public"|"unlisted"|"private"|"direct"}
161
+ */
162
+ function computeVisibility(object) {
163
+ const to = new Set((object.toIds || []).map((u) => u.href));
164
+ const cc = new Set((object.ccIds || []).map((u) => u.href));
165
+
166
+ if (to.has(PUBLIC)) return "public";
167
+ if (cc.has(PUBLIC)) return "unlisted";
168
+ // Without knowing the remote actor's followers URL, we can't distinguish
169
+ // "private" (followers-only) from "direct". Both are non-public.
170
+ if (to.size > 0 || cc.size > 0) return "private";
171
+ return "direct";
172
+ }
173
+
174
+ /**
175
+ * Recursively fetch and store ancestor posts for a reply chain.
176
+ * Each ancestor is stored with isContext: true so it can be filtered
177
+ * from the main timeline while being available for thread views.
178
+ *
179
+ * @param {object} object - Fedify object (Note, Article, etc.)
180
+ * @param {object} collections - MongoDB collections
181
+ * @param {object} authLoader - Authenticated document loader
182
+ * @param {number} maxDepth - Maximum recursion depth
183
+ */
184
+ async function fetchReplyChain(object, collections, authLoader, maxDepth) {
185
+ if (maxDepth <= 0) return;
186
+ const parentUrl = object.replyTargetId?.href;
187
+ if (!parentUrl) return;
188
+
189
+ // Skip if we already have this post
190
+ if (collections.ap_timeline) {
191
+ const existing = await collections.ap_timeline.findOne({ uid: parentUrl });
192
+ if (existing) return;
193
+ }
194
+
195
+ // Fetch the parent post
196
+ let parent;
197
+ try {
198
+ parent = await object.getReplyTarget({ documentLoader: authLoader });
199
+ } catch {
200
+ // Remote server unreachable — stop climbing
201
+ return;
202
+ }
203
+ if (!parent || !parent.id) return;
204
+
205
+ // Store as context item
206
+ try {
207
+ const timelineItem = await extractObjectData(parent, {
208
+ documentLoader: authLoader,
209
+ });
210
+ timelineItem.isContext = true;
211
+ timelineItem.visibility = computeVisibility(parent);
212
+ await addTimelineItem(collections, timelineItem);
213
+ } catch {
214
+ // Extraction failed — stop climbing
215
+ return;
216
+ }
217
+
218
+ // Recurse for the parent's parent
219
+ await fetchReplyChain(parent, collections, authLoader, maxDepth - 1);
220
+ }
221
+
155
222
  // ---------------------------------------------------------------------------
156
223
  // Individual handlers
157
224
  // ---------------------------------------------------------------------------
@@ -515,7 +582,7 @@ export async function handleAnnounce(item, collections, ctx, handle) {
515
582
  boostedAt: announce.published ? String(announce.published) : new Date().toISOString(),
516
583
  documentLoader: authLoader,
517
584
  });
518
-
585
+ timelineItem.visibility = computeVisibility(object);
519
586
  await addTimelineItem(collections, timelineItem);
520
587
 
521
588
  // Fire-and-forget quote enrichment for boosted posts
@@ -688,6 +755,18 @@ export async function handleCreate(item, collections, ctx, handle) {
688
755
  }
689
756
  }
690
757
 
758
+ // --- Recursive reply chain fetching ---
759
+ // Fetch and store ancestor posts so conversation threads have context.
760
+ // Each ancestor is stored with isContext: true to distinguish from organic timeline items.
761
+ if (inReplyTo) {
762
+ try {
763
+ await fetchReplyChain(object, collections, authLoader, 5);
764
+ } catch (error) {
765
+ // Non-critical — incomplete context is acceptable
766
+ console.warn("[inbox-handlers] Reply chain fetch failed:", error.message);
767
+ }
768
+ }
769
+
691
770
  // Check for mentions of our actor
692
771
  if (object.tag) {
693
772
  const tags = Array.isArray(object.tag) ? object.tag : [object.tag];
@@ -728,6 +807,7 @@ export async function handleCreate(item, collections, ctx, handle) {
728
807
  actorFallback: actorObj,
729
808
  documentLoader: authLoader,
730
809
  });
810
+ timelineItem.visibility = computeVisibility(object);
731
811
  await addTimelineItem(collections, timelineItem);
732
812
 
733
813
  // Fire-and-forget OG unfurling for notes and articles (not boosts)
@@ -768,6 +848,7 @@ export async function handleCreate(item, collections, ctx, handle) {
768
848
  actorFallback: actorObj,
769
849
  documentLoader: authLoader,
770
850
  });
851
+ timelineItem.visibility = computeVisibility(object);
771
852
  await addTimelineItem(collections, timelineItem);
772
853
  }
773
854
  }
@@ -27,6 +27,7 @@ import {
27
27
 
28
28
  import { isServerBlocked } from "./storage/server-blocks.js";
29
29
  import { touchKeyFreshness } from "./key-refresh.js";
30
+ import { resetDeliveryStrikes } from "./outbox-failure.js";
30
31
  import { enqueueActivity } from "./inbox-queue.js";
31
32
  import { extractActorInfo } from "./timeline-store.js";
32
33
  import { addNotification } from "./storage/notifications.js";
@@ -53,6 +54,7 @@ export function registerInboxListeners(inboxChain, options) {
53
54
  const actorUrl = follow.actorId?.href || "";
54
55
  if (await isServerBlocked(actorUrl, collections)) return;
55
56
  await touchKeyFreshness(collections, actorUrl);
57
+ await resetDeliveryStrikes(collections, actorUrl);
56
58
 
57
59
  const authLoader = await getAuthLoader(ctx);
58
60
  const followerActor = await follow.getActor({ documentLoader: authLoader });
@@ -152,6 +154,7 @@ export function registerInboxListeners(inboxChain, options) {
152
154
  const actorUrl = undo.actorId?.href || "";
153
155
  if (await isServerBlocked(actorUrl, collections)) return;
154
156
  await touchKeyFreshness(collections, actorUrl);
157
+ await resetDeliveryStrikes(collections, actorUrl);
155
158
 
156
159
  await enqueueActivity(collections, {
157
160
  activityType: "Undo",
@@ -165,6 +168,7 @@ export function registerInboxListeners(inboxChain, options) {
165
168
  const actorUrl = accept.actorId?.href || "";
166
169
  if (await isServerBlocked(actorUrl, collections)) return;
167
170
  await touchKeyFreshness(collections, actorUrl);
171
+ await resetDeliveryStrikes(collections, actorUrl);
168
172
 
169
173
  await enqueueActivity(collections, {
170
174
  activityType: "Accept",
@@ -178,6 +182,7 @@ export function registerInboxListeners(inboxChain, options) {
178
182
  const actorUrl = reject.actorId?.href || "";
179
183
  if (await isServerBlocked(actorUrl, collections)) return;
180
184
  await touchKeyFreshness(collections, actorUrl);
185
+ await resetDeliveryStrikes(collections, actorUrl);
181
186
 
182
187
  await enqueueActivity(collections, {
183
188
  activityType: "Reject",
@@ -191,6 +196,7 @@ export function registerInboxListeners(inboxChain, options) {
191
196
  const actorUrl = like.actorId?.href || "";
192
197
  if (await isServerBlocked(actorUrl, collections)) return;
193
198
  await touchKeyFreshness(collections, actorUrl);
199
+ await resetDeliveryStrikes(collections, actorUrl);
194
200
 
195
201
  await enqueueActivity(collections, {
196
202
  activityType: "Like",
@@ -205,6 +211,7 @@ export function registerInboxListeners(inboxChain, options) {
205
211
  const actorUrl = announce.actorId?.href || "";
206
212
  if (await isServerBlocked(actorUrl, collections)) return;
207
213
  await touchKeyFreshness(collections, actorUrl);
214
+ await resetDeliveryStrikes(collections, actorUrl);
208
215
 
209
216
  await enqueueActivity(collections, {
210
217
  activityType: "Announce",
@@ -219,11 +226,47 @@ export function registerInboxListeners(inboxChain, options) {
219
226
  const actorUrl = create.actorId?.href || "";
220
227
  if (await isServerBlocked(actorUrl, collections)) return;
221
228
  await touchKeyFreshness(collections, actorUrl);
229
+ await resetDeliveryStrikes(collections, actorUrl);
230
+
231
+ // Forward public replies to our posts to our followers.
232
+ // Must happen here (not in async handler) because forwardActivity
233
+ // is only available on InboxContext, not base Context.
234
+ const objectUrl = create.objectId?.href || "";
235
+ try {
236
+ const obj = await create.getObject();
237
+ const inReplyTo = obj?.replyTargetId?.href || "";
238
+ if (
239
+ inReplyTo &&
240
+ collections._publicationUrl &&
241
+ inReplyTo.startsWith(collections._publicationUrl)
242
+ ) {
243
+ // Check if the reply is public (to/cc includes PUBLIC collection)
244
+ const toUrls = (obj.toIds || []).map((u) => u.href);
245
+ const ccUrls = (obj.ccIds || []).map((u) => u.href);
246
+ const isPublic = [...toUrls, ...ccUrls].includes(
247
+ "https://www.w3.org/ns/activitystreams#Public",
248
+ );
249
+ if (isPublic) {
250
+ await ctx.forwardActivity(
251
+ { identifier: handle },
252
+ "followers",
253
+ {
254
+ skipIfUnsigned: true,
255
+ preferSharedInbox: true,
256
+ excludeBaseUris: [new URL(ctx.origin)],
257
+ },
258
+ );
259
+ }
260
+ }
261
+ } catch (error) {
262
+ // Non-critical — forwarding failure shouldn't block processing
263
+ console.warn("[inbox-listeners] Reply forwarding failed:", error.message);
264
+ }
222
265
 
223
266
  await enqueueActivity(collections, {
224
267
  activityType: "Create",
225
268
  actorUrl,
226
- objectUrl: create.objectId?.href || "",
269
+ objectUrl,
227
270
  rawJson: await create.toJsonLd(),
228
271
  });
229
272
  })
@@ -233,6 +276,7 @@ export function registerInboxListeners(inboxChain, options) {
233
276
  const actorUrl = del.actorId?.href || "";
234
277
  if (await isServerBlocked(actorUrl, collections)) return;
235
278
  await touchKeyFreshness(collections, actorUrl);
279
+ await resetDeliveryStrikes(collections, actorUrl);
236
280
 
237
281
  await enqueueActivity(collections, {
238
282
  activityType: "Delete",
@@ -247,6 +291,7 @@ export function registerInboxListeners(inboxChain, options) {
247
291
  const actorUrl = move.actorId?.href || "";
248
292
  if (await isServerBlocked(actorUrl, collections)) return;
249
293
  await touchKeyFreshness(collections, actorUrl);
294
+ await resetDeliveryStrikes(collections, actorUrl);
250
295
 
251
296
  await enqueueActivity(collections, {
252
297
  activityType: "Move",
@@ -260,6 +305,7 @@ export function registerInboxListeners(inboxChain, options) {
260
305
  const actorUrl = update.actorId?.href || "";
261
306
  if (await isServerBlocked(actorUrl, collections)) return;
262
307
  await touchKeyFreshness(collections, actorUrl);
308
+ await resetDeliveryStrikes(collections, actorUrl);
263
309
 
264
310
  await enqueueActivity(collections, {
265
311
  activityType: "Update",
@@ -299,6 +345,7 @@ export function registerInboxListeners(inboxChain, options) {
299
345
  const actorUrl = flag.actorId?.href || "";
300
346
  if (await isServerBlocked(actorUrl, collections)) return;
301
347
  await touchKeyFreshness(collections, actorUrl);
348
+ await resetDeliveryStrikes(collections, actorUrl);
302
349
 
303
350
  await enqueueActivity(collections, {
304
351
  activityType: "Flag",
@@ -0,0 +1,139 @@
1
+ /**
2
+ * Outbox permanent failure handling.
3
+ * Cleans up dead followers when delivery permanently fails.
4
+ *
5
+ * - 410 Gone: Immediate full cleanup (actor is permanently gone)
6
+ * - 404: Strike system — 3 failures over 7+ days triggers full cleanup
7
+ *
8
+ * @module outbox-failure
9
+ */
10
+
11
+ import { logActivity } from "./activity-log.js";
12
+
13
+ const STRIKE_THRESHOLD = 3;
14
+ const STRIKE_WINDOW_MS = 7 * 24 * 60 * 60 * 1000; // 7 days
15
+
16
+ /**
17
+ * Clean up all data associated with an actor.
18
+ * Removes follower record, their timeline items, and their notifications.
19
+ *
20
+ * @param {object} collections - MongoDB collections
21
+ * @param {string} actorUrl - Actor URL to clean up
22
+ * @param {string} reason - Reason for cleanup (for logging)
23
+ */
24
+ async function cleanupActor(collections, actorUrl, reason) {
25
+ const { ap_followers, ap_timeline, ap_notifications } = collections;
26
+
27
+ // Remove from followers
28
+ const deleted = await ap_followers.deleteOne({ actorUrl });
29
+
30
+ // Remove their timeline items
31
+ if (ap_timeline) {
32
+ await ap_timeline.deleteMany({ "author.url": actorUrl });
33
+ }
34
+
35
+ // Remove their notifications
36
+ if (ap_notifications) {
37
+ await ap_notifications.deleteMany({ actorUrl });
38
+ }
39
+
40
+ if (deleted.deletedCount > 0) {
41
+ console.info(`[outbox-failure] Cleaned up actor ${actorUrl}: ${reason}`);
42
+ }
43
+ }
44
+
45
+ /**
46
+ * Handle permanent outbox delivery failure.
47
+ * Called by Fedify's setOutboxPermanentFailureHandler.
48
+ *
49
+ * @param {number} statusCode - HTTP status code (404, 410, etc.)
50
+ * @param {readonly URL[]} actorIds - Array of actor ID URLs
51
+ * @param {URL} inbox - The inbox URL that failed
52
+ * @param {object} collections - MongoDB collections
53
+ */
54
+ export async function onOutboxPermanentFailure(statusCode, actorIds, inbox, collections) {
55
+ const inboxUrl = inbox?.href || String(inbox);
56
+
57
+ for (const actorId of actorIds) {
58
+ const actorUrl = actorId?.href || String(actorId);
59
+
60
+ if (statusCode === 410) {
61
+ // 410 Gone — immediate full cleanup
62
+ await cleanupActor(collections, actorUrl, `410 Gone from ${inboxUrl}`);
63
+
64
+ await logActivity(collections.ap_activities, {
65
+ direction: "outbound",
66
+ type: "DeliveryFailed:410",
67
+ actorUrl,
68
+ objectUrl: inboxUrl,
69
+ summary: `Permanent delivery failure (410 Gone) to ${inboxUrl} — actor cleaned up`,
70
+ }, {});
71
+ } else {
72
+ // 404 or other — strike system
73
+ const now = new Date();
74
+ const result = await collections.ap_followers.findOneAndUpdate(
75
+ { actorUrl },
76
+ {
77
+ $inc: { deliveryFailures: 1 },
78
+ $setOnInsert: { firstFailureAt: now.toISOString() },
79
+ $set: { lastFailureAt: now.toISOString() },
80
+ },
81
+ { returnDocument: "after" },
82
+ );
83
+
84
+ if (!result) {
85
+ // Not a follower — nothing to track or clean up
86
+ continue;
87
+ }
88
+
89
+ const failures = result.deliveryFailures || 1;
90
+ const firstFailure = result.firstFailureAt
91
+ ? new Date(result.firstFailureAt)
92
+ : now;
93
+ const windowElapsed = now.getTime() - firstFailure.getTime() >= STRIKE_WINDOW_MS;
94
+
95
+ if (failures >= STRIKE_THRESHOLD && windowElapsed) {
96
+ // Confirmed dead — full cleanup
97
+ await cleanupActor(
98
+ collections,
99
+ actorUrl,
100
+ `${failures} failures over ${Math.round((now.getTime() - firstFailure.getTime()) / 86400000)}d (HTTP ${statusCode})`,
101
+ );
102
+
103
+ await logActivity(collections.ap_activities, {
104
+ direction: "outbound",
105
+ type: `DeliveryFailed:${statusCode}:cleanup`,
106
+ actorUrl,
107
+ objectUrl: inboxUrl,
108
+ summary: `${failures} delivery failures over 7+ days — actor cleaned up`,
109
+ }, {});
110
+ } else {
111
+ // Strike recorded, not yet confirmed dead
112
+ await logActivity(collections.ap_activities, {
113
+ direction: "outbound",
114
+ type: `DeliveryFailed:${statusCode}:strike`,
115
+ actorUrl,
116
+ objectUrl: inboxUrl,
117
+ summary: `Delivery strike ${failures}/${STRIKE_THRESHOLD} for ${actorUrl} (HTTP ${statusCode})`,
118
+ }, {});
119
+ }
120
+ }
121
+ }
122
+ }
123
+
124
+ /**
125
+ * Reset delivery failure strikes for an actor.
126
+ * Called when we receive an inbound activity from an actor,
127
+ * proving they are alive despite previous delivery failures.
128
+ *
129
+ * @param {object} collections - MongoDB collections
130
+ * @param {string} actorUrl - Actor URL
131
+ */
132
+ export async function resetDeliveryStrikes(collections, actorUrl) {
133
+ if (!actorUrl) return;
134
+ // Only update if the fields exist — avoid unnecessary writes
135
+ await collections.ap_followers.updateOne(
136
+ { actorUrl, deliveryFailures: { $exists: true } },
137
+ { $unset: { deliveryFailures: "", firstFailureAt: "", lastFailureAt: "" } },
138
+ );
139
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rmdes/indiekit-endpoint-activitypub",
3
- "version": "2.14.0",
3
+ "version": "2.15.0",
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",