feat: use authenticated document loader for all inbox handler fetches

Pass ctx.getDocumentLoader({ identifier: handle }) to every .getActor(),
.getObject(), and .getTarget() call in inbox handlers. This signs outbound
fetches with our actor's key, fixing silent failures against Authorized
Fetch (Secure Mode) servers like hachyderm.io.

The authenticated loader is also threaded through extractObjectData() and
extractActorInfo() in timeline-store.js so internal calls to
.getAttributedTo(), .getIcon(), .getTags(), and .getAttachments() also
use signed requests.

Also removes the endpoints.type workaround in federation-bridge.js since
Fedify 2.0 fixed issue #576 upstream. The attachment array workaround
for Mastodon compatibility remains.

Bumps version to 2.0.26.
This commit is contained in:
Ricardo
2026-02-25 09:41:29 +01:00
parent 17b0f582d1
commit fceac1f344
5 changed files with 81 additions and 57 deletions
+16 -9
View File
@@ -77,7 +77,7 @@ Reader: Followed account posts → Create inbox → timeline-store → ap_time
### 1. Express ↔ Fedify Bridge (CUSTOM — NOT @fedify/express)
We **cannot** use `@fedify/express`'s `integrateFederation()` because Indiekit mounts plugins at sub-paths. Express strips the mount prefix from `req.url`, breaking Fedify's URI template matching. Instead, `federation-bridge.js` uses `req.originalUrl` to build the full URL.
We **cannot** use `@fedify/express`'s `integrateFederation()` because Indiekit mounts plugins at sub-paths. Express strips the mount prefix from `req.url`, breaking Fedify's URI template matching. **Verified in Fedify 2.0**: `@fedify/express` still uses `req.url` (not `req.originalUrl`), so the custom bridge remains necessary. Instead, `federation-bridge.js` uses `req.originalUrl` to build the full URL.
The bridge also **reconstructs POST bodies** from `req.body` when Express body parser has already consumed the request stream (checked via `req.readable === false`). Without this, POST handlers in Fedify (e.g. the `@fedify/debugger` login form) receive empty bodies and fail with `"Response body object should not be disturbed or locked"`.
@@ -91,14 +91,19 @@ The `contentNegotiationRoutes` router is mounted at `/` (root). It MUST only pas
In `routesPublic`, the middleware skips paths starting with `/admin`. Without this, Fedify would intercept admin UI requests and return 404/406 responses instead of letting Express serve the authenticated pages.
### 4. Use .objectId/.actorId — NOT .getObject()/.getActor() in Inbox Handlers
### 4. Authenticated Document Loader for Inbox Handlers
Fedify's `.getObject()` and `.getActor()` trigger HTTP fetches to remote servers. This fails silently or retries ~10 times when:
- Remote server has **Authorized Fetch** enabled (returns 401)
- Server is down or unreachable
- Object has been deleted
All `.getObject()` / `.getActor()` / `.getTarget()` calls in inbox handlers **must** pass an authenticated `DocumentLoader` to sign outbound fetches. Without this, requests to Authorized Fetch (Secure Mode) servers like hachyderm.io fail with 401.
**Always prefer** `.objectId?.href` and `.actorId?.href` (zero network requests) for Like, Announce, Undo, and Delete handlers. Only use `.getObject()` / `.getActor()` when you need the full object, and **always wrap in try-catch**.
```javascript
const authLoader = await ctx.getDocumentLoader({ identifier: handle });
const actor = await activity.getActor({ documentLoader: authLoader });
const object = await activity.getObject({ documentLoader: authLoader });
```
The `getAuthLoader` helper in `inbox-listeners.js` wraps this pattern. The authenticated loader is also passed through to `extractObjectData()` and `extractActorInfo()` in `timeline-store.js` so that `.getAttributedTo()`, `.getIcon()`, `.getTags()`, and `.getAttachments()` also sign their fetches.
**Still prefer** `.objectId?.href` and `.actorId?.href` (zero network requests) when you only need the URL — e.g. Like, Delete, and the filter check in Announce. Only use the fetching getters when you need the full object, and **always wrap in try-catch**.
### 5. Accept(Follow) Matching — Don't Check Inner Object Type
@@ -118,9 +123,11 @@ Template names resolve across ALL registered plugin view directories. If two plu
Express 5 removed the `"back"` magic keyword from `response.redirect()`. It's treated as a literal URL, causing 404s at paths like `/admin/featured/back`. Always use explicit redirect paths.
### 9. Fedify Endpoints Type Bug (Workaround)
### 9. Attachment Array Workaround (Mastodon Compatibility)
Fedify serializes `endpoints` with `"type": "as:Endpoints"` which is not a real ActivityStreams type. `sendFedifyResponse()` in `federation-bridge.js` strips this from actor JSON responses. Remove the workaround when [fedify#576](https://github.com/fedify-dev/fedify/issues/576) is fixed upstream.
JSON-LD compaction collapses single-element arrays to plain objects. Mastodon's `update_account_fields` checks `attachment.is_a?(Array)` and silently skips if it's not an array. `sendFedifyResponse()` in `federation-bridge.js` forces `attachment` to always be an array.
**Note:** The old `endpoints.type` bug ([fedify#576](https://github.com/fedify-dev/fedify/issues/576)) was fixed in Fedify 2.0 — that workaround has been removed.
### 10. Profile Links — Express qs Body Parser Key Mismatch
+5 -13
View File
@@ -72,11 +72,11 @@ async function sendFedifyResponse(res, response, request) {
return;
}
// WORKAROUND: Fedify serializes endpoints with "type": "as:Endpoints"
// which is not a real ActivityStreams type (fails browser.pub validation).
// For actor JSON responses, buffer the body and strip the invalid type.
// See: https://github.com/fedify-dev/fedify/issues/576
// TODO: Remove this workaround when Fedify fixes the upstream issue.
// WORKAROUND: JSON-LD compaction collapses single-element arrays to a
// plain object. Mastodon's update_account_fields checks
// `attachment.is_a?(Array)` and skips if it's not an array, so
// profile links/PropertyValues are silently ignored.
// Force `attachment` to always be an array for Mastodon compatibility.
const contentType = response.headers.get("content-type") || "";
const isActorJson =
contentType.includes("activity+json") ||
@@ -86,14 +86,6 @@ async function sendFedifyResponse(res, response, request) {
const body = await response.text();
try {
const json = JSON.parse(body);
if (json.endpoints?.type) {
delete json.endpoints.type;
}
// WORKAROUND: Fedify's JSON-LD compaction collapses single-element
// arrays to a plain object. Mastodon's update_account_fields checks
// `attachment.is_a?(Array)` and skips if it's not an array, so
// profile links/PropertyValues are silently ignored.
// Force `attachment` to always be an array for Mastodon compatibility.
if (json.attachment && !Array.isArray(json.attachment)) {
json.attachment = [json.attachment];
}
+47 -27
View File
@@ -41,9 +41,20 @@ import { fetchAndStorePreviews } from "./og-unfurl.js";
export function registerInboxListeners(inboxChain, options) {
const { collections, handle, storeRawActivities } = options;
/**
* Get an authenticated DocumentLoader that signs outbound fetches with
* our actor's key. This allows .getActor()/.getObject() to succeed
* against Authorized Fetch (Secure Mode) servers like hachyderm.io.
*
* @param {import("@fedify/fedify").Context} ctx - Fedify context
* @returns {Promise<import("@fedify/fedify").DocumentLoader>}
*/
const getAuthLoader = (ctx) => ctx.getDocumentLoader({ identifier: handle });
inboxChain
.on(Follow, async (ctx, follow) => {
const followerActor = await follow.getActor();
const authLoader = await getAuthLoader(ctx);
const followerActor = await follow.getActor({ documentLoader: authLoader });
if (!followerActor?.id) return;
const followerUrl = followerActor.id.href;
@@ -90,7 +101,7 @@ export function registerInboxListeners(inboxChain, options) {
});
// Store notification
const followerInfo = await extractActorInfo(followerActor);
const followerInfo = await extractActorInfo(followerActor, { documentLoader: authLoader });
await addNotification(collections, {
uid: follow.id?.href || `follow:${followerUrl}`,
type: "follow",
@@ -104,9 +115,10 @@ export function registerInboxListeners(inboxChain, options) {
})
.on(Undo, async (ctx, undo) => {
const actorUrl = undo.actorId?.href || "";
const authLoader = await getAuthLoader(ctx);
let inner;
try {
inner = await undo.getObject();
inner = await undo.getObject({ documentLoader: authLoader });
} catch {
// Inner activity not dereferenceable — can't determine what was undone
return;
@@ -150,7 +162,8 @@ export function registerInboxListeners(inboxChain, options) {
// it to a Person (the Follow's target) rather than the Follow itself.
// Instead, we match directly against ap_following — if we have a
// pending follow for this actor, any Accept from them confirms it.
const actorObj = await accept.getActor();
const authLoader = await getAuthLoader(ctx);
const actorObj = await accept.getActor({ documentLoader: authLoader });
const actorUrl = actorObj?.id?.href || "";
if (!actorUrl) return;
@@ -186,7 +199,8 @@ export function registerInboxListeners(inboxChain, options) {
}
})
.on(Reject, async (ctx, reject) => {
const actorObj = await reject.getActor();
const authLoader = await getAuthLoader(ctx);
const actorObj = await reject.getActor({ documentLoader: authLoader });
const actorUrl = actorObj?.id?.href || "";
if (!actorUrl) return;
@@ -217,20 +231,19 @@ export function registerInboxListeners(inboxChain, options) {
}
})
.on(Like, async (ctx, like) => {
// Use .objectId to get the URL without dereferencing the remote object.
// Calling .getObject() would trigger an HTTP fetch to the remote server,
// which fails with 404 when the server has Authorized Fetch (Secure Mode)
// enabled — causing pointless retries and log spam.
// Use .objectId (non-fetching) for the liked URL — we only need the
// URL to filter and log, not the full remote object.
const objectId = like.objectId?.href || "";
// Only log likes of our own content
const pubUrl = collections._publicationUrl;
if (!objectId || (pubUrl && !objectId.startsWith(pubUrl))) return;
const authLoader = await getAuthLoader(ctx);
const actorUrl = like.actorId?.href || "";
let actorObj;
try {
actorObj = await like.getActor();
actorObj = await like.getActor({ documentLoader: authLoader });
} catch {
actorObj = null;
}
@@ -250,7 +263,7 @@ export function registerInboxListeners(inboxChain, options) {
});
// Store notification
const actorInfo = await extractActorInfo(actorObj);
const actorInfo = await extractActorInfo(actorObj, { documentLoader: authLoader });
await addNotification(collections, {
uid: like.id?.href || `like:${actorUrl}:${objectId}`,
type: "like",
@@ -268,6 +281,7 @@ export function registerInboxListeners(inboxChain, options) {
const objectId = announce.objectId?.href || "";
if (!objectId) return;
const authLoader = await getAuthLoader(ctx);
const actorUrl = announce.actorId?.href || "";
const pubUrl = collections._publicationUrl;
@@ -277,7 +291,7 @@ export function registerInboxListeners(inboxChain, options) {
if (pubUrl && objectId.startsWith(pubUrl)) {
let actorObj;
try {
actorObj = await announce.getActor();
actorObj = await announce.getActor({ documentLoader: authLoader });
} catch {
actorObj = null;
}
@@ -298,7 +312,7 @@ export function registerInboxListeners(inboxChain, options) {
});
// Create notification
const actorInfo = await extractActorInfo(actorObj);
const actorInfo = await extractActorInfo(actorObj, { documentLoader: authLoader });
await addNotification(collections, {
uid: announce.id?.href || `${actorUrl}#boost-${objectId}`,
type: "boost",
@@ -319,8 +333,8 @@ export function registerInboxListeners(inboxChain, options) {
const following = await collections.ap_following.findOne({ actorUrl });
if (following) {
try {
// Fetch the original object being boosted
const object = await announce.getObject();
// Fetch the original object being boosted (authenticated for Secure Mode servers)
const object = await announce.getObject({ documentLoader: authLoader });
if (!object) return;
// Skip non-content objects (Lemmy/PieFed like/create activities
@@ -329,13 +343,14 @@ export function registerInboxListeners(inboxChain, options) {
if (!hasContent) return;
// Get booster actor info
const boosterActor = await announce.getActor();
const boosterInfo = await extractActorInfo(boosterActor);
const boosterActor = await announce.getActor({ documentLoader: authLoader });
const boosterInfo = await extractActorInfo(boosterActor, { documentLoader: authLoader });
// Extract and store with boost metadata
const timelineItem = await extractObjectData(object, {
boostedBy: boosterInfo,
boostedAt: announce.published ? String(announce.published) : new Date().toISOString(),
documentLoader: authLoader,
});
await addTimelineItem(collections, timelineItem);
@@ -347,11 +362,12 @@ export function registerInboxListeners(inboxChain, options) {
}
})
.on(Create, async (ctx, create) => {
const authLoader = await getAuthLoader(ctx);
let object;
try {
object = await create.getObject();
object = await create.getObject({ documentLoader: authLoader });
} catch {
// Remote object not dereferenceable (Authorized Fetch, deleted, etc.)
// Remote object not dereferenceable (deleted, etc.)
return;
}
if (!object) return;
@@ -359,7 +375,7 @@ export function registerInboxListeners(inboxChain, options) {
const actorUrl = create.actorId?.href || "";
let actorObj;
try {
actorObj = await create.getActor();
actorObj = await create.getActor({ documentLoader: authLoader });
} catch {
// Actor not dereferenceable — use URL as fallback
actorObj = null;
@@ -389,7 +405,7 @@ export function registerInboxListeners(inboxChain, options) {
// Create notification if reply is to one of OUR posts
if (pubUrl && inReplyTo.startsWith(pubUrl)) {
const actorInfo = await extractActorInfo(actorObj);
const actorInfo = await extractActorInfo(actorObj, { documentLoader: authLoader });
const rawHtml = object.content?.toString() || "";
const contentHtml = sanitizeContent(rawHtml);
const contentText = rawHtml.replace(/<[^>]*>/g, "").substring(0, 200);
@@ -420,7 +436,7 @@ export function registerInboxListeners(inboxChain, options) {
for (const tag of tags) {
if (tag.type === "Mention" && tag.href?.href === ourActorUrl) {
const actorInfo = await extractActorInfo(actorObj);
const actorInfo = await extractActorInfo(actorObj, { documentLoader: authLoader });
const rawMentionHtml = object.content?.toString() || "";
const mentionHtml = sanitizeContent(rawMentionHtml);
const contentText = rawMentionHtml.replace(/<[^>]*>/g, "").substring(0, 200);
@@ -451,6 +467,7 @@ export function registerInboxListeners(inboxChain, options) {
try {
const timelineItem = await extractObjectData(object, {
actorFallback: actorObj,
documentLoader: authLoader,
});
await addTimelineItem(collections, timelineItem);
@@ -479,9 +496,10 @@ export function registerInboxListeners(inboxChain, options) {
}
})
.on(Move, async (ctx, move) => {
const oldActorObj = await move.getActor();
const authLoader = await getAuthLoader(ctx);
const oldActorObj = await move.getActor({ documentLoader: authLoader });
const oldActorUrl = oldActorObj?.id?.href || "";
const target = await move.getTarget();
const target = await move.getTarget({ documentLoader: authLoader });
const newActorUrl = target?.id?.href || "";
if (oldActorUrl && newActorUrl) {
@@ -501,11 +519,12 @@ export function registerInboxListeners(inboxChain, options) {
})
.on(Update, async (ctx, update) => {
// Update can be for a profile OR for a post (edited content)
const authLoader = await getAuthLoader(ctx);
// Try to get the object being updated
let object;
try {
object = await update.getObject();
object = await update.getObject({ documentLoader: authLoader });
} catch {
object = null;
}
@@ -538,7 +557,7 @@ export function registerInboxListeners(inboxChain, options) {
}
// PATH 2: Otherwise, assume profile update — refresh stored follower data
const actorObj = await update.getActor();
const actorObj = await update.getActor({ documentLoader: authLoader });
const actorUrl = actorObj?.id?.href || "";
if (!actorUrl) return;
@@ -564,7 +583,8 @@ export function registerInboxListeners(inboxChain, options) {
})
.on(Block, async (ctx, block) => {
// Remote actor blocked us — remove them from followers
const actorObj = await block.getActor();
const authLoader = await getAuthLoader(ctx);
const actorObj = await block.getActor({ documentLoader: authLoader });
const actorUrl = actorObj?.id?.href || "";
if (actorUrl) {
await collections.ap_followers.deleteOne({ actorUrl });
+12 -7
View File
@@ -36,9 +36,11 @@ export function sanitizeContent(html) {
/**
* Extract actor information from Fedify Person/Application/Service object
* @param {object} actor - Fedify actor object
* @param {object} [options] - Options
* @param {object} [options.documentLoader] - Authenticated DocumentLoader for Secure Mode servers
* @returns {object} { name, url, photo, handle }
*/
export async function extractActorInfo(actor) {
export async function extractActorInfo(actor, options = {}) {
if (!actor) {
return {
name: "Unknown",
@@ -54,10 +56,11 @@ export async function extractActorInfo(actor) {
const url = actor.id?.href || "";
// Extract photo URL from icon (Fedify uses async getters)
const loaderOpts = options.documentLoader ? { documentLoader: options.documentLoader } : {};
let photo = "";
try {
if (typeof actor.getIcon === "function") {
const iconObj = await actor.getIcon();
const iconObj = await actor.getIcon(loaderOpts);
photo = iconObj?.url?.href || "";
} else {
const iconObj = await actor.icon;
@@ -89,6 +92,7 @@ export async function extractActorInfo(actor) {
* @param {object} [options.boostedBy] - Actor info for boosts
* @param {Date} [options.boostedAt] - Boost timestamp
* @param {object} [options.actorFallback] - Fedify actor to use when object.getAttributedTo() fails
* @param {object} [options.documentLoader] - Authenticated DocumentLoader for Secure Mode servers
* @returns {Promise<object>} Timeline item data
*/
export async function extractObjectData(object, options = {}) {
@@ -130,14 +134,15 @@ export async function extractObjectData(object, options = {}) {
: new Date().toISOString();
// Extract author — try multiple strategies in order of reliability
const loaderOpts = options.documentLoader ? { documentLoader: options.documentLoader } : {};
let authorObj = null;
try {
if (typeof object.getAttributedTo === "function") {
const attr = await object.getAttributedTo();
const attr = await object.getAttributedTo(loaderOpts);
authorObj = Array.isArray(attr) ? attr[0] : attr;
}
} catch {
// getAttributedTo() failed (Authorized Fetch, unreachable, etc.)
// getAttributedTo() failed (unreachable, deleted, etc.)
}
// If getAttributedTo() returned nothing, use the actor from the wrapping activity
if (!authorObj && options.actorFallback) {
@@ -150,7 +155,7 @@ export async function extractObjectData(object, options = {}) {
let author;
if (authorObj) {
author = await extractActorInfo(authorObj);
author = await extractActorInfo(authorObj, loaderOpts);
} else {
// Last resort: use attributionIds (non-fetching) to get at least a URL
const attrIds = object.attributionIds;
@@ -184,7 +189,7 @@ export async function extractObjectData(object, options = {}) {
const category = [];
try {
if (typeof object.getTags === "function") {
const tags = await object.getTags();
const tags = await object.getTags(loaderOpts);
for await (const tag of tags) {
if (tag.name) {
const tagName = tag.name.toString().replace(/^#/, "");
@@ -203,7 +208,7 @@ export async function extractObjectData(object, options = {}) {
try {
if (typeof object.getAttachments === "function") {
const attachments = await object.getAttachments();
const attachments = await object.getAttachments(loaderOpts);
for await (const att of attachments) {
const mediaUrl = att.url?.href || "";
if (!mediaUrl) continue;
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@rmdes/indiekit-endpoint-activitypub",
"version": "2.0.25",
"version": "2.0.26",
"description": "ActivityPub federation endpoint for Indiekit via Fedify. Adds full fediverse support: actor, inbox, outbox, followers, following, syndication, and Mastodon migration.",
"keywords": [
"indiekit",