diff --git a/scripts/patch-ap-account-lookup-cache-fallback.mjs b/scripts/patch-ap-account-lookup-cache-fallback.mjs
deleted file mode 100644
index a451caf6..00000000
--- a/scripts/patch-ap-account-lookup-cache-fallback.mjs
+++ /dev/null
@@ -1,110 +0,0 @@
-/**
- * Patch: check ap_actor_cache in GET /api/v1/accounts/:id before returning 404.
- *
- * Root cause:
- * resolveActorData() only searches followers, following, and ap_timeline.
- * When a user searches for a brand-new remote account (resolve=true), the
- * search call populates ap_actor_cache but the actor isn't in any of those
- * three collections yet. So the next request — GET /api/v1/accounts/:id from
- * Phanpy to load the profile page — returns 404, leaving the follow button
- * non-functional even though the cache entry is present.
- *
- * Fix:
- * After resolveActorData returns null, check the in-memory idToUrl map and
- * ap_actor_cache (both populated by resolveRemoteAccount). If a URL is found,
- * call resolveRemoteAccount directly and return the result.
- */
-
-import { access, readFile, writeFile } from "node:fs/promises";
-
-const candidates = [
- "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",
-];
-
-const MARKER = "// [patch] ap-account-lookup-cache-fallback";
-
-const OLD_SNIPPET = ` return res.status(404).json({ error: "Record not found" });
- } catch (error) {
- next(error);
- }
-});
-
-// ─── GET /api/v1/accounts/:id/statuses ──────────────────────────────────────`;
-
-const NEW_SNIPPET = ` // Check ap_actor_cache — populated by resolveRemoteAccount after search/lookup [patch] ap-account-lookup-cache-fallback
- const actorCacheUrl = getActorUrlFromId(id)
- || (collections.ap_actor_cache ? (await collections.ap_actor_cache.findOne({ _id: id }))?.actorUrl : null);
- if (actorCacheUrl) {
- const remoteAccount = await resolveRemoteAccount(actorCacheUrl, pluginOptions, baseUrl, collections);
- if (remoteAccount) return res.json(remoteAccount);
- }
-
- return res.status(404).json({ error: "Record not found" });
- } catch (error) {
- next(error);
- }
-});
-
-// ─── GET /api/v1/accounts/:id/statuses ──────────────────────────────────────`;
-
-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-account-lookup-cache-fallback: already applied to ${filePath}`);
- continue;
- }
-
- // Upstream may have already added a cachedUrl block in GET /api/v1/accounts/:id
- // (e.g. fix: ap actor cache commit). If so, the functionality is already present — skip.
- if (source.includes("const cachedUrl = getActorUrlFromId(id)")) {
- console.log(`[postinstall] patch-ap-account-lookup-cache-fallback: already fixed upstream in ${filePath}`);
- continue;
- }
-
- if (!source.includes(OLD_SNIPPET)) {
- console.warn(
- `[postinstall] patch-ap-account-lookup-cache-fallback: target snippet not found in ${filePath} — skipping`,
- );
- continue;
- }
-
- const updated = source.replace(OLD_SNIPPET, NEW_SNIPPET);
-
- if (updated === source) {
- console.log(`[postinstall] patch-ap-account-lookup-cache-fallback: no changes applied to ${filePath}`);
- continue;
- }
-
- await writeFile(filePath, updated, "utf8");
- patched += 1;
- console.log(`[postinstall] Applied patch-ap-account-lookup-cache-fallback to ${filePath}`);
-}
-
-if (checked === 0) {
- console.log("[postinstall] patch-ap-account-lookup-cache-fallback: no target files found");
-} else if (patched === 0) {
- console.log("[postinstall] patch-ap-account-lookup-cache-fallback: already up to date");
-} else {
- console.log(
- `[postinstall] patch-ap-account-lookup-cache-fallback: patched ${patched}/${checked} file(s)`,
- );
-}
diff --git a/scripts/patch-ap-actor-cache-await.mjs b/scripts/patch-ap-actor-cache-await.mjs
deleted file mode 100644
index a65876f8..00000000
--- a/scripts/patch-ap-actor-cache-await.mjs
+++ /dev/null
@@ -1,100 +0,0 @@
-/**
- * Patch: await the ap_actor_cache write in resolveRemoteAccount.
- *
- * Root cause:
- * resolveRemoteAccount() populates the in-memory idToUrl map synchronously
- * (via cacheAccountStats), so same-session follow requests find the actor.
- * But the ap_actor_cache MongoDB write is fire-and-forget. If the server
- * restarts between search and follow (or if the client caches the search
- * result), the in-memory cache is gone and the MongoDB fallback may not
- * have been written yet → resolveActorUrl returns null → 404.
- *
- * Fix:
- * Await the ap_actor_cache upsert (still catch errors so it's non-fatal).
- * This ensures the entry is in MongoDB before the search response is sent,
- * making follow reliable even after server restarts.
- */
-
-import { access, readFile, writeFile } from "node:fs/promises";
-
-const candidates = [
- "node_modules/@rmdes/indiekit-endpoint-activitypub/lib/mastodon/helpers/resolve-account.js",
- "node_modules/@indiekit/indiekit/node_modules/@rmdes/indiekit-endpoint-activitypub/lib/mastodon/helpers/resolve-account.js",
-];
-
-const MARKER = "// [patch] ap-actor-cache-await";
-
-const OLD_SNIPPET = ` // 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
- }`;
-
-const NEW_SNIPPET = ` // 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
- }`;
-
-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-actor-cache-await: already applied to ${filePath}`);
- continue;
- }
-
- if (!source.includes(OLD_SNIPPET)) {
- console.warn(
- `[postinstall] patch-ap-actor-cache-await: target snippet not found in ${filePath} — skipping`,
- );
- continue;
- }
-
- const updated = source.replace(OLD_SNIPPET, NEW_SNIPPET);
-
- if (updated === source) {
- console.log(`[postinstall] patch-ap-actor-cache-await: no changes applied to ${filePath}`);
- continue;
- }
-
- await writeFile(filePath, updated, "utf8");
- patched += 1;
- console.log(`[postinstall] Applied patch-ap-actor-cache-await to ${filePath}`);
-}
-
-if (checked === 0) {
- console.log("[postinstall] patch-ap-actor-cache-await: no target files found");
-} else if (patched === 0) {
- console.log("[postinstall] patch-ap-actor-cache-await: already up to date");
-} else {
- console.log(
- `[postinstall] patch-ap-actor-cache-await: patched ${patched}/${checked} file(s)`,
- );
-}
diff --git a/scripts/patch-ap-compose-default-checked.mjs b/scripts/patch-ap-compose-default-checked.mjs
deleted file mode 100644
index 5f5520eb..00000000
--- a/scripts/patch-ap-compose-default-checked.mjs
+++ /dev/null
@@ -1,90 +0,0 @@
-/**
- * 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)`);
-}
diff --git a/scripts/patch-ap-federation-bridge-base-url.mjs b/scripts/patch-ap-federation-bridge-base-url.mjs
deleted file mode 100644
index cc5dd21e..00000000
--- a/scripts/patch-ap-federation-bridge-base-url.mjs
+++ /dev/null
@@ -1,156 +0,0 @@
-/**
- * Patch: override Fedify request URL with the configured publication URL.
- *
- * Root cause:
- * fromExpressRequest() in federation-bridge.js builds the Request URL as
- * `${req.protocol}://${req.get("host")}${req.originalUrl}`. Fedify only handles
- * requests whose URL matches its configured base URL (https://blog.giersig.eu).
- * If nginx does not forward `Host: blog.giersig.eu` and `X-Forwarded-Proto: https`,
- * the URL Fedify sees will be wrong (e.g. http://127.0.0.1:3000/...) and Fedify
- * calls next() → the request falls through to auth middleware → returns 302 to
- * the login page. This breaks webfinger, nodeinfo, actor lookups, and AP inbox
- * delivery for any server that cannot follow the redirect.
- *
- * Fix:
- * - Add an optional third parameter `publicationUrl` to createFedifyMiddleware().
- * - Pass it through to fromExpressRequest(), which uses it as the URL base when
- * provided, ignoring req.protocol / req.get("host") entirely.
- * - In index.js, pass `this._publicationUrl` to createFedifyMiddleware() so all
- * Fedify-delegated requests use the correct canonical URL.
- */
-
-import { access, readFile, writeFile } from "node:fs/promises";
-
-const MARKER = "// ap-base-url patch";
-
-const candidates = [
- "node_modules/@rmdes/indiekit-endpoint-activitypub/lib/federation-bridge.js",
- "node_modules/@indiekit/indiekit/node_modules/@rmdes/indiekit-endpoint-activitypub/lib/federation-bridge.js",
-];
-
-const indexCandidates = [
- "node_modules/@rmdes/indiekit-endpoint-activitypub/index.js",
- "node_modules/@indiekit/indiekit/node_modules/@rmdes/indiekit-endpoint-activitypub/index.js",
-];
-
-// ---------------------------------------------------------------------------
-// Patches for federation-bridge.js
-// ---------------------------------------------------------------------------
-
-const OLD_FROM_EXPRESS_SIG = `export function fromExpressRequest(req) {
- const url = \`\${req.protocol}://\${req.get("host")}\${req.originalUrl}\`;`;
-
-const NEW_FROM_EXPRESS_SIG = `export function fromExpressRequest(req, baseUrl) { // ap-base-url patch
- const url = baseUrl
- ? \`\${baseUrl.replace(/\\/$/, "")}\${req.originalUrl}\` // ap-base-url patch
- : \`\${req.protocol}://\${req.get("host")}\${req.originalUrl}\`;`;
-
-const OLD_MIDDLEWARE_SIG = `export function createFedifyMiddleware(federation, contextDataFactory) {`;
-
-const NEW_MIDDLEWARE_SIG = `export function createFedifyMiddleware(federation, contextDataFactory, publicationUrl) { // ap-base-url patch`;
-
-const OLD_FROM_EXPRESS_CALL = ` const request = fromExpressRequest(req);`;
-
-const NEW_FROM_EXPRESS_CALL = ` const request = fromExpressRequest(req, publicationUrl); // ap-base-url patch`;
-
-// ---------------------------------------------------------------------------
-// Patch for index.js
-// ---------------------------------------------------------------------------
-
-const OLD_INDEX_CALL = ` this._fedifyMiddleware = createFedifyMiddleware(federation, () => ({}));`;
-
-const NEW_INDEX_CALL = ` this._fedifyMiddleware = createFedifyMiddleware(federation, () => ({}), this._publicationUrl); // ap-base-url patch`;
-
-// ---------------------------------------------------------------------------
-
-async function exists(filePath) {
- try {
- await access(filePath);
- return true;
- } catch {
- return false;
- }
-}
-
-let patched = 0;
-let checked = 0;
-
-// Patch federation-bridge.js
-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-federation-bridge-base-url: already applied to ${filePath}`);
- continue;
- }
-
- let updated = source;
- let changed = false;
-
- if (updated.includes(OLD_FROM_EXPRESS_SIG)) {
- updated = updated.replace(OLD_FROM_EXPRESS_SIG, NEW_FROM_EXPRESS_SIG);
- changed = true;
- } else {
- console.warn(`[postinstall] patch-ap-federation-bridge-base-url: fromExpressRequest signature not found in ${filePath}`);
- }
-
- if (updated.includes(OLD_MIDDLEWARE_SIG)) {
- updated = updated.replace(OLD_MIDDLEWARE_SIG, NEW_MIDDLEWARE_SIG);
- changed = true;
- } else {
- console.warn(`[postinstall] patch-ap-federation-bridge-base-url: createFedifyMiddleware signature not found in ${filePath}`);
- }
-
- if (updated.includes(OLD_FROM_EXPRESS_CALL)) {
- updated = updated.replace(OLD_FROM_EXPRESS_CALL, NEW_FROM_EXPRESS_CALL);
- changed = true;
- } else {
- console.warn(`[postinstall] patch-ap-federation-bridge-base-url: fromExpressRequest call not found in ${filePath}`);
- }
-
- if (!changed || updated === source) {
- console.log(`[postinstall] patch-ap-federation-bridge-base-url: no changes applied to ${filePath}`);
- continue;
- }
-
- await writeFile(filePath, updated, "utf8");
- patched += 1;
- console.log(`[postinstall] Applied patch-ap-federation-bridge-base-url to ${filePath}`);
-}
-
-// Patch index.js
-for (const filePath of indexCandidates) {
- if (!(await exists(filePath))) continue;
- const source = await readFile(filePath, "utf8");
-
- if (source.includes(MARKER)) {
- console.log(`[postinstall] patch-ap-federation-bridge-base-url: index.js already patched at ${filePath}`);
- continue;
- }
-
- if (!source.includes(OLD_INDEX_CALL)) {
- console.warn(`[postinstall] patch-ap-federation-bridge-base-url: createFedifyMiddleware call not found in ${filePath}`);
- continue;
- }
-
- const updated = source.replace(OLD_INDEX_CALL, NEW_INDEX_CALL);
-
- if (updated === source) {
- console.log(`[postinstall] patch-ap-federation-bridge-base-url: no changes in index.js at ${filePath}`);
- continue;
- }
-
- await writeFile(filePath, updated, "utf8");
- patched += 1;
- console.log(`[postinstall] Applied patch-ap-federation-bridge-base-url (index.js) to ${filePath}`);
-}
-
-if (checked === 0) {
- console.log("[postinstall] patch-ap-federation-bridge-base-url: no target files found");
-} else if (patched === 0) {
- console.log("[postinstall] patch-ap-federation-bridge-base-url: already up to date");
-} else {
- console.log(`[postinstall] patch-ap-federation-bridge-base-url: patched ${patched} file(s)`);
-}
diff --git a/scripts/patch-ap-inbox-delivery-debug.mjs b/scripts/patch-ap-inbox-delivery-debug.mjs
deleted file mode 100644
index 6061ca18..00000000
--- a/scripts/patch-ap-inbox-delivery-debug.mjs
+++ /dev/null
@@ -1,116 +0,0 @@
-/**
- * Patch: add inbox delivery diagnostics.
- *
- * Problems:
- * 1. The ["fedify","federation","inbox"] LogTape category is hardcoded to
- * lowestLevel "fatal", hiding HTTP Signature verification failures (401s).
- * Remote servers that receive 401s stop retrying → activities are lost.
- * 2. No request-level logging for incoming inbox POSTs, so we can't tell
- * whether remote servers are even attempting delivery.
- *
- * Fix A (federation-setup.js):
- * Change inbox log category from "fatal" → "error".
- * Real verification failures (wrong key, clock skew, digest mismatch) surface.
- * High-volume 404/410 key-fetch warnings from deleted actors stay silent.
- *
- * Fix B (federation-bridge.js):
- * Add a console.info before fromExpressRequest() that logs every POST to an
- * inbox path (path + content-type + raw body length). Fires BEFORE Fedify's
- * signature check, confirming whether remote servers reach our inbox at all.
- * Guarded by AP_LOG_LEVEL=debug or AP_DEBUG=1 to keep production logs quiet.
- */
-
-import { access, readFile, writeFile } from "node:fs/promises";
-
-const MARKER_A = "// [patch] ap-inbox-delivery-debug-A";
-const MARKER_B = "// [patch] ap-inbox-delivery-debug-B";
-
-// ── Fix A: federation-setup.js — inbox logger level ──────────────────────────
-
-const setupCandidates = [
- "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_INBOX_LOGGER = ` {
- // Noise guard: HTTP Signature verification failures are expected for
- // incoming activities from servers with expired/gone keys (e.g. deleted
- // actors, migrated servers). These produce high log volume with no
- // actionable signal — suppress everything below fatal.
- category: ["fedify", "federation", "inbox"],
- sinks: ["console"],
- lowestLevel: "fatal",
- },`;
-
-const NEW_INBOX_LOGGER = ` {
- // Surfacing real verification failures (wrong key, clock skew, digest
- // mismatch) at "error" level while keeping high-volume key-fetch
- // 404/410 warnings from deleted actors silent. ${MARKER_A}
- category: ["fedify", "federation", "inbox"],
- sinks: ["console"],
- lowestLevel: "error",
- },`;
-
-// ── Fix B: federation-bridge.js — request-level inbox logging ────────────────
-
-const bridgeCandidates = [
- "node_modules/@rmdes/indiekit-endpoint-activitypub/lib/federation-bridge.js",
- "node_modules/@indiekit/indiekit/node_modules/@rmdes/indiekit-endpoint-activitypub/lib/federation-bridge.js",
-];
-
-// Insert a debug log right before "const request = fromExpressRequest(req, publicationUrl);"
-// This is patched by ap-base-url already so that comment marker is present.
-const OLD_BRIDGE_REQUEST = ` const request = fromExpressRequest(req, publicationUrl); // ap-base-url patch`;
-
-const NEW_BRIDGE_REQUEST = ` // Log incoming inbox POSTs before Fedify signature check. ${MARKER_B}
- // Enabled by AP_LOG_LEVEL=debug or AP_DEBUG=1.
- if (
- (process.env.AP_LOG_LEVEL === "debug" || process.env.AP_DEBUG === "1") &&
- req.method === "POST" &&
- (req.path.includes("/inbox") || req.path.includes("/users/"))
- ) {
- const _bct = (req.headers["content-type"] || "").split(";")[0].trim();
- const _bsz = req._rawBody?.length ?? (req.body ? "pre-parsed" : "none");
- console.info(\`[AP-inbox] POST \${req.path} ct=\${_bct} body=\${_bsz}B\`);
- }
- const request = fromExpressRequest(req, publicationUrl); // ap-base-url patch`;
-
-async function exists(p) {
- try { await access(p); return true; } catch { return false; }
-}
-
-async function applyPatch(candidates, oldSnippet, newSnippet, label, marker) {
- 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-delivery-debug: ${label} already applied to ${filePath}`);
- continue;
- }
- if (!source.includes(oldSnippet)) {
- console.warn(`[postinstall] patch-ap-inbox-delivery-debug: ${label} snippet not found in ${filePath}`);
- continue;
- }
- await writeFile(filePath, source.replace(oldSnippet, newSnippet), "utf8");
- patched++;
- console.log(`[postinstall] Applied patch-ap-inbox-delivery-debug (${label}) to ${filePath}`);
- }
- return { checked, patched };
-}
-
-const a = await applyPatch(setupCandidates, OLD_INBOX_LOGGER, NEW_INBOX_LOGGER, "inbox-logger-level", MARKER_A);
-const b = await applyPatch(bridgeCandidates, OLD_BRIDGE_REQUEST, NEW_BRIDGE_REQUEST, "bridge-request-log", MARKER_B);
-
-const total = a.checked + b.checked;
-const totalPatched = a.patched + b.patched;
-
-if (total === 0) {
- console.log("[postinstall] patch-ap-inbox-delivery-debug: no target files found");
-} else if (totalPatched === 0) {
- console.log("[postinstall] patch-ap-inbox-delivery-debug: already up to date");
-} else {
- console.log(`[postinstall] patch-ap-inbox-delivery-debug: patched ${totalPatched} file(s)`);
-}
diff --git a/scripts/patch-ap-inbox-publication-url.mjs b/scripts/patch-ap-inbox-publication-url.mjs
deleted file mode 100644
index dab36e10..00000000
--- a/scripts/patch-ap-inbox-publication-url.mjs
+++ /dev/null
@@ -1,115 +0,0 @@
-/**
- * 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)`);
-}
diff --git a/scripts/patch-ap-interactions-accounts-uid.mjs b/scripts/patch-ap-interactions-accounts-uid.mjs
deleted file mode 100644
index b9fa6ba7..00000000
--- a/scripts/patch-ap-interactions-accounts-uid.mjs
+++ /dev/null
@@ -1,98 +0,0 @@
-/**
- * Patch: fix interaction state in GET /api/v1/accounts/:id/statuses.
- *
- * Root cause:
- * The account statuses route loads interaction state (liked/boosted/bookmarked)
- * for the returned timeline items. It builds the lookup URL list correctly
- * (both item.uid and item.url), but then adds ix.objectUrl directly to the
- * favouritedIds/rebloggedIds/bookmarkedIds Sets. serializeStatus() checks
- * favouritedIds.has(item.uid) — so when ix.objectUrl === item.url and
- * item.url !== item.uid, the check fails and the post shows as not-liked.
- *
- * Fix:
- * Build a urlToUid map (same approach as loadInteractionState in timelines.js)
- * and resolve ix.objectUrl to the canonical uid before adding to the Sets.
- */
-
-import { access, readFile, writeFile } from "node:fs/promises";
-
-const MARKER = "// [patch] ap-interactions-accounts-uid";
-
-const candidates = [
- "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",
-];
-
-const OLD_SNIPPET = ` 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);
- }
- }`;
-
-const NEW_SNIPPET = ` const urlToUid = new Map(); ${MARKER}
- 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);
- }
- }`;
-
-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-interactions-accounts-uid: already applied to ${filePath}`);
- continue;
- }
-
- if (!source.includes(OLD_SNIPPET)) {
- console.warn(`[postinstall] patch-ap-interactions-accounts-uid: snippet not found in ${filePath}`);
- continue;
- }
-
- const updated = source.replace(OLD_SNIPPET, NEW_SNIPPET);
- await writeFile(filePath, updated, "utf8");
- patched += 1;
- console.log(`[postinstall] Applied patch-ap-interactions-accounts-uid to ${filePath}`);
-}
-
-if (checked === 0) {
- console.log("[postinstall] patch-ap-interactions-accounts-uid: no target files found");
-} else if (patched === 0) {
- console.log("[postinstall] patch-ap-interactions-accounts-uid: already up to date");
-} else {
- console.log(`[postinstall] patch-ap-interactions-accounts-uid: patched ${patched} file(s)`);
-}
diff --git a/scripts/patch-ap-interactions-cleanup-preserve.mjs b/scripts/patch-ap-interactions-cleanup-preserve.mjs
deleted file mode 100644
index 590d3e52..00000000
--- a/scripts/patch-ap-interactions-cleanup-preserve.mjs
+++ /dev/null
@@ -1,112 +0,0 @@
-/**
- * Patch: preserve liked/bookmarked/boosted items during timeline cleanup.
- *
- * Root cause:
- * cleanupTimeline() removes old remote posts from ap_timeline and also
- * deletes their ap_interactions entries. Any post the user has liked,
- * bookmarked, or boosted disappears from GET /api/v1/favourites and
- * GET /api/v1/bookmarks after the next daily cleanup run.
- *
- * Fix:
- * Before deleting, fetch the set of objectUrls that have ap_interactions
- * entries (likes, bookmarks, boosts). Filter those out of the deletion
- * candidate list so they are preserved in ap_timeline (and their
- * ap_interactions entries are never touched).
- */
-
-import { access, readFile, writeFile } from "node:fs/promises";
-
-const MARKER = "// [patch] ap-interactions-cleanup-preserve";
-
-const candidates = [
- "node_modules/@rmdes/indiekit-endpoint-activitypub/lib/timeline-cleanup.js",
- "node_modules/@indiekit/indiekit/node_modules/@rmdes/indiekit-endpoint-activitypub/lib/timeline-cleanup.js",
-];
-
-const OLD_SNIPPET = ` 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;
- }`;
-
-const NEW_SNIPPET = ` const removedUids = toDelete.map((item) => item.uid).filter(Boolean);
-
- // Preserve items the user has interacted with (liked, bookmarked, boosted). ${MARKER}
- // 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;
- }`;
-
-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-interactions-cleanup-preserve: already applied to ${filePath}`);
- continue;
- }
-
- if (!source.includes(OLD_SNIPPET)) {
- console.warn(`[postinstall] patch-ap-interactions-cleanup-preserve: snippet not found in ${filePath}`);
- continue;
- }
-
- const updated = source.replace(OLD_SNIPPET, NEW_SNIPPET);
- await writeFile(filePath, updated, "utf8");
- patched += 1;
- console.log(`[postinstall] Applied patch-ap-interactions-cleanup-preserve to ${filePath}`);
-}
-
-if (checked === 0) {
- console.log("[postinstall] patch-ap-interactions-cleanup-preserve: no target files found");
-} else if (patched === 0) {
- console.log("[postinstall] patch-ap-interactions-cleanup-preserve: already up to date");
-} else {
- console.log(`[postinstall] patch-ap-interactions-cleanup-preserve: patched ${patched} file(s)`);
-}
diff --git a/scripts/patch-ap-interactions-context-state.mjs b/scripts/patch-ap-interactions-context-state.mjs
deleted file mode 100644
index 6707995b..00000000
--- a/scripts/patch-ap-interactions-context-state.mjs
+++ /dev/null
@@ -1,104 +0,0 @@
-/**
- * Patch: load real interaction state for thread context ancestors/descendants.
- *
- * Root cause:
- * GET /api/v1/statuses/:id/context serializes all ancestors and descendants
- * using emptyInteractions (all empty Sets). Any post in the thread that the
- * user has liked/bookmarked/boosted shows as not-liked/not-bookmarked/not-boosted.
- *
- * Fix:
- * Replace emptyInteractions with a batch lookup against ap_interactions for
- * all items in the thread (same approach as loadInteractionState in timelines.js).
- */
-
-import { access, readFile, writeFile } from "node:fs/promises";
-
-const MARKER = "// [patch] ap-interactions-context-state";
-
-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 = ` // Serialize all items
- const emptyInteractions = {
- favouritedIds: new Set(),
- rebloggedIds: new Set(),
- bookmarkedIds: new Set(),
- pinnedIds: new Set(),
- };
-
- const allItems = [...ancestors, ...descendants];
- const { replyIdMap, replyAccountIdMap } = await resolveReplyIds(collections.ap_timeline, allItems);
- const serializeOpts = { baseUrl, ...emptyInteractions, replyIdMap, replyAccountIdMap };`;
-
-const NEW_SNIPPET = ` // Serialize all items
- const allItems = [...ancestors, ...descendants];
- const { replyIdMap, replyAccountIdMap } = await resolveReplyIds(collections.ap_timeline, allItems);
-
- // Load real interaction state for thread context ${MARKER}
- const ctxFavouritedIds = new Set();
- const ctxRebloggedIds = new Set();
- const ctxBookmarkedIds = new Set();
- if (allItems.length > 0 && collections.ap_interactions) {
- const ctxUrlToUid = new Map();
- for (const ci of allItems) {
- if (ci.uid) { ctxUrlToUid.set(ci.uid, ci.uid); }
- if (ci.url && ci.url !== ci.uid) { ctxUrlToUid.set(ci.url, ci.uid || ci.url); }
- }
- const ctxLookupUrls = [...ctxUrlToUid.keys()];
- if (ctxLookupUrls.length > 0) {
- const ctxInteractions = await collections.ap_interactions
- .find({ objectUrl: { $in: ctxLookupUrls } })
- .toArray();
- for (const ci of ctxInteractions) {
- const uid = ctxUrlToUid.get(ci.objectUrl) || ci.objectUrl;
- if (ci.type === "like") ctxFavouritedIds.add(uid);
- else if (ci.type === "boost") ctxRebloggedIds.add(uid);
- else if (ci.type === "bookmark") ctxBookmarkedIds.add(uid);
- }
- }
- }
- const serializeOpts = { baseUrl, favouritedIds: ctxFavouritedIds, rebloggedIds: ctxRebloggedIds, bookmarkedIds: ctxBookmarkedIds, pinnedIds: new Set(), replyIdMap, replyAccountIdMap };`;
-
-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-interactions-context-state: already applied to ${filePath}`);
- continue;
- }
-
- if (!source.includes(OLD_SNIPPET)) {
- console.warn(`[postinstall] patch-ap-interactions-context-state: snippet not found in ${filePath}`);
- continue;
- }
-
- const updated = source.replace(OLD_SNIPPET, NEW_SNIPPET);
- await writeFile(filePath, updated, "utf8");
- patched += 1;
- console.log(`[postinstall] Applied patch-ap-interactions-context-state to ${filePath}`);
-}
-
-if (checked === 0) {
- console.log("[postinstall] patch-ap-interactions-context-state: no target files found");
-} else if (patched === 0) {
- console.log("[postinstall] patch-ap-interactions-context-state: already up to date");
-} else {
- console.log(`[postinstall] patch-ap-interactions-context-state: patched ${patched} file(s)`);
-}
diff --git a/scripts/patch-ap-interactions-send-guard.mjs b/scripts/patch-ap-interactions-send-guard.mjs
deleted file mode 100644
index af3afbb6..00000000
--- a/scripts/patch-ap-interactions-send-guard.mjs
+++ /dev/null
@@ -1,120 +0,0 @@
-/**
- * Patch: guard sendActivity calls in likePost and boostPost with try/catch.
- *
- * Root cause:
- * - likePost: `ctx.sendActivity({ identifier }, recipient, like, ...)` is not
- * wrapped in try/catch. If Fedify or the underlying queue (Redis) throws, the
- * error propagates up through the route handler → 500, and the ap_interactions
- * DB write that follows is never reached (like not recorded locally either).
- *
- * - boostPost: `ctx.sendActivity({ identifier }, "followers", announce, ...)` is
- * the very first await in the function — also not wrapped. Same consequence:
- * any delivery error aborts the function before the DB write, returning 500.
- *
- * Fix:
- * Wrap both sendActivity calls in try/catch so federation delivery failures are
- * non-fatal. The interaction is still recorded in ap_interactions so the client
- * sees the correct UI state. Delivery of the boost to the original post author is
- * already guarded (separate try/catch added previously).
- */
-
-import { access, readFile, writeFile } from "node:fs/promises";
-
-const MARKER = "// [patch] ap-interactions-send-guard";
-
-const candidates = [
- "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",
-];
-
-// ── Change 1: guard likePost sendActivity ─────────────────────────────────────
-
-const OLD_LIKE_SEND = ` if (recipient) {
- await ctx.sendActivity({ identifier: handle }, recipient, like, {
- orderingKey: targetUrl,
- });
- }`;
-
-const NEW_LIKE_SEND = ` if (recipient) {
- try { ${MARKER}
- await ctx.sendActivity({ identifier: handle }, recipient, like, {
- orderingKey: targetUrl,
- });
- } catch { /* delivery failed — interaction still recorded locally */ }
- }`;
-
-// ── Change 2: guard boostPost sendActivity("followers") ──────────────────────
-
-const OLD_BOOST_SEND = ` // Send to followers
- await ctx.sendActivity({ identifier: handle }, "followers", announce, {
- preferSharedInbox: true,
- syncCollection: true,
- orderingKey: targetUrl,
- });`;
-
-const NEW_BOOST_SEND = ` // Send to followers
- try { ${MARKER}
- await ctx.sendActivity({ identifier: handle }, "followers", announce, {
- preferSharedInbox: true,
- syncCollection: true,
- orderingKey: targetUrl,
- });
- } catch { /* delivery failed — interaction still recorded locally */ }`;
-
-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-interactions-send-guard: already applied to ${filePath}`);
- continue;
- }
-
- let updated = source;
- let changes = 0;
-
- if (!updated.includes(OLD_LIKE_SEND)) {
- console.warn(`[postinstall] patch-ap-interactions-send-guard: likePost snippet not found in ${filePath}`);
- } else {
- updated = updated.replace(OLD_LIKE_SEND, NEW_LIKE_SEND);
- changes++;
- }
-
- if (!updated.includes(OLD_BOOST_SEND)) {
- console.warn(`[postinstall] patch-ap-interactions-send-guard: boostPost snippet not found in ${filePath}`);
- } else {
- updated = updated.replace(OLD_BOOST_SEND, NEW_BOOST_SEND);
- changes++;
- }
-
- if (changes === 0) {
- console.log(`[postinstall] patch-ap-interactions-send-guard: no changes in ${filePath}`);
- continue;
- }
-
- await writeFile(filePath, updated, "utf8");
- patched += 1;
- console.log(`[postinstall] Applied patch-ap-interactions-send-guard to ${filePath} (${changes}/2 changes)`);
-}
-
-if (checked === 0) {
- console.log("[postinstall] patch-ap-interactions-send-guard: no target files found");
-} else if (patched === 0) {
- console.log("[postinstall] patch-ap-interactions-send-guard: already up to date");
-} else {
- console.log(`[postinstall] patch-ap-interactions-send-guard: patched ${patched} file(s)`);
-}
diff --git a/scripts/patch-ap-mastodon-delete-fix.mjs b/scripts/patch-ap-mastodon-delete-fix.mjs
deleted file mode 100644
index df144a56..00000000
--- a/scripts/patch-ap-mastodon-delete-fix.mjs
+++ /dev/null
@@ -1,134 +0,0 @@
-/**
- * Patch: fix DELETE /api/v1/statuses/:id — two bugs.
- *
- * Bug 1 (ReferenceError — primary failure):
- * Line: await collections.ap_timeline.deleteOne({ _id: objectId });
- * `objectId` is never defined in the route handler. MongoDB ObjectId is
- * imported as the class `ObjectId`, not an instance. Every delete request
- * throws ReferenceError → 500 → the timeline entry is never removed.
- * Fix: use `item._id` (the document's own _id from findTimelineItemById).
- *
- * Bug 2 (AP Delete not broadcast):
- * The route calls postContent.delete() directly, bypassing the Indiekit
- * framework that normally invokes syndicator.delete(). No Delete(Note)
- * activity is ever sent to followers — they keep seeing the post.
- * Fix:
- * a) Add broadcastDelete: (url) => pluginRef.broadcastDelete(url) to
- * mastodonPluginOptions in index.js so the router can reach it.
- * b) Call req.app.locals.mastodonPluginOptions.broadcastDelete(postUrl)
- * in the delete route after the timeline entry is removed.
- */
-
-import { access, readFile, writeFile } from "node:fs/promises";
-
-const MARKER = "// [patch] ap-mastodon-delete-fix";
-
-const indexCandidates = [
- "node_modules/@rmdes/indiekit-endpoint-activitypub/index.js",
- "node_modules/@indiekit/indiekit/node_modules/@rmdes/indiekit-endpoint-activitypub/index.js",
-];
-
-const statusesCandidates = [
- "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",
-];
-
-// ── Change A: expose broadcastDelete in mastodonPluginOptions (index.js) ──────
-
-const OLD_PLUGIN_OPTS = ` loadRsaKey: () => pluginRef._loadRsaPrivateKey(),
- broadcastActorUpdate: () => pluginRef.broadcastActorUpdate(),`;
-
-const NEW_PLUGIN_OPTS = ` loadRsaKey: () => pluginRef._loadRsaPrivateKey(),
- broadcastActorUpdate: () => pluginRef.broadcastActorUpdate(),
- broadcastDelete: (url) => pluginRef.broadcastDelete(url), ${MARKER}`;
-
-// ── Change B: fix objectId → item._id (statuses.js) ──────────────────────────
-
-const OLD_DELETE_ONE = ` // Delete from timeline
- await collections.ap_timeline.deleteOne({ _id: objectId });`;
-
-const NEW_DELETE_ONE = ` // Delete from timeline
- await collections.ap_timeline.deleteOne({ _id: item._id }); ${MARKER}`;
-
-// ── Change C: call broadcastDelete after timeline removal (statuses.js) ───────
-// NOTE: Change B (objectId → item._id) was already fixed upstream.
-// OLD_AFTER_DELETE matches the upstream code directly (no MARKER dependency).
-
-const OLD_AFTER_DELETE = ` // Delete from timeline
- await collections.ap_timeline.deleteOne({ _id: item._id });
-
- // Clean up interactions`;
-
-const NEW_AFTER_DELETE = ` // Delete from timeline
- await collections.ap_timeline.deleteOne({ _id: item._id }); ${MARKER}
-
- // Broadcast AP Delete activity to followers ${MARKER}
- const _pluginOpts = req.app.locals.mastodonPluginOptions || {};
- if (_pluginOpts.broadcastDelete && postUrl) {
- _pluginOpts.broadcastDelete(postUrl).catch((err) =>
- console.warn(\`[Mastodon API] broadcastDelete failed for \${postUrl}: \${err.message}\`),
- );
- }
-
- // Clean up interactions`;
-
-async function exists(p) {
- try { await access(p); return true; } catch { return false; }
-}
-
-async function patchFile(filePath, replacements) {
- const source = await readFile(filePath, "utf8");
- if (source.includes(MARKER)) {
- console.log(`[postinstall] patch-ap-mastodon-delete-fix: already applied to ${filePath}`);
- return false;
- }
-
- let updated = source;
- let applied = 0;
-
- for (const { old: oldSnippet, newSnippet, label } of replacements) {
- if (!updated.includes(oldSnippet)) {
- console.warn(`[postinstall] patch-ap-mastodon-delete-fix: snippet "${label}" not found in ${filePath}`);
- continue;
- }
- updated = updated.replace(oldSnippet, newSnippet);
- applied++;
- }
-
- if (applied === 0) return false;
-
- await writeFile(filePath, updated, "utf8");
- console.log(`[postinstall] Applied patch-ap-mastodon-delete-fix to ${filePath} (${applied} change(s))`);
- return true;
-}
-
-let totalPatched = 0;
-let totalChecked = 0;
-
-// Patch index.js candidates (Change A)
-for (const filePath of indexCandidates) {
- if (!(await exists(filePath))) continue;
- totalChecked++;
- const ok = await patchFile(filePath, [
- { old: OLD_PLUGIN_OPTS, newSnippet: NEW_PLUGIN_OPTS, label: "broadcastDelete in pluginOptions" },
- ]);
- if (ok) totalPatched++;
-}
-
-// Patch statuses.js candidates (Change C only — Change B already fixed upstream)
-for (const filePath of statusesCandidates) {
- if (!(await exists(filePath))) continue;
- totalChecked++;
- const ok = await patchFile(filePath, [
- { old: OLD_AFTER_DELETE, newSnippet: NEW_AFTER_DELETE, label: "broadcastDelete call" },
- ]);
- if (ok) totalPatched++;
-}
-
-if (totalChecked === 0) {
- console.log("[postinstall] patch-ap-mastodon-delete-fix: no target files found");
-} else if (totalPatched === 0) {
- console.log("[postinstall] patch-ap-mastodon-delete-fix: already up to date");
-} else {
- console.log(`[postinstall] patch-ap-mastodon-delete-fix: patched ${totalPatched} file(s)`);
-}
diff --git a/scripts/patch-ap-mastodon-reply-threading.mjs b/scripts/patch-ap-mastodon-reply-threading.mjs
deleted file mode 100644
index 90618912..00000000
--- a/scripts/patch-ap-mastodon-reply-threading.mjs
+++ /dev/null
@@ -1,124 +0,0 @@
-/**
- * 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 30–120 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: \`
\${contentHtml}
\` }, ${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)`);
-}
diff --git a/scripts/patch-ap-mastodon-status-id.mjs b/scripts/patch-ap-mastodon-status-id.mjs
deleted file mode 100644
index 6a822c75..00000000
--- a/scripts/patch-ap-mastodon-status-id.mjs
+++ /dev/null
@@ -1,106 +0,0 @@
-/**
- * Patch: fix POST /api/v1/statuses response ID to match ap_timeline _id.
- *
- * Root cause:
- * The POST /api/v1/statuses handler returns `id: String(Date.now())` — the
- * wall-clock time when the response is sent. The ap_timeline item inserted by
- * patch-ap-mastodon-reply-threading uses addTimelineItem(), which stores the
- * item with a MongoDB-generated ObjectId as _id.
- *
- * When Phanpy/Elk receives the creation response and the user then replies to
- * that post, the client sends `in_reply_to_id: `.
- * The handler calls findTimelineItemById which does:
- * collection.findOne({ _id: new ObjectId(id) })
- * A String(Date.now()) value is not a valid ObjectId → lookup returns null →
- * inReplyTo = null → jf2["in-reply-to"] not set → getPostType returns "note"
- * instead of "reply".
- *
- * Fix:
- * 1. Capture the return value of addTimelineItem() into `_tlItem`.
- * 2. Use `_tlItem?._id?.toString() || String(Date.now())` as the status ID.
- *
- * This ensures the creation response ID matches what findTimelineItemById will
- * resolve in subsequent in_reply_to_id lookups.
- */
-
-import { access, readFile, writeFile } from "node:fs/promises";
-
-const MARKER = "// [patch] ap-mastodon-status-id";
-
-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",
-];
-
-// Change 1: capture return value of addTimelineItem
-const OLD_TL_INSERT = ` await addTimelineItem(collections, { // [patch] ap-mastodon-reply-threading`;
-const NEW_TL_INSERT = ` _tlItem = await addTimelineItem(collections, { // [patch] ap-mastodon-reply-threading ${MARKER}`;
-
-// Change 2: declare _tlItem before the try block
-const OLD_TRY = ` try { // [patch] ap-mastodon-reply-threading`;
-const NEW_TRY = ` let _tlItem = null; ${MARKER}
- try { // [patch] ap-mastodon-reply-threading`;
-
-// Change 3: use _tlItem._id as the status response ID
-const OLD_ID = ` id: String(Date.now()),`;
-const NEW_ID = ` id: _tlItem?._id?.toString() || String(Date.now()), ${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-status-id: already applied to ${filePath}`);
- continue;
- }
-
- let missing = false;
- for (const [label, snippet] of [
- ["await addTimelineItem", OLD_TL_INSERT],
- ["try block", OLD_TRY],
- ["response id", OLD_ID],
- ]) {
- if (!source.includes(snippet)) {
- console.warn(`[postinstall] patch-ap-mastodon-status-id: "${label}" snippet not found in ${filePath}`);
- missing = true;
- }
- }
- if (missing) continue;
-
- // Apply in order: TRY first (adds let _tlItem before try), then INSERT (changes await to assign), then ID
- let updated = source
- .replace(OLD_TRY, NEW_TRY)
- .replace(OLD_TL_INSERT, NEW_TL_INSERT)
- .replace(OLD_ID, NEW_ID);
-
- if (updated === source) {
- console.log(`[postinstall] patch-ap-mastodon-status-id: no changes in ${filePath}`);
- continue;
- }
-
- await writeFile(filePath, updated, "utf8");
- patched += 1;
- console.log(`[postinstall] Applied patch-ap-mastodon-status-id to ${filePath}`);
-}
-
-if (checked === 0) {
- console.log("[postinstall] patch-ap-mastodon-status-id: no target files found");
-} else if (patched === 0) {
- console.log("[postinstall] patch-ap-mastodon-status-id: already up to date");
-} else {
- console.log(`[postinstall] patch-ap-mastodon-status-id: patched ${patched} file(s)`);
-}
diff --git a/scripts/patch-ap-notifications-status-lookup.mjs b/scripts/patch-ap-notifications-status-lookup.mjs
deleted file mode 100644
index bc4ca4fb..00000000
--- a/scripts/patch-ap-notifications-status-lookup.mjs
+++ /dev/null
@@ -1,97 +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 candidates = [
- "node_modules/@rmdes/indiekit-endpoint-activitypub/lib/mastodon/routes/notifications.js",
- "node_modules/@indiekit/indiekit/node_modules/@rmdes/indiekit-endpoint-activitypub/lib/mastodon/routes/notifications.js",
-];
-
-const MARKER = "// [patch] ap-notifications-status-lookup";
-
-const OLD_SNIPPET = ` const targetUrls = [
- ...new Set(
- notifications
- .map((n) => n.targetUrl)
- .filter(Boolean),
- ),
- ];`;
-
-const NEW_SNIPPET = ` const targetUrls = [ // [patch] ap-notifications-status-lookup
- ...new Set(
- notifications
- .flatMap((n) => [n.targetUrl, n.url])
- .filter(Boolean),
- ),
- ];`;
-
-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-notifications-status-lookup: already applied to ${filePath}`);
- continue;
- }
-
- if (!source.includes(OLD_SNIPPET)) {
- console.warn(
- `[postinstall] patch-ap-notifications-status-lookup: target snippet not found in ${filePath} — skipping`,
- );
- continue;
- }
-
- const updated = source.replace(OLD_SNIPPET, NEW_SNIPPET);
-
- if (updated === source) {
- console.log(`[postinstall] patch-ap-notifications-status-lookup: no changes applied to ${filePath}`);
- continue;
- }
-
- await writeFile(filePath, updated, "utf8");
- patched += 1;
- console.log(`[postinstall] Applied patch-ap-notifications-status-lookup to ${filePath}`);
-}
-
-if (checked === 0) {
- console.log("[postinstall] patch-ap-notifications-status-lookup: no target files found");
-} else if (patched === 0) {
- console.log("[postinstall] patch-ap-notifications-status-lookup: already up to date");
-} else {
- console.log(
- `[postinstall] patch-ap-notifications-status-lookup: patched ${patched}/${checked} file(s)`,
- );
-}
diff --git a/scripts/patch-ap-og-image.mjs b/scripts/patch-ap-og-image.mjs
deleted file mode 100644
index d63c150e..00000000
--- a/scripts/patch-ap-og-image.mjs
+++ /dev/null
@@ -1,147 +0,0 @@
-/**
- * Patch: fix OG image URL generation in ActivityPub jf2-to-as2.js.
- *
- * Root cause (original):
- * jf2-to-as2.js used a date-based URL regex to extract the post slug, which
- * never matches this blog's flat URLs (/articles/slug/ vs /articles/2024/.../slug/).
- * The image property was never set, so no preview card reached Mastodon.
- *
- * Fix (v2 — this patch):
- * For posts with a photo attachment (properties.photo), use the photo URL
- * directly as the preview image — Eleventy does NOT generate /og/*.png for
- * photo post types.
- * For all other post types (replies, bookmarks, articles) fall back to
- * /og/{slug}.png, which Eleventy does generate.
- *
- * Both jf2ToActivityStreams() (plain JSON-LD) and jf2ToAS2Activity() (Fedify
- * vocab objects) are patched. Handles all known file states:
- * - Original upstream code (ogMatch / ogMatchF variable names)
- * - v1 patch (ogSlug / ogSlugF + // og-image fix comments)
- * - Already v2 (// og-image-v2 marker) → skip
- */
-
-import { access, readFile, writeFile } from "node:fs/promises";
-
-const candidates = [
- "node_modules/@rmdes/indiekit-endpoint-activitypub/lib/jf2-to-as2.js",
- "node_modules/@indiekit/indiekit/node_modules/@rmdes/indiekit-endpoint-activitypub/lib/jf2-to-as2.js",
-];
-
-const MARKER = "// og-image-v2";
-
-// ---------------------------------------------------------------------------
-// Match the OG image block in jf2ToActivityStreams.
-// Handles both the original upstream code (ogMatch) and the v1 patch (ogSlug).
-// ---------------------------------------------------------------------------
-const CN_BLOCK_RE =
- / const og(?:Slug|Match) = postUrl && postUrl\.match\([^\n]+\n if \(og(?:Slug|Match)\) \{[\s\S]*?\n \}/;
-
-// Match the OG image block in jf2ToAS2Activity (ogMatchF / ogSlugF variants).
-const AS2_BLOCK_RE =
- / const og(?:SlugF|MatchF) = postUrl && postUrl\.match\([^\n]+\n if \(og(?:SlugF|MatchF)\) \{[\s\S]*?\n \}/;
-
-// ---------------------------------------------------------------------------
-// v2 replacements:
-// 1. Use properties.photo[0] URL for photo posts (resolveMediaUrl handles
-// relative paths; guessMediaType detects jpeg/png/webp).
-// 2. Fall back to /og/{slug}.png for replies, bookmarks, articles.
-//
-// Template literal escaping (patch string → injected JS source):
-// \\/ → \/ (regex escaped slash)
-// [\\\w-] → [\w-] (word-char class)
-// \`\${ → `${ (start of injected template literal)
-// ---------------------------------------------------------------------------
-const NEW_CN = ` const _ogPhoto = properties.photo && asArray(properties.photo)[0]; // og-image-v2
- const _ogPhotoUrl = _ogPhoto && (typeof _ogPhoto === "string" ? _ogPhoto : _ogPhoto.url); // og-image-v2
- const ogSlug = postUrl && postUrl.match(/\\/([\\\w-]+)\\/?$/)?.[1]; // og-image-v2
- const _ogUrl = _ogPhotoUrl
- ? resolveMediaUrl(_ogPhotoUrl, publicationUrl) // og-image-v2
- : ogSlug ? \`\${publicationUrl.replace(/\\/$/, "")}/og/\${ogSlug}.png\` : null; // og-image-v2
- if (_ogUrl) { // og-image-v2
- object.image = {
- type: "Image",
- url: _ogUrl, // og-image-v2
- mediaType: _ogPhotoUrl ? guessMediaType(_ogUrl) : "image/png", // og-image-v2
- };
- }`;
-
-const NEW_AS2 = ` const _ogPhotoF = properties.photo && asArray(properties.photo)[0]; // og-image-v2
- const _ogPhotoUrlF = _ogPhotoF && (typeof _ogPhotoF === "string" ? _ogPhotoF : _ogPhotoF.url); // og-image-v2
- const ogSlugF = postUrl && postUrl.match(/\\/([\\\w-]+)\\/?$/)?.[1]; // og-image-v2
- const _ogUrlF = _ogPhotoUrlF
- ? resolveMediaUrl(_ogPhotoUrlF, publicationUrl) // og-image-v2
- : ogSlugF ? \`\${publicationUrl.replace(/\\/$/, "")}/og/\${ogSlugF}.png\` : null; // og-image-v2
- if (_ogUrlF) { // og-image-v2
- noteOptions.image = new Image({
- url: new URL(_ogUrlF), // og-image-v2
- mediaType: _ogPhotoUrlF ? guessMediaType(_ogUrlF) : "image/png", // og-image-v2
- });
- }`;
-
-// ---------------------------------------------------------------------------
-
-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-og-image: already applied to ${filePath}`);
- continue;
- }
-
- let updated = source;
- let changed = false;
-
- // Fix the jf2ToActivityStreams OG block
- if (CN_BLOCK_RE.test(updated)) {
- updated = updated.replace(CN_BLOCK_RE, NEW_CN);
- changed = true;
- } else {
- console.warn(
- `[postinstall] patch-ap-og-image: jf2ToActivityStreams OG block not found in ${filePath} — skipping`,
- );
- }
-
- // Fix the jf2ToAS2Activity OG block
- if (AS2_BLOCK_RE.test(updated)) {
- updated = updated.replace(AS2_BLOCK_RE, NEW_AS2);
- changed = true;
- } else {
- console.warn(
- `[postinstall] patch-ap-og-image: jf2ToAS2Activity OG block not found in ${filePath} — skipping`,
- );
- }
-
- if (!changed || updated === source) {
- console.log(`[postinstall] patch-ap-og-image: no changes applied to ${filePath}`);
- continue;
- }
-
- await writeFile(filePath, updated, "utf8");
- patched += 1;
- console.log(`[postinstall] Applied patch-ap-og-image to ${filePath}`);
-}
-
-if (checked === 0) {
- console.log("[postinstall] patch-ap-og-image: no target files found");
-} else if (patched === 0) {
- console.log("[postinstall] patch-ap-og-image: already up to date");
-} else {
- console.log(`[postinstall] patch-ap-og-image: patched ${patched}/${checked} file(s)`);
-}
diff --git a/scripts/patch-ap-repost-announce-fix.mjs b/scripts/patch-ap-repost-announce-fix.mjs
deleted file mode 100644
index c26c86b1..00000000
--- a/scripts/patch-ap-repost-announce-fix.mjs
+++ /dev/null
@@ -1,123 +0,0 @@
-/**
- * Patch: fix repost syndication to ActivityPub.
- *
- * Previously, any repost sent an Announce activity with the repost-of URL as
- * the object — even when that URL is a plain web article (not an AP object).
- * Remote servers receive the Announce but cannot resolve the object, so the
- * boost never appears on followers' timelines.
- *
- * The same problem applies to Likes, where the fix already exists: check
- * isApUrl() first. This patch applies the same logic to reposts:
- *
- * - repost-of is an AP URL → proper Announce with id + cc (native boost)
- * - repost-of is NOT an AP URL → fall through to Create(Note) which renders
- * as "🔁 " on the fediverse (same as the with-commentary path)
- *
- * Also adds missing `id` and `cc: followers` to the Announce (the interactive
- * boostPost() method already generates these; the syndication path did not).
- */
-import { access, readFile, writeFile } from "node:fs/promises";
-
-const MARKER = "// [patch] ap-repost-announce-fix";
-
-const candidates = [
- "node_modules/@rmdes/indiekit-endpoint-activitypub/lib/jf2-to-as2.js",
- "node_modules/@indiekit/indiekit/node_modules/@rmdes/indiekit-endpoint-activitypub/lib/jf2-to-as2.js",
-];
-
-const OLD_SNIPPET = ` // 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.
- }`;
-
-const NEW_SNIPPET = ` // 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)) { ${MARKER}
- 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.
- }`;
-
-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)) {
- continue;
- }
-
- if (!source.includes(OLD_SNIPPET)) {
- console.warn(
- `[postinstall] Skipping ap-repost-announce-fix patch for ${filePath}: upstream format changed`,
- );
- continue;
- }
-
- const updated = source.replace(OLD_SNIPPET, NEW_SNIPPET);
- await writeFile(filePath, updated, "utf8");
- patched += 1;
-}
-
-if (checked === 0) {
- console.log("[postinstall] No AP jf2-to-as2 files found for repost announce fix patch");
-} else if (patched === 0) {
- console.log("[postinstall] ap-repost-announce-fix patch already applied");
-} else {
- console.log(
- `[postinstall] Patched AP repost announce fix in ${patched} file(s)`,
- );
-}
diff --git a/scripts/patch-ap-resolve-account-timeout-safe.mjs b/scripts/patch-ap-resolve-account-timeout-safe.mjs
deleted file mode 100644
index cb9b2823..00000000
--- a/scripts/patch-ap-resolve-account-timeout-safe.mjs
+++ /dev/null
@@ -1,116 +0,0 @@
-/**
- * Patch: fix unhandled rejection crash + parallelise collection count fetches.
- *
- * Root cause (crash):
- * withTimeout() races the original promise against a 5 s timer.
- * When the timer fires first, the original getFollowers() / getFollowing() /
- * getOutbox() promise is still running with no .catch() handler. If it later
- * rejects (e.g. network error, TLS failure), Node.js ≥15 treats it as an
- * unhandled rejection and crashes the process → nginx sees "Connection refused".
- *
- * Fix:
- * Call promise.catch(() => {}) before racing so the rejection is always
- * "handled", even if we never observe the result.
- *
- * Bonus — parallel fetches:
- * The three collection-count fetches were sequential: worst case 3 × 5 s = 15 s.
- * Replaced with Promise.allSettled so all three run concurrently; max wait = 5 s.
- */
-
-import { access, readFile, writeFile } from "node:fs/promises";
-
-const candidates = [
- "node_modules/@rmdes/indiekit-endpoint-activitypub/lib/mastodon/helpers/resolve-account.js",
- "node_modules/@indiekit/indiekit/node_modules/@rmdes/indiekit-endpoint-activitypub/lib/mastodon/helpers/resolve-account.js",
-];
-
-const MARKER = "// [patch] ap-resolve-account-timeout-safe";
-
-const OLD_SNIPPET = ` 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 */ }`;
-
-const NEW_SNIPPET = ` 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;`;
-
-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-resolve-account-timeout-safe: already applied to ${filePath}`);
- continue;
- }
-
- if (!source.includes(OLD_SNIPPET)) {
- console.warn(
- `[postinstall] patch-ap-resolve-account-timeout-safe: target snippet not found in ${filePath} — skipping`,
- );
- continue;
- }
-
- const updated = source.replace(OLD_SNIPPET, NEW_SNIPPET);
-
- if (updated === source) {
- console.log(`[postinstall] patch-ap-resolve-account-timeout-safe: no changes applied to ${filePath}`);
- continue;
- }
-
- await writeFile(filePath, updated, "utf8");
- patched += 1;
- console.log(`[postinstall] Applied patch-ap-resolve-account-timeout-safe to ${filePath}`);
-}
-
-if (checked === 0) {
- console.log("[postinstall] patch-ap-resolve-account-timeout-safe: no target files found");
-} else if (patched === 0) {
- console.log("[postinstall] patch-ap-resolve-account-timeout-safe: already up to date");
-} else {
- console.log(
- `[postinstall] patch-ap-resolve-account-timeout-safe: patched ${patched}/${checked} file(s)`,
- );
-}
diff --git a/scripts/patch-ap-signature-host-header.mjs b/scripts/patch-ap-signature-host-header.mjs
deleted file mode 100644
index 03023c17..00000000
--- a/scripts/patch-ap-signature-host-header.mjs
+++ /dev/null
@@ -1,103 +0,0 @@
-/**
- * Patch: fix HTTP Signature verification failures by normalising the `host`
- * header in fromExpressRequest().
- *
- * Root cause:
- * HTTP Signatures (Mastodon, Pleroma, …) include "host:" in the signed
- * components string. The signer uses the public hostname, e.g.
- * "host: blog.giersig.eu". Fedify reconstructs that string from
- * request.headers.get("host") when verifying.
- *
- * In our two-jail setup (nginx → node jail), nginx may proxy to the node
- * jail with a different Host header than the public hostname (e.g. the
- * internal IP "10.100.0.20" or "10.100.0.20:3000"). The
- * patch-ap-federation-bridge-base-url patch already fixed URL routing
- * (fromExpressRequest builds the correct canonical URL from publicationUrl),
- * but the host HEADER value in the Headers object was still copied verbatim
- * from req.headers — meaning Fedify's signature verifier reconstructed the
- * signed string with the wrong host value and the check always failed.
- *
- * Fix:
- * After copying headers from the Express request, override "host" with the
- * hostname extracted from publicationUrl when publicationUrl is provided.
- * This is safe even when nginx already forwards the correct Host header —
- * the value is identical and the set() is a no-op in that case.
- */
-
-import { access, readFile, writeFile } from "node:fs/promises";
-
-const MARKER = "// [patch] ap-signature-host-header";
-
-const candidates = [
- "node_modules/@rmdes/indiekit-endpoint-activitypub/lib/federation-bridge.js",
- "node_modules/@indiekit/indiekit/node_modules/@rmdes/indiekit-endpoint-activitypub/lib/federation-bridge.js",
-];
-
-// The headers-copy loop is followed immediately by the body reconstruction.
-// Insert the host-override right after the closing brace of the loop.
-const OLD_HEADERS_LOOP = ` for (const [key, value] of Object.entries(req.headers)) {
- if (Array.isArray(value)) {
- for (const v of value) headers.append(key, v);
- } else if (typeof value === "string") {
- headers.append(key, value);
- }
- }
-
- let body;`;
-
-const NEW_HEADERS_LOOP = ` for (const [key, value] of Object.entries(req.headers)) {
- if (Array.isArray(value)) {
- for (const v of value) headers.append(key, v);
- } else if (typeof value === "string") {
- headers.append(key, value);
- }
- }
-
- // Normalise "host" to the public hostname so Fedify's HTTP Signature
- // verifier reconstructs the same signed-string the remote server created.
- // Without this, nginx may forward an internal Host (e.g. "10.100.0.20")
- // which doesn't match what the sender signed, causing every inbox POST
- // to fail with "Failed to verify the request's HTTP Signatures". ${MARKER}
- if (baseUrl) {
- try {
- const _canonicalHost = new URL(baseUrl).host; // e.g. "blog.giersig.eu"
- headers.set("host", _canonicalHost);
- } catch { /* invalid baseUrl — leave header as-is */ }
- }
-
- let body;`;
-
-async function exists(p) {
- try { await access(p); return true; } catch { return false; }
-}
-
-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-signature-host-header: already applied to ${filePath}`);
- continue;
- }
-
- if (!source.includes(OLD_HEADERS_LOOP)) {
- console.warn(`[postinstall] patch-ap-signature-host-header: target snippet not found in ${filePath}`);
- continue;
- }
-
- await writeFile(filePath, source.replace(OLD_HEADERS_LOOP, NEW_HEADERS_LOOP), "utf8");
- patched++;
- console.log(`[postinstall] Applied patch-ap-signature-host-header to ${filePath}`);
-}
-
-if (checked === 0) {
- console.log("[postinstall] patch-ap-signature-host-header: no target files found");
-} else if (patched === 0) {
- console.log("[postinstall] patch-ap-signature-host-header: already up to date");
-} else {
- console.log(`[postinstall] patch-ap-signature-host-header: patched ${patched} file(s)`);
-}
diff --git a/scripts/patch-ap-skip-draft-syndication.mjs b/scripts/patch-ap-skip-draft-syndication.mjs
deleted file mode 100644
index 51869106..00000000
--- a/scripts/patch-ap-skip-draft-syndication.mjs
+++ /dev/null
@@ -1,109 +0,0 @@
-/**
- * Patch: add a post-status === "draft" guard to the ActivityPub syndicator's
- * syndicate() method, mirroring the existing visibility === "unlisted" guard.
- *
- * Without this patch, a draft post that somehow reaches the AP syndicator
- * directly (bypassing the syndicate-endpoint DB-level filter) would be
- * federated to followers.
- */
-import { access, readFile, writeFile } from "node:fs/promises";
-
-const candidates = [
- "node_modules/@rmdes/indiekit-endpoint-activitypub/index.js",
- "node_modules/@indiekit/indiekit/node_modules/@rmdes/indiekit-endpoint-activitypub/index.js",
-];
-
-const oldSnippet = ` const visibility = String(properties?.visibility || "").toLowerCase();
- if (visibility === "unlisted") {
- console.info(
- "[ActivityPub] Skipping federation for unlisted post: " +
- (properties?.url || "unknown"),
- );
- await logActivity(self._collections.ap_activities, {
- direction: "outbound",
- type: "Syndicate",
- actorUrl: self._publicationUrl,
- objectUrl: properties?.url,
- summary: "Syndication skipped: post visibility is unlisted",
- }).catch(() => {});
- return undefined;
- }`;
-
-const newSnippet = ` const postStatus = String(properties?.["post-status"] || "").toLowerCase();
- if (postStatus === "draft") {
- console.info(
- "[ActivityPub] Skipping federation for draft post: " +
- (properties?.url || "unknown"),
- );
- await logActivity(self._collections.ap_activities, {
- direction: "outbound",
- type: "Syndicate",
- actorUrl: self._publicationUrl,
- objectUrl: properties?.url,
- summary: "Syndication skipped: post is a draft",
- }).catch(() => {});
- return undefined;
- }
-
- const visibility = String(properties?.visibility || "").toLowerCase();
- if (visibility === "unlisted") {
- console.info(
- "[ActivityPub] Skipping federation for unlisted post: " +
- (properties?.url || "unknown"),
- );
- await logActivity(self._collections.ap_activities, {
- direction: "outbound",
- type: "Syndicate",
- actorUrl: self._publicationUrl,
- objectUrl: properties?.url,
- summary: "Syndication skipped: post visibility is unlisted",
- }).catch(() => {});
- return undefined;
- }`;
-
-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(newSnippet)) {
- continue;
- }
-
- if (!source.includes(oldSnippet)) {
- console.warn(
- `[postinstall] Skipping ap-skip-draft-syndication patch for ${filePath}: upstream format changed`,
- );
- continue;
- }
-
- const updated = source.replace(oldSnippet, newSnippet);
- await writeFile(filePath, updated, "utf8");
- patched += 1;
-}
-
-if (checked === 0) {
- console.log("[postinstall] No AP endpoint files found for draft guard patch");
-} else if (patched === 0) {
- console.log("[postinstall] ap-skip-draft-syndication patch already applied");
-} else {
- console.log(
- `[postinstall] Patched AP draft syndication guard in ${patched} file(s)`,
- );
-}
diff --git a/scripts/patch-ap-status-reply-id.mjs b/scripts/patch-ap-status-reply-id.mjs
deleted file mode 100644
index aa9bae3b..00000000
--- a/scripts/patch-ap-status-reply-id.mjs
+++ /dev/null
@@ -1,106 +0,0 @@
-/**
- * Patch: fix in_reply_to_id always being null in Mastodon status serializer.
- *
- * Bug:
- * status.js line 207:
- * in_reply_to_id: item.inReplyTo ? null : null, // TODO: resolve to local ID
- *
- * Both branches of the ternary return null, so in_reply_to_id is ALWAYS null.
- * Mastodon clients (Phanpy, Elk) use this field to display reply threading —
- * without it, replies appear as standalone posts with no thread context.
- *
- * Fix (two changes):
- *
- * A) status.js — use item.inReplyToId (the encoded cursor of the parent post)
- * instead of the tautological null.
- *
- * B) statuses.js POST /api/v1/statuses handler — when pre-inserting own posts
- * into ap_timeline (reply-threading patch), also store
- * inReplyToId: inReplyToId || null
- * (inReplyToId is already in scope as the raw in_reply_to_id param from the
- * client, which IS a valid encodeCursor value.)
- *
- * Note: inbound AP replies from remote servers will still have inReplyToId = null
- * until a separate patch populates it from ap_timeline lookups. Own replies via
- * the Mastodon client API are fully fixed by this patch.
- */
-
-import { access, readFile, writeFile } from "node:fs/promises";
-
-const MARKER = "// [patch] ap-status-reply-id";
-
-// ── Change A: fix tautological null in status.js ──────────────────────────────
-
-const statusEntityCandidates = [
- "node_modules/@rmdes/indiekit-endpoint-activitypub/lib/mastodon/entities/status.js",
- "node_modules/@indiekit/indiekit/node_modules/@rmdes/indiekit-endpoint-activitypub/lib/mastodon/entities/status.js",
-];
-
-const OLD_TAUTOLOGY = ` in_reply_to_id: item.inReplyTo ? null : null, // TODO: resolve to local ID`;
-const NEW_REPLY_ID = ` in_reply_to_id: item.inReplyToId || null, ${MARKER}`;
-
-// ── Change B: store inReplyToId in the Mastodon API timeline insert ───────────
-
-const statusesRouteCandidates = [
- "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_REPLY_INSERT = ` inReplyTo: inReplyTo || null, // [patch] ap-mastodon-reply-threading`;
-const NEW_REPLY_INSERT = ` inReplyTo: inReplyTo || null, // [patch] ap-mastodon-reply-threading
- inReplyToId: inReplyToId || null, ${MARKER}`;
-
-async function exists(p) {
- try { await access(p); return true; } catch { return false; }
-}
-
-// Upstream fix indicator for Change A — if present, the tautological null is
-// already replaced by the upstream's replyIdMap-based lookup (better than our patch).
-const UPSTREAM_FIX_A = `in_reply_to_id: replyIdMap?.get(item.inReplyTo)`;
-
-async function applyPatch(candidates, oldSnippet, newSnippet, label, upstreamFix) {
- 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-status-reply-id: ${label} already applied to ${filePath}`);
- continue;
- }
-
- // If upstream has already fixed the issue (better fix), skip silently
- if (upstreamFix && source.includes(upstreamFix)) {
- console.log(`[postinstall] patch-ap-status-reply-id: ${label} already fixed upstream in ${filePath}`);
- continue;
- }
-
- if (!source.includes(oldSnippet)) {
- console.warn(`[postinstall] patch-ap-status-reply-id: ${label} snippet not found in ${filePath}`);
- continue;
- }
-
- await writeFile(filePath, source.replace(oldSnippet, newSnippet), "utf8");
- patched++;
- console.log(`[postinstall] Applied patch-ap-status-reply-id (${label}) to ${filePath}`);
- }
-
- return { checked, patched };
-}
-
-const a = await applyPatch(statusEntityCandidates, OLD_TAUTOLOGY, NEW_REPLY_ID, "status entity", UPSTREAM_FIX_A);
-const b = await applyPatch(statusesRouteCandidates, OLD_REPLY_INSERT, NEW_REPLY_INSERT, "timeline insert");
-
-const totalChecked = a.checked + b.checked;
-const totalPatched = a.patched + b.patched;
-
-if (totalChecked === 0) {
- console.log("[postinstall] patch-ap-status-reply-id: no target files found");
-} else if (totalPatched === 0) {
- console.log("[postinstall] patch-ap-status-reply-id: already up to date");
-} else {
- console.log(`[postinstall] patch-ap-status-reply-id: patched ${totalPatched} file(s)`);
-}
diff --git a/scripts/patch-ap-syndicate-dedup.mjs b/scripts/patch-ap-syndicate-dedup.mjs
deleted file mode 100644
index 11e397ed..00000000
--- a/scripts/patch-ap-syndicate-dedup.mjs
+++ /dev/null
@@ -1,95 +0,0 @@
-/**
- * Patch: dedup guard in AP syndicator.syndicate() to prevent double-posting.
- *
- * Root cause:
- * The build CI calls POST /syndicate?source_url=X (force=true) after every
- * Eleventy build. When syndicateToTargets() records the first syndication, it
- * issues a Micropub update to save the syndication URL — which commits a new
- * file to Gitea, triggering another build. That second build's CI call also
- * hits the syndicate endpoint with force=true.
- *
- * In force mode with no mp-syndicate-to, syndicateToTargets() re-selects
- * targets whose origin matches any existing syndication URL. Since the AP
- * syndicator's UID (publicationUrl, e.g. "https://blog.giersig.eu/") and the
- * first syndication return value (properties.url, e.g.
- * "https://blog.giersig.eu/notes/my-post/") share the same origin,
- * the AP syndicator is matched and called a second time → duplicate Create(Note).
- *
- * Fix:
- * At the start of syndicate(), query ap_activities for an existing outbound
- * Create/Announce/Update for properties.url. If found, log and return the
- * existing URL without re-federating.
- *
- * This is self-contained (no CI or force-mode changes needed) and correct
- * regardless of how syndication is triggered.
- */
-
-import { access, readFile, writeFile } from "node:fs/promises";
-
-const MARKER = "// [patch] ap-syndicate-dedup";
-
-const candidates = [
- "node_modules/@rmdes/indiekit-endpoint-activitypub/lib/syndicator.js",
- "node_modules/@indiekit/indiekit/node_modules/@rmdes/indiekit-endpoint-activitypub/lib/syndicator.js",
-];
-
-const OLD = ` try {
- const actorUrl = plugin._getActorUrl();`;
-
-const NEW = ` // Dedup: skip re-federation if we've already sent an activity for this URL. ${MARKER}
- // 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();`;
-
-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-syndicate-dedup: already applied to ${filePath}`);
- continue;
- }
-
- if (!source.includes(OLD)) {
- console.warn(`[postinstall] patch-ap-syndicate-dedup: snippet not found in ${filePath}`);
- continue;
- }
-
- await writeFile(filePath, source.replace(OLD, NEW), "utf8");
- patched += 1;
- console.log(`[postinstall] Applied patch-ap-syndicate-dedup to ${filePath}`);
-}
-
-if (checked === 0) {
- console.log("[postinstall] patch-ap-syndicate-dedup: no target files found");
-} else if (patched === 0) {
- console.log("[postinstall] patch-ap-syndicate-dedup: already up to date");
-} else {
- console.log(`[postinstall] patch-ap-syndicate-dedup: patched ${patched} file(s)`);
-}
diff --git a/scripts/patch-ap-syndicate-skip-checkin.mjs b/scripts/patch-ap-syndicate-skip-checkin.mjs
deleted file mode 100644
index 248be025..00000000
--- a/scripts/patch-ap-syndicate-skip-checkin.mjs
+++ /dev/null
@@ -1,77 +0,0 @@
-/**
- * Patch: skip AP syndication for location checkins.
- *
- * Location checkins arrive via Micropub as regular notes but carry a
- * `location` property (geo URI or h-card object, normalised by jf2.js).
- * They are personal log entries and should not be federated to followers.
- *
- * Fix:
- * At the start of syndicate(), if properties.location is set, log and
- * return undefined so Indiekit records no syndication URL for the post.
- */
-
-import { access, readFile, writeFile } from "node:fs/promises";
-
-const MARKER = "// [patch] ap-syndicate-skip-checkin";
-
-const candidates = [
- "node_modules/@rmdes/indiekit-endpoint-activitypub/lib/syndicator.js",
- "node_modules/@indiekit/indiekit/node_modules/@rmdes/indiekit-endpoint-activitypub/lib/syndicator.js",
-];
-
-const OLD = ` async syndicate(properties) {
- if (!plugin._federation) {
- return undefined;
- }`;
-
-const NEW = ` async syndicate(properties) {
- if (!plugin._federation) {
- return undefined;
- }
-
- // Skip location checkins — they have a JF2 \`location\` property. ${MARKER}
- if (properties.location) {
- console.info(\`[ActivityPub] Skipping syndication for location checkin: \${properties.url}\`);
- return undefined;
- }`;
-
-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-syndicate-skip-checkin: already applied to ${filePath}`);
- continue;
- }
-
- if (!source.includes(OLD)) {
- console.warn(`[postinstall] patch-ap-syndicate-skip-checkin: snippet not found in ${filePath}`);
- continue;
- }
-
- await writeFile(filePath, source.replace(OLD, NEW), "utf8");
- patched += 1;
- console.log(`[postinstall] Applied patch-ap-syndicate-skip-checkin to ${filePath}`);
-}
-
-if (checked === 0) {
- console.log("[postinstall] patch-ap-syndicate-skip-checkin: no target files found");
-} else if (patched === 0) {
- console.log("[postinstall] patch-ap-syndicate-skip-checkin: already up to date");
-} else {
- console.log(`[postinstall] patch-ap-syndicate-skip-checkin: patched ${patched} file(s)`);
-}
diff --git a/scripts/patch-ap-syndicate-skip-draft.mjs b/scripts/patch-ap-syndicate-skip-draft.mjs
deleted file mode 100644
index 4356eac4..00000000
--- a/scripts/patch-ap-syndicate-skip-draft.mjs
+++ /dev/null
@@ -1,79 +0,0 @@
-/**
- * Patch: skip AP syndication for posts with post-status="draft".
- *
- * Draft posts should not be federated to followers.
- * They have `properties["post-status"] === "draft"`.
- *
- * Fix:
- * At the start of syndicate(), after the checkin guard, if
- * properties["post-status"] === "draft", log and return undefined so
- * Indiekit records no syndication URL for the post.
- */
-
-import { access, readFile, writeFile } from "node:fs/promises";
-
-const MARKER = "// [patch] ap-syndicate-skip-draft";
-
-const candidates = [
- "node_modules/@rmdes/indiekit-endpoint-activitypub/lib/syndicator.js",
- "node_modules/@indiekit/indiekit/node_modules/@rmdes/indiekit-endpoint-activitypub/lib/syndicator.js",
-];
-
-const OLD = ` // Skip location checkins — they have a JF2 \`location\` property. // [patch] ap-syndicate-skip-checkin
- if (properties.location) {
- console.info(\`[ActivityPub] Skipping syndication for location checkin: \${properties.url}\`);
- return undefined;
- }`;
-
-const NEW = ` // Skip location checkins — they have a JF2 \`location\` property. // [patch] ap-syndicate-skip-checkin
- if (properties.location) {
- console.info(\`[ActivityPub] Skipping syndication for location checkin: \${properties.url}\`);
- return undefined;
- }
-
- // Skip draft posts — they should not be federated to followers. ${MARKER}
- if (properties["post-status"] === "draft") {
- console.info(\`[ActivityPub] Skipping syndication for draft post: \${properties.url}\`);
- return undefined;
- }`;
-
-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-syndicate-skip-draft: already applied to ${filePath}`);
- continue;
- }
-
- if (!source.includes(OLD)) {
- console.warn(`[postinstall] patch-ap-syndicate-skip-draft: snippet not found in ${filePath}`);
- continue;
- }
-
- await writeFile(filePath, source.replace(OLD, NEW), "utf8");
- patched += 1;
- console.log(`[postinstall] Applied patch-ap-syndicate-skip-draft to ${filePath}`);
-}
-
-if (checked === 0) {
- console.log("[postinstall] patch-ap-syndicate-skip-draft: no target files found");
-} else if (patched === 0) {
- console.log("[postinstall] patch-ap-syndicate-skip-draft: already up to date");
-} else {
- console.log(`[postinstall] patch-ap-syndicate-skip-draft: patched ${patched} file(s)`);
-}
diff --git a/scripts/patch-ap-syndicate-skip-unlisted.mjs b/scripts/patch-ap-syndicate-skip-unlisted.mjs
deleted file mode 100644
index bf852cbb..00000000
--- a/scripts/patch-ap-syndicate-skip-unlisted.mjs
+++ /dev/null
@@ -1,79 +0,0 @@
-/**
- * Patch: skip AP syndication for posts with visibility="unlisted".
- *
- * Unlisted posts should not be federated to followers.
- * They have `properties.visibility === "unlisted"`.
- *
- * Fix:
- * At the start of syndicate(), after the draft guard, if
- * properties.visibility === "unlisted", log and return undefined so
- * Indiekit records no syndication URL for the post.
- */
-
-import { access, readFile, writeFile } from "node:fs/promises";
-
-const MARKER = "// [patch] ap-syndicate-skip-unlisted";
-
-const candidates = [
- "node_modules/@rmdes/indiekit-endpoint-activitypub/lib/syndicator.js",
- "node_modules/@indiekit/indiekit/node_modules/@rmdes/indiekit-endpoint-activitypub/lib/syndicator.js",
-];
-
-const OLD = ` // Skip draft posts — they should not be federated to followers. // [patch] ap-syndicate-skip-draft
- if (properties["post-status"] === "draft") {
- console.info(\`[ActivityPub] Skipping syndication for draft post: \${properties.url}\`);
- return undefined;
- }`;
-
-const NEW = ` // Skip draft posts — they should not be federated to followers. // [patch] ap-syndicate-skip-draft
- if (properties["post-status"] === "draft") {
- console.info(\`[ActivityPub] Skipping syndication for draft post: \${properties.url}\`);
- return undefined;
- }
-
- // Skip unlisted posts — they should not be federated to followers. ${MARKER}
- if (properties.visibility === "unlisted") {
- console.info(\`[ActivityPub] Skipping syndication for unlisted post: \${properties.url}\`);
- return undefined;
- }`;
-
-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-syndicate-skip-unlisted: already applied to ${filePath}`);
- continue;
- }
-
- if (!source.includes(OLD)) {
- console.warn(`[postinstall] patch-ap-syndicate-skip-unlisted: snippet not found in ${filePath}`);
- continue;
- }
-
- await writeFile(filePath, source.replace(OLD, NEW), "utf8");
- patched += 1;
- console.log(`[postinstall] Applied patch-ap-syndicate-skip-unlisted to ${filePath}`);
-}
-
-if (checked === 0) {
- console.log("[postinstall] patch-ap-syndicate-skip-unlisted: no target files found");
-} else if (patched === 0) {
- console.log("[postinstall] patch-ap-syndicate-skip-unlisted: already up to date");
-} else {
- console.log(`[postinstall] patch-ap-syndicate-skip-unlisted: patched ${patched} file(s)`);
-}
diff --git a/scripts/patch-ap-webfinger-before-auth.mjs b/scripts/patch-ap-webfinger-before-auth.mjs
deleted file mode 100644
index 2cfd033f..00000000
--- a/scripts/patch-ap-webfinger-before-auth.mjs
+++ /dev/null
@@ -1,110 +0,0 @@
-/**
- * Patch: serve /.well-known/webfinger (and other /.well-known/ discovery routes)
- * via Fedify BEFORE indiekit's auth middleware redirects them to the login page.
- *
- * Root cause:
- * indiekit mounts the AP endpoint's `routesWellKnown` router at `/.well-known/`.
- * Express strips that prefix from req.url, so Fedify sees "/webfinger" instead of
- * "/.well-known/webfinger" and cannot match its internal route — it calls next().
- * The request then falls through to indiekit's auth middleware, which issues a 302
- * redirect to /session/login. Remote servers (e.g. digitalhub.social) receive
- * the redirect instead of the JSON response and log a Webfinger error, causing all
- * subsequent ActivityPub deliveries to that instance to fail with 401 Unauthorized.
- *
- * Fix:
- * The AP endpoint also registers `contentNegotiationRoutes` at "/", where Express
- * does NOT strip any prefix and req.path retains the full original path. This patch
- * extends the Fedify delegation guard inside that router to also forward any request
- * whose path starts with "/.well-known/", in addition to the existing "/nodeinfo/"
- * delegation. Because `contentNegotiationRoutes` is injected before auth by
- * patch-indiekit-routes-rate-limits.mjs, Fedify handles the request before auth
- * middleware ever runs.
- */
-
-import { access, readFile, writeFile } from "node:fs/promises";
-
-const candidates = [
- "node_modules/@rmdes/indiekit-endpoint-activitypub/index.js",
- "node_modules/@indiekit/indiekit/node_modules/@rmdes/indiekit-endpoint-activitypub/index.js",
-];
-
-const MARKER = "// ap-webfinger-before-auth patch";
-
-const OLD_SNIPPET = ` if (!self._fedifyMiddleware) return next();
- if (req.method !== "GET" && req.method !== "HEAD") return next();
- // Only delegate to Fedify for NodeInfo data endpoint (/nodeinfo/2.1).
- // All other paths in this root-mounted router are handled by the
- // content negotiation catch-all below. Passing arbitrary paths like
- // /notes/... to Fedify causes harmless but noisy 404 warnings.
- if (!req.path.startsWith("/nodeinfo/")) return next();
- return self._fedifyMiddleware(req, res, next);`;
-
-const NEW_SNIPPET = ` if (!self._fedifyMiddleware) return next();
- if (req.method !== "GET" && req.method !== "HEAD") return next();
- // Delegate to Fedify for discovery endpoints:
- // /.well-known/webfinger — actor/resource identity resolution
- // /.well-known/nodeinfo — server capabilities advertised to the fediverse
- // /nodeinfo/2.1 — NodeInfo data document
- // This router is mounted at "/" so req.url retains the full path, allowing
- // Fedify to match its internal routes correctly. (routesWellKnown strips
- // the /.well-known/ prefix, causing Fedify to miss the webfinger route.)
- // ap-webfinger-before-auth patch
- const isDiscoveryRoute =
- req.path.startsWith("/nodeinfo/") ||
- req.path.startsWith("/.well-known/");
- if (!isDiscoveryRoute) return next();
- return self._fedifyMiddleware(req, res, next);`;
-
-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-webfinger-before-auth: already applied to ${filePath}`);
- continue;
- }
-
- if (!source.includes(OLD_SNIPPET)) {
- console.warn(
- `[postinstall] patch-ap-webfinger-before-auth: target snippet not found in ${filePath} — skipping`,
- );
- continue;
- }
-
- const updated = source.replace(OLD_SNIPPET, NEW_SNIPPET);
-
- if (updated === source) {
- console.log(`[postinstall] patch-ap-webfinger-before-auth: no changes applied to ${filePath}`);
- continue;
- }
-
- await writeFile(filePath, updated, "utf8");
- patched += 1;
- console.log(`[postinstall] Applied patch-ap-webfinger-before-auth to ${filePath}`);
-}
-
-if (checked === 0) {
- console.log("[postinstall] patch-ap-webfinger-before-auth: no target files found");
-} else if (patched === 0) {
- console.log("[postinstall] patch-ap-webfinger-before-auth: already up to date");
-} else {
- console.log(
- `[postinstall] patch-ap-webfinger-before-auth: patched ${patched}/${checked} file(s)`,
- );
-}
diff --git a/scripts/patch-inbox-ignore-view-activity.mjs b/scripts/patch-inbox-ignore-view-activity.mjs
deleted file mode 100644
index 4d308f79..00000000
--- a/scripts/patch-inbox-ignore-view-activity.mjs
+++ /dev/null
@@ -1,113 +0,0 @@
-/**
- * Patch: silently ignore PeerTube View (WatchAction) activities in the inbox.
- *
- * PeerTube broadcasts a non-standard ActivityStreams `View` activity to all
- * followers whenever someone watches a video. Fedify has no built-in handler
- * registered for this type, which causes a noisy
- * "Unsupported activity type" error in the federation inbox log on every view.
- *
- * Fix: register a no-op `.on(View, ...)` handler at the end of the inbox
- * listener chain so Fedify accepts and silently discards these activities
- * instead of logging them as errors.
- */
-
-import { access, readFile, writeFile } from "node:fs/promises";
-
-const candidates = [
- "node_modules/@rmdes/indiekit-endpoint-activitypub/lib/inbox-listeners.js",
- "node_modules/@indiekit/indiekit/node_modules/@rmdes/indiekit-endpoint-activitypub/lib/inbox-listeners.js",
-];
-
-const patchSpecs = [
- {
- name: "inbox-ignore-view-activity-import",
- marker: "// View imported",
- oldSnippet: ` Undo,
- Update,
-} from "@fedify/fedify/vocab";`,
- newSnippet: ` Undo,
- Update,
- View, // View imported
-} from "@fedify/fedify/vocab";`,
- },
- {
- name: "inbox-ignore-view-activity-handler",
- 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 () => {});
-}`,
- },
-];
-
-async function exists(filePath) {
- try {
- await access(filePath);
- return true;
- } catch {
- return false;
- }
-}
-
-const checkedFiles = new Set();
-const patchedFiles = new Set();
-
-for (const spec of patchSpecs) {
- let foundAnyTarget = false;
-
- for (const filePath of candidates) {
- if (!(await exists(filePath))) {
- continue;
- }
-
- foundAnyTarget = true;
- checkedFiles.add(filePath);
-
- const source = await readFile(filePath, "utf8");
-
- if (spec.marker && source.includes(spec.marker)) {
- continue;
- }
-
- if (!source.includes(spec.oldSnippet)) {
- continue;
- }
-
- const updated = source.replace(spec.oldSnippet, spec.newSnippet);
-
- if (updated === source) {
- continue;
- }
-
- await writeFile(filePath, updated, "utf8");
- patchedFiles.add(filePath);
- }
-
- if (!foundAnyTarget) {
- console.log(`[postinstall] ${spec.name}: no target files found`);
- }
-}
-
-if (checkedFiles.size === 0) {
- console.log("[postinstall] No inbox-listeners files found for View activity patch");
-} else if (patchedFiles.size === 0) {
- console.log("[postinstall] inbox-ignore-view-activity patches already applied");
-} else {
- console.log(
- `[postinstall] Patched inbox-ignore-view-activity in ${patchedFiles.size}/${checkedFiles.size} file(s)`,
- );
-}
diff --git a/scripts/patch-inbox-skip-view-activity-parse.mjs b/scripts/patch-inbox-skip-view-activity-parse.mjs
deleted file mode 100644
index 647a7753..00000000
--- a/scripts/patch-inbox-skip-view-activity-parse.mjs
+++ /dev/null
@@ -1,234 +0,0 @@
-/**
- * Patch: skip PeerTube View (WatchAction) activities before Fedify parses them.
- *
- * PeerTube's View activities embed Schema.org extensions such as
- * `InteractionCounter` that Fedify's JSON-LD deserializer doesn't recognise.
- * This causes a hard "Failed to parse activity" error *before* any inbox
- * listener is reached, so the .on(View, ...) no-op handler added earlier
- * never fires.
- *
- * Root cause of the previous (broken) patch: Express's JSON body parser only
- * handles `application/json`, not `application/activity+json`. So `req.body`
- * is always undefined for ActivityPub inbox POSTs, meaning the check
- * `req.body?.type === "View"` never matched and Fedify still received the raw
- * stream.
- *
- * Fix (two changes to federation-bridge.js):
- *
- * 1. In createFedifyMiddleware: for ActivityPub POST requests where the body
- * hasn't been parsed yet, buffer the raw stream, JSON-parse it, and store
- * the result on req.body before the guard runs. Then check type === "View"
- * and return 200 if so (preventing retries from the sender).
- *
- * 2. In fromExpressRequest: extend the content-type check to also handle
- * `application/activity+json` and `application/ld+json` bodies (i.e. use
- * JSON.stringify(req.body) to reconstruct the stream), so that non-View
- * ActivityPub activities are forwarded correctly to Fedify.
- */
-
-import { access, readFile, writeFile } from "node:fs/promises";
-
-const candidates = [
- "node_modules/@rmdes/indiekit-endpoint-activitypub/lib/federation-bridge.js",
- "node_modules/@indiekit/indiekit/node_modules/@rmdes/indiekit-endpoint-activitypub/lib/federation-bridge.js",
-];
-
-const patchSpecs = [
- // --- Patch 1: extend fromExpressRequest to handle activity+json bodies ---
- {
- name: "from-express-request-activity-json-fix",
- marker: "// PeerTube activity+json body fix",
- oldSnippet: ` if (ct.includes("application/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 1b: upgrade fromExpressRequest to use _rawBody (fixes Digest verification) ---
- // Handles files where Patch 1 was already applied but without the _rawBody fix.
- {
- name: "from-express-request-raw-body-fix",
- 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 1c: upgrade middleware buffer guard to set _rawBody (fixes Digest verification) ---
- // Handles files where Patch 2 was already applied but without the _rawBody fix.
- {
- name: "inbox-buffer-raw-body-fix",
- 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"));`,
- },
-
- // --- Patch 2a: replace the old (broken) v1 guard with the buffering v2 guard ---
- // Handles the case where the previous version of this script was already run.
- {
- name: "inbox-skip-view-activity-parse-v2",
- marker: "// PeerTube View parse skip v2",
- oldSnippet: ` // Short-circuit PeerTube View (WatchAction) activities before Fedify
- // attempts JSON-LD parsing. Fedify's vocab parser throws on PeerTube's
- // Schema.org extensions (e.g. InteractionCounter), causing a
- // "Failed to parse activity" error. Return 200 to prevent retries.
- // PeerTube View parse skip
- if (req.method === "POST" && req.body?.type === "View") {
- return res.status(200).end();
- }
- const request = fromExpressRequest(req);`,
- newSnippet: ` // Short-circuit PeerTube View (WatchAction) activities before Fedify
- // attempts JSON-LD parsing. Fedify's vocab parser throws on PeerTube's
- // Schema.org extensions (e.g. InteractionCounter), causing a
- // "Failed to parse activity" error. Return 200 to prevent retries.
- // PeerTube View parse skip v2
- const _apct = req.headers["content-type"] || "";
- if (
- req.method === "POST" &&
- !req.body &&
- req.readable &&
- (_apct.includes("activity+json") || _apct.includes("ld+json"))
- ) {
- // Express doesn't parse application/activity+json, so buffer it ourselves.
- 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"));
- } catch {
- req.body = {};
- }
- }
- if (req.method === "POST" && req.body?.type === "View") {
- return res.status(200).end();
- }
- const request = fromExpressRequest(req);`,
- },
-
- // --- Patch 2b: apply the buffering v2 guard on a fresh (unpatched) file ---
- // Handles the case where neither v1 nor v2 patch has been applied yet.
- {
- name: "inbox-skip-view-activity-parse-v2-fresh",
- marker: "// PeerTube View parse skip v2",
- oldSnippet: ` return async (req, res, next) => {
- try {
- const request = fromExpressRequest(req);`,
- newSnippet: ` return async (req, res, next) => {
- try {
- // Short-circuit PeerTube View (WatchAction) activities before Fedify
- // attempts JSON-LD parsing. Fedify's vocab parser throws on PeerTube's
- // Schema.org extensions (e.g. InteractionCounter), causing a
- // "Failed to parse activity" error. Return 200 to prevent retries.
- // PeerTube View parse skip v2
- const _apct = req.headers["content-type"] || "";
- if (
- req.method === "POST" &&
- !req.body &&
- req.readable &&
- (_apct.includes("activity+json") || _apct.includes("ld+json"))
- ) {
- // Express doesn't parse application/activity+json, so buffer it ourselves.
- 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"));
- } catch {
- req.body = {};
- }
- }
- if (req.method === "POST" && req.body?.type === "View") {
- return res.status(200).end();
- }
- const request = fromExpressRequest(req);`,
- },
-];
-
-async function exists(filePath) {
- try {
- await access(filePath);
- return true;
- } catch {
- return false;
- }
-}
-
-const checkedFiles = new Set();
-const patchedFiles = new Set();
-
-for (const spec of patchSpecs) {
- let foundAnyTarget = false;
-
- for (const filePath of candidates) {
- if (!(await exists(filePath))) {
- continue;
- }
-
- foundAnyTarget = true;
- checkedFiles.add(filePath);
-
- const source = await readFile(filePath, "utf8");
-
- if (spec.marker && source.includes(spec.marker)) {
- continue;
- }
-
- if (!source.includes(spec.oldSnippet)) {
- continue;
- }
-
- const updated = source.replace(spec.oldSnippet, spec.newSnippet);
-
- if (updated === source) {
- continue;
- }
-
- await writeFile(filePath, updated, "utf8");
- patchedFiles.add(filePath);
- console.log(`[postinstall] Applied ${spec.name} to ${filePath}`);
- }
-
- if (!foundAnyTarget) {
- console.log(`[postinstall] ${spec.name}: no target files found`);
- }
-}
-
-if (checkedFiles.size === 0) {
- console.log("[postinstall] No federation-bridge files found for View activity parse-skip patch");
-} else if (patchedFiles.size === 0) {
- console.log("[postinstall] inbox-skip-view-activity-parse patch already up to date");
-} else {
- console.log(
- `[postinstall] Patched inbox-skip-view-activity-parse in ${patchedFiles.size}/${checkedFiles.size} file(s)`,
- );
-}