mirror of
https://github.com/svemagie/indiekit-endpoint-activitypub.git
synced 2026-04-02 15:44:58 +02:00
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:
24
index.js
24
index.js
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
1387
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user