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:
@@ -41,6 +41,7 @@ const defaults = {
|
|||||||
alsoKnownAs: "",
|
alsoKnownAs: "",
|
||||||
activityRetentionDays: 90,
|
activityRetentionDays: 90,
|
||||||
storeRawActivities: false,
|
storeRawActivities: false,
|
||||||
|
redisUrl: "",
|
||||||
};
|
};
|
||||||
|
|
||||||
export default class ActivityPubEndpoint {
|
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
|
// Seed actor profile from config on first run
|
||||||
this._seedProfile().catch((error) => {
|
this._seedProfile().catch((error) => {
|
||||||
console.warn("[ActivityPub] Profile seed failed:", error.message);
|
console.warn("[ActivityPub] Profile seed failed:", error.message);
|
||||||
@@ -628,6 +651,7 @@ export default class ActivityPubEndpoint {
|
|||||||
mountPath: this.options.mountPath,
|
mountPath: this.options.mountPath,
|
||||||
handle: this.options.actor.handle,
|
handle: this.options.actor.handle,
|
||||||
storeRawActivities: this.options.storeRawActivities,
|
storeRawActivities: this.options.storeRawActivities,
|
||||||
|
redisUrl: this.options.redisUrl,
|
||||||
});
|
});
|
||||||
|
|
||||||
this._federation = federation;
|
this._federation = federation;
|
||||||
|
|||||||
+7
-3
@@ -19,12 +19,16 @@
|
|||||||
* @param {string} [record.content] - Content excerpt
|
* @param {string} [record.content] - Content excerpt
|
||||||
* @param {string} record.summary - Human-readable summary
|
* @param {string} record.summary - Human-readable summary
|
||||||
*/
|
*/
|
||||||
export async function logActivity(collection, record) {
|
export async function logActivity(collection, record, options = {}) {
|
||||||
try {
|
try {
|
||||||
await collection.insertOne({
|
const doc = {
|
||||||
...record,
|
...record,
|
||||||
receivedAt: new Date().toISOString(),
|
receivedAt: new Date().toISOString(),
|
||||||
});
|
};
|
||||||
|
if (options.rawJson) {
|
||||||
|
doc.rawJson = options.rawJson;
|
||||||
|
}
|
||||||
|
await collection.insertOne(doc);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.warn("[ActivityPub] Failed to log activity:", error.message);
|
console.warn("[ActivityPub] Failed to log activity:", error.message);
|
||||||
}
|
}
|
||||||
|
|||||||
+76
-16
@@ -15,10 +15,14 @@ import {
|
|||||||
Person,
|
Person,
|
||||||
PropertyValue,
|
PropertyValue,
|
||||||
createFederation,
|
createFederation,
|
||||||
|
exportJwk,
|
||||||
generateCryptoKeyPair,
|
generateCryptoKeyPair,
|
||||||
|
importJwk,
|
||||||
importSpki,
|
importSpki,
|
||||||
} from "@fedify/fedify";
|
} from "@fedify/fedify";
|
||||||
import { configure, getConsoleSink } from "@logtape/logtape";
|
import { configure, getConsoleSink } from "@logtape/logtape";
|
||||||
|
import { RedisMessageQueue } from "@fedify/redis";
|
||||||
|
import Redis from "ioredis";
|
||||||
import { MongoKvStore } from "./kv-store.js";
|
import { MongoKvStore } from "./kv-store.js";
|
||||||
import { registerInboxListeners } from "./inbox-listeners.js";
|
import { registerInboxListeners } from "./inbox-listeners.js";
|
||||||
|
|
||||||
@@ -41,6 +45,7 @@ export function setupFederation(options) {
|
|||||||
mountPath,
|
mountPath,
|
||||||
handle,
|
handle,
|
||||||
storeRawActivities = false,
|
storeRawActivities = false,
|
||||||
|
redisUrl = "",
|
||||||
} = options;
|
} = options;
|
||||||
|
|
||||||
// Configure LogTape for Fedify delivery logging (once per process)
|
// 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({
|
const federation = createFederation({
|
||||||
kv: new MongoKvStore(collections.ap_kv),
|
kv: new MongoKvStore(collections.ap_kv),
|
||||||
queue: new InProcessMessageQueue(),
|
queue,
|
||||||
});
|
});
|
||||||
|
|
||||||
// --- Actor dispatcher ---
|
// --- Actor dispatcher ---
|
||||||
@@ -113,7 +129,7 @@ export function setupFederation(options) {
|
|||||||
|
|
||||||
if (keyPairs.length > 0) {
|
if (keyPairs.length > 0) {
|
||||||
personOptions.publicKey = keyPairs[0].cryptographicKey;
|
personOptions.publicKey = keyPairs[0].cryptographicKey;
|
||||||
personOptions.assertionMethod = keyPairs[0].multikey;
|
personOptions.assertionMethods = keyPairs.map((k) => k.multikey);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (profile.attachments?.length > 0) {
|
if (profile.attachments?.length > 0) {
|
||||||
@@ -141,26 +157,70 @@ export function setupFederation(options) {
|
|||||||
|
|
||||||
const keyPairs = [];
|
const keyPairs = [];
|
||||||
|
|
||||||
// Import legacy RSA key pair (for HTTP Signatures compatibility)
|
// --- Legacy RSA key pair (HTTP Signatures) ---
|
||||||
const legacyKey = await collections.ap_keys.findOne({});
|
const legacyKey = await collections.ap_keys.findOne({ type: "rsa" });
|
||||||
if (legacyKey?.publicKeyPem && legacyKey?.privateKeyPem) {
|
// 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 {
|
try {
|
||||||
const publicKey = await importSpki(legacyKey.publicKeyPem);
|
const publicKey = await importSpki(rsaDoc.publicKeyPem);
|
||||||
const privateKey = await importPkcs8Pem(legacyKey.privateKeyPem);
|
const privateKey = await importPkcs8Pem(rsaDoc.privateKeyPem);
|
||||||
keyPairs.push({ publicKey, privateKey });
|
keyPairs.push({ publicKey, privateKey });
|
||||||
} catch {
|
} catch {
|
||||||
console.warn(
|
console.warn("[ActivityPub] Could not import legacy RSA keys");
|
||||||
"[ActivityPub] Could not import legacy RSA keys",
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Generate Ed25519 key pair (for Object Integrity Proofs)
|
// --- Ed25519 key pair (Object Integrity Proofs) ---
|
||||||
try {
|
// Load from DB or generate + persist on first use
|
||||||
const ed25519 = await generateCryptoKeyPair("Ed25519");
|
let ed25519Doc = await collections.ap_keys.findOne({
|
||||||
keyPairs.push(ed25519);
|
type: "ed25519",
|
||||||
} catch (error) {
|
});
|
||||||
console.warn("[ActivityPub] Could not generate Ed25519 key pair:", error.message);
|
|
||||||
|
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;
|
return keyPairs;
|
||||||
|
|||||||
+38
-2
@@ -16,6 +16,7 @@ import {
|
|||||||
Like,
|
Like,
|
||||||
Move,
|
Move,
|
||||||
Note,
|
Note,
|
||||||
|
Reject,
|
||||||
Remove,
|
Remove,
|
||||||
Undo,
|
Undo,
|
||||||
Update,
|
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) => {
|
.on(Like, async (ctx, like) => {
|
||||||
const objectId = (await like.getObject())?.id?.href || "";
|
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
|
* Wrapper around the shared utility that accepts the (collections, storeRaw, record) signature
|
||||||
* used throughout this file.
|
* used throughout this file.
|
||||||
*/
|
*/
|
||||||
async function logActivity(collections, storeRaw, record) {
|
async function logActivity(collections, storeRaw, record, rawJson) {
|
||||||
await logActivityShared(collections.ap_activities, record);
|
await logActivityShared(
|
||||||
|
collections.ap_activities,
|
||||||
|
record,
|
||||||
|
storeRaw && rawJson ? { rawJson } : {},
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Cached ActivityPub channel ObjectId
|
// Cached ActivityPub channel ObjectId
|
||||||
|
|||||||
Generated
+1387
File diff suppressed because it is too large
Load Diff
+5
-3
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@rmdes/indiekit-endpoint-activitypub",
|
"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.",
|
"description": "ActivityPub federation endpoint for Indiekit via Fedify. Adds full fediverse support: actor, inbox, outbox, followers, following, syndication, and Mastodon migration.",
|
||||||
"keywords": [
|
"keywords": [
|
||||||
"indiekit",
|
"indiekit",
|
||||||
@@ -37,10 +37,12 @@
|
|||||||
"url": "https://github.com/rmdes/indiekit-endpoint-activitypub/issues"
|
"url": "https://github.com/rmdes/indiekit-endpoint-activitypub/issues"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@fedify/fedify": "^1.10.0",
|
|
||||||
"@fedify/express": "^1.9.0",
|
"@fedify/express": "^1.9.0",
|
||||||
|
"@fedify/fedify": "^1.10.0",
|
||||||
|
"@fedify/redis": "^1.10.3",
|
||||||
"@js-temporal/polyfill": "^0.5.0",
|
"@js-temporal/polyfill": "^0.5.0",
|
||||||
"express": "^5.0.0"
|
"express": "^5.0.0",
|
||||||
|
"ioredis": "^5.9.3"
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"@indiekit/error": "^1.0.0-beta.25",
|
"@indiekit/error": "^1.0.0-beta.25",
|
||||||
|
|||||||
Reference in New Issue
Block a user