Files
indiekit-endpoint-activitypub/lib/key-refresh.js
Ricardo 1567b7c4e5 feat: operational resilience hardening — server blocking, caching, key refresh, async inbox (v2.14.0)
- Server-level blocking: O(1) Redis SISMEMBER check in all inbox listeners,
  admin UI for blocking/unblocking servers by hostname, MongoDB fallback
- Redis caching for collection dispatchers: 300s TTL on followers/following/liked
  counters and paginated pages, one-shot followers recipients cache
- Proactive key refresh: daily cron re-fetches actor documents for followers
  with 7+ day stale keys using lookupWithSecurity()
- Async inbox processing: MongoDB-backed queue with 3s polling, retry (3 attempts),
  24h TTL auto-prune. Follow keeps synchronous Accept, Block keeps synchronous
  follower removal. All other activity types fully deferred to background processor.

Inspired by wafrn's battle-tested multi-user AP implementation.

Confab-Link: http://localhost:8080/sessions/af5f8b45-6b8d-442d-8f25-78c326190709
2026-03-17 09:16:05 +01:00

139 lines
4.2 KiB
JavaScript

/**
* Proactive key refresh for remote actors.
* Periodically re-fetches actor documents for active followers
* whose keys may have rotated, keeping Fedify's KV cache fresh.
* @module key-refresh
*/
import { lookupWithSecurity } from "./lookup-helpers.js";
/**
* Update key freshness tracking after successfully processing
* an activity from a remote actor.
* @param {object} collections - MongoDB collections
* @param {string} actorUrl - Remote actor URL
*/
export async function touchKeyFreshness(collections, actorUrl) {
if (!actorUrl || !collections.ap_key_freshness) return;
try {
await collections.ap_key_freshness.updateOne(
{ actorUrl },
{
$set: { lastSeenAt: new Date().toISOString() },
$setOnInsert: { lastRefreshedAt: new Date().toISOString() },
},
{ upsert: true },
);
} catch {
// Non-critical
}
}
/**
* Refresh stale keys for active followers.
* Finds followers whose keys haven't been refreshed in 7+ days
* and re-fetches their actor documents (up to 10 per cycle).
*
* @param {object} collections - MongoDB collections
* @param {object} ctx - Fedify context (for lookupObject)
* @param {string} handle - Our actor handle
*/
export async function refreshStaleKeys(collections, ctx, handle) {
if (!collections.ap_key_freshness || !collections.ap_followers) return;
const sevenDaysAgo = new Date(Date.now() - 7 * 86_400_000).toISOString();
// Find actors with stale keys who are still our followers
const staleActors = await collections.ap_key_freshness
.aggregate([
{
$match: {
lastRefreshedAt: { $lt: sevenDaysAgo },
},
},
{
$lookup: {
from: "ap_followers",
localField: "actorUrl",
foreignField: "actorUrl",
as: "follower",
},
},
{ $match: { "follower.0": { $exists: true } } },
{ $limit: 10 },
])
.toArray();
if (staleActors.length === 0) return;
console.info(`[ActivityPub] Refreshing keys for ${staleActors.length} stale actors`);
const documentLoader = await ctx.getDocumentLoader({ identifier: handle });
for (const entry of staleActors) {
try {
const result = await lookupWithSecurity(ctx, new URL(entry.actorUrl), {
documentLoader,
});
await collections.ap_key_freshness.updateOne(
{ actorUrl: entry.actorUrl },
{ $set: { lastRefreshedAt: new Date().toISOString() } },
);
if (!result) {
// Actor gone — log as stale
await collections.ap_activities?.insertOne({
direction: "system",
type: "StaleActor",
actorUrl: entry.actorUrl,
summary: `Actor ${entry.actorUrl} could not be resolved during key refresh`,
receivedAt: new Date().toISOString(),
});
}
} catch (error) {
const status = error?.cause?.status || error?.message || "unknown";
if (status === 410 || String(status).includes("410")) {
// 410 Gone — actor deleted
await collections.ap_activities?.insertOne({
direction: "system",
type: "StaleActor",
actorUrl: entry.actorUrl,
summary: `Actor ${entry.actorUrl} returned 410 Gone during key refresh`,
receivedAt: new Date().toISOString(),
});
}
// Update lastRefreshedAt even on failure to avoid retrying every cycle
await collections.ap_key_freshness.updateOne(
{ actorUrl: entry.actorUrl },
{ $set: { lastRefreshedAt: new Date().toISOString() } },
);
}
}
}
/**
* Schedule key refresh job (runs on startup + every 24h).
* @param {object} collections - MongoDB collections
* @param {Function} getCtx - Function returning a Fedify context
* @param {string} handle - Our actor handle
*/
export function scheduleKeyRefresh(collections, getCtx, handle) {
const run = async () => {
try {
const ctx = getCtx();
if (ctx) {
await refreshStaleKeys(collections, ctx, handle);
}
} catch (error) {
console.error("[ActivityPub] Key refresh failed:", error.message);
}
};
// Run once on startup (delayed to let federation initialize)
setTimeout(run, 30_000);
// Then every 24 hours
setInterval(run, 86_400_000);
}