diff --git a/package.json b/package.json index b70b71ec..98e697b5 100644 --- a/package.json +++ b/package.json @@ -5,8 +5,8 @@ "main": "index.js", "scripts": { "preinstall": "node scripts/setup-gitea-url-rewrite.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-store-github-error-message.mjs && node scripts/patch-store-github-update-fallback.mjs && node scripts/patch-store-github-gitea-methods.mjs && node scripts/patch-store-github-put-fallback.mjs && node scripts/patch-store-github-content-type.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-retry.mjs && node scripts/patch-webmention-sender-livefetch.mjs && node scripts/patch-webmention-sender-reset-stale.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-bluesky-og-own-post-title.mjs && node scripts/patch-bluesky-syndicator-delete.mjs && node scripts/patch-micropub-delete-propagation.mjs && node scripts/patch-ap-oauth-token-expiry-fix.mjs && node scripts/patch-ap-startup-gate-bypass.mjs && node scripts/patch-ap-federation-infra.mjs && node scripts/patch-ap-syndication.mjs && node scripts/patch-ap-mastodon-statuses.mjs && node scripts/patch-ap-mastodon-accounts.mjs && node scripts/patch-ap-mastodon-notifications.mjs && node scripts/patch-ap-mastodon-misc.mjs && node scripts/patch-micropub-category-from-posts.mjs && node scripts/patch-tag-input-autocomplete.mjs && node scripts/patch-microsub-batch-concurrency.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/preflight-startup-gate.mjs && node scripts/patch-ap-startup-gate-bypass.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-store-github-error-message.mjs && node scripts/patch-store-github-update-fallback.mjs && node scripts/patch-store-github-gitea-methods.mjs && node scripts/patch-store-github-put-fallback.mjs && node scripts/patch-store-github-content-type.mjs && node scripts/patch-microsub-reader-ap-dispatch.mjs && node scripts/patch-microsub-compose-draft-guard.mjs && node scripts/patch-microsub-no-bookmark-autofollow.mjs && node scripts/patch-microsub-batch-concurrency.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-retry.mjs && node scripts/patch-webmention-sender-livefetch.mjs && node scripts/patch-webmention-sender-reset-stale.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-bluesky-og-own-post-title.mjs && node scripts/patch-bluesky-syndicator-delete.mjs && node scripts/patch-micropub-delete-propagation.mjs && node scripts/patch-ap-oauth-token-expiry-fix.mjs && node scripts/patch-ap-federation-infra.mjs && node scripts/patch-ap-syndication.mjs && node scripts/patch-ap-mastodon-statuses.mjs && node scripts/patch-ap-mastodon-accounts.mjs && node scripts/patch-ap-mastodon-notifications.mjs && node scripts/patch-ap-mastodon-misc.mjs && node scripts/patch-micropub-category-from-posts.mjs && node scripts/patch-tag-input-autocomplete.mjs && node --max-old-space-size=1024 --max-semi-space-size=32 --require ./metrics-shim.cjs node_modules/@indiekit/indiekit/bin/cli.js serve --config indiekit.config.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-store-github-error-message.mjs && node scripts/patch-store-github-update-fallback.mjs && node scripts/patch-store-github-gitea-methods.mjs && node scripts/patch-store-github-put-fallback.mjs && node scripts/patch-store-github-content-type.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-retry.mjs && node scripts/patch-webmention-sender-livefetch.mjs && node scripts/patch-webmention-sender-reset-stale.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-bluesky-og-own-post-title.mjs && node scripts/patch-bluesky-syndicator-delete.mjs && node scripts/patch-micropub-delete-propagation.mjs && node scripts/patch-ap-startup-gate-bypass.mjs && node scripts/patch-ap-federation-infra.mjs && node scripts/patch-ap-syndication.mjs && node scripts/patch-ap-mastodon-statuses.mjs && node scripts/patch-ap-mastodon-misc.mjs && node scripts/patch-micropub-category-from-posts.mjs && node scripts/patch-tag-input-autocomplete.mjs && node scripts/patch-microsub-batch-concurrency.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/preflight-startup-gate.mjs && node scripts/patch-ap-startup-gate-bypass.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-store-github-error-message.mjs && node scripts/patch-store-github-update-fallback.mjs && node scripts/patch-store-github-gitea-methods.mjs && node scripts/patch-store-github-put-fallback.mjs && node scripts/patch-store-github-content-type.mjs && node scripts/patch-microsub-reader-ap-dispatch.mjs && node scripts/patch-microsub-compose-draft-guard.mjs && node scripts/patch-microsub-no-bookmark-autofollow.mjs && node scripts/patch-microsub-batch-concurrency.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-retry.mjs && node scripts/patch-webmention-sender-livefetch.mjs && node scripts/patch-webmention-sender-reset-stale.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-bluesky-og-own-post-title.mjs && node scripts/patch-bluesky-syndicator-delete.mjs && node scripts/patch-micropub-delete-propagation.mjs && node scripts/patch-ap-federation-infra.mjs && node scripts/patch-ap-syndication.mjs && node scripts/patch-ap-mastodon-statuses.mjs && node scripts/patch-ap-mastodon-misc.mjs && node scripts/patch-micropub-category-from-posts.mjs && node scripts/patch-tag-input-autocomplete.mjs && node --max-old-space-size=1024 --max-semi-space-size=32 --require ./metrics-shim.cjs node_modules/@indiekit/indiekit/bin/cli.js serve --config indiekit.config.mjs", "test": "echo \"Error: no test specified\" && exit 1" }, "keywords": [], diff --git a/scripts/patch-ap-mastodon-accounts.mjs b/scripts/patch-ap-mastodon-accounts.mjs deleted file mode 100644 index a4598aa8..00000000 --- a/scripts/patch-ap-mastodon-accounts.mjs +++ /dev/null @@ -1,127 +0,0 @@ -/** - * Consolidated patch: Mastodon account resolution fixes. - * - * Absorbs: - * - patch-ap-actor-cache-await - * Await the ap_actor_cache MongoDB write so the entry exists before the - * search response is sent, making follow reliable after server restarts. - * - * - patch-ap-resolve-account-timeout-safe - * Fix unhandled rejection crash when withTimeout() timer fires before the - * original promise settles, and parallelise collection count fetches with - * Promise.allSettled (max 5 s instead of worst-case 15 s). - * - * Both patches target: - * lib/mastodon/helpers/resolve-account.js - */ - -import { access, readFile, writeFile } from "node:fs/promises"; - -const AP_BASE = "@rmdes/indiekit-endpoint-activitypub"; -const AP_ROOTS = [ - `node_modules/${AP_BASE}`, - `node_modules/@indiekit/indiekit/node_modules/${AP_BASE}`, -]; - -function apPath(rel) { - return AP_ROOTS.map(r => `${r}/${rel}`); -} - -async function fileExists(p) { - try { await access(p); return true; } catch { return false; } -} - -async function applyPatch(filePath, marker, oldSnippet, newSnippet) { - if (!(await fileExists(filePath))) return "file_not_found"; - const src = await readFile(filePath, "utf8"); - if (src.includes(marker)) return "already_applied"; - if (!src.includes(oldSnippet)) return "snippet_not_found"; - await writeFile(filePath, src.replace(oldSnippet, newSnippet), "utf8"); - return "applied"; -} - -const SCRIPT = "patch-ap-mastodon-accounts"; - -const PATCHES = [ - { - name: "ap-actor-cache-await", - files: apPath("lib/mastodon/helpers/resolve-account.js"), - marker: "// [patch] ap-actor-cache-await", - oldSnippet: ` // Persist actor URL mapping to MongoDB so follow/unfollow survives server restarts - if (collections?.ap_actor_cache && actorUrl) { - const hashId = remoteActorId(actorUrl); - collections.ap_actor_cache.updateOne( - { _id: hashId }, - { $set: { actorUrl, updatedAt: new Date() } }, - { upsert: true }, - ).catch(() => {}); // fire-and-forget, non-fatal - }`, - newSnippet: ` // Persist actor URL mapping to MongoDB so follow/unfollow survives server restarts - // [patch] ap-actor-cache-await - if (collections?.ap_actor_cache && actorUrl) { - const hashId = remoteActorId(actorUrl); - await collections.ap_actor_cache.updateOne( - { _id: hashId }, - { $set: { actorUrl, updatedAt: new Date() } }, - { upsert: true }, - ).catch(() => {}); // non-fatal, but now awaited so entry exists before response - }`, - }, - { - name: "ap-resolve-account-timeout-safe", - files: apPath("lib/mastodon/helpers/resolve-account.js"), - marker: "// [patch] ap-resolve-account-timeout-safe", - oldSnippet: ` const withTimeout = (promise, ms = 5000) => - Promise.race([promise, new Promise((_, reject) => setTimeout(() => reject(new Error("timeout")), ms))]); - - let followersCount = 0; - let followingCount = 0; - let statusesCount = 0; - try { - const followers = await withTimeout(actor.getFollowers()); - if (followers?.totalItems != null) followersCount = followers.totalItems; - } catch { /* ignore */ } - try { - const following = await withTimeout(actor.getFollowing()); - if (following?.totalItems != null) followingCount = following.totalItems; - } catch { /* ignore */ } - try { - const outbox = await withTimeout(actor.getOutbox()); - if (outbox?.totalItems != null) statusesCount = outbox.totalItems; - } catch { /* ignore */ }`, - newSnippet: ` const withTimeout = (promise, ms = 5000) => { // [patch] ap-resolve-account-timeout-safe - const abort = new Promise((_, reject) => setTimeout(() => reject(new Error("timeout")), ms)); - promise.catch(() => {}); // suppress unhandled rejection if timeout settles first - return Promise.race([promise, abort]); - }; - - // Fetch collection counts in parallel (max 5 s each) [patch] ap-resolve-account-timeout-safe - const [followersResult, followingResult, outboxResult] = await Promise.allSettled([ - withTimeout(actor.getFollowers()), - withTimeout(actor.getFollowing()), - withTimeout(actor.getOutbox()), - ]); - const followersCount = followersResult.status === "fulfilled" && followersResult.value?.totalItems != null ? followersResult.value.totalItems : 0; - const followingCount = followingResult.status === "fulfilled" && followingResult.value?.totalItems != null ? followingResult.value.totalItems : 0; - const statusesCount = outboxResult.status === "fulfilled" && outboxResult.value?.totalItems != null ? outboxResult.value.totalItems : 0;`, - }, -]; - -let total = 0; -for (const p of PATCHES) { - let done = false; - for (const f of p.files) { - const r = await applyPatch(f, p.marker, p.oldSnippet, p.newSnippet); - if (r === "applied") { - console.log(`[postinstall] ${SCRIPT}: applied ${p.name} to ${f}`); - total++; done = true; break; - } else if (r === "already_applied") { - console.log(`[postinstall] ${SCRIPT}: ${p.name} already applied in ${f}`); - done = true; break; - } else if (r === "snippet_not_found") { - console.warn(`[postinstall] ${SCRIPT}: ${p.name} — snippet not found in ${f}, skipping`); - } - } - if (!done) console.log(`[postinstall] ${SCRIPT}: ${p.name} — no target file found`); -} -console.log(`[postinstall] ${SCRIPT}: done (${total} patch(es) applied)`); diff --git a/scripts/patch-ap-mastodon-misc.mjs b/scripts/patch-ap-mastodon-misc.mjs index 562ea939..745857fd 100644 --- a/scripts/patch-ap-mastodon-misc.mjs +++ b/scripts/patch-ap-mastodon-misc.mjs @@ -1,17 +1,19 @@ /** - * Consolidated patch: AP compose, OG image, repost announce, interactions - * send guard, interactions cleanup preserve, interactions accounts uid, - * inbox ignore View activity, inbox skip View activity parse. + * Patch: AP OG image generation in jf2-to-as2.js. * - * Absorbs: + * Adds `image` property to ActivityStreams objects using: + * - post photo attachment (if present), or + * - generated OG image at /og/{slug}.png + * + * Note: All other patches previously in this file have been integrated + * into the @rmdes/indiekit-endpoint-activitypub source directly: * - patch-ap-compose-default-checked - * - patch-ap-og-image * - patch-ap-repost-announce-fix * - patch-ap-interactions-send-guard * - patch-ap-interactions-cleanup-preserve * - patch-ap-interactions-accounts-uid * - patch-inbox-ignore-view-activity - * - patch-inbox-skip-view-activity-parse + * - patch-inbox-skip-view-activity-parse (raw body fixes) */ import { access, readFile, writeFile } from "node:fs/promises"; @@ -30,31 +32,9 @@ async function fileExists(p) { try { await access(p); return true; } catch { return false; } } -async function applyPatch(filePath, marker, oldSnippet, newSnippet) { - if (!(await fileExists(filePath))) return "file_not_found"; - const src = await readFile(filePath, "utf8"); - if (src.includes(marker)) return "already_applied"; - if (!src.includes(oldSnippet)) return "snippet_not_found"; - await writeFile(filePath, src.replace(oldSnippet, newSnippet), "utf8"); - return "applied"; -} - -// applyRegexPatch: like applyPatch but uses a regex instead of a literal string match. -// oldRegex must be a RegExp (tested + replaced against src). -async function applyRegexPatch(filePath, marker, oldRegex, newSnippet) { - if (!(await fileExists(filePath))) return "file_not_found"; - const src = await readFile(filePath, "utf8"); - if (src.includes(marker)) return "already_applied"; - if (!oldRegex.test(src)) return "snippet_not_found"; - await writeFile(filePath, src.replace(oldRegex, newSnippet), "utf8"); - return "applied"; -} - const SCRIPT = "patch-ap-mastodon-misc"; // ── patch-ap-og-image: regex-based, two replacements per file ───────────────── -// This patch is handled separately below the main loop because it uses regexes -// and applies two independent replacements to the same file. const OG_MARKER = "// og-image-v2"; const OG_CANDIDATES = apPath("lib/jf2-to-as2.js"); @@ -92,379 +72,35 @@ const NEW_AS2 = ` const _ogPhotoF = properties.photo && asArray(properties.phot }); }`; -// ── Standard patches (string-replace) ───────────────────────────────────────── - -const PATCHES = [ - // ── patch-ap-compose-default-checked ────────────────────────────────────── - { - name: "ap-compose-default-checked", - files: [ - "node_modules/@rmdes/indiekit-endpoint-activitypub/lib/controllers/compose.js", - "node_modules/@indiekit/indiekit/node_modules/@rmdes/indiekit-endpoint-activitypub/lib/controllers/compose.js", - ], - marker: "// [patch] ap-compose-default-checked", - oldSnippet: ` // 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"; - }`, - newSnippet: ` // Pre-check syndication targets based on their configured checked state // [patch] ap-compose-default-checked - for (const target of syndicationTargets) { // [patch] ap-compose-default-checked - target.defaultChecked = target.checked === true; // [patch] ap-compose-default-checked - } // [patch] ap-compose-default-checked`, - }, - - // ── patch-ap-repost-announce-fix ────────────────────────────────────────── - { - name: "ap-repost-announce-fix", - files: apPath("lib/jf2-to-as2.js"), - marker: "// [patch] ap-repost-announce-fix", - oldSnippet: ` // Reposts are always public — upstream @rmdes addressing - if (postType === "repost") { - const repostOf = properties["repost-of"]; - if (!repostOf) return null; - const repostContent = properties.content?.html || properties.content || ""; - if (!repostContent) { - // Pure repost — send as a native Announce (boost) so remote servers - // can display it as a boost of the original post. - return new Announce({ - actor: actorUri, - object: new URL(repostOf), - to: new URL("https://www.w3.org/ns/activitystreams#Public"), - }); - } - // Has commentary — fall through to Create(Note) so the text is federated. - // The note content block below handles the "repost" post-type. - }`, - newSnippet: ` // Reposts are always public — upstream @rmdes addressing - if (postType === "repost") { - const repostOf = Array.isArray(properties["repost-of"]) - ? properties["repost-of"][0] - : properties["repost-of"]; - if (!repostOf) return null; - const repostContent = properties.content?.html || properties.content || ""; - if (!repostContent) { - // Only send Announce if repost-of is an ActivityPub URL. - // Non-AP URLs (web articles) cannot be federated as a boost — fall - // through to Create(Note) which renders as "🔁 " on the fediverse. - if (await isApUrl(repostOf)) { // [patch] ap-repost-announce-fix - const actorPath = new URL(actorUrl).pathname; - const mp = actorPath.replace(/\\/users\\/[^/]+$/, ""); - const postRelPath = (properties.url || "") - .replace(publicationUrl.replace(/\\/$/, ""), "") - .replace(/^\\//, "") - .replace(/\\/$/, ""); - const announceId = \`\${publicationUrl.replace(/\\/$/, "")}\${mp}/activities/boost/\${postRelPath}\`; - return new Announce({ - id: new URL(announceId), - actor: actorUri, - object: new URL(repostOf), - to: new URL("https://www.w3.org/ns/activitystreams#Public"), - cc: new URL(\`\${actorUrl.replace(/\\/$/, "")}/followers\`), - }); - } - } - // Has commentary or non-AP repost-of URL — fall through to Create(Note) so the text is federated. - // The note content block below handles the "repost" post-type. - }`, - }, - - // ── patch-ap-interactions-send-guard: likePost ──────────────────────────── - { - name: "ap-interactions-send-guard-like", - files: [ - "node_modules/@rmdes/indiekit-endpoint-activitypub/lib/mastodon/helpers/interactions.js", - "node_modules/@indiekit/indiekit/node_modules/@rmdes/indiekit-endpoint-activitypub/lib/mastodon/helpers/interactions.js", - ], - marker: "// [patch] ap-interactions-send-guard", - oldSnippet: ` if (recipient) { - await ctx.sendActivity({ identifier: handle }, recipient, like, { - orderingKey: targetUrl, - }); - }`, - newSnippet: ` if (recipient) { - try { // [patch] ap-interactions-send-guard - await ctx.sendActivity({ identifier: handle }, recipient, like, { - orderingKey: targetUrl, - }); - } catch { /* delivery failed — interaction still recorded locally */ } - }`, - }, - - // ── patch-ap-interactions-send-guard: boostPost ─────────────────────────── - { - name: "ap-interactions-send-guard-boost", - files: [ - "node_modules/@rmdes/indiekit-endpoint-activitypub/lib/mastodon/helpers/interactions.js", - "node_modules/@indiekit/indiekit/node_modules/@rmdes/indiekit-endpoint-activitypub/lib/mastodon/helpers/interactions.js", - ], - marker: "// [patch] ap-interactions-send-guard", - oldSnippet: ` // Send to followers - await ctx.sendActivity({ identifier: handle }, "followers", announce, { - preferSharedInbox: true, - syncCollection: true, - orderingKey: targetUrl, - });`, - newSnippet: ` // Send to followers - try { // [patch] ap-interactions-send-guard - await ctx.sendActivity({ identifier: handle }, "followers", announce, { - preferSharedInbox: true, - syncCollection: true, - orderingKey: targetUrl, - }); - } catch { /* delivery failed — interaction still recorded locally */ }`, - }, - - // ── patch-ap-interactions-cleanup-preserve ──────────────────────────────── - { - name: "ap-interactions-cleanup-preserve", - files: apPath("lib/timeline-cleanup.js"), - marker: "// [patch] ap-interactions-cleanup-preserve", - oldSnippet: ` const removedUids = toDelete.map((item) => item.uid).filter(Boolean); - - // Delete old timeline items by UID - const deleteResult = await collections.ap_timeline.deleteMany({ - _id: { $in: toDelete.map((item) => item._id) }, - }); - - // Clean up stale interactions for removed items - let interactionsRemoved = 0; - if (removedUids.length > 0 && collections.ap_interactions) { - const interactionResult = await collections.ap_interactions.deleteMany({ - objectUrl: { $in: removedUids }, - }); - interactionsRemoved = interactionResult.deletedCount || 0; - }`, - newSnippet: ` const removedUids = toDelete.map((item) => item.uid).filter(Boolean); - - // Preserve items the user has interacted with (liked, bookmarked, boosted). // [patch] ap-interactions-cleanup-preserve - // Deleting them would silently remove entries from the Favourites/Bookmarks pages. - let interactedUids = new Set(); - if (removedUids.length > 0 && collections.ap_interactions) { - const interacted = await collections.ap_interactions.distinct("objectUrl"); - interactedUids = new Set(interacted); - } - const itemsToDelete = toDelete.filter((item) => !interactedUids.has(item.uid)); - const uidsToDelete = itemsToDelete.map((item) => item.uid).filter(Boolean); - - if (!itemsToDelete.length) { - return { removed: 0, interactionsRemoved: 0 }; - } - - // Delete old timeline items by UID - const deleteResult = await collections.ap_timeline.deleteMany({ - _id: { $in: itemsToDelete.map((item) => item._id) }, - }); - - // Clean up stale interactions for removed items - let interactionsRemoved = 0; - if (uidsToDelete.length > 0 && collections.ap_interactions) { - const interactionResult = await collections.ap_interactions.deleteMany({ - objectUrl: { $in: uidsToDelete }, - }); - interactionsRemoved = interactionResult.deletedCount || 0; - }`, - }, - - // ── patch-ap-interactions-accounts-uid ─────────────────────────────────── - { - name: "ap-interactions-accounts-uid", - files: [ - "node_modules/@rmdes/indiekit-endpoint-activitypub/lib/mastodon/routes/accounts.js", - "node_modules/@indiekit/indiekit/node_modules/@rmdes/indiekit-endpoint-activitypub/lib/mastodon/routes/accounts.js", - ], - marker: "// [patch] ap-interactions-accounts-uid", - oldSnippet: ` const lookupUrls = items.flatMap((i) => [i.uid, i.url].filter(Boolean)); - if (lookupUrls.length > 0) { - const interactions = await collections.ap_interactions - .find({ objectUrl: { $in: lookupUrls } }) - .toArray(); - for (const ix of interactions) { - if (ix.type === "like") favouritedIds.add(ix.objectUrl); - else if (ix.type === "boost") rebloggedIds.add(ix.objectUrl); - else if (ix.type === "bookmark") bookmarkedIds.add(ix.objectUrl); - } - }`, - newSnippet: ` const urlToUid = new Map(); // [patch] ap-interactions-accounts-uid - for (const i of items) { - if (i.uid) { - urlToUid.set(i.uid, i.uid); - if (i.url && i.url !== i.uid) urlToUid.set(i.url, i.uid); - } - } - const lookupUrls = [...urlToUid.keys()]; - if (lookupUrls.length > 0) { - const interactions = await collections.ap_interactions - .find({ objectUrl: { $in: lookupUrls } }) - .toArray(); - for (const ix of interactions) { - const uid = urlToUid.get(ix.objectUrl) || ix.objectUrl; - if (ix.type === "like") favouritedIds.add(uid); - else if (ix.type === "boost") rebloggedIds.add(uid); - else if (ix.type === "bookmark") bookmarkedIds.add(uid); - } - }`, - }, - - // ── patch-inbox-ignore-view-activity: import ────────────────────────────── - { - name: "inbox-ignore-view-activity-import", - files: apPath("lib/inbox-listeners.js"), - marker: "// View imported", - oldSnippet: ` Undo, - Update, -} from "@fedify/fedify/vocab";`, - newSnippet: ` Undo, - Update, - View, // View imported -} from "@fedify/fedify/vocab";`, - }, - - // ── patch-inbox-ignore-view-activity: handler ──────────────────────────── - // Two variants: one for files with the console.warn block (original upstream), - // one for files with the simpler enqueueActivity-only structure (evolved upstream). - { - name: "inbox-ignore-view-activity-handler", - files: apPath("lib/inbox-listeners.js"), - marker: "// PeerTube View handler", - oldSnippet: ` console.info(\`[ActivityPub] Flag received from \${reporterName} — \${reportedIds.length} objects reported\`); - } catch (error) { - console.warn("[ActivityPub] Flag handler error:", error.message); - } - }); -}`, - newSnippet: ` console.info(\`[ActivityPub] Flag received from \${reporterName} — \${reportedIds.length} objects reported\`); - } catch (error) { - console.warn("[ActivityPub] Flag handler error:", error.message); - } - }) - // ── View (PeerTube watch) ───────────────────────────────────────────── - // PeerTube broadcasts View (WatchAction) activities to all followers - // whenever someone watches a video. Fedify has no built-in handler for - // this type, producing noisy "Unsupported activity type" log errors. - // Silently accept and discard. // PeerTube View handler - .on(View, async () => {}); -}`, - }, - { - name: "inbox-ignore-view-activity-handler-v2", - files: apPath("lib/inbox-listeners.js"), - marker: "// PeerTube View handler", - oldSnippet: ` await enqueueActivity(collections, { - activityType: "Flag", - actorUrl, - rawJson: await flag.toJsonLd(), - }); - }); -}`, - newSnippet: ` await enqueueActivity(collections, { - activityType: "Flag", - actorUrl, - rawJson: await flag.toJsonLd(), - }); - }) - // ── View (PeerTube watch) ───────────────────────────────────────────── - // PeerTube broadcasts View (WatchAction) activities to all followers - // whenever someone watches a video. Fedify has no built-in handler for - // this type, producing noisy "Unsupported activity type" log errors. - // Silently accept and discard. // PeerTube View handler - .on(View, async () => {}); -}`, - }, - - // ── patch-inbox-skip-view-activity-parse: upgrade v1 raw-body fix ───────── - { - name: "from-express-request-raw-body-fix", - files: apPath("lib/federation-bridge.js"), - marker: "req._rawBody || JSON.stringify", - oldSnippet: ` // PeerTube activity+json body fix - if (ct.includes("application/json") || ct.includes("activity+json") || ct.includes("ld+json")) { - body = JSON.stringify(req.body); - } else if (ct.includes("application/x-www-form-urlencoded")) {`, - newSnippet: ` // PeerTube activity+json body fix - if (ct.includes("application/json") || ct.includes("activity+json") || ct.includes("ld+json")) { - // Use original raw bytes when available (set by createFedifyMiddleware buffer guard). - // JSON.stringify() changes byte layout, breaking Fedify's HTTP Signature Digest check. - body = req._rawBody || JSON.stringify(req.body); // raw body digest fix - } else if (ct.includes("application/x-www-form-urlencoded")) {`, - }, - - // ── patch-inbox-skip-view-activity-parse: middleware buffer _rawBody fix ── - { - name: "inbox-buffer-raw-body-fix", - files: apPath("lib/federation-bridge.js"), - marker: "req._rawBody = _raw", - oldSnippet: ` const _chunks = []; - for await (const _chunk of req) { - _chunks.push(Buffer.isBuffer(_chunk) ? _chunk : Buffer.from(_chunk)); - } - try { - req.body = JSON.parse(Buffer.concat(_chunks).toString("utf8"));`, - newSnippet: ` const _chunks = []; - for await (const _chunk of req) { - _chunks.push(Buffer.isBuffer(_chunk) ? _chunk : Buffer.from(_chunk)); - } - const _raw = Buffer.concat(_chunks); // raw body digest fix - req._rawBody = _raw; // Preserve original bytes for Fedify HTTP Signature Digest verification - try { - req.body = JSON.parse(_raw.toString("utf8"));`, - }, - -]; - -// ── Run standard patches ─────────────────────────────────────────────────────── - let total = 0; -for (const p of PATCHES) { - let done = false; - for (const f of p.files) { - const r = await applyPatch(f, p.marker, p.oldSnippet, p.newSnippet); - if (r === "applied") { - console.log(`[postinstall] ${SCRIPT}: applied ${p.name} to ${f}`); - total++; done = true; break; - } else if (r === "already_applied") { - console.log(`[postinstall] ${SCRIPT}: ${p.name} already applied in ${f}`); - done = true; break; - } else if (r === "snippet_not_found") { - console.warn(`[postinstall] ${SCRIPT}: ${p.name} — snippet not found in ${f}, skipping`); - } +let ogDone = false; +for (const f of OG_CANDIDATES) { + if (!(await fileExists(f))) continue; + const src = await readFile(f, "utf8"); + if (src.includes(OG_MARKER)) { + console.log(`[postinstall] ${SCRIPT}: ap-og-image already applied in ${f}`); + ogDone = true; break; } - if (!done) console.log(`[postinstall] ${SCRIPT}: ${p.name} — no target file found`); -} - -// ── Run og-image patch (regex-based, two replacements per file) ─────────────── - -{ - let ogDone = false; - for (const f of OG_CANDIDATES) { - if (!(await fileExists(f))) continue; - const src = await readFile(f, "utf8"); - if (src.includes(OG_MARKER)) { - console.log(`[postinstall] ${SCRIPT}: ap-og-image already applied in ${f}`); - ogDone = true; break; - } - let updated = src; - let changed = false; - if (CN_BLOCK_RE.test(updated)) { - updated = updated.replace(CN_BLOCK_RE, NEW_CN); - changed = true; - } else { - console.warn(`[postinstall] ${SCRIPT}: ap-og-image — jf2ToActivityStreams OG block not found in ${f}`); - } - if (AS2_BLOCK_RE.test(updated)) { - updated = updated.replace(AS2_BLOCK_RE, NEW_AS2); - changed = true; - } else { - console.warn(`[postinstall] ${SCRIPT}: ap-og-image — jf2ToAS2Activity OG block not found in ${f}`); - } - if (changed && updated !== src) { - await writeFile(f, updated, "utf8"); - console.log(`[postinstall] ${SCRIPT}: applied ap-og-image to ${f}`); - total++; ogDone = true; break; - } + let updated = src; + let changed = false; + if (CN_BLOCK_RE.test(updated)) { + updated = updated.replace(CN_BLOCK_RE, NEW_CN); + changed = true; + } else { + console.warn(`[postinstall] ${SCRIPT}: ap-og-image — jf2ToActivityStreams OG block not found in ${f}`); + } + if (AS2_BLOCK_RE.test(updated)) { + updated = updated.replace(AS2_BLOCK_RE, NEW_AS2); + changed = true; + } else { + console.warn(`[postinstall] ${SCRIPT}: ap-og-image — jf2ToAS2Activity OG block not found in ${f}`); + } + if (changed && updated !== src) { + await writeFile(f, updated, "utf8"); + console.log(`[postinstall] ${SCRIPT}: applied ap-og-image to ${f}`); + total++; ogDone = true; break; } - if (!ogDone) console.log(`[postinstall] ${SCRIPT}: ap-og-image — no target file found or no changes`); } +if (!ogDone) console.log(`[postinstall] ${SCRIPT}: ap-og-image — no target file found or no changes`); console.log(`[postinstall] ${SCRIPT}: done (${total} patch(es) applied)`); diff --git a/scripts/patch-ap-mastodon-notifications.mjs b/scripts/patch-ap-mastodon-notifications.mjs deleted file mode 100644 index a0d9d93d..00000000 --- a/scripts/patch-ap-mastodon-notifications.mjs +++ /dev/null @@ -1,86 +0,0 @@ -/** - * Patch: include notif.url in batchFetchStatuses so mention notifications - * resolve their associated status correctly. - * - * Root cause: - * batchFetchStatuses() only collected notif.targetUrl for the batch lookup. - * serializeNotification() looks up mentions by notif.url (the incoming - * reply URL), not notif.targetUrl (the own post being replied to). These - * are different URLs, so the statusMap never has an entry for the mention → - * fallback fires → status.id = notif._id.toString() (a notification ObjectId, - * not a timeline ObjectId) → Phanpy uses that ID for subsequent requests → - * GET /api/v1/statuses/:id/context returns 404 because ap_timeline has no - * document with that _id. - * - * Fix: - * Collect both notif.url and notif.targetUrl so the batch covers all URL - * shapes used by any notification type. - */ - -import { access, readFile, writeFile } from "node:fs/promises"; - -const AP_BASE = "@rmdes/indiekit-endpoint-activitypub"; -const AP_ROOTS = [ - `node_modules/${AP_BASE}`, - `node_modules/@indiekit/indiekit/node_modules/${AP_BASE}`, -]; - -function apPath(rel) { - return AP_ROOTS.map(r => `${r}/${rel}`); -} - -async function fileExists(p) { - try { await access(p); return true; } catch { return false; } -} - -async function applyPatch(filePath, marker, oldSnippet, newSnippet) { - if (!(await fileExists(filePath))) return "file_not_found"; - const src = await readFile(filePath, "utf8"); - if (src.includes(marker)) return "already_applied"; - if (!src.includes(oldSnippet)) return "snippet_not_found"; - await writeFile(filePath, src.replace(oldSnippet, newSnippet), "utf8"); - return "applied"; -} - -const SCRIPT = "patch-ap-mastodon-notifications"; - -const PATCHES = [ - { - name: "ap-notifications-status-lookup", - files: apPath("lib/mastodon/routes/notifications.js"), - marker: "// [patch] ap-notifications-status-lookup", - oldSnippet: ` const targetUrls = [ - ...new Set( - notifications - .map((n) => n.targetUrl) - .filter(Boolean), - ), - ];`, - newSnippet: ` const targetUrls = [ // [patch] ap-notifications-status-lookup - ...new Set( - notifications - .flatMap((n) => [n.targetUrl, n.url]) - .filter(Boolean), - ), - ];`, - }, -]; - -let total = 0; -for (const p of PATCHES) { - let done = false; - for (const f of p.files) { - const r = await applyPatch(f, p.marker, p.oldSnippet, p.newSnippet); - if (r === "applied") { - console.log(`[postinstall] ${SCRIPT}: applied ${p.name} to ${f}`); - total++; done = true; break; - } else if (r === "already_applied") { - console.log(`[postinstall] ${SCRIPT}: ${p.name} already applied in ${f}`); - done = true; break; - } else if (r === "snippet_not_found") { - console.warn(`[postinstall] ${SCRIPT}: ${p.name} — snippet not found in ${f}, skipping`); - } - } - if (!done) console.log(`[postinstall] ${SCRIPT}: ${p.name} — no target file found`); -} -console.log(`[postinstall] ${SCRIPT}: done (${total} patch(es) applied)`); diff --git a/scripts/patch-ap-oauth-token-expiry-fix.mjs b/scripts/patch-ap-oauth-token-expiry-fix.mjs deleted file mode 100644 index 82ff2cd6..00000000 --- a/scripts/patch-ap-oauth-token-expiry-fix.mjs +++ /dev/null @@ -1,80 +0,0 @@ -/** - * Patch: fix OAuth access tokens expiring 10 minutes after login. - * - * Root cause: - * The authorization code document is created with expiresAt = now + 10 min - * (the authorization code TTL). When the access token is issued via the - * authorization_code grant, the existing document is updated in-place with - * $set: { accessToken, refreshToken, ... } but expiresAt is never cleared. - * - * The tokenRequired middleware then rejects the token once 10 minutes pass: - * if (token.expiresAt && token.expiresAt < new Date()) return null; - * - * The refresh_token grant already has $unset: { expiresAt: "" } — this patch - * applies the same fix to the authorization_code exchange path. - * - * Symptom: Mastodon clients (Phanpy, etc.) receive 200 on /api/v2/instance but - * 401 on all authenticated endpoints ~10 minutes after logging in. - */ - -import { access, readFile, writeFile } from "node:fs/promises"; - -const MARKER = "// [patch] ap-oauth-token-expiry-fix"; - -const candidates = [ - "node_modules/@rmdes/indiekit-endpoint-activitypub/lib/mastodon/routes/oauth.js", - "node_modules/@indiekit/indiekit/node_modules/@rmdes/indiekit-endpoint-activitypub/lib/mastodon/routes/oauth.js", -]; - -const OLD_SNIPPET = ` { - $set: { - accessToken, - refreshToken, - refreshExpiresAt: new Date(Date.now() + REFRESH_TOKEN_TTL), - }, - },`; - -const NEW_SNIPPET = ` { - $set: { - accessToken, - refreshToken, - refreshExpiresAt: new Date(Date.now() + REFRESH_TOKEN_TTL), - }, - $unset: { expiresAt: "" }, ${MARKER} - },`; - -async function exists(p) { - try { await access(p); return true; } catch { return false; } -} - -let totalPatched = 0; -let totalChecked = 0; - -for (const filePath of candidates) { - if (!(await exists(filePath))) continue; - totalChecked++; - - const source = await readFile(filePath, "utf8"); - if (source.includes(MARKER)) { - console.log(`[postinstall] patch-ap-oauth-token-expiry-fix: already applied to ${filePath}`); - continue; - } - - if (!source.includes(OLD_SNIPPET)) { - console.warn(`[postinstall] patch-ap-oauth-token-expiry-fix: snippet not found in ${filePath} (upstream changed?)`); - continue; - } - - const updated = source.replace(OLD_SNIPPET, NEW_SNIPPET); - await writeFile(filePath, updated, "utf8"); - console.log(`[postinstall] Applied patch-ap-oauth-token-expiry-fix to ${filePath}`); - totalPatched++; -} - -if (totalChecked === 0) { - console.log("[postinstall] patch-ap-oauth-token-expiry-fix: no target files found"); -} else if (totalPatched === 0) { - console.log("[postinstall] patch-ap-oauth-token-expiry-fix: already up to date"); -} else { - console.log(`[postinstall] patch-ap-oauth-token-expiry-fix: patched ${totalPatched} file(s)`); -} diff --git a/scripts/patch-ap-syndication.mjs b/scripts/patch-ap-syndication.mjs index eff241e4..04b74c21 100644 --- a/scripts/patch-ap-syndication.mjs +++ b/scripts/patch-ap-syndication.mjs @@ -2,15 +2,14 @@ * Consolidated patch: AP syndication guards in syndicator.js. * * Absorbs: - * - patch-ap-syndicate-dedup (prevent double-posting) * - patch-ap-syndicate-skip-checkin (skip location checkins) * - patch-ap-syndicate-skip-draft (skip draft posts) * - patch-ap-syndicate-skip-unlisted (skip unlisted posts) * - * ORDER MATTERS: dedup → checkin → draft → unlisted - * checkin/draft/unlisted chain (each newSnippet is the next oldSnippet). - * dedup patches a separate anchor and must run first (before checkin - * rewrites the surrounding context). + * Note: patch-ap-syndicate-dedup integrated into source directly. + * + * ORDER MATTERS: checkin → draft → unlisted + * Each newSnippet is the next oldSnippet. */ import { access, readFile, writeFile } from "node:fs/promises"; @@ -45,31 +44,7 @@ async function applyPatch(filePath, marker, oldSnippet, newSnippet) { const SCRIPT = "patch-ap-syndication"; const PATCHES = [ - // ORDER: dedup → checkin → draft → unlisted (each builds on prior output) - - { - name: "dedup", - files: apPath("lib/syndicator.js"), - marker: "// [patch] ap-syndicate-dedup", - oldSnippet: ` try { - const actorUrl = plugin._getActorUrl();`, - newSnippet: ` // Dedup: skip re-federation if we've already sent an activity for this URL. // [patch] ap-syndicate-dedup - // ap_activities is the authoritative record of "already federated". - try { - const existingActivity = await plugin._collections.ap_activities?.findOne({ - direction: "outbound", - type: { $in: ["Create", "Announce", "Update"] }, - objectUrl: properties.url, - }); - if (existingActivity) { - console.info(\`[ActivityPub] Skipping duplicate syndication for \${properties.url} — already sent (\${existingActivity.type})\`); - return properties.url || undefined; - } - } catch { /* DB unavailable — proceed */ } - - try { - const actorUrl = plugin._getActorUrl();`, - }, + // ORDER: checkin → draft → unlisted (each builds on prior output) { name: "skip-checkin",