@rmdes/indiekit-endpoint-activitypub 1.0.19 → 1.0.21

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
@@ -41,6 +41,7 @@ const defaults = {
41
41
  alsoKnownAs: "",
42
42
  activityRetentionDays: 90,
43
43
  storeRawActivities: false,
44
+ redisUrl: "",
44
45
  };
45
46
 
46
47
  export default class ActivityPubEndpoint {
@@ -261,10 +262,50 @@ export default class ActivityPubEndpoint {
261
262
 
262
263
  try {
263
264
  const actorUrl = self._getActorUrl();
265
+ const handle = self.options.actor.handle;
266
+
267
+ const ctx = self._federation.createContext(
268
+ new URL(self._publicationUrl),
269
+ {},
270
+ );
271
+
272
+ // For replies, resolve the original post author for proper
273
+ // addressing (CC) and direct inbox delivery
274
+ let replyToActor = null;
275
+ if (properties["in-reply-to"]) {
276
+ try {
277
+ const remoteObject = await ctx.lookupObject(
278
+ new URL(properties["in-reply-to"]),
279
+ );
280
+ if (remoteObject && typeof remoteObject.getAttributedTo === "function") {
281
+ const author = await remoteObject.getAttributedTo();
282
+ const authorActor = Array.isArray(author) ? author[0] : author;
283
+ if (authorActor?.id) {
284
+ replyToActor = {
285
+ url: authorActor.id.href,
286
+ handle: authorActor.preferredUsername || null,
287
+ recipient: authorActor,
288
+ };
289
+ console.info(
290
+ `[ActivityPub] Reply to ${properties["in-reply-to"]} — resolved author: ${replyToActor.url}`,
291
+ );
292
+ }
293
+ }
294
+ } catch (error) {
295
+ console.warn(
296
+ `[ActivityPub] Could not resolve reply-to author for ${properties["in-reply-to"]}: ${error.message}`,
297
+ );
298
+ }
299
+ }
300
+
264
301
  const activity = jf2ToAS2Activity(
265
302
  properties,
266
303
  actorUrl,
267
304
  self._publicationUrl,
305
+ {
306
+ replyToActorUrl: replyToActor?.url,
307
+ replyToActorHandle: replyToActor?.handle,
308
+ },
268
309
  );
269
310
 
270
311
  if (!activity) {
@@ -278,11 +319,6 @@ export default class ActivityPubEndpoint {
278
319
  return undefined;
279
320
  }
280
321
 
281
- const ctx = self._federation.createContext(
282
- new URL(self._publicationUrl),
283
- {},
284
- );
285
-
286
322
  // Count followers for logging
287
323
  const followerCount =
288
324
  await self._collections.ap_followers.countDocuments();
@@ -291,26 +327,50 @@ export default class ActivityPubEndpoint {
291
327
  `[ActivityPub] Sending ${activity.constructor?.name || "activity"} for ${properties.url} to ${followerCount} followers`,
292
328
  );
293
329
 
330
+ // Send to followers
294
331
  await ctx.sendActivity(
295
- { identifier: self.options.actor.handle },
332
+ { identifier: handle },
296
333
  "followers",
297
334
  activity,
298
335
  );
299
336
 
337
+ // For replies, also deliver to the original post author's inbox
338
+ // so their server can thread the reply under the original post
339
+ if (replyToActor?.recipient) {
340
+ try {
341
+ await ctx.sendActivity(
342
+ { identifier: handle },
343
+ replyToActor.recipient,
344
+ activity,
345
+ );
346
+ console.info(
347
+ `[ActivityPub] Reply delivered to author: ${replyToActor.url}`,
348
+ );
349
+ } catch (error) {
350
+ console.warn(
351
+ `[ActivityPub] Failed to deliver reply to ${replyToActor.url}: ${error.message}`,
352
+ );
353
+ }
354
+ }
355
+
300
356
  // Determine activity type name
301
357
  const typeName =
302
358
  activity.constructor?.name || "Create";
359
+ const replyNote = replyToActor
360
+ ? ` (reply to ${replyToActor.url})`
361
+ : "";
303
362
 
304
363
  await logActivity(self._collections.ap_activities, {
305
364
  direction: "outbound",
306
365
  type: typeName,
307
366
  actorUrl: self._publicationUrl,
308
367
  objectUrl: properties.url,
309
- summary: `Sent ${typeName} for ${properties.url} to ${followerCount} followers`,
368
+ targetUrl: replyToActor?.url || undefined,
369
+ summary: `Sent ${typeName} for ${properties.url} to ${followerCount} followers${replyNote}`,
310
370
  });
311
371
 
312
372
  console.info(
313
- `[ActivityPub] Syndication queued: ${typeName} for ${properties.url}`,
373
+ `[ActivityPub] Syndication queued: ${typeName} for ${properties.url}${replyNote}`,
314
374
  );
315
375
 
316
376
  return properties.url || undefined;
@@ -558,6 +618,28 @@ export default class ActivityPubEndpoint {
558
618
  );
559
619
  }
560
620
 
621
+ // Performance indexes for inbox handlers and batch refollow
622
+ this._collections.ap_followers.createIndex(
623
+ { actorUrl: 1 },
624
+ { unique: true, background: true },
625
+ );
626
+ this._collections.ap_following.createIndex(
627
+ { actorUrl: 1 },
628
+ { unique: true, background: true },
629
+ );
630
+ this._collections.ap_following.createIndex(
631
+ { source: 1 },
632
+ { background: true },
633
+ );
634
+ this._collections.ap_activities.createIndex(
635
+ { objectUrl: 1 },
636
+ { background: true },
637
+ );
638
+ this._collections.ap_activities.createIndex(
639
+ { type: 1, actorUrl: 1, objectUrl: 1 },
640
+ { background: true },
641
+ );
642
+
561
643
  // Seed actor profile from config on first run
562
644
  this._seedProfile().catch((error) => {
563
645
  console.warn("[ActivityPub] Profile seed failed:", error.message);
@@ -569,6 +651,7 @@ export default class ActivityPubEndpoint {
569
651
  mountPath: this.options.mountPath,
570
652
  handle: this.options.actor.handle,
571
653
  storeRawActivities: this.options.storeRawActivities,
654
+ redisUrl: this.options.redisUrl,
572
655
  });
573
656
 
574
657
  this._federation = federation;
@@ -19,12 +19,16 @@
19
19
  * @param {string} [record.content] - Content excerpt
20
20
  * @param {string} record.summary - Human-readable summary
21
21
  */
22
- export async function logActivity(collection, record) {
22
+ export async function logActivity(collection, record, options = {}) {
23
23
  try {
24
- await collection.insertOne({
24
+ const doc = {
25
25
  ...record,
26
26
  receivedAt: new Date().toISOString(),
27
- });
27
+ };
28
+ if (options.rawJson) {
29
+ doc.rawJson = options.rawJson;
30
+ }
31
+ await collection.insertOne(doc);
28
32
  } catch (error) {
29
33
  console.warn("[ActivityPub] Failed to log activity:", error.message);
30
34
  }
@@ -15,10 +15,14 @@ import {
15
15
  Person,
16
16
  PropertyValue,
17
17
  createFederation,
18
+ exportJwk,
18
19
  generateCryptoKeyPair,
20
+ importJwk,
19
21
  importSpki,
20
22
  } from "@fedify/fedify";
21
23
  import { configure, getConsoleSink } from "@logtape/logtape";
24
+ import { RedisMessageQueue } from "@fedify/redis";
25
+ import Redis from "ioredis";
22
26
  import { MongoKvStore } from "./kv-store.js";
23
27
  import { registerInboxListeners } from "./inbox-listeners.js";
24
28
 
@@ -41,6 +45,7 @@ export function setupFederation(options) {
41
45
  mountPath,
42
46
  handle,
43
47
  storeRawActivities = false,
48
+ redisUrl = "",
44
49
  } = options;
45
50
 
46
51
  // Configure LogTape for Fedify delivery logging (once per process)
@@ -64,9 +69,20 @@ export function setupFederation(options) {
64
69
  });
65
70
  }
66
71
 
72
+ let queue;
73
+ if (redisUrl) {
74
+ queue = new RedisMessageQueue(() => new Redis(redisUrl));
75
+ console.info("[ActivityPub] Using Redis message queue");
76
+ } else {
77
+ queue = new InProcessMessageQueue();
78
+ console.warn(
79
+ "[ActivityPub] Using in-process message queue (not recommended for production)",
80
+ );
81
+ }
82
+
67
83
  const federation = createFederation({
68
84
  kv: new MongoKvStore(collections.ap_kv),
69
- queue: new InProcessMessageQueue(),
85
+ queue,
70
86
  });
71
87
 
72
88
  // --- Actor dispatcher ---
@@ -113,7 +129,7 @@ export function setupFederation(options) {
113
129
 
114
130
  if (keyPairs.length > 0) {
115
131
  personOptions.publicKey = keyPairs[0].cryptographicKey;
116
- personOptions.assertionMethod = keyPairs[0].multikey;
132
+ personOptions.assertionMethods = keyPairs.map((k) => k.multikey);
117
133
  }
118
134
 
119
135
  if (profile.attachments?.length > 0) {
@@ -141,26 +157,70 @@ export function setupFederation(options) {
141
157
 
142
158
  const keyPairs = [];
143
159
 
144
- // Import legacy RSA key pair (for HTTP Signatures compatibility)
145
- const legacyKey = await collections.ap_keys.findOne({});
146
- if (legacyKey?.publicKeyPem && legacyKey?.privateKeyPem) {
160
+ // --- Legacy RSA key pair (HTTP Signatures) ---
161
+ const legacyKey = await collections.ap_keys.findOne({ type: "rsa" });
162
+ // Fall back to old schema (no type field) for backward compat
163
+ const rsaDoc =
164
+ legacyKey ||
165
+ (await collections.ap_keys.findOne({
166
+ publicKeyPem: { $exists: true },
167
+ }));
168
+
169
+ if (rsaDoc?.publicKeyPem && rsaDoc?.privateKeyPem) {
147
170
  try {
148
- const publicKey = await importSpki(legacyKey.publicKeyPem);
149
- const privateKey = await importPkcs8Pem(legacyKey.privateKeyPem);
171
+ const publicKey = await importSpki(rsaDoc.publicKeyPem);
172
+ const privateKey = await importPkcs8Pem(rsaDoc.privateKeyPem);
150
173
  keyPairs.push({ publicKey, privateKey });
151
174
  } catch {
175
+ console.warn("[ActivityPub] Could not import legacy RSA keys");
176
+ }
177
+ }
178
+
179
+ // --- Ed25519 key pair (Object Integrity Proofs) ---
180
+ // Load from DB or generate + persist on first use
181
+ let ed25519Doc = await collections.ap_keys.findOne({
182
+ type: "ed25519",
183
+ });
184
+
185
+ if (ed25519Doc?.publicKeyJwk && ed25519Doc?.privateKeyJwk) {
186
+ try {
187
+ const publicKey = await importJwk(
188
+ ed25519Doc.publicKeyJwk,
189
+ "public",
190
+ );
191
+ const privateKey = await importJwk(
192
+ ed25519Doc.privateKeyJwk,
193
+ "private",
194
+ );
195
+ keyPairs.push({ publicKey, privateKey });
196
+ } catch (error) {
152
197
  console.warn(
153
- "[ActivityPub] Could not import legacy RSA keys",
198
+ "[ActivityPub] Could not import Ed25519 keys, regenerating:",
199
+ error.message,
154
200
  );
201
+ ed25519Doc = null; // Force regeneration below
155
202
  }
156
203
  }
157
204
 
158
- // Generate Ed25519 key pair (for Object Integrity Proofs)
159
- try {
160
- const ed25519 = await generateCryptoKeyPair("Ed25519");
161
- keyPairs.push(ed25519);
162
- } catch (error) {
163
- console.warn("[ActivityPub] Could not generate Ed25519 key pair:", error.message);
205
+ if (!ed25519Doc) {
206
+ try {
207
+ const ed25519 = await generateCryptoKeyPair("Ed25519");
208
+ await collections.ap_keys.insertOne({
209
+ type: "ed25519",
210
+ publicKeyJwk: await exportJwk(ed25519.publicKey),
211
+ privateKeyJwk: await exportJwk(ed25519.privateKey),
212
+ createdAt: new Date().toISOString(),
213
+ });
214
+ keyPairs.push(ed25519);
215
+ console.info(
216
+ "[ActivityPub] Generated and persisted Ed25519 key pair",
217
+ );
218
+ } catch (error) {
219
+ console.warn(
220
+ "[ActivityPub] Could not generate Ed25519 key pair:",
221
+ error.message,
222
+ );
223
+ }
164
224
  }
165
225
 
166
226
  return keyPairs;
@@ -16,6 +16,7 @@ import {
16
16
  Like,
17
17
  Move,
18
18
  Note,
19
+ Reject,
19
20
  Remove,
20
21
  Undo,
21
22
  Update,
@@ -160,6 +161,37 @@ export function registerInboxListeners(inboxChain, options) {
160
161
  });
161
162
  }
162
163
  })
164
+ .on(Reject, async (ctx, reject) => {
165
+ const actorObj = await reject.getActor();
166
+ const actorUrl = actorObj?.id?.href || "";
167
+ if (!actorUrl) return;
168
+
169
+ // Mark rejected follow in ap_following
170
+ const result = await collections.ap_following.findOneAndUpdate(
171
+ {
172
+ actorUrl,
173
+ source: { $in: ["refollow:sent", "microsub-reader"] },
174
+ },
175
+ {
176
+ $set: {
177
+ source: "rejected",
178
+ rejectedAt: new Date().toISOString(),
179
+ },
180
+ },
181
+ { returnDocument: "after" },
182
+ );
183
+
184
+ if (result) {
185
+ const actorName = result.name || result.handle || actorUrl;
186
+ await logActivity(collections, storeRawActivities, {
187
+ direction: "inbound",
188
+ type: "Reject(Follow)",
189
+ actorUrl,
190
+ actorName,
191
+ summary: `${actorName} rejected our Follow`,
192
+ });
193
+ }
194
+ })
163
195
  .on(Like, async (ctx, like) => {
164
196
  const objectId = (await like.getObject())?.id?.href || "";
165
197
 
@@ -324,8 +356,12 @@ export function registerInboxListeners(inboxChain, options) {
324
356
  * Wrapper around the shared utility that accepts the (collections, storeRaw, record) signature
325
357
  * used throughout this file.
326
358
  */
327
- async function logActivity(collections, storeRaw, record) {
328
- await logActivityShared(collections.ap_activities, record);
359
+ async function logActivity(collections, storeRaw, record, rawJson) {
360
+ await logActivityShared(
361
+ collections.ap_activities,
362
+ record,
363
+ storeRaw && rawJson ? { rawJson } : {},
364
+ );
329
365
  }
330
366
 
331
367
  // Cached ActivityPub channel ObjectId
package/lib/jf2-to-as2.js CHANGED
@@ -15,6 +15,7 @@ import {
15
15
  Hashtag,
16
16
  Image,
17
17
  Like,
18
+ Mention,
18
19
  Note,
19
20
  Video,
20
21
  } from "@fedify/fedify";
@@ -126,9 +127,12 @@ export function jf2ToActivityStreams(properties, actorUrl, publicationUrl) {
126
127
  * @param {object} properties - JF2 post properties
127
128
  * @param {string} actorUrl - Actor URL (e.g. "https://example.com/activitypub/users/rick")
128
129
  * @param {string} publicationUrl - Publication base URL with trailing slash
130
+ * @param {object} [options] - Optional settings
131
+ * @param {string} [options.replyToActorUrl] - Original post author's actor URL (for reply addressing)
132
+ * @param {string} [options.replyToActorHandle] - Original post author's handle (for Mention tag)
129
133
  * @returns {import("@fedify/fedify").Activity | null}
130
134
  */
131
- export function jf2ToAS2Activity(properties, actorUrl, publicationUrl) {
135
+ export function jf2ToAS2Activity(properties, actorUrl, publicationUrl, options = {}) {
132
136
  const postType = properties["post-type"];
133
137
  const actorUri = new URL(actorUrl);
134
138
 
@@ -154,13 +158,25 @@ export function jf2ToAS2Activity(properties, actorUrl, publicationUrl) {
154
158
  const isArticle = postType === "article" && properties.name;
155
159
  const postUrl = resolvePostUrl(properties.url, publicationUrl);
156
160
  const followersUrl = `${actorUrl.replace(/\/$/, "")}/followers`;
161
+ const { replyToActorUrl, replyToActorHandle } = options;
157
162
 
158
163
  const noteOptions = {
159
164
  attributedTo: actorUri,
160
- to: new URL("https://www.w3.org/ns/activitystreams#Public"),
161
- cc: new URL(followersUrl),
162
165
  };
163
166
 
167
+ // Addressing: for replies, include original author in CC so their server
168
+ // threads the reply and notifies them
169
+ if (replyToActorUrl && properties["in-reply-to"]) {
170
+ noteOptions.to = new URL("https://www.w3.org/ns/activitystreams#Public");
171
+ noteOptions.ccs = [
172
+ new URL(followersUrl),
173
+ new URL(replyToActorUrl),
174
+ ];
175
+ } else {
176
+ noteOptions.to = new URL("https://www.w3.org/ns/activitystreams#Public");
177
+ noteOptions.cc = new URL(followersUrl);
178
+ }
179
+
164
180
  if (postUrl) {
165
181
  noteOptions.id = new URL(postUrl);
166
182
  noteOptions.url = new URL(postUrl);
@@ -208,8 +224,18 @@ export function jf2ToAS2Activity(properties, actorUrl, publicationUrl) {
208
224
  noteOptions.attachments = fedifyAttachments;
209
225
  }
210
226
 
211
- // Hashtags
227
+ // Tags: hashtags + Mention for reply addressing
212
228
  const fedifyTags = buildFedifyTags(properties, publicationUrl, postType);
229
+
230
+ if (replyToActorUrl) {
231
+ fedifyTags.push(
232
+ new Mention({
233
+ href: new URL(replyToActorUrl),
234
+ name: replyToActorHandle ? `@${replyToActorHandle}` : undefined,
235
+ }),
236
+ );
237
+ }
238
+
213
239
  if (fedifyTags.length > 0) {
214
240
  noteOptions.tags = fedifyTags;
215
241
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rmdes/indiekit-endpoint-activitypub",
3
- "version": "1.0.19",
3
+ "version": "1.0.21",
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",
@@ -37,10 +37,12 @@
37
37
  "url": "https://github.com/rmdes/indiekit-endpoint-activitypub/issues"
38
38
  },
39
39
  "dependencies": {
40
- "@fedify/fedify": "^1.10.0",
41
40
  "@fedify/express": "^1.9.0",
41
+ "@fedify/fedify": "^1.10.0",
42
+ "@fedify/redis": "^1.10.3",
42
43
  "@js-temporal/polyfill": "^0.5.0",
43
- "express": "^5.0.0"
44
+ "express": "^5.0.0",
45
+ "ioredis": "^5.9.3"
44
46
  },
45
47
  "peerDependencies": {
46
48
  "@indiekit/error": "^1.0.0-beta.25",