feat: federation hardening — persistent keys, Redis queue, indexes

- Persist Ed25519 key pair to ap_keys collection via exportJwk/importJwk
  instead of regenerating on every request (fixes OIP verification failures)
- Use assertionMethods (plural array) per Fedify spec
- Add @fedify/redis + ioredis for persistent message queue that survives
  process restarts (falls back to InProcessMessageQueue when no Redis)
- Add Reject inbox listener to mark rejected Follow requests
- Add performance indexes on ap_followers, ap_following, ap_activities
- Wire storeRawActivities flag through to activity logging
- Bump version to 1.0.21
This commit is contained in:
Ricardo
2026-02-20 16:33:13 +01:00
parent 5604771c69
commit be369b1677
6 changed files with 1537 additions and 24 deletions

View File

@@ -41,6 +41,7 @@ const defaults = {
alsoKnownAs: "",
activityRetentionDays: 90,
storeRawActivities: false,
redisUrl: "",
};
export default class ActivityPubEndpoint {
@@ -617,6 +618,28 @@ export default class ActivityPubEndpoint {
);
}
// Performance indexes for inbox handlers and batch refollow
this._collections.ap_followers.createIndex(
{ actorUrl: 1 },
{ unique: true, background: true },
);
this._collections.ap_following.createIndex(
{ actorUrl: 1 },
{ unique: true, background: true },
);
this._collections.ap_following.createIndex(
{ source: 1 },
{ background: true },
);
this._collections.ap_activities.createIndex(
{ objectUrl: 1 },
{ background: true },
);
this._collections.ap_activities.createIndex(
{ type: 1, actorUrl: 1, objectUrl: 1 },
{ background: true },
);
// Seed actor profile from config on first run
this._seedProfile().catch((error) => {
console.warn("[ActivityPub] Profile seed failed:", error.message);
@@ -628,6 +651,7 @@ export default class ActivityPubEndpoint {
mountPath: this.options.mountPath,
handle: this.options.actor.handle,
storeRawActivities: this.options.storeRawActivities,
redisUrl: this.options.redisUrl,
});
this._federation = federation;

View File

@@ -19,12 +19,16 @@
* @param {string} [record.content] - Content excerpt
* @param {string} record.summary - Human-readable summary
*/
export async function logActivity(collection, record) {
export async function logActivity(collection, record, options = {}) {
try {
await collection.insertOne({
const doc = {
...record,
receivedAt: new Date().toISOString(),
});
};
if (options.rawJson) {
doc.rawJson = options.rawJson;
}
await collection.insertOne(doc);
} catch (error) {
console.warn("[ActivityPub] Failed to log activity:", error.message);
}

View File

@@ -15,10 +15,14 @@ import {
Person,
PropertyValue,
createFederation,
exportJwk,
generateCryptoKeyPair,
importJwk,
importSpki,
} from "@fedify/fedify";
import { configure, getConsoleSink } from "@logtape/logtape";
import { RedisMessageQueue } from "@fedify/redis";
import Redis from "ioredis";
import { MongoKvStore } from "./kv-store.js";
import { registerInboxListeners } from "./inbox-listeners.js";
@@ -41,6 +45,7 @@ export function setupFederation(options) {
mountPath,
handle,
storeRawActivities = false,
redisUrl = "",
} = options;
// Configure LogTape for Fedify delivery logging (once per process)
@@ -64,9 +69,20 @@ export function setupFederation(options) {
});
}
let queue;
if (redisUrl) {
queue = new RedisMessageQueue(() => new Redis(redisUrl));
console.info("[ActivityPub] Using Redis message queue");
} else {
queue = new InProcessMessageQueue();
console.warn(
"[ActivityPub] Using in-process message queue (not recommended for production)",
);
}
const federation = createFederation({
kv: new MongoKvStore(collections.ap_kv),
queue: new InProcessMessageQueue(),
queue,
});
// --- Actor dispatcher ---
@@ -113,7 +129,7 @@ export function setupFederation(options) {
if (keyPairs.length > 0) {
personOptions.publicKey = keyPairs[0].cryptographicKey;
personOptions.assertionMethod = keyPairs[0].multikey;
personOptions.assertionMethods = keyPairs.map((k) => k.multikey);
}
if (profile.attachments?.length > 0) {
@@ -141,26 +157,70 @@ export function setupFederation(options) {
const keyPairs = [];
// Import legacy RSA key pair (for HTTP Signatures compatibility)
const legacyKey = await collections.ap_keys.findOne({});
if (legacyKey?.publicKeyPem && legacyKey?.privateKeyPem) {
// --- Legacy RSA key pair (HTTP Signatures) ---
const legacyKey = await collections.ap_keys.findOne({ type: "rsa" });
// Fall back to old schema (no type field) for backward compat
const rsaDoc =
legacyKey ||
(await collections.ap_keys.findOne({
publicKeyPem: { $exists: true },
}));
if (rsaDoc?.publicKeyPem && rsaDoc?.privateKeyPem) {
try {
const publicKey = await importSpki(legacyKey.publicKeyPem);
const privateKey = await importPkcs8Pem(legacyKey.privateKeyPem);
const publicKey = await importSpki(rsaDoc.publicKeyPem);
const privateKey = await importPkcs8Pem(rsaDoc.privateKeyPem);
keyPairs.push({ publicKey, privateKey });
} catch {
console.warn(
"[ActivityPub] Could not import legacy RSA keys",
);
console.warn("[ActivityPub] Could not import legacy RSA keys");
}
}
// Generate Ed25519 key pair (for Object Integrity Proofs)
try {
const ed25519 = await generateCryptoKeyPair("Ed25519");
keyPairs.push(ed25519);
} catch (error) {
console.warn("[ActivityPub] Could not generate Ed25519 key pair:", error.message);
// --- Ed25519 key pair (Object Integrity Proofs) ---
// Load from DB or generate + persist on first use
let ed25519Doc = await collections.ap_keys.findOne({
type: "ed25519",
});
if (ed25519Doc?.publicKeyJwk && ed25519Doc?.privateKeyJwk) {
try {
const publicKey = await importJwk(
ed25519Doc.publicKeyJwk,
"public",
);
const privateKey = await importJwk(
ed25519Doc.privateKeyJwk,
"private",
);
keyPairs.push({ publicKey, privateKey });
} catch (error) {
console.warn(
"[ActivityPub] Could not import Ed25519 keys, regenerating:",
error.message,
);
ed25519Doc = null; // Force regeneration below
}
}
if (!ed25519Doc) {
try {
const ed25519 = await generateCryptoKeyPair("Ed25519");
await collections.ap_keys.insertOne({
type: "ed25519",
publicKeyJwk: await exportJwk(ed25519.publicKey),
privateKeyJwk: await exportJwk(ed25519.privateKey),
createdAt: new Date().toISOString(),
});
keyPairs.push(ed25519);
console.info(
"[ActivityPub] Generated and persisted Ed25519 key pair",
);
} catch (error) {
console.warn(
"[ActivityPub] Could not generate Ed25519 key pair:",
error.message,
);
}
}
return keyPairs;

View File

@@ -16,6 +16,7 @@ import {
Like,
Move,
Note,
Reject,
Remove,
Undo,
Update,
@@ -160,6 +161,37 @@ export function registerInboxListeners(inboxChain, options) {
});
}
})
.on(Reject, async (ctx, reject) => {
const actorObj = await reject.getActor();
const actorUrl = actorObj?.id?.href || "";
if (!actorUrl) return;
// Mark rejected follow in ap_following
const result = await collections.ap_following.findOneAndUpdate(
{
actorUrl,
source: { $in: ["refollow:sent", "microsub-reader"] },
},
{
$set: {
source: "rejected",
rejectedAt: new Date().toISOString(),
},
},
{ returnDocument: "after" },
);
if (result) {
const actorName = result.name || result.handle || actorUrl;
await logActivity(collections, storeRawActivities, {
direction: "inbound",
type: "Reject(Follow)",
actorUrl,
actorName,
summary: `${actorName} rejected our Follow`,
});
}
})
.on(Like, async (ctx, like) => {
const objectId = (await like.getObject())?.id?.href || "";
@@ -324,8 +356,12 @@ export function registerInboxListeners(inboxChain, options) {
* Wrapper around the shared utility that accepts the (collections, storeRaw, record) signature
* used throughout this file.
*/
async function logActivity(collections, storeRaw, record) {
await logActivityShared(collections.ap_activities, record);
async function logActivity(collections, storeRaw, record, rawJson) {
await logActivityShared(
collections.ap_activities,
record,
storeRaw && rawJson ? { rawJson } : {},
);
}
// Cached ActivityPub channel ObjectId

1387
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
{
"name": "@rmdes/indiekit-endpoint-activitypub",
"version": "1.0.20",
"version": "1.0.21",
"description": "ActivityPub federation endpoint for Indiekit via Fedify. Adds full fediverse support: actor, inbox, outbox, followers, following, syndication, and Mastodon migration.",
"keywords": [
"indiekit",
@@ -37,10 +37,12 @@
"url": "https://github.com/rmdes/indiekit-endpoint-activitypub/issues"
},
"dependencies": {
"@fedify/fedify": "^1.10.0",
"@fedify/express": "^1.9.0",
"@fedify/fedify": "^1.10.0",
"@fedify/redis": "^1.10.3",
"@js-temporal/polyfill": "^0.5.0",
"express": "^5.0.0"
"express": "^5.0.0",
"ioredis": "^5.9.3"
},
"peerDependencies": {
"@indiekit/error": "^1.0.0-beta.25",