diff --git a/lib/resolve-author.js b/lib/resolve-author.js
index 9d45d4c..4d5dc24 100644
--- a/lib/resolve-author.js
+++ b/lib/resolve-author.js
@@ -51,6 +51,47 @@ export function extractAuthorUrl(postUrl) {
}
}
+/**
+ * Wraps a Fedify document loader to allow private/loopback addresses for
+ * requests targeting the publication's own hostname.
+ *
+ * Fedify blocks requests to private IP ranges by default. When the publication
+ * is self-hosted (e.g. localhost or a private IP), author lookups for posts on
+ * that same host fail with a private-address error. This wrapper opts in to
+ * allowPrivateAddress only when the target URL is on the publication's own host.
+ *
+ * @param {Function} documentLoader - Fedify authenticated document loader
+ * @param {string} publicationUrl - The publication's canonical URL (e.g. ctx.url.href)
+ * @returns {Function} Wrapped document loader
+ */
+function createPublicationAwareDocumentLoader(documentLoader, publicationUrl) {
+ if (typeof documentLoader !== "function") {
+ return documentLoader;
+ }
+
+ let publicationHost = "";
+ try {
+ publicationHost = new URL(publicationUrl).hostname;
+ } catch {
+ return documentLoader;
+ }
+
+ return (url, options = {}) => {
+ try {
+ const parsed = new URL(
+ typeof url === "string" ? url : (url?.href || String(url)),
+ );
+ if (parsed.hostname === publicationHost) {
+ return documentLoader(url, { ...options, allowPrivateAddress: true });
+ }
+ } catch {
+ // Fall through to default loader behavior.
+ }
+
+ return documentLoader(url, options);
+ };
+}
+
/**
* Resolve the author Actor for a given post URL.
*
@@ -66,13 +107,20 @@ export async function resolveAuthor(
documentLoader,
collections,
) {
+ const publicationLoader = createPublicationAwareDocumentLoader(
+ documentLoader,
+ ctx?.url?.href || "",
+ );
+
// Strategy 1: Look up remote post via Fedify (signed request)
try {
const remoteObject = await ctx.lookupObject(new URL(postUrl), {
- documentLoader,
+ documentLoader: publicationLoader,
});
if (remoteObject && typeof remoteObject.getAttributedTo === "function") {
- const author = await remoteObject.getAttributedTo({ documentLoader });
+ const author = await remoteObject.getAttributedTo({
+ documentLoader: publicationLoader,
+ });
const recipient = Array.isArray(author) ? author[0] : author;
if (recipient) {
console.info(
@@ -113,7 +161,7 @@ export async function resolveAuthor(
if (authorUrl) {
try {
const actor = await ctx.lookupObject(new URL(authorUrl), {
- documentLoader,
+ documentLoader: publicationLoader,
});
if (actor) {
console.info(
@@ -135,7 +183,7 @@ export async function resolveAuthor(
if (extractedUrl) {
try {
const actor = await ctx.lookupObject(new URL(extractedUrl), {
- documentLoader,
+ documentLoader: publicationLoader,
});
if (actor) {
console.info(
diff --git a/package.json b/package.json
index 51ddbe1..9308048 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "@rmdes/indiekit-endpoint-activitypub",
- "version": "2.8.2",
+ "version": "2.9.2",
"description": "ActivityPub federation endpoint for Indiekit via Fedify. Adds full fediverse support: actor, inbox, outbox, followers, following, syndication, and Mastodon migration.",
"keywords": [
"indiekit",
diff --git a/views/layouts/ap-reader.njk b/views/layouts/ap-reader.njk
index 37d1e48..7c482cf 100644
--- a/views/layouts/ap-reader.njk
+++ b/views/layouts/ap-reader.njk
@@ -13,7 +13,8 @@
{# Avatar fallback — remove broken images to reveal initials fallback underneath #}
- {# Alpine.js loaded by default.njk — AP scripts register via alpine:init before it initializes #}
+ {# Alpine.js — must load after component scripts so alpine:init listeners are registered first #}
+
{# Reader stylesheet — loaded in body is fine for modern browsers #}