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:
@@ -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)`);
|
||||||
Reference in New Issue
Block a user