@rmdes/indiekit-endpoint-activitypub 2.0.26 → 2.0.27

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/README.md CHANGED
@@ -268,11 +268,66 @@ The JF2-to-ActivityStreams converter handles these Indiekit post types:
268
268
 
269
269
  Categories are converted to `Hashtag` tags. Bookmarks include a bookmark emoji and link.
270
270
 
271
+ ## Fedify Workarounds and Implementation Notes
272
+
273
+ This plugin uses [Fedify](https://fedify.dev) 2.0 but carries several workarounds for issues in Fedify or its Express integration. These are documented here so they can be revisited when Fedify upgrades.
274
+
275
+ ### Custom Express Bridge (instead of `@fedify/express`)
276
+
277
+ **File:** `lib/federation-bridge.js`
278
+ **Upstream issue:** `@fedify/express` uses `req.url` ([source](https://github.com/fedify-dev/fedify/blob/main/packages/express/src/index.ts), line 73), not `req.originalUrl`.
279
+
280
+ Indiekit plugins mount at a sub-path (e.g. `/activitypub`). Express strips the mount prefix from `req.url`, so Fedify's URI template matching breaks — WebFinger, actor endpoints, and inbox all return 404. The custom bridge uses `req.originalUrl` to preserve the full path.
281
+
282
+ The bridge also reconstructs POST bodies that Express's body parser has already consumed (`req.readable === false`). Without this, Fedify handlers like the `@fedify/debugger` login form receive empty bodies.
283
+
284
+ **Revisit when:** `@fedify/express` switches to `req.originalUrl`, or provides an option to pass a custom URL builder.
285
+
286
+ ### JSON-LD Attachment Array Compaction
287
+
288
+ **File:** `lib/federation-bridge.js` (in `sendFedifyResponse()`)
289
+ **Upstream issue:** JSON-LD compaction collapses single-element arrays to plain objects.
290
+
291
+ Mastodon's `update_account_fields` checks `attachment.is_a?(Array)` and silently skips profile links (PropertyValues) when `attachment` is a plain object instead of an array. The bridge intercepts actor JSON-LD responses and forces `attachment` to always be an array.
292
+
293
+ **Revisit when:** Fedify adds an option to preserve arrays during JSON-LD serialization, or Mastodon fixes their array check.
294
+
295
+ ### `.authorize()` Not Chained on Actor Dispatcher
296
+
297
+ **File:** `lib/federation-setup.js` (line ~254)
298
+ **Upstream issue:** No authenticated document loading for outgoing key fetches during signature verification.
299
+
300
+ Fedify's `.authorize()` predicate triggers HTTP Signature verification on every GET to the actor endpoint. When a remote server that requires Authorized Fetch (e.g. kobolds.online) requests our actor, Fedify tries to fetch *their* public key to verify the signature. Those servers return 401 on unsigned GETs, causing uncaught `FetchError` and 500 responses.
301
+
302
+ This means we do **not** enforce Authorized Fetch on our actor endpoint. Any server can read our actor document without signing the request.
303
+
304
+ **Revisit when:** Fedify supports using the instance actor's keys for outgoing document fetches during signature verification (i.e. authenticated document loading in the verification path, not just in inbox handlers).
305
+
306
+ ### `importSpkiPem()` / `importPkcs8Pem()` — Local PEM Import
307
+
308
+ **File:** `lib/federation-setup.js` (lines ~784–816)
309
+ **Upstream change:** Fedify 1.x exported `importSpki()` for loading PEM public keys. This was removed in Fedify 2.0.
310
+
311
+ The plugin carries local `importSpkiPem()` and `importPkcs8Pem()` functions that use the Web Crypto API directly (`crypto.subtle.importKey`) to load legacy RSA key pairs stored in MongoDB from the Fedify 1.x era. New key pairs are generated using Fedify 2.0's `generateCryptoKeyPair()` and stored as JWK, so these functions only matter for existing installations that migrated from Fedify 1.x.
312
+
313
+ **Revisit when:** All existing installations have been migrated to JWK-stored keys, or Fedify re-exports a PEM import utility.
314
+
315
+ ### Authenticated Document Loader for Inbox Handlers
316
+
317
+ **File:** `lib/inbox-listeners.js`
318
+ **Upstream behavior:** Fedify's personal inbox handlers do not automatically use authenticated (signed) HTTP fetches.
319
+
320
+ All `.getActor()`, `.getObject()`, and `.getTarget()` calls in inbox handlers must explicitly pass an authenticated `DocumentLoader` obtained via `ctx.getDocumentLoader({ identifier: handle })`. Without this, fetches to Authorized Fetch (Secure Mode) servers like hachyderm.io fail with 401, causing timeline items to show "Unknown" authors and missing content.
321
+
322
+ This is not a bug — Fedify requires explicit opt-in for signed fetches. But it's a pattern that every inbox handler must follow, and forgetting it silently degrades functionality.
323
+
324
+ **Revisit when:** Fedify provides an option to default to authenticated fetches in inbox handler context, or adds a middleware layer that handles this automatically.
325
+
271
326
  ## Known Limitations
272
327
 
273
328
  - **No automated tests** — Manual testing against real fediverse servers
274
329
  - **Single actor** — One fediverse identity per Indiekit instance
275
- - **No Authorized Fetch enforcement** — Disabled due to Fedify's current limitation with authenticated outgoing fetches (causes infinite loops with servers that require it)
330
+ - **No Authorized Fetch enforcement** — `.authorize()` disabled on actor dispatcher (see workarounds above)
276
331
  - **No image upload in reader** — Compose form is text-only
277
332
  - **In-process queue without Redis** — Activities may be lost on restart
278
333
 
@@ -253,17 +253,20 @@ export function registerInboxListeners(inboxChain, options) {
253
253
  actorObj?.preferredUsername?.toString() ||
254
254
  actorUrl;
255
255
 
256
+ // Extract actor info (including avatar) before logging so we can store it
257
+ const actorInfo = await extractActorInfo(actorObj, { documentLoader: authLoader });
258
+
256
259
  await logActivity(collections, storeRawActivities, {
257
260
  direction: "inbound",
258
261
  type: "Like",
259
262
  actorUrl,
260
263
  actorName,
264
+ actorAvatar: actorInfo.photo || "",
261
265
  objectUrl: objectId,
262
266
  summary: `${actorName} liked ${objectId}`,
263
267
  });
264
268
 
265
269
  // Store notification
266
- const actorInfo = await extractActorInfo(actorObj, { documentLoader: authLoader });
267
270
  await addNotification(collections, {
268
271
  uid: like.id?.href || `like:${actorUrl}:${objectId}`,
269
272
  type: "like",
@@ -301,18 +304,21 @@ export function registerInboxListeners(inboxChain, options) {
301
304
  actorObj?.preferredUsername?.toString() ||
302
305
  actorUrl;
303
306
 
307
+ // Extract actor info (including avatar) before logging so we can store it
308
+ const actorInfo = await extractActorInfo(actorObj, { documentLoader: authLoader });
309
+
304
310
  // Log the boost activity
305
311
  await logActivity(collections, storeRawActivities, {
306
312
  direction: "inbound",
307
313
  type: "Announce",
308
314
  actorUrl,
309
315
  actorName,
316
+ actorAvatar: actorInfo.photo || "",
310
317
  objectUrl: objectId,
311
318
  summary: `${actorName} boosted ${objectId}`,
312
319
  });
313
320
 
314
321
  // Create notification
315
- const actorInfo = await extractActorInfo(actorObj, { documentLoader: authLoader });
316
322
  await addNotification(collections, {
317
323
  uid: announce.id?.href || `${actorUrl}#boost-${objectId}`,
318
324
  type: "boost",
@@ -392,11 +398,16 @@ export function registerInboxListeners(inboxChain, options) {
392
398
  const pubUrl = collections._publicationUrl;
393
399
  if (inReplyTo) {
394
400
  const content = object.content?.toString() || "";
401
+
402
+ // Extract actor info (including avatar) before logging so we can store it
403
+ const actorInfo = await extractActorInfo(actorObj, { documentLoader: authLoader });
404
+
395
405
  await logActivity(collections, storeRawActivities, {
396
406
  direction: "inbound",
397
407
  type: "Reply",
398
408
  actorUrl,
399
409
  actorName,
410
+ actorAvatar: actorInfo.photo || "",
400
411
  objectUrl: object.id?.href || "",
401
412
  targetUrl: inReplyTo,
402
413
  content,
@@ -405,7 +416,6 @@ export function registerInboxListeners(inboxChain, options) {
405
416
 
406
417
  // Create notification if reply is to one of OUR posts
407
418
  if (pubUrl && inReplyTo.startsWith(pubUrl)) {
408
- const actorInfo = await extractActorInfo(actorObj, { documentLoader: authLoader });
409
419
  const rawHtml = object.content?.toString() || "";
410
420
  const contentHtml = sanitizeContent(rawHtml);
411
421
  const contentText = rawHtml.replace(/<[^>]*>/g, "").substring(0, 200);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rmdes/indiekit-endpoint-activitypub",
3
- "version": "2.0.26",
3
+ "version": "2.0.27",
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",