fix(ap): fix reply threading — pre-check AP syndication and resolve in_reply_to_id immediately

Two bugs caused replies-to-replies to be posted as 'note' type without
ActivityPub federation:

1. patch-ap-compose-default-checked: The AP reader compose form had
   defaultChecked hardcoded to '@rick@rmendes.net' (original dev's handle),
   so the AP syndication checkbox was never pre-checked. Fixed to use
   target.checked from the Micropub q=config response, which already
   carries checked: true for the AP syndicator.

2. patch-ap-mastodon-reply-threading: POST /api/v1/statuses deferred
   ap_timeline insertion until the Eleventy build webhook fired (30–120 s).
   If the user replied to their own new post before the build finished,
   findTimelineItemById returned null → inReplyTo = null → no in-reply-to
   in JF2 → post-type-discovery returned 'note' → reply saved at /notes/
   and sent without inReplyTo in the AP activity, breaking thread display
   on remote servers. Fixed by eagerly inserting the provisional timeline
   item immediately after postContent.create() ($setOnInsert — idempotent;
   syndicator upsert later is a no-op).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Sven
2026-03-30 08:12:43 +02:00
parent b4fc7ffb4f
commit 97d99976ea
3 changed files with 216 additions and 2 deletions
+2 -2
View File
@@ -4,8 +4,8 @@
"description": "", "description": "",
"main": "index.js", "main": "index.js",
"scripts": { "scripts": {
"postinstall": "xattr -w com.apple.fileprovider.ignore#P 1 node_modules 2>/dev/null || true && node scripts/patch-lightningcss.mjs && node scripts/patch-endpoint-media-scope.mjs && node scripts/patch-endpoint-media-sharp-runtime.mjs && node scripts/patch-frontend-sharp-runtime.mjs && node scripts/patch-endpoint-files-upload-route.mjs && node scripts/patch-endpoint-files-upload-locales.mjs && node scripts/patch-endpoint-activitypub-locales.mjs && node scripts/patch-endpoint-homepage-locales.mjs && node scripts/patch-endpoint-homepage-identity-defaults.mjs && node scripts/patch-federation-unlisted-guards.mjs && node scripts/patch-endpoint-micropub-where-note-visibility.mjs && node scripts/patch-endpoint-podroll-opml-upload.mjs && node scripts/patch-frontend-serviceworker-file.mjs && node scripts/patch-endpoint-comments-locales.mjs && node scripts/patch-endpoint-posts-locales.mjs && node scripts/patch-endpoint-conversations-locales.mjs && node scripts/patch-conversations-collection-guards.mjs && node scripts/patch-indiekit-routes-rate-limits.mjs && node scripts/patch-indiekit-error-production-stack.mjs && node scripts/patch-indieauth-devmode-guard.mjs && node scripts/patch-listening-endpoint-runtime-guards.mjs && node scripts/patch-endpoint-github-changelog-categories.mjs && node scripts/patch-endpoint-github-contributions-log.mjs && node scripts/patch-endpoint-blogroll-feeds-alias.mjs && node scripts/patch-endpoint-posts-uid-lookup.mjs && node scripts/patch-conversations-bluesky-self-filter.mjs && node scripts/patch-conversations-bluesky-cursor-fix.mjs && node scripts/patch-endpoint-micropub-source-filter.mjs && node scripts/patch-syndicate-force-checked-default.mjs && node scripts/patch-syndicate-normalize-syndication-array.mjs && node scripts/patch-endpoint-posts-fetch-diagnostic.mjs && node scripts/patch-micropub-fetch-internal-url.mjs && node scripts/patch-micropub-session-token.mjs && node scripts/patch-indiekit-endpoint-urls-protocol.mjs && node scripts/patch-webmention-sender-hentry-syntax.mjs && node scripts/patch-webmention-sender-retry.mjs && node scripts/patch-webmention-sender-livefetch.mjs && node scripts/patch-webmention-sender-empty-details.mjs && node scripts/patch-bluesky-syndicator-internal-url.mjs && node scripts/patch-bluesky-syndicator-media-type-guard.mjs && node scripts/patch-ap-skip-draft-syndication.mjs && node scripts/patch-ap-og-image.mjs && node scripts/patch-ap-webfinger-before-auth.mjs && node scripts/patch-ap-federation-bridge-base-url.mjs", "postinstall": "xattr -w com.apple.fileprovider.ignore#P 1 node_modules 2>/dev/null || true && node scripts/patch-lightningcss.mjs && node scripts/patch-endpoint-media-scope.mjs && node scripts/patch-endpoint-media-sharp-runtime.mjs && node scripts/patch-frontend-sharp-runtime.mjs && node scripts/patch-endpoint-files-upload-route.mjs && node scripts/patch-endpoint-files-upload-locales.mjs && node scripts/patch-endpoint-activitypub-locales.mjs && node scripts/patch-endpoint-homepage-locales.mjs && node scripts/patch-endpoint-homepage-identity-defaults.mjs && node scripts/patch-federation-unlisted-guards.mjs && node scripts/patch-endpoint-micropub-where-note-visibility.mjs && node scripts/patch-endpoint-podroll-opml-upload.mjs && node scripts/patch-frontend-serviceworker-file.mjs && node scripts/patch-endpoint-comments-locales.mjs && node scripts/patch-endpoint-posts-locales.mjs && node scripts/patch-endpoint-conversations-locales.mjs && node scripts/patch-conversations-collection-guards.mjs && node scripts/patch-indiekit-routes-rate-limits.mjs && node scripts/patch-indiekit-error-production-stack.mjs && node scripts/patch-indieauth-devmode-guard.mjs && node scripts/patch-listening-endpoint-runtime-guards.mjs && node scripts/patch-endpoint-github-changelog-categories.mjs && node scripts/patch-endpoint-github-contributions-log.mjs && node scripts/patch-endpoint-blogroll-feeds-alias.mjs && node scripts/patch-endpoint-posts-uid-lookup.mjs && node scripts/patch-conversations-bluesky-self-filter.mjs && node scripts/patch-conversations-bluesky-cursor-fix.mjs && node scripts/patch-endpoint-micropub-source-filter.mjs && node scripts/patch-syndicate-force-checked-default.mjs && node scripts/patch-syndicate-normalize-syndication-array.mjs && node scripts/patch-endpoint-posts-fetch-diagnostic.mjs && node scripts/patch-micropub-fetch-internal-url.mjs && node scripts/patch-micropub-session-token.mjs && node scripts/patch-indiekit-endpoint-urls-protocol.mjs && node scripts/patch-webmention-sender-hentry-syntax.mjs && node scripts/patch-webmention-sender-retry.mjs && node scripts/patch-webmention-sender-livefetch.mjs && node scripts/patch-webmention-sender-empty-details.mjs && node scripts/patch-bluesky-syndicator-internal-url.mjs && node scripts/patch-bluesky-syndicator-media-type-guard.mjs && node scripts/patch-ap-skip-draft-syndication.mjs && node scripts/patch-ap-og-image.mjs && node scripts/patch-ap-webfinger-before-auth.mjs && node scripts/patch-ap-federation-bridge-base-url.mjs && node scripts/patch-ap-compose-default-checked.mjs && node scripts/patch-ap-mastodon-reply-threading.mjs",
"serve": "export NODE_ENV=${NODE_ENV:-production} INDIEKIT_DEBUG=${INDIEKIT_DEBUG:-0} && node scripts/preflight-production-security.mjs && node scripts/preflight-mongo-connection.mjs && node scripts/preflight-activitypub-rsa-key.mjs && node scripts/preflight-activitypub-profile-urls.mjs && node scripts/patch-lightningcss.mjs && node scripts/patch-endpoint-media-scope.mjs && node scripts/patch-endpoint-media-sharp-runtime.mjs && node scripts/patch-frontend-sharp-runtime.mjs && node scripts/patch-endpoint-files-upload-route.mjs && node scripts/patch-endpoint-files-upload-locales.mjs && node scripts/patch-endpoint-activitypub-locales.mjs && node scripts/patch-endpoint-homepage-locales.mjs && node scripts/patch-endpoint-homepage-identity-defaults.mjs && node scripts/patch-federation-unlisted-guards.mjs && node scripts/patch-endpoint-micropub-where-note-visibility.mjs && node scripts/patch-endpoint-podroll-opml-upload.mjs && node scripts/patch-frontend-serviceworker-file.mjs && node scripts/patch-endpoint-comments-locales.mjs && node scripts/patch-endpoint-posts-locales.mjs && node scripts/patch-endpoint-conversations-locales.mjs && node scripts/patch-conversations-collection-guards.mjs && node scripts/patch-indiekit-routes-rate-limits.mjs && node scripts/patch-indiekit-error-production-stack.mjs && node scripts/patch-indieauth-devmode-guard.mjs && node scripts/patch-listening-endpoint-runtime-guards.mjs && node scripts/patch-endpoint-github-changelog-categories.mjs && node scripts/patch-endpoint-github-contributions-log.mjs && node scripts/patch-microsub-reader-ap-dispatch.mjs && node scripts/patch-microsub-compose-draft-guard.mjs && node scripts/patch-endpoint-blogroll-feeds-alias.mjs && node scripts/patch-endpoint-posts-uid-lookup.mjs && node scripts/patch-conversations-bluesky-self-filter.mjs && node scripts/patch-conversations-bluesky-cursor-fix.mjs && node scripts/patch-endpoint-micropub-source-filter.mjs && node scripts/patch-syndicate-force-checked-default.mjs && node scripts/patch-syndicate-normalize-syndication-array.mjs && node scripts/patch-endpoint-posts-fetch-diagnostic.mjs && node scripts/patch-micropub-fetch-internal-url.mjs && node scripts/patch-micropub-session-token.mjs && node scripts/patch-indiekit-endpoint-urls-protocol.mjs && node scripts/patch-webmention-sender-hentry-syntax.mjs && node scripts/patch-webmention-sender-retry.mjs && node scripts/patch-webmention-sender-livefetch.mjs && node scripts/patch-webmention-sender-empty-details.mjs && node scripts/patch-bluesky-syndicator-internal-url.mjs && node scripts/patch-bluesky-syndicator-media-type-guard.mjs && node scripts/patch-ap-skip-draft-syndication.mjs && node scripts/patch-ap-og-image.mjs && node scripts/patch-ap-webfinger-before-auth.mjs && node scripts/patch-ap-federation-bridge-base-url.mjs && node --require ./metrics-shim.cjs node_modules/@indiekit/indiekit/bin/cli.js serve --config indiekit.config.mjs", "serve": "export NODE_ENV=${NODE_ENV:-production} INDIEKIT_DEBUG=${INDIEKIT_DEBUG:-0} && node scripts/preflight-production-security.mjs && node scripts/preflight-mongo-connection.mjs && node scripts/preflight-activitypub-rsa-key.mjs && node scripts/preflight-activitypub-profile-urls.mjs && node scripts/patch-lightningcss.mjs && node scripts/patch-endpoint-media-scope.mjs && node scripts/patch-endpoint-media-sharp-runtime.mjs && node scripts/patch-frontend-sharp-runtime.mjs && node scripts/patch-endpoint-files-upload-route.mjs && node scripts/patch-endpoint-files-upload-locales.mjs && node scripts/patch-endpoint-activitypub-locales.mjs && node scripts/patch-endpoint-homepage-locales.mjs && node scripts/patch-endpoint-homepage-identity-defaults.mjs && node scripts/patch-federation-unlisted-guards.mjs && node scripts/patch-endpoint-micropub-where-note-visibility.mjs && node scripts/patch-endpoint-podroll-opml-upload.mjs && node scripts/patch-frontend-serviceworker-file.mjs && node scripts/patch-endpoint-comments-locales.mjs && node scripts/patch-endpoint-posts-locales.mjs && node scripts/patch-endpoint-conversations-locales.mjs && node scripts/patch-conversations-collection-guards.mjs && node scripts/patch-indiekit-routes-rate-limits.mjs && node scripts/patch-indiekit-error-production-stack.mjs && node scripts/patch-indieauth-devmode-guard.mjs && node scripts/patch-listening-endpoint-runtime-guards.mjs && node scripts/patch-endpoint-github-changelog-categories.mjs && node scripts/patch-endpoint-github-contributions-log.mjs && node scripts/patch-microsub-reader-ap-dispatch.mjs && node scripts/patch-microsub-compose-draft-guard.mjs && node scripts/patch-endpoint-blogroll-feeds-alias.mjs && node scripts/patch-endpoint-posts-uid-lookup.mjs && node scripts/patch-conversations-bluesky-self-filter.mjs && node scripts/patch-conversations-bluesky-cursor-fix.mjs && node scripts/patch-endpoint-micropub-source-filter.mjs && node scripts/patch-syndicate-force-checked-default.mjs && node scripts/patch-syndicate-normalize-syndication-array.mjs && node scripts/patch-endpoint-posts-fetch-diagnostic.mjs && node scripts/patch-micropub-fetch-internal-url.mjs && node scripts/patch-micropub-session-token.mjs && node scripts/patch-indiekit-endpoint-urls-protocol.mjs && node scripts/patch-webmention-sender-hentry-syntax.mjs && node scripts/patch-webmention-sender-retry.mjs && node scripts/patch-webmention-sender-livefetch.mjs && node scripts/patch-webmention-sender-empty-details.mjs && node scripts/patch-bluesky-syndicator-internal-url.mjs && node scripts/patch-bluesky-syndicator-media-type-guard.mjs && node scripts/patch-ap-skip-draft-syndication.mjs && node scripts/patch-ap-og-image.mjs && node scripts/patch-ap-webfinger-before-auth.mjs && node scripts/patch-ap-federation-bridge-base-url.mjs && node scripts/patch-ap-compose-default-checked.mjs && node scripts/patch-ap-mastodon-reply-threading.mjs && node --require ./metrics-shim.cjs node_modules/@indiekit/indiekit/bin/cli.js serve --config indiekit.config.mjs",
"test": "echo \"Error: no test specified\" && exit 1" "test": "echo \"Error: no test specified\" && exit 1"
}, },
"keywords": [], "keywords": [],
@@ -0,0 +1,90 @@
/**
* Patch: fix hardcoded defaultChecked handles in AP reader compose controller.
*
* Root cause:
* composeController() in compose.js sets target.defaultChecked using a
* hardcoded name comparison:
*
* target.defaultChecked = name === "@rick@rmendes.net" || name === "@rmendes.net";
*
* These are the original developer's handles and will never match any target
* on this installation. As a result, ALL syndication checkboxes in the AP
* reader compose form are rendered unchecked, so replies composed through the
* AP reader are never syndicated to ActivityPub.
*
* Fix:
* Replace the hardcoded comparison with `target.checked === true`.
* The Micropub config endpoint (q=config) already returns each syndicator's
* `checked` state. The AP syndicator has `checked: true` in indiekit.config.mjs,
* so the AP checkbox will be pre-checked by default, matching the same behaviour
* as the microsub reader compose form.
*/
import { access, readFile, writeFile } from "node:fs/promises";
const MARKER = "// [patch] ap-compose-default-checked";
const candidates = [
"node_modules/@rmdes/indiekit-endpoint-activitypub/lib/controllers/compose.js",
"node_modules/@indiekit/indiekit/node_modules/@rmdes/indiekit-endpoint-activitypub/lib/controllers/compose.js",
];
const OLD_SNIPPET = ` // Default-check only AP (Fedify) and Bluesky targets
// "@rick@rmendes.net" = AP Fedify, "@rmendes.net" = Bluesky
for (const target of syndicationTargets) {
const name = target.name || "";
target.defaultChecked = name === "@rick@rmendes.net" || name === "@rmendes.net";
}`;
const NEW_SNIPPET = ` // Pre-check syndication targets based on their configured checked state ${MARKER}
for (const target of syndicationTargets) { ${MARKER}
target.defaultChecked = target.checked === true; ${MARKER}
} ${MARKER}`;
async function exists(filePath) {
try {
await access(filePath);
return true;
} catch {
return false;
}
}
let checked = 0;
let patched = 0;
for (const filePath of candidates) {
if (!(await exists(filePath))) continue;
checked += 1;
const source = await readFile(filePath, "utf8");
if (source.includes(MARKER)) {
console.log(`[postinstall] patch-ap-compose-default-checked: already applied to ${filePath}`);
continue;
}
if (!source.includes(OLD_SNIPPET)) {
console.warn(`[postinstall] patch-ap-compose-default-checked: target snippet not found in ${filePath}`);
continue;
}
const updated = source.replace(OLD_SNIPPET, NEW_SNIPPET);
if (updated === source) {
console.log(`[postinstall] patch-ap-compose-default-checked: no changes in ${filePath}`);
continue;
}
await writeFile(filePath, updated, "utf8");
patched += 1;
console.log(`[postinstall] Applied patch-ap-compose-default-checked to ${filePath}`);
}
if (checked === 0) {
console.log("[postinstall] patch-ap-compose-default-checked: no target files found");
} else if (patched === 0) {
console.log("[postinstall] patch-ap-compose-default-checked: already up to date");
} else {
console.log(`[postinstall] patch-ap-compose-default-checked: patched ${patched} file(s)`);
}
@@ -0,0 +1,124 @@
/**
* Patch: eagerly insert own post into ap_timeline after Mastodon API POST /statuses.
*
* Root cause:
* When a post is created via POST /api/v1/statuses (Mastodon client API), the
* handler creates the post through the Micropub pipeline but intentionally does
* NOT insert a timeline item immediately. The comment says:
*
* "No timeline entry is created here — the post will appear in the timeline
* after the normal flow: Eleventy rebuild → syndication webhook → AP delivery."
*
* This means there is a window (typically 30120 s while Eleventy rebuilds) where
* the own post does NOT exist in ap_timeline. If the user tries to reply to their
* own newly-created post during this window, POST /api/v1/statuses receives
* `in_reply_to_id` for the new post, but `findTimelineItemById` returns null.
* With inReplyTo = null, the JF2 object has no "in-reply-to" property, and
* post-type-discovery classifies the reply as "note" instead of "reply". The
* reply is then saved at /notes/{slug}/ rather than /replies/{slug}/, and
* since there is no in-reply-to, the ActivityPub activity has no inReplyTo
* field and the thread is broken on remote Mastodon servers.
*
* Fix:
* After calling postContent.create(), immediately insert a provisional timeline
* item into ap_timeline using addTimelineItem() (which uses $setOnInsert —
* idempotent). The AP syndicator will later attempt the same upsert after the
* build webhook fires, which is a no-op since the document already exists.
* This ensures the post is resolvable via in_reply_to_id with zero delay.
*/
import { access, readFile, writeFile } from "node:fs/promises";
const MARKER = "// [patch] ap-mastodon-reply-threading";
const candidates = [
"node_modules/@rmdes/indiekit-endpoint-activitypub/lib/mastodon/routes/statuses.js",
"node_modules/@indiekit/indiekit/node_modules/@rmdes/indiekit-endpoint-activitypub/lib/mastodon/routes/statuses.js",
];
const OLD_SNIPPET = ` // Return a minimal status to the Mastodon client.
// No timeline entry is created here — the post will appear in the timeline
// after the normal flow: Eleventy rebuild → syndication webhook → AP delivery.
const profile = await collections.ap_profile.findOne({});
const handle = pluginOptions.handle || "user";`;
const NEW_SNIPPET = ` // Return a minimal status to the Mastodon client. ${MARKER}
// Eagerly insert own post into ap_timeline so the Mastodon client can resolve ${MARKER}
// in_reply_to_id for this post immediately, without waiting for the build webhook. ${MARKER}
// The AP syndicator will upsert the same uid later via $setOnInsert (no-op). ${MARKER}
const profile = await collections.ap_profile.findOne({});
const handle = pluginOptions.handle || "user";
try { ${MARKER}
const _ph = (() => { try { return new URL(publicationUrl).hostname; } catch { return ""; } })(); ${MARKER}
await addTimelineItem(collections, { ${MARKER}
uid: postUrl, ${MARKER}
url: postUrl, ${MARKER}
type: data.properties["post-type"] || "note", ${MARKER}
content: { text: contentText, html: \`<p>\${contentHtml}</p>\` }, ${MARKER}
author: { ${MARKER}
name: profile?.name || handle, ${MARKER}
url: profile?.url || publicationUrl, ${MARKER}
photo: profile?.icon || "", ${MARKER}
handle: \`@\${handle}@\${_ph}\`, ${MARKER}
emojis: [], ${MARKER}
bot: false, ${MARKER}
}, ${MARKER}
published: data.properties.published || new Date().toISOString(), ${MARKER}
createdAt: new Date().toISOString(), ${MARKER}
inReplyTo: inReplyTo || null, ${MARKER}
visibility: jf2.visibility || "public", ${MARKER}
sensitive: jf2.sensitive === "true", ${MARKER}
category: [], ${MARKER}
counts: { likes: 0, boosts: 0, replies: 0 }, ${MARKER}
}); ${MARKER}
} catch (tlErr) { ${MARKER}
console.warn(\`[Mastodon API] Failed to pre-insert own post into timeline: \${tlErr.message}\`); ${MARKER}
} ${MARKER}`;
async function exists(filePath) {
try {
await access(filePath);
return true;
} catch {
return false;
}
}
let checked = 0;
let patched = 0;
for (const filePath of candidates) {
if (!(await exists(filePath))) continue;
checked += 1;
const source = await readFile(filePath, "utf8");
if (source.includes(MARKER)) {
console.log(`[postinstall] patch-ap-mastodon-reply-threading: already applied to ${filePath}`);
continue;
}
if (!source.includes(OLD_SNIPPET)) {
console.warn(`[postinstall] patch-ap-mastodon-reply-threading: target snippet not found in ${filePath}`);
continue;
}
const updated = source.replace(OLD_SNIPPET, NEW_SNIPPET);
if (updated === source) {
console.log(`[postinstall] patch-ap-mastodon-reply-threading: no changes in ${filePath}`);
continue;
}
await writeFile(filePath, updated, "utf8");
patched += 1;
console.log(`[postinstall] Applied patch-ap-mastodon-reply-threading to ${filePath}`);
}
if (checked === 0) {
console.log("[postinstall] patch-ap-mastodon-reply-threading: no target files found");
} else if (patched === 0) {
console.log("[postinstall] patch-ap-mastodon-reply-threading: already up to date");
} else {
console.log(`[postinstall] patch-ap-mastodon-reply-threading: patched ${patched} file(s)`);
}