From ea130f0c505bd3d66c2f9956d34ca8af8487478d Mon Sep 17 00:00:00 2001 From: Sven Date: Sun, 12 Apr 2026 13:40:59 +0200 Subject: [PATCH] feat: consolidated patch-ap-federation-infra (bridge, signatures, inbox, webfinger, delete-fix Change A) Absorbs patch-ap-federation-bridge-base-url, patch-ap-signature-host-header, patch-ap-inbox-delivery-debug, patch-ap-inbox-publication-url, patch-ap-webfinger-before-auth, and patch-ap-mastodon-delete-fix Change A into a single ordered patch script using the shared applyPatch helper pattern. Co-Authored-By: Claude Sonnet 4.6 --- scripts/patch-ap-federation-infra.mjs | 273 ++++++++++++++++++++++++++ 1 file changed, 273 insertions(+) create mode 100644 scripts/patch-ap-federation-infra.mjs diff --git a/scripts/patch-ap-federation-infra.mjs b/scripts/patch-ap-federation-infra.mjs new file mode 100644 index 00000000..b3462a2f --- /dev/null +++ b/scripts/patch-ap-federation-infra.mjs @@ -0,0 +1,273 @@ +/** + * Consolidated patch: AP federation infrastructure. + * + * Absorbs: + * - patch-ap-federation-bridge-base-url (federation-bridge.js + index.js) + * - patch-ap-signature-host-header (federation-bridge.js) + * - patch-ap-inbox-delivery-debug (federation-setup.js + federation-bridge.js) + * - patch-ap-inbox-publication-url (federation-setup.js + inbox-handlers.js) + * - patch-ap-webfinger-before-auth (index.js) + * - patch-ap-mastodon-delete-fix Change A only (index.js) + * + * ORDERING within federation-bridge.js entries: + * federation-bridge-base-url patches MUST come before inbox-delivery-debug + * because delivery-debug Fix B anchors on the "// ap-base-url patch" comment + * injected by the base-url patch. + */ + +import { access, readFile, writeFile } from "node:fs/promises"; + +const AP_BASE = "@rmdes/indiekit-endpoint-activitypub"; +const AP_ROOTS = [ + `node_modules/${AP_BASE}`, + `node_modules/@indiekit/indiekit/node_modules/${AP_BASE}`, +]; + +function apPath(rel) { + return AP_ROOTS.map(r => `${r}/${rel}`); +} + +async function fileExists(p) { + try { await access(p); return true; } catch { return false; } +} + +async function applyPatch(filePath, marker, oldSnippet, newSnippet) { + if (!(await fileExists(filePath))) return "file_not_found"; + const src = await readFile(filePath, "utf8"); + if (src.includes(marker)) return "already_applied"; + if (!src.includes(oldSnippet)) return "snippet_not_found"; + await writeFile(filePath, src.replace(oldSnippet, newSnippet), "utf8"); + return "applied"; +} + +const SCRIPT = "patch-ap-federation-infra"; + +const PATCHES = [ + // ── federation-bridge-base-url: fromExpressRequest signature ──────────────── + // ORDERING: must come before inbox-delivery-debug entries + { + name: "bridge-base-url/fromExpressRequest-sig", + files: apPath("lib/federation-bridge.js"), + marker: "// ap-base-url patch", + oldSnippet: `export function fromExpressRequest(req) { + const url = \`\${req.protocol}://\${req.get("host")}\${req.originalUrl}\`;`, + newSnippet: `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}\`;`, + }, + + // ── federation-bridge-base-url: createFedifyMiddleware signature ───────────── + { + name: "bridge-base-url/createFedifyMiddleware-sig", + files: apPath("lib/federation-bridge.js"), + marker: "// ap-base-url patch", + oldSnippet: `export function createFedifyMiddleware(federation, contextDataFactory) {`, + newSnippet: `export function createFedifyMiddleware(federation, contextDataFactory, publicationUrl) { // ap-base-url patch`, + }, + + // ── federation-bridge-base-url: fromExpressRequest call ────────────────────── + { + name: "bridge-base-url/fromExpressRequest-call", + files: apPath("lib/federation-bridge.js"), + marker: "// ap-base-url patch", + oldSnippet: ` const request = fromExpressRequest(req);`, + newSnippet: ` const request = fromExpressRequest(req, publicationUrl); // ap-base-url patch`, + }, + + // ── federation-bridge-base-url: index.js createFedifyMiddleware call ───────── + { + name: "bridge-base-url/index-createFedifyMiddleware-call", + files: apPath("index.js"), + marker: "// ap-base-url patch", + oldSnippet: ` this._fedifyMiddleware = createFedifyMiddleware(federation, () => ({}));`, + newSnippet: ` this._fedifyMiddleware = createFedifyMiddleware(federation, () => ({}), this._publicationUrl); // ap-base-url patch`, + }, + + // ── signature-host-header: normalise "host" header in fromExpressRequest ───── + { + name: "signature-host-header/normalise-host", + files: apPath("lib/federation-bridge.js"), + marker: "// [patch] ap-signature-host-header", + oldSnippet: ` 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;`, + newSnippet: ` 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". // [patch] ap-signature-host-header + 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;`, + }, + + // ── inbox-delivery-debug Fix A: federation-setup.js — inbox logger level ───── + { + name: "inbox-delivery-debug/inbox-logger-level", + files: apPath("lib/federation-setup.js"), + marker: "// [patch] ap-inbox-delivery-debug-A", + oldSnippet: ` { + // 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", + },`, + newSnippet: ` { + // 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. // [patch] ap-inbox-delivery-debug-A + category: ["fedify", "federation", "inbox"], + sinks: ["console"], + lowestLevel: "error", + },`, + }, + + // ── inbox-delivery-debug Fix B: federation-bridge.js — request-level logging ─ + // ORDERING: must come AFTER bridge-base-url patches (anchors on "// ap-base-url patch") + { + name: "inbox-delivery-debug/bridge-request-log", + files: apPath("lib/federation-bridge.js"), + marker: "// [patch] ap-inbox-delivery-debug-B", + oldSnippet: ` const request = fromExpressRequest(req, publicationUrl); // ap-base-url patch`, + newSnippet: ` // Log incoming inbox POSTs before Fedify signature check. // [patch] ap-inbox-delivery-debug-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`, + }, + + // ── inbox-publication-url Fix A: federation-setup.js — set _publicationUrl ─── + { + name: "inbox-publication-url/set-publicationUrl", + files: apPath("lib/federation-setup.js"), + marker: "// [patch] ap-inbox-publication-url", + oldSnippet: ` registerInboxListeners(inboxChain, { + collections, + handle, + storeRawActivities, + });`, + newSnippet: ` // Expose publicationUrl on collections so inbox handlers can gate // [patch] ap-inbox-publication-url + // notifications/timeline-storage to our own content only. + collections._publicationUrl = publicationUrl; + registerInboxListeners(inboxChain, { + collections, + handle, + storeRawActivities, + });`, + }, + + // ── inbox-publication-url Fix B: inbox-handlers.js — store reply from non-follower + { + name: "inbox-publication-url/store-reply-non-follower", + files: apPath("lib/inbox-handlers.js"), + marker: "// [patch] ap-inbox-publication-url", + oldSnippet: ` } else if (collections.ap_followed_tags) { + // Not a followed account — check if the post's hashtags match any followed tags`, + newSnippet: ` } else if (pubUrl && inReplyTo && inReplyTo.startsWith(pubUrl)) { + // Reply to our post from a non-followed account — store in timeline // [patch] ap-inbox-publication-url + // 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`, + }, + + // ── webfinger-before-auth: extend Fedify delegation to /.well-known/ ───────── + { + name: "webfinger-before-auth/extend-discovery-routes", + files: apPath("index.js"), + marker: "// ap-webfinger-before-auth patch", + oldSnippet: ` 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);`, + newSnippet: ` 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);`, + }, + + // ── mastodon-delete-fix Change A: expose broadcastDelete in pluginOptions ───── + { + name: "mastodon-delete-fix/broadcastDelete-in-pluginOptions", + files: apPath("index.js"), + marker: "// [patch] ap-mastodon-delete-fix", + oldSnippet: ` loadRsaKey: () => pluginRef._loadRsaPrivateKey(), + broadcastActorUpdate: () => pluginRef.broadcastActorUpdate(),`, + newSnippet: ` loadRsaKey: () => pluginRef._loadRsaPrivateKey(), + broadcastActorUpdate: () => pluginRef.broadcastActorUpdate(), + broadcastDelete: (url) => pluginRef.broadcastDelete(url), // [patch] ap-mastodon-delete-fix`, + }, +]; + +let total = 0; +for (const p of PATCHES) { + let done = false; + for (const f of p.files) { + const r = await applyPatch(f, p.marker, p.oldSnippet, p.newSnippet); + if (r === "applied") { + console.log(`[postinstall] ${SCRIPT}: applied ${p.name} to ${f}`); + total++; done = true; break; + } else if (r === "already_applied") { + console.log(`[postinstall] ${SCRIPT}: ${p.name} already applied in ${f}`); + done = true; break; + } else if (r === "snippet_not_found") { + console.warn(`[postinstall] ${SCRIPT}: ${p.name} — snippet not found in ${f}, skipping`); + } + } + if (!done) console.log(`[postinstall] ${SCRIPT}: ${p.name} — no target file found`); +} +console.log(`[postinstall] ${SCRIPT}: done (${total} patch(es) applied)`);