feat: migrate to Fedify 2.0 with debug dashboard and modular imports

- Upgrade @fedify/fedify, @fedify/redis to ^2.0.0
- Add @fedify/debugger ^2.0.0 for live federation traffic dashboard
- Move all vocab type imports to @fedify/fedify/vocab (13 files)
- Move crypto imports (exportJwk, importJwk, generateCryptoKeyPair) to @fedify/fedify/sig
- Replace removed importSpki() with local Web Crypto API helper
- Add KvStore.list() async generator required by Fedify 2.0
- Add setOutboxPermanentFailureHandler for delivery failure logging
- Add debugDashboard/debugPassword config options
- Skip manual LogTape configure when debugger auto-configures it
- Fix Express-Fedify bridge to reconstruct body from req.body when
  Express body parser has already consumed the stream (fixes debug
  dashboard login TypeError)
- Add response.bodyUsed safety check in sendFedifyResponse
- Remove @fedify/express dependency (custom bridge handles sub-path mounting)
This commit is contained in:
Ricardo
2026-02-22 14:28:31 +01:00
parent 5c5e53bf3d
commit dd9bba711f
16 changed files with 2258 additions and 133 deletions

View File

@@ -86,6 +86,8 @@ const defaults = {
logLevel: "warning",
timelineRetention: 1000,
notificationRetentionDays: 30,
debugDashboard: false,
debugPassword: "",
};
export default class ActivityPubEndpoint {
@@ -505,7 +507,7 @@ export default class ActivityPubEndpoint {
}
try {
const { Follow } = await import("@fedify/fedify");
const { Follow } = await import("@fedify/fedify/vocab");
const handle = this.options.actor.handle;
const ctx = this._federation.createContext(
new URL(this._publicationUrl),
@@ -607,7 +609,7 @@ export default class ActivityPubEndpoint {
}
try {
const { Follow, Undo } = await import("@fedify/fedify");
const { Follow, Undo } = await import("@fedify/fedify/vocab");
const handle = this.options.actor.handle;
const ctx = this._federation.createContext(
new URL(this._publicationUrl),
@@ -692,7 +694,7 @@ export default class ActivityPubEndpoint {
if (!this._federation) return;
try {
const { Update } = await import("@fedify/fedify");
const { Update } = await import("@fedify/fedify/vocab");
const handle = this.options.actor.handle;
const ctx = this._federation.createContext(
new URL(this._publicationUrl),
@@ -967,6 +969,8 @@ export default class ActivityPubEndpoint {
parallelWorkers: this.options.parallelWorkers,
actorType: this.options.actorType,
logLevel: this.options.logLevel,
debugDashboard: this.options.debugDashboard,
debugPassword: this.options.debugPassword,
});
this._federation = federation;

View File

@@ -11,7 +11,7 @@
* import → refollow:sent → refollow:failed (after MAX_RETRIES)
*/
import { Follow } from "@fedify/fedify";
import { Follow } from "@fedify/fedify/vocab";
import { logActivity } from "./activity-log.js";
const BATCH_SIZE = 10;

View File

@@ -188,7 +188,7 @@ export function submitComposeController(mountPath, plugin) {
});
}
const { Create, Note } = await import("@fedify/fedify");
const { Create, Note } = await import("@fedify/fedify/vocab");
const handle = plugin.options.actor.handle;
const ctx = plugin._federation.createContext(
new URL(plugin._publicationUrl),

View File

@@ -34,7 +34,7 @@ export function boostController(mountPath, plugin) {
});
}
const { Announce } = await import("@fedify/fedify");
const { Announce } = await import("@fedify/fedify/vocab");
const handle = plugin.options.actor.handle;
const ctx = plugin._federation.createContext(
new URL(plugin._publicationUrl),
@@ -168,7 +168,7 @@ export function unboostController(mountPath, plugin) {
});
}
const { Announce, Undo } = await import("@fedify/fedify");
const { Announce, Undo } = await import("@fedify/fedify/vocab");
const handle = plugin.options.actor.handle;
const ctx = plugin._federation.createContext(
new URL(plugin._publicationUrl),

View File

@@ -36,7 +36,7 @@ export function likeController(mountPath, plugin) {
});
}
const { Like } = await import("@fedify/fedify");
const { Like } = await import("@fedify/fedify/vocab");
const handle = plugin.options.actor.handle;
const ctx = plugin._federation.createContext(
new URL(plugin._publicationUrl),
@@ -191,7 +191,7 @@ export function unlikeController(mountPath, plugin) {
});
}
const { Like, Undo } = await import("@fedify/fedify");
const { Like, Undo } = await import("@fedify/fedify/vocab");
const handle = plugin.options.actor.handle;
const ctx = plugin._federation.createContext(
new URL(plugin._publicationUrl),

View File

@@ -144,7 +144,7 @@ export function blockController(mountPath, plugin) {
// Send Block activity via federation
if (plugin._federation) {
try {
const { Block } = await import("@fedify/fedify");
const { Block } = await import("@fedify/fedify/vocab");
const handle = plugin.options.actor.handle;
const ctx = plugin._federation.createContext(
new URL(plugin._publicationUrl),
@@ -223,7 +223,7 @@ export function unblockController(mountPath, plugin) {
// Send Undo(Block) via federation
if (plugin._federation) {
try {
const { Block, Undo } = await import("@fedify/fedify");
const { Block, Undo } = await import("@fedify/fedify/vocab");
const handle = plugin.options.actor.handle;
const ctx = plugin._federation.createContext(
new URL(plugin._publicationUrl),

View File

@@ -1,5 +1,5 @@
// Post detail controller — view individual AP posts/notes/articles
import { Article, Note, Person, Service, Application } from "@fedify/fedify";
import { Article, Note, Person, Service, Application } from "@fedify/fedify/vocab";
import { getToken } from "../csrf.js";
import { extractObjectData } from "../timeline-store.js";
import { getCached, setCache } from "../lookup-cache.js";

View File

@@ -10,7 +10,7 @@ import {
Application,
Organization,
Group,
} from "@fedify/fedify";
} from "@fedify/fedify/vocab";
/**
* GET /admin/reader/resolve?q=<url-or-handle>

View File

@@ -28,14 +28,29 @@ export function fromExpressRequest(req) {
}
}
let body;
if (req.method === "GET" || req.method === "HEAD") {
body = undefined;
} else if (!req.readable && req.body) {
// Express body parser already consumed the stream — reconstruct
// so downstream handlers (e.g. @fedify/debugger login) can read it.
const ct = req.headers["content-type"] || "";
if (ct.includes("application/json")) {
body = JSON.stringify(req.body);
} else if (ct.includes("application/x-www-form-urlencoded")) {
body = new URLSearchParams(req.body).toString();
} else {
body = undefined;
}
} else {
body = Readable.toWeb(req);
}
return new Request(url, {
method: req.method,
headers,
duplex: "half",
body:
req.method === "GET" || req.method === "HEAD"
? undefined
: Readable.toWeb(req),
body,
});
}
@@ -52,7 +67,7 @@ async function sendFedifyResponse(res, response, request) {
res.setHeader(key, value);
});
if (!response.body) {
if (!response.body || response.bodyUsed) {
res.end();
return;
}

View File

@@ -9,6 +9,16 @@
import { AsyncLocalStorage } from "node:async_hooks";
import { createRequire } from "node:module";
import { Temporal } from "@js-temporal/polyfill";
import {
createFederation,
InProcessMessageQueue,
ParallelMessageQueue,
} from "@fedify/fedify";
import {
exportJwk,
generateCryptoKeyPair,
importJwk,
} from "@fedify/fedify/sig";
import {
Application,
Article,
@@ -17,21 +27,15 @@ import {
Group,
Hashtag,
Image,
InProcessMessageQueue,
Note,
Organization,
ParallelMessageQueue,
Person,
PropertyValue,
Service,
createFederation,
exportJwk,
generateCryptoKeyPair,
importJwk,
importSpki,
} from "@fedify/fedify";
} from "@fedify/fedify/vocab";
import { configure, getConsoleSink } from "@logtape/logtape";
import { RedisMessageQueue } from "@fedify/redis";
import { createFederationDebugger } from "@fedify/debugger";
import Redis from "ioredis";
import { MongoKvStore } from "./kv-store.js";
import { registerInboxListeners } from "./inbox-listeners.js";
@@ -61,17 +65,21 @@ export function setupFederation(options) {
parallelWorkers = 5,
actorType = "Person",
logLevel = "warning",
debugDashboard = false,
debugPassword = "",
} = options;
// Map config string to Fedify actor class
const actorTypeMap = { Person, Service, Application, Organization, Group };
const ActorClass = actorTypeMap[actorType] || Person;
// Configure LogTape for Fedify delivery logging (once per process)
// Configure LogTape for Fedify delivery logging (once per process).
// When the debug dashboard is enabled, skip this — the debugger
// auto-configures LogTape with per-trace log collection + OpenTelemetry.
// Valid levels: "debug" | "info" | "warning" | "error" | "fatal"
const validLevels = ["debug", "info", "warning", "error", "fatal"];
const resolvedLevel = validLevels.includes(logLevel) ? logLevel : "warning";
if (!_logtapeConfigured) {
if (!debugDashboard && !_logtapeConfigured) {
_logtapeConfigured = true;
configure({
contextLocalStorage: new AsyncLocalStorage(),
@@ -186,7 +194,7 @@ export function setupFederation(options) {
if (rsaDoc?.publicKeyPem && rsaDoc?.privateKeyPem) {
try {
const publicKey = await importSpki(rsaDoc.publicKeyPem);
const publicKey = await importSpkiPem(rsaDoc.publicKeyPem);
const privateKey = await importPkcs8Pem(rsaDoc.privateKeyPem);
keyPairs.push({ publicKey, privateKey });
} catch {
@@ -284,13 +292,13 @@ export function setupFederation(options) {
setupObjectDispatchers(federation, mountPath, handle, collections, publicationUrl);
// --- NodeInfo ---
let softwareVersion = { major: 1, minor: 0, patch: 0 };
// Fedify 2.0: software.version is now a plain string (was SemVer object)
let softwareVersion = "1.0.0";
try {
const require = createRequire(import.meta.url);
const pkg = require("@indiekit/indiekit/package.json");
const [major, minor, patch] = pkg.version.split(/[.-]/).map(Number);
if (!Number.isNaN(major)) softwareVersion = { major, minor: minor || 0, patch: patch || 0 };
} catch { /* fallback to 1.0.0 */ }
if (pkg.version) softwareVersion = pkg.version;
} catch { /* fallback to "1.0.0" */ }
federation.setNodeInfoDispatcher("/nodeinfo/2.1", async () => {
const postsCount = collections.posts
@@ -311,15 +319,57 @@ export function setupFederation(options) {
};
});
// Handle permanent delivery failures (Fedify 2.0).
// Fires when a remote inbox returns 404/410 — the server is gone.
// Log it and let the admin see which followers are unreachable.
federation.setOutboxPermanentFailureHandler(async (_ctx, values) => {
const { inbox, error, actorIds } = values;
const inboxUrl = inbox?.href || String(inbox);
const actors = actorIds?.map((id) => id?.href || String(id)) || [];
console.warn(
`[ActivityPub] Permanent delivery failure to ${inboxUrl}: ${error?.message || "unknown"}` +
(actors.length ? ` (actors: ${actors.join(", ")})` : ""),
);
collections.ap_activities.insertOne({
direction: "outbound",
type: "DeliveryFailed",
actorUrl: publicationUrl,
objectUrl: inboxUrl,
summary: `Permanent delivery failure to ${inboxUrl}: ${error?.message || "unknown"}`,
affectedActors: actors,
receivedAt: new Date().toISOString(),
}).catch(() => {});
});
// Wrap with debug dashboard if enabled. The debugger proxies the
// Federation object and intercepts requests at {mountPath}/__debug__/,
// serving a live dashboard showing traces, activities, signature
// verification, and correlated logs. It auto-configures OpenTelemetry
// tracing and LogTape per-trace log collection.
let activeFederation = federation;
if (debugDashboard) {
const debugOptions = {
path: `${mountPath}/__debug__`,
};
if (debugPassword) {
debugOptions.auth = { type: "password", password: debugPassword };
}
activeFederation = createFederationDebugger(federation, debugOptions);
console.info(
`[ActivityPub] Debug dashboard enabled at ${mountPath}/__debug__/` +
(debugPassword ? " (password-protected)" : " (WARNING: no password set)"),
);
}
// Start the message queue for outbound activity delivery.
// Without this, ctx.sendActivity() enqueues delivery tasks but the
// InProcessMessageQueue never processes them — activities are never
// actually POSTed to follower inboxes.
federation.startQueue().catch((error) => {
activeFederation.startQueue().catch((error) => {
console.error("[ActivityPub] Failed to start delivery queue:", error.message);
});
return { federation };
return { federation: activeFederation };
}
// --- Collection setup helpers ---
@@ -695,6 +745,25 @@ export async function buildPersonActor(
return new ResolvedActorClass(personOptions);
}
/**
* Import an SPKI PEM public key using Web Crypto API.
* Replaces Fedify 1.x's importSpki() which was removed in 2.0.
*/
async function importSpkiPem(pem) {
const lines = pem
.replace("-----BEGIN PUBLIC KEY-----", "")
.replace("-----END PUBLIC KEY-----", "")
.replace(/\s/g, "");
const der = Uint8Array.from(atob(lines), (c) => c.charCodeAt(0));
return crypto.subtle.importKey(
"spki",
der,
{ name: "RSASSA-PKCS1-v1_5", hash: "SHA-256" },
true,
["verify"],
);
}
/**
* Import a PKCS#8 PEM private key using Web Crypto API.
* Fedify's importPem only handles PKCS#1, but Node.js crypto generates PKCS#8.

View File

@@ -21,7 +21,7 @@ import {
Remove,
Undo,
Update,
} from "@fedify/fedify";
} from "@fedify/fedify/vocab";
import { logActivity as logActivityShared } from "./activity-log.js";
import { sanitizeContent, extractActorInfo, extractObjectData } from "./timeline-store.js";

View File

@@ -18,7 +18,7 @@ import {
Mention,
Note,
Video,
} from "@fedify/fedify";
} from "@fedify/fedify/vocab";
// ---------------------------------------------------------------------------
// Plain JSON-LD (content negotiation on individual post URLs)

View File

@@ -52,4 +52,25 @@ export class MongoKvStore {
async delete(key) {
await this.collection.deleteOne({ _id: this._serializeKey(key) });
}
/**
* List all entries whose key starts with the given prefix.
* Required by Fedify 2.0's KvStore interface.
*
* @param {string[]} [prefix=[]]
* @returns {AsyncIterable<{ key: string[], value: unknown }>}
*/
async *list(prefix = []) {
const prefixStr = this._serializeKey(prefix);
const filter = prefixStr
? { _id: { $regex: `^${prefixStr.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}` } }
: {};
const cursor = this.collection.find(filter);
for await (const doc of cursor) {
yield {
key: doc._id.split("/"),
value: doc.value,
};
}
}
}

View File

@@ -3,7 +3,7 @@
* @module timeline-store
*/
import { Article } from "@fedify/fedify";
import { Article } from "@fedify/fedify/vocab";
import sanitizeHtml from "sanitize-html";
/**

2198
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
{
"name": "@rmdes/indiekit-endpoint-activitypub",
"version": "1.1.20",
"version": "2.0.1",
"description": "ActivityPub federation endpoint for Indiekit via Fedify. Adds full fediverse support: actor, inbox, outbox, followers, following, syndication, and Mastodon migration.",
"keywords": [
"indiekit",
@@ -37,9 +37,9 @@
"url": "https://github.com/rmdes/indiekit-endpoint-activitypub/issues"
},
"dependencies": {
"@fedify/express": "^1.10.3",
"@fedify/fedify": "^1.10.3",
"@fedify/redis": "^1.10.3",
"@fedify/debugger": "^2.0.0",
"@fedify/fedify": "^2.0.0",
"@fedify/redis": "^2.0.0",
"@js-temporal/polyfill": "^0.5.0",
"express": "^5.0.0",
"ioredis": "^5.9.3",