diff --git a/CLAUDE.md b/CLAUDE.md index 243c6bc..21d9238 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 diff --git a/lib/federation-bridge.js b/lib/federation-bridge.js index 9e7466c..1295745 100644 --- a/lib/federation-bridge.js +++ b/lib/federation-bridge.js @@ -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]; } diff --git a/lib/inbox-listeners.js b/lib/inbox-listeners.js index 1b43f28..b974673 100644 --- a/lib/inbox-listeners.js +++ b/lib/inbox-listeners.js @@ -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} + */ + 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 }); diff --git a/lib/timeline-store.js b/lib/timeline-store.js index 73822d0..f0ffb2b 100644 --- a/lib/timeline-store.js +++ b/lib/timeline-store.js @@ -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} 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; diff --git a/package.json b/package.json index 44899aa..07dbc1a 100644 --- a/package.json +++ b/package.json @@ -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",