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 <noreply@anthropic.com>
This commit is contained in:
Sven
2026-04-12 13:40:59 +02:00
parent 9a590f3cff
commit ea130f0c50
+273
View File
@@ -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)`);