mirror of
https://github.com/svemagie/indiekit-endpoint-activitypub.git
synced 2026-04-02 15:44:58 +02:00
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:
10
index.js
10
index.js
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
Application,
|
||||
Organization,
|
||||
Group,
|
||||
} from "@fedify/fedify";
|
||||
} from "@fedify/fedify/vocab";
|
||||
|
||||
/**
|
||||
* GET /admin/reader/resolve?q=<url-or-handle>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -18,7 +18,7 @@ import {
|
||||
Mention,
|
||||
Note,
|
||||
Video,
|
||||
} from "@fedify/fedify";
|
||||
} from "@fedify/fedify/vocab";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Plain JSON-LD (content negotiation on individual post URLs)
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
2198
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user