Files
indiekit-server/scripts/patch-ap-inbox-publication-url.mjs
Sven 63bc41ebb5
All checks were successful
Deploy Indiekit Server / deploy (push) Successful in 1m14s
fix(activitypub): inbound replies/notifications broken — publicationUrl missing in inbox handlers
collections._publicationUrl was never set, so every pubUrl guard in
handleCreate/handleAnnounce evaluated to undefined:
  - Reply notifications were never created (if pubUrl && ...) always false
  - Boost notifications for our content never created
  - Replies from non-followed accounts never stored in ap_timeline

Fix A: set collections._publicationUrl = publicationUrl before
registerInboxListeners() in federation-setup.js.

Fix B: in handleCreate, add an else-if branch that stores replies to
our own posts in ap_timeline even when the sender is not in ap_following,
so they appear in Mastodon client conversation/notification views.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-01 15:28:24 +02:00

116 lines
5.0 KiB
JavaScript

/**
* Patch: fix inbound reply/like/boost handling — publicationUrl missing in inbox handlers.
*
* Root cause:
* setupFederation() passes `publicationUrl` to its own scope but does NOT
* pass it to registerInboxListeners(). All inbox handlers (handleCreate,
* handleLike, handleAnnounce) read `collections._publicationUrl` to gate
* notifications and timeline storage, but that property is never set on the
* collections object.
*
* Consequence:
* - handleCreate: `if (pubUrl && inReplyTo.startsWith(pubUrl))` is always
* false → reply notifications are never created.
* - handleAnnounce: boost notifications for our content never created.
* - handleCreate: replies to our posts from non-followed accounts are never
* stored in ap_timeline → invisible in Mastodon client conversation views.
*
* Fix A (federation-setup.js):
* Set `collections._publicationUrl = publicationUrl` immediately before
* registerInboxListeners() so the value flows through to all handlers.
*
* Fix B (inbox-handlers.js):
* In handleCreate, add an else-if branch that stores replies to our own posts
* in ap_timeline even when the replier is not in ap_following. This runs only
* when pubUrl is correctly set (Fix A).
*/
import { access, readFile, writeFile } from "node:fs/promises";
const MARKER = "// [patch] ap-inbox-publication-url";
// ── Fix A: federation-setup.js ────────────────────────────────────────────────
const federationCandidates = [
"node_modules/@rmdes/indiekit-endpoint-activitypub/lib/federation-setup.js",
"node_modules/@indiekit/indiekit/node_modules/@rmdes/indiekit-endpoint-activitypub/lib/federation-setup.js",
];
const OLD_REGISTER = ` registerInboxListeners(inboxChain, {
collections,
handle,
storeRawActivities,
});`;
const NEW_REGISTER = ` // Expose publicationUrl on collections so inbox handlers can gate ${MARKER}
// notifications/timeline-storage to our own content only.
collections._publicationUrl = publicationUrl;
registerInboxListeners(inboxChain, {
collections,
handle,
storeRawActivities,
});`;
// ── Fix B: inbox-handlers.js ──────────────────────────────────────────────────
const handlersCandidates = [
"node_modules/@rmdes/indiekit-endpoint-activitypub/lib/inbox-handlers.js",
"node_modules/@indiekit/indiekit/node_modules/@rmdes/indiekit-endpoint-activitypub/lib/inbox-handlers.js",
];
const OLD_FOLLOWED_TAGS = ` } else if (collections.ap_followed_tags) {
// Not a followed account — check if the post's hashtags match any followed tags`;
const NEW_FOLLOWED_TAGS = ` } else if (pubUrl && inReplyTo && inReplyTo.startsWith(pubUrl)) {
// Reply to our post from a non-followed account — store in timeline ${MARKER}
// so it appears in the Mastodon client API's conversation/notification view.
try {
const timelineItem = await extractObjectData(object, {
actorFallback: actorObj,
documentLoader: authLoader,
});
timelineItem.visibility = computeVisibility(object);
await addTimelineItem(collections, timelineItem);
} catch (error) {
console.error("[inbox-handlers] Failed to store reply timeline item:", error.message);
}
} else if (collections.ap_followed_tags) {
// Not a followed account — check if the post's hashtags match any followed tags`;
async function exists(p) {
try { await access(p); return true; } catch { return false; }
}
async function patch(candidates, oldSnippet, newSnippet, label) {
let checked = 0; let patched = 0;
for (const filePath of candidates) {
if (!(await exists(filePath))) continue;
checked++;
const source = await readFile(filePath, "utf8");
if (source.includes(MARKER)) {
console.log(`[postinstall] patch-ap-inbox-publication-url: ${label} already applied to ${filePath}`);
continue;
}
if (!source.includes(oldSnippet)) {
console.warn(`[postinstall] patch-ap-inbox-publication-url: ${label} snippet not found in ${filePath}`);
continue;
}
await writeFile(filePath, source.replace(oldSnippet, newSnippet), "utf8");
patched++;
console.log(`[postinstall] Applied patch-ap-inbox-publication-url (${label}) to ${filePath}`);
}
return { checked, patched };
}
const a = await patch(federationCandidates, OLD_REGISTER, NEW_REGISTER, "set _publicationUrl");
const b = await patch(handlersCandidates, OLD_FOLLOWED_TAGS, NEW_FOLLOWED_TAGS, "store reply from non-follower");
const total = a.patched + b.patched;
if (a.checked + b.checked === 0) {
console.log("[postinstall] patch-ap-inbox-publication-url: no target files found");
} else if (total === 0) {
console.log("[postinstall] patch-ap-inbox-publication-url: already up to date");
} else {
console.log(`[postinstall] patch-ap-inbox-publication-url: patched ${total} file(s)`);
}