fix: comprehensive security, performance, and architecture audit fixes

27 issues fixed from multi-dimensional code review (4 Critical, 6 High, 11 Medium, 6 Low):

Security (Critical):
- Escape HTML in OAuth authorization page to prevent XSS (C1)
- Add CSRF protection to OAuth authorize flow (C2)
- Replace bypassable regex sanitizer with sanitize-html library (C3)
- Enforce OAuth scopes on all Mastodon API routes (C4)

Security (Medium/Low):
- Fix SSRF via DNS resolution before private IP check (M1)
- Add rate limiting to API, auth, and app registration endpoints (M2)
- Validate redirect_uri on POST /oauth/authorize (M4)
- Fix custom emoji URL injection with scheme validation + escaping (M5)
- Remove data: scheme from allowed image sources (L6)
- Add access token expiry (1hr) and refresh token rotation (90d) (M3)
- Hash client secrets before storage (L3)

Architecture:
- Extract batch-broadcast.js — shared delivery logic (H1a)
- Extract init-indexes.js — MongoDB index creation (H1b)
- Extract syndicator.js — syndication logic (H1c)
- Create federation-actions.js facade for controllers (M6)
- index.js reduced from 1810 to ~1169 lines (35%)

Performance:
- Cache moderation data with 30s TTL + write invalidation (H6)
- Increase inbox queue throughput to 10 items/sec (H5)
- Make account enrichment non-blocking with fire-and-forget (H4)
- Remove ephemeral getReplies/getLikes/getShares from ingest (M11)
- Fix LRU caches to use true LRU eviction (L1)
- Fix N+1 backfill queries with batch $in lookup (L2)

UI/UX:
- Split 3441-line reader.css into 15 feature-scoped files (H2)
- Extract inline Alpine.js interaction component (H3)
- Reduce sidebar navigation from 7 to 3 items (M7)
- Add ARIA live regions for dynamic content updates (M8)
- Extract shared CW/non-CW content partial (M9)
- Document form handling pattern convention (M10)
- Add accessible labels to functional emoji icons (L4)
- Convert profile editor to Alpine.js (L5)

Audit: documentation-central/audits/2026-03-24-activitypub-code-review.md
Plan: documentation-central/plans/2026-03-24-activitypub-audit-fixes.md
This commit is contained in:
Ricardo
2026-03-25 07:41:20 +01:00
parent 3ace60a1c8
commit 12454749ad
55 changed files with 4845 additions and 4731 deletions

728
index.js
View File

@@ -4,6 +4,7 @@ import { setupFederation, buildPersonActor } from "./lib/federation-setup.js";
import { createMastodonRouter } from "./lib/mastodon/router.js";
import { setLocalIdentity } from "./lib/mastodon/entities/status.js";
import { initRedisCache } from "./lib/redis-cache.js";
import { createIndexes } from "./lib/init-indexes.js";
import { lookupWithSecurity } from "./lib/lookup-helpers.js";
import {
needsDirectFollow,
@@ -16,8 +17,8 @@ import {
import {
jf2ToActivityStreams,
jf2ToAS2Activity,
parseMentions,
} from "./lib/jf2-to-as2.js";
import { createSyndicator } from "./lib/syndicator.js";
import { dashboardController } from "./lib/controllers/dashboard.js";
import {
readerController,
@@ -115,6 +116,7 @@ import {
} from "./lib/controllers/refollow.js";
import { startBatchRefollow } from "./lib/batch-refollow.js";
import { logActivity } from "./lib/activity-log.js";
import { batchBroadcast } from "./lib/batch-broadcast.js";
import { scheduleCleanup } from "./lib/timeline-cleanup.js";
import { runSeparateMentionsMigration } from "./lib/migrations/separate-mentions.js";
import { loadBlockedServersToRedis } from "./lib/storage/server-blocks.js";
@@ -178,26 +180,6 @@ export default class ActivityPubEndpoint {
text: "activitypub.reader.title",
requiresDatabase: true,
},
{
href: `${this.options.mountPath}/admin/reader/notifications`,
text: "activitypub.notifications.title",
requiresDatabase: true,
},
{
href: `${this.options.mountPath}/admin/reader/messages`,
text: "activitypub.messages.title",
requiresDatabase: true,
},
{
href: `${this.options.mountPath}/admin/reader/moderation`,
text: "activitypub.moderation.title",
requiresDatabase: true,
},
{
href: `${this.options.mountPath}/admin/my-profile`,
text: "activitypub.myProfile.title",
requiresDatabase: true,
},
{
href: `${this.options.mountPath}/admin/federation`,
text: "activitypub.federationMgmt.title",
@@ -465,228 +447,7 @@ export default class ActivityPubEndpoint {
* Syndicator — delivers posts to ActivityPub followers via Fedify.
*/
get syndicator() {
const self = this;
return {
name: "ActivityPub syndicator",
options: { checked: self.options.checked },
get info() {
const hostname = self._publicationUrl
? new URL(self._publicationUrl).hostname
: "example.com";
return {
checked: self.options.checked,
name: `@${self.options.actor.handle}@${hostname}`,
uid: self._publicationUrl || "https://example.com/",
service: {
name: "ActivityPub (Fediverse)",
photo: "/assets/@rmdes-indiekit-endpoint-activitypub/icon.svg",
url: self._publicationUrl || "https://example.com/",
},
};
},
async syndicate(properties) {
if (!self._federation) {
return undefined;
}
try {
const actorUrl = self._getActorUrl();
const handle = self.options.actor.handle;
const ctx = self._federation.createContext(
new URL(self._publicationUrl),
{ handle, publicationUrl: self._publicationUrl },
);
// For replies, resolve the original post author for proper
// addressing (CC) and direct inbox delivery
let replyToActor = null;
if (properties["in-reply-to"]) {
try {
const remoteObject = await lookupWithSecurity(ctx,
new URL(properties["in-reply-to"]),
);
if (remoteObject && typeof remoteObject.getAttributedTo === "function") {
const author = await remoteObject.getAttributedTo();
const authorActor = Array.isArray(author) ? author[0] : author;
if (authorActor?.id) {
replyToActor = {
url: authorActor.id.href,
handle: authorActor.preferredUsername || null,
recipient: authorActor,
};
console.info(
`[ActivityPub] Reply to ${properties["in-reply-to"]} — resolved author: ${replyToActor.url}`,
);
}
}
} catch (error) {
console.warn(
`[ActivityPub] Could not resolve reply-to author for ${properties["in-reply-to"]}: ${error.message}`,
);
}
}
// Resolve @user@domain mentions in content via WebFinger
const contentText = properties.content?.html || properties.content || "";
const mentionHandles = parseMentions(contentText);
const resolvedMentions = [];
const mentionRecipients = [];
for (const { handle } of mentionHandles) {
try {
const mentionedActor = await lookupWithSecurity(ctx,
new URL(`acct:${handle}`),
);
if (mentionedActor?.id) {
resolvedMentions.push({
handle,
actorUrl: mentionedActor.id.href,
profileUrl: mentionedActor.url?.href || null,
});
mentionRecipients.push({
handle,
actorUrl: mentionedActor.id.href,
actor: mentionedActor,
});
console.info(
`[ActivityPub] Resolved mention @${handle}${mentionedActor.id.href}`,
);
}
} catch (error) {
console.warn(
`[ActivityPub] Could not resolve mention @${handle}: ${error.message}`,
);
// Still add with no actorUrl so it gets a fallback link
resolvedMentions.push({ handle, actorUrl: null });
}
}
const activity = jf2ToAS2Activity(
properties,
actorUrl,
self._publicationUrl,
{
replyToActorUrl: replyToActor?.url,
replyToActorHandle: replyToActor?.handle,
visibility: self.options.defaultVisibility,
mentions: resolvedMentions,
},
);
if (!activity) {
await logActivity(self._collections.ap_activities, {
direction: "outbound",
type: "Syndicate",
actorUrl: self._publicationUrl,
objectUrl: properties.url,
summary: `Syndication skipped: could not convert post to AS2`,
});
return undefined;
}
// Count followers for logging
const followerCount =
await self._collections.ap_followers.countDocuments();
console.info(
`[ActivityPub] Sending ${activity.constructor?.name || "activity"} for ${properties.url} to ${followerCount} followers`,
);
// Send to followers via shared inboxes with collection sync (FEP-8fcf)
await ctx.sendActivity(
{ identifier: handle },
"followers",
activity,
{
preferSharedInbox: true,
syncCollection: true,
orderingKey: properties.url,
},
);
// For replies, also deliver to the original post author's inbox
// so their server can thread the reply under the original post
if (replyToActor?.recipient) {
try {
await ctx.sendActivity(
{ identifier: handle },
replyToActor.recipient,
activity,
{ orderingKey: properties.url },
);
console.info(
`[ActivityPub] Reply delivered to author: ${replyToActor.url}`,
);
} catch (error) {
console.warn(
`[ActivityPub] Failed to deliver reply to ${replyToActor.url}: ${error.message}`,
);
}
}
// Deliver to mentioned actors' inboxes (skip reply-to author, already delivered above)
for (const { handle: mHandle, actorUrl: mUrl, actor: mActor } of mentionRecipients) {
if (replyToActor?.url === mUrl) continue;
try {
await ctx.sendActivity(
{ identifier: handle },
mActor,
activity,
{ orderingKey: properties.url },
);
console.info(
`[ActivityPub] Mention delivered to @${mHandle}: ${mUrl}`,
);
} catch (error) {
console.warn(
`[ActivityPub] Failed to deliver mention to @${mHandle}: ${error.message}`,
);
}
}
// Determine activity type name
const typeName =
activity.constructor?.name || "Create";
const replyNote = replyToActor
? ` (reply to ${replyToActor.url})`
: "";
const mentionNote = mentionRecipients.length > 0
? ` (mentions: ${mentionRecipients.map(m => `@${m.handle}`).join(", ")})`
: "";
await logActivity(self._collections.ap_activities, {
direction: "outbound",
type: typeName,
actorUrl: self._publicationUrl,
objectUrl: properties.url,
targetUrl: properties["in-reply-to"] || undefined,
summary: `Sent ${typeName} for ${properties.url} to ${followerCount} followers${replyNote}${mentionNote}`,
});
console.info(
`[ActivityPub] Syndication queued: ${typeName} for ${properties.url}${replyNote}`,
);
return properties.url || undefined;
} catch (error) {
console.error("[ActivityPub] Syndication failed:", error.message);
await logActivity(self._collections.ap_activities, {
direction: "outbound",
type: "Syndicate",
actorUrl: self._publicationUrl,
objectUrl: properties.url,
summary: `Syndication failed: ${error.message}`,
}).catch(() => {});
return undefined;
}
},
delete: async (url) => this.delete(url),
update: async (properties) => this.update(properties),
};
return createSyndicator(this);
}
/**
@@ -959,9 +720,6 @@ export default class ActivityPubEndpoint {
{ handle, publicationUrl: this._publicationUrl },
);
// Build the full actor object (same as what the dispatcher serves).
// Note: ctx.getActor() only exists on RequestContext, not the base
// Context returned by createContext(), so we use the shared helper.
const actor = await buildPersonActor(
ctx,
handle,
@@ -978,86 +736,17 @@ export default class ActivityPubEndpoint {
object: actor,
});
// Fetch followers and deduplicate by shared inbox so each remote
// server only gets one delivery (same as preferSharedInbox but
// gives us control over batching).
const followers = await this._collections.ap_followers
.find({})
.project({ actorUrl: 1, inbox: 1, sharedInbox: 1 })
.toArray();
// Group by shared inbox (or direct inbox if none)
const inboxMap = new Map();
for (const f of followers) {
const key = f.sharedInbox || f.inbox;
if (key && !inboxMap.has(key)) {
inboxMap.set(key, f);
}
}
const uniqueRecipients = [...inboxMap.values()];
const BATCH_SIZE = 25;
const BATCH_DELAY_MS = 5000;
let delivered = 0;
let failed = 0;
console.info(
`[ActivityPub] Broadcasting Update(Person) to ${uniqueRecipients.length} ` +
`unique inboxes (${followers.length} followers) in batches of ${BATCH_SIZE}`,
);
for (let i = 0; i < uniqueRecipients.length; i += BATCH_SIZE) {
const batch = uniqueRecipients.slice(i, i + BATCH_SIZE);
// Build Fedify-compatible Recipient objects:
// extractInboxes() reads: recipient.id, recipient.inboxId,
// recipient.endpoints?.sharedInbox
const recipients = batch.map((f) => ({
id: new URL(f.actorUrl),
inboxId: new URL(f.inbox || f.sharedInbox),
endpoints: f.sharedInbox
? { sharedInbox: new URL(f.sharedInbox) }
: undefined,
}));
try {
await ctx.sendActivity(
{ identifier: handle },
recipients,
update,
{ preferSharedInbox: true },
);
delivered += batch.length;
} catch (error) {
failed += batch.length;
console.warn(
`[ActivityPub] Batch ${Math.floor(i / BATCH_SIZE) + 1} failed: ${error.message}`,
);
}
// Stagger batches so remote servers don't all re-fetch at once
if (i + BATCH_SIZE < uniqueRecipients.length) {
await new Promise((resolve) => setTimeout(resolve, BATCH_DELAY_MS));
}
}
console.info(
`[ActivityPub] Update(Person) broadcast complete: ` +
`${delivered} delivered, ${failed} failed`,
);
await logActivity(this._collections.ap_activities, {
direction: "outbound",
type: "Update",
actorUrl: this._publicationUrl,
await batchBroadcast({
federation: this._federation,
collections: this._collections,
publicationUrl: this._publicationUrl,
handle,
activity: update,
label: "Update(Person)",
objectUrl: this._getActorUrl(),
summary: `Sent Update(Person) to ${delivered}/${uniqueRecipients.length} inboxes`,
}).catch(() => {});
});
} catch (error) {
console.error(
"[ActivityPub] broadcastActorUpdate failed:",
error.message,
);
console.error("[ActivityPub] broadcastActorUpdate failed:", error.message);
}
}
@@ -1082,71 +771,15 @@ export default class ActivityPubEndpoint {
object: new URL(postUrl),
});
const followers = await this._collections.ap_followers
.find({})
.project({ actorUrl: 1, inbox: 1, sharedInbox: 1 })
.toArray();
const inboxMap = new Map();
for (const f of followers) {
const key = f.sharedInbox || f.inbox;
if (key && !inboxMap.has(key)) {
inboxMap.set(key, f);
}
}
const uniqueRecipients = [...inboxMap.values()];
const BATCH_SIZE = 25;
const BATCH_DELAY_MS = 5000;
let delivered = 0;
let failed = 0;
console.info(
`[ActivityPub] Broadcasting Delete for ${postUrl} to ${uniqueRecipients.length} ` +
`unique inboxes (${followers.length} followers)`,
);
for (let i = 0; i < uniqueRecipients.length; i += BATCH_SIZE) {
const batch = uniqueRecipients.slice(i, i + BATCH_SIZE);
const recipients = batch.map((f) => ({
id: new URL(f.actorUrl),
inboxId: new URL(f.inbox || f.sharedInbox),
endpoints: f.sharedInbox
? { sharedInbox: new URL(f.sharedInbox) }
: undefined,
}));
try {
await ctx.sendActivity(
{ identifier: handle },
recipients,
del,
{ preferSharedInbox: true },
);
delivered += batch.length;
} catch (error) {
failed += batch.length;
console.warn(
`[ActivityPub] Delete batch ${Math.floor(i / BATCH_SIZE) + 1} failed: ${error.message}`,
);
}
if (i + BATCH_SIZE < uniqueRecipients.length) {
await new Promise((resolve) => setTimeout(resolve, BATCH_DELAY_MS));
}
}
console.info(
`[ActivityPub] Delete broadcast complete for ${postUrl}: ${delivered} delivered, ${failed} failed`,
);
await logActivity(this._collections.ap_activities, {
direction: "outbound",
type: "Delete",
actorUrl: this._publicationUrl,
await batchBroadcast({
federation: this._federation,
collections: this._collections,
publicationUrl: this._publicationUrl,
handle,
activity: del,
label: "Delete",
objectUrl: postUrl,
summary: `Sent Delete for ${postUrl} to ${delivered} inboxes`,
}).catch(() => {});
});
} catch (error) {
console.warn("[ActivityPub] broadcastDelete failed:", error.message);
}
@@ -1191,10 +824,6 @@ export default class ActivityPubEndpoint {
{ handle, publicationUrl: this._publicationUrl },
);
// Build the Note/Article object by calling jf2ToAS2Activity() and
// extracting the wrapped object from the returned Create activity.
// For post edits, Fediverse servers expect an Update activity wrapping
// the updated object — NOT a second Create activity.
const createActivity = jf2ToAS2Activity(
properties,
actorUrl,
@@ -1207,78 +836,21 @@ export default class ActivityPubEndpoint {
return;
}
// Extract the Note/Article object from the Create wrapper, then build
// an Update activity around it — matching broadcastActorUpdate() pattern.
const noteObject = await createActivity.getObject();
const activity = new Update({
actor: ctx.getActorUri(handle),
object: noteObject,
});
const followers = await this._collections.ap_followers
.find({})
.project({ actorUrl: 1, inbox: 1, sharedInbox: 1 })
.toArray();
const inboxMap = new Map();
for (const f of followers) {
const key = f.sharedInbox || f.inbox;
if (key && !inboxMap.has(key)) {
inboxMap.set(key, f);
}
}
const uniqueRecipients = [...inboxMap.values()];
const BATCH_SIZE = 25;
const BATCH_DELAY_MS = 5000;
let delivered = 0;
let failed = 0;
console.info(
`[ActivityPub] Broadcasting Update for ${properties.url} to ${uniqueRecipients.length} unique inboxes`,
);
for (let i = 0; i < uniqueRecipients.length; i += BATCH_SIZE) {
const batch = uniqueRecipients.slice(i, i + BATCH_SIZE);
const recipients = batch.map((f) => ({
id: new URL(f.actorUrl),
inboxId: new URL(f.inbox || f.sharedInbox),
endpoints: f.sharedInbox
? { sharedInbox: new URL(f.sharedInbox) }
: undefined,
}));
try {
await ctx.sendActivity(
{ identifier: handle },
recipients,
activity,
{ preferSharedInbox: true },
);
delivered += batch.length;
} catch (error) {
failed += batch.length;
console.warn(
`[ActivityPub] Update batch ${Math.floor(i / BATCH_SIZE) + 1} failed: ${error.message}`,
);
}
if (i + BATCH_SIZE < uniqueRecipients.length) {
await new Promise((resolve) => setTimeout(resolve, BATCH_DELAY_MS));
}
}
console.info(
`[ActivityPub] Update broadcast complete for ${properties.url}: ${delivered} delivered, ${failed} failed`,
);
await logActivity(this._collections.ap_activities, {
direction: "outbound",
type: "Update",
actorUrl: this._publicationUrl,
await batchBroadcast({
federation: this._federation,
collections: this._collections,
publicationUrl: this._publicationUrl,
handle,
activity,
label: "Update(Note)",
objectUrl: properties.url,
summary: `Sent Update for ${properties.url} to ${delivered} inboxes`,
}).catch(() => {});
});
} catch (error) {
console.warn("[ActivityPub] broadcastPostUpdate failed:", error.message);
}
@@ -1378,243 +950,11 @@ export default class ActivityPubEndpoint {
_publicationUrl: this._publicationUrl,
};
// Create indexes — wrapped in try-catch because collection references
// may be undefined if MongoDB hasn't finished connecting yet.
// Indexes are idempotent; they'll be created on next successful startup.
try {
// TTL index for activity cleanup (MongoDB handles expiry automatically)
const retentionDays = this.options.activityRetentionDays;
if (retentionDays > 0) {
this._collections.ap_activities.createIndex(
{ receivedAt: 1 },
{ expireAfterSeconds: retentionDays * 86_400 },
);
}
// 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 },
);
// Reader indexes (timeline, notifications, moderation, interactions)
this._collections.ap_timeline.createIndex(
{ uid: 1 },
{ unique: true, background: true },
);
this._collections.ap_timeline.createIndex(
{ published: -1 },
{ background: true },
);
this._collections.ap_timeline.createIndex(
{ "author.url": 1 },
{ background: true },
);
this._collections.ap_timeline.createIndex(
{ type: 1, published: -1 },
{ background: true },
);
this._collections.ap_notifications.createIndex(
{ uid: 1 },
{ unique: true, background: true },
);
this._collections.ap_notifications.createIndex(
{ published: -1 },
{ background: true },
);
this._collections.ap_notifications.createIndex(
{ read: 1 },
{ background: true },
);
this._collections.ap_notifications.createIndex(
{ type: 1, published: -1 },
{ background: true },
);
// TTL index for notification cleanup
const notifRetention = this.options.notificationRetentionDays;
if (notifRetention > 0) {
this._collections.ap_notifications.createIndex(
{ createdAt: 1 },
{ expireAfterSeconds: notifRetention * 86_400 },
);
}
// Message indexes
this._collections.ap_messages.createIndex(
{ uid: 1 },
{ unique: true, background: true },
);
this._collections.ap_messages.createIndex(
{ published: -1 },
{ background: true },
);
this._collections.ap_messages.createIndex(
{ read: 1 },
{ background: true },
);
this._collections.ap_messages.createIndex(
{ conversationId: 1, published: -1 },
{ background: true },
);
this._collections.ap_messages.createIndex(
{ direction: 1 },
{ background: true },
);
// TTL index for message cleanup (reuse notification retention)
if (notifRetention > 0) {
this._collections.ap_messages.createIndex(
{ createdAt: 1 },
{ expireAfterSeconds: notifRetention * 86_400 },
);
}
// Muted collection — sparse unique indexes (allow multiple null values)
this._collections.ap_muted
.dropIndex("url_1")
.catch(() => {})
.then(() =>
this._collections.ap_muted.createIndex(
{ url: 1 },
{ unique: true, sparse: true, background: true },
),
)
.catch(() => {});
this._collections.ap_muted
.dropIndex("keyword_1")
.catch(() => {})
.then(() =>
this._collections.ap_muted.createIndex(
{ keyword: 1 },
{ unique: true, sparse: true, background: true },
),
)
.catch(() => {});
this._collections.ap_blocked.createIndex(
{ url: 1 },
{ unique: true, background: true },
);
this._collections.ap_interactions.createIndex(
{ objectUrl: 1, type: 1 },
{ unique: true, background: true },
);
this._collections.ap_interactions.createIndex(
{ type: 1 },
{ background: true },
);
// Followed hashtags — unique on tag (case-insensitive via normalization at write time)
this._collections.ap_followed_tags.createIndex(
{ tag: 1 },
{ unique: true, background: true },
);
// Tag filtering index on timeline
this._collections.ap_timeline.createIndex(
{ category: 1, published: -1 },
{ background: true },
);
// Explore tab indexes
// Compound unique on (type, domain, scope, hashtag) prevents duplicate tabs.
// ALL insertions must explicitly set all four fields (unused fields = null)
// because MongoDB treats missing fields differently from null in unique indexes.
this._collections.ap_explore_tabs.createIndex(
{ type: 1, domain: 1, scope: 1, hashtag: 1 },
{ unique: true, background: true },
);
// Order index for efficient sorting of tab bar
this._collections.ap_explore_tabs.createIndex(
{ order: 1 },
{ background: true },
);
// ap_reports indexes
if (notifRetention > 0) {
this._collections.ap_reports.createIndex(
{ createdAt: 1 },
{ expireAfterSeconds: notifRetention * 86_400 },
);
}
this._collections.ap_reports.createIndex(
{ reporterUrl: 1 },
{ background: true },
);
this._collections.ap_reports.createIndex(
{ reportedUrls: 1 },
{ background: true },
);
// Pending follow requests — unique on actorUrl
this._collections.ap_pending_follows.createIndex(
{ actorUrl: 1 },
{ unique: true, background: true },
);
this._collections.ap_pending_follows.createIndex(
{ requestedAt: -1 },
{ background: true },
);
// Server-level blocks
this._collections.ap_blocked_servers.createIndex(
{ hostname: 1 },
{ unique: true, background: true },
);
// Key freshness tracking
this._collections.ap_key_freshness.createIndex(
{ actorUrl: 1 },
{ unique: true, background: true },
);
// Inbox queue indexes
this._collections.ap_inbox_queue.createIndex(
{ status: 1, receivedAt: 1 },
{ background: true },
);
// TTL: auto-prune completed items after 24h
this._collections.ap_inbox_queue.createIndex(
{ processedAt: 1 },
{ expireAfterSeconds: 86_400, background: true },
);
// Mastodon Client API indexes
this._collections.ap_oauth_apps.createIndex(
{ clientId: 1 },
{ unique: true, background: true },
);
this._collections.ap_oauth_tokens.createIndex(
{ accessToken: 1 },
{ unique: true, sparse: true, background: true },
);
this._collections.ap_oauth_tokens.createIndex(
{ code: 1 },
{ unique: true, sparse: true, background: true },
);
this._collections.ap_markers.createIndex(
{ userId: 1, timeline: 1 },
{ unique: true, background: true },
);
} catch {
// Index creation failed — collections not yet available.
// Indexes already exist from previous startups; non-fatal.
}
// Create indexes (idempotent — safe on every startup)
createIndexes(this._collections, {
activityRetentionDays: this.options.activityRetentionDays,
notificationRetentionDays: this.options.notificationRetentionDays,
});
// Seed actor profile from config on first run
this._seedProfile().catch((error) => {