diff --git a/index.js b/index.js
index 238d693..05b0733 100644
--- a/index.js
+++ b/index.js
@@ -1,6 +1,8 @@
import express from "express";
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 { lookupWithSecurity } from "./lib/lookup-helpers.js";
import {
@@ -224,6 +226,14 @@ export default class ActivityPubEndpoint {
// Skip Fedify for admin UI routes — they're handled by the
// authenticated `routes` getter, not the federation layer.
if (req.path.startsWith("/admin")) return next();
+
+ // Diagnostic: log inbox POSTs to detect federation stalls
+ if (req.method === "POST" && req.path.includes("inbox")) {
+ const ua = req.get("user-agent") || "unknown";
+ const bodyParsed = req.body !== undefined && Object.keys(req.body || {}).length > 0;
+ console.info(`[federation-diag] POST ${req.path} from=${ua.slice(0, 60)} bodyParsed=${bodyParsed} readable=${req.readable}`);
+ }
+
return self._fedifyMiddleware(req, res, next);
});
@@ -1375,6 +1385,10 @@ export default class ActivityPubEndpoint {
Indiekit.addCollection("ap_key_freshness");
// Async inbox processing queue
Indiekit.addCollection("ap_inbox_queue");
+ // Mastodon Client API collections
+ Indiekit.addCollection("ap_oauth_apps");
+ Indiekit.addCollection("ap_oauth_tokens");
+ Indiekit.addCollection("ap_markers");
// Store collection references (posts resolved lazily)
const indiekitCollections = Indiekit.collections;
@@ -1408,6 +1422,10 @@ export default class ActivityPubEndpoint {
ap_key_freshness: indiekitCollections.get("ap_key_freshness"),
// Async inbox processing queue
ap_inbox_queue: indiekitCollections.get("ap_inbox_queue"),
+ // Mastodon Client API collections
+ ap_oauth_apps: indiekitCollections.get("ap_oauth_apps"),
+ ap_oauth_tokens: indiekitCollections.get("ap_oauth_tokens"),
+ ap_markers: indiekitCollections.get("ap_markers"),
get posts() {
return indiekitCollections.get("posts");
},
@@ -1629,6 +1647,24 @@ export default class ActivityPubEndpoint {
{ 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.
@@ -1695,6 +1731,29 @@ export default class ActivityPubEndpoint {
routesPublic: this.contentNegotiationRoutes,
});
+ // Set local identity for own-post detection in status serialization
+ setLocalIdentity(this._publicationUrl, this.options.actor?.handle || "user");
+
+ // Mastodon Client API — virtual endpoint at root
+ // Mastodon-compatible clients (Phanpy, Elk, etc.) expect /api/v1/*,
+ // /api/v2/*, /oauth/* at the domain root, not under /activitypub.
+ const pluginRef = this;
+ const mastodonRouter = createMastodonRouter({
+ collections: this._collections,
+ pluginOptions: {
+ handle: this.options.actor?.handle || "user",
+ publicationUrl: this._publicationUrl,
+ federation: this._federation,
+ followActor: (url, info) => pluginRef.followActor(url, info),
+ unfollowActor: (url) => pluginRef.unfollowActor(url),
+ },
+ });
+ Indiekit.addEndpoint({
+ name: "Mastodon Client API",
+ mountPath: "/",
+ routesPublic: mastodonRouter,
+ });
+
// Register syndicator (appears in post editing UI)
Indiekit.addSyndicator(this.syndicator);
@@ -1743,6 +1802,20 @@ export default class ActivityPubEndpoint {
keyRefreshHandle,
);
+ // Backfill ap_timeline from posts collection (idempotent, runs on every startup)
+ import("./lib/mastodon/backfill-timeline.js").then(({ backfillTimeline }) => {
+ // Delay to let MongoDB connections settle
+ setTimeout(() => {
+ backfillTimeline(this._collections).then(({ total, inserted, skipped }) => {
+ if (inserted > 0) {
+ console.log(`[Mastodon API] Timeline backfill: ${inserted} posts added (${skipped} already existed, ${total} total)`);
+ }
+ }).catch((error) => {
+ console.warn("[Mastodon API] Timeline backfill failed:", error.message);
+ });
+ }, 5000);
+ });
+
// Start async inbox queue processor (processes one item every 3s)
this._inboxProcessorInterval = startInboxProcessor(
this._collections,
diff --git a/lib/mastodon/backfill-timeline.js b/lib/mastodon/backfill-timeline.js
new file mode 100644
index 0000000..2b229fd
--- /dev/null
+++ b/lib/mastodon/backfill-timeline.js
@@ -0,0 +1,311 @@
+/**
+ * Backfill ap_timeline from the posts collection.
+ *
+ * Runs on startup (idempotent — uses upsert by uid).
+ * Converts Micropub JF2 posts into ap_timeline format so they
+ * appear in Mastodon Client API timelines and profile views.
+ */
+
+/**
+ * Backfill ap_timeline with published posts from the posts collection.
+ *
+ * @param {object} collections - MongoDB collections (must include posts, ap_timeline, ap_profile)
+ * @returns {Promise<{ total: number, inserted: number, skipped: number }>}
+ */
+export async function backfillTimeline(collections) {
+ const { posts, ap_timeline, ap_profile } = collections;
+
+ if (!posts || !ap_timeline) {
+ return { total: 0, inserted: 0, skipped: 0 };
+ }
+
+ // Get local profile for author info
+ const profile = await ap_profile?.findOne({});
+ const siteUrl = profile?.url?.replace(/\/$/, "") || "";
+ const author = profile
+ ? {
+ name: profile.name || "",
+ url: profile.url || "",
+ photo: profile.icon || "",
+ handle: "",
+ }
+ : { name: "", url: "", photo: "", handle: "" };
+
+ // Fetch all published posts
+ const allPosts = await posts
+ .find({
+ "properties.post-status": { $ne: "draft" },
+ "properties.deleted": { $exists: false },
+ "properties.url": { $exists: true },
+ })
+ .toArray();
+
+ let inserted = 0;
+ let skipped = 0;
+
+ for (const post of allPosts) {
+ const props = post.properties;
+ if (!props?.url) {
+ skipped++;
+ continue;
+ }
+
+ const uid = props.url;
+
+ // Check if already in timeline (fast path to avoid unnecessary upserts)
+ const exists = await ap_timeline.findOne({ uid }, { projection: { _id: 1 } });
+ if (exists) {
+ skipped++;
+ continue;
+ }
+
+ // Build content — interaction types (bookmark, like, repost) may not have
+ // body content, so synthesize it from the interaction target URL
+ const content = buildContent(props);
+ const type = mapPostType(props["post-type"]);
+
+ // Extract categories + inline hashtags from content
+ const categories = normalizeArray(props.category);
+ const inlineHashtags = extractHashtags(content.text + " " + (content.html || ""));
+ const mergedCategories = mergeCategories(categories, inlineHashtags);
+
+ const timelineItem = {
+ uid,
+ url: uid,
+ type,
+ content: rewriteHashtagLinks(content, siteUrl),
+ author,
+ published: props.published || props.date || new Date().toISOString(),
+ createdAt: props.published || props.date || new Date().toISOString(),
+ visibility: "public",
+ sensitive: false,
+ category: mergedCategories,
+ photo: normalizeMediaArray(props.photo, siteUrl),
+ video: normalizeMediaArray(props.video, siteUrl),
+ audio: normalizeMediaArray(props.audio, siteUrl),
+ readBy: [],
+ };
+
+ // Optional fields
+ if (props.name) timelineItem.name = props.name;
+ if (props.summary) timelineItem.summary = props.summary;
+ if (props["in-reply-to"]) {
+ timelineItem.inReplyTo = Array.isArray(props["in-reply-to"])
+ ? props["in-reply-to"][0]
+ : props["in-reply-to"];
+ }
+
+ try {
+ const result = await ap_timeline.updateOne(
+ { uid },
+ { $setOnInsert: timelineItem },
+ { upsert: true },
+ );
+ if (result.upsertedCount > 0) {
+ inserted++;
+ } else {
+ skipped++;
+ }
+ } catch {
+ skipped++;
+ }
+ }
+
+ return { total: allPosts.length, inserted, skipped };
+}
+
+// ─── Content Building ─────────────────────────────────────────────────────────
+
+/**
+ * Build content from JF2 properties, synthesizing content for interaction types.
+ * Bookmarks, likes, and reposts often have no body text — show the target URL.
+ */
+function buildContent(props) {
+ const raw = normalizeContent(props.content);
+
+ // If there's already content, use it
+ if (raw.text || raw.html) return raw;
+
+ // Synthesize content for interaction types
+ const bookmarkOf = props["bookmark-of"];
+ const likeOf = props["like-of"];
+ const repostOf = props["repost-of"];
+ const name = props.name;
+
+ if (bookmarkOf) {
+ const label = name || bookmarkOf;
+ return {
+ text: `Bookmarked: ${label}`,
+ html: `
Bookmarked: ${escapeHtml(label)}
`,
+ };
+ }
+
+ if (likeOf) {
+ return {
+ text: `Liked: ${likeOf}`,
+ html: `Liked: ${escapeHtml(likeOf)}
`,
+ };
+ }
+
+ if (repostOf) {
+ const label = name || repostOf;
+ return {
+ text: `Reposted: ${label}`,
+ html: `Reposted: ${escapeHtml(label)}
`,
+ };
+ }
+
+ // Article with title but no body
+ if (name) {
+ return { text: name, html: `${escapeHtml(name)}
` };
+ }
+
+ return raw;
+}
+
+/**
+ * Normalize content from JF2 properties to { text, html } format.
+ */
+function normalizeContent(content) {
+ if (!content) return { text: "", html: "" };
+ if (typeof content === "string") return { text: content, html: `${content}
` };
+ if (typeof content === "object") {
+ return {
+ text: content.text || content.value || "",
+ html: content.html || content.text || content.value || "",
+ };
+ }
+ return { text: "", html: "" };
+}
+
+// ─── Hashtag Handling ─────────────────────────────────────────────────────────
+
+/**
+ * Extract hashtags from text content.
+ * Matches #word patterns, returns lowercase tag names without the # prefix.
+ */
+function extractHashtags(text) {
+ if (!text) return [];
+ const matches = text.match(/(?:^|\s)#([a-zA-Z_]\w*)/g);
+ if (!matches) return [];
+ return [...new Set(matches.map((m) => m.trim().slice(1).toLowerCase()))];
+}
+
+/**
+ * Merge explicit categories with inline hashtags (deduplicated, case-insensitive).
+ */
+function mergeCategories(categories, hashtags) {
+ const seen = new Set(categories.map((c) => c.toLowerCase()));
+ const result = [...categories];
+ for (const tag of hashtags) {
+ if (!seen.has(tag)) {
+ seen.add(tag);
+ result.push(tag);
+ }
+ }
+ return result;
+}
+
+/**
+ * Rewrite hashtag links in HTML from site-internal (/categories/tag/) to
+ * Mastodon-compatible format. Mastodon clients use the tag objects, not
+ * inline links, but having correct href helps with link following.
+ */
+function rewriteHashtagLinks(content, siteUrl) {
+ if (!content.html) return content;
+ // Rewrite /categories/tag/ links to /tags/tag (Mastodon convention)
+ let html = content.html.replace(
+ /href="\/categories\/([^/"]+)\/?"/g,
+ (_, tag) => `href="${siteUrl}/tags/${tag}" class="hashtag" rel="tag"`,
+ );
+ // Also rewrite absolute site category links
+ if (siteUrl) {
+ html = html.replace(
+ new RegExp(`href="${escapeRegex(siteUrl)}/categories/([^/"]+)/?"`, "g"),
+ (_, tag) => `href="${siteUrl}/tags/${tag}" class="hashtag" rel="tag"`,
+ );
+ }
+ return { ...content, html };
+}
+
+// ─── Post Type Mapping ────────────────────────────────────────────────────────
+
+/**
+ * Map Micropub post-type to timeline type.
+ */
+function mapPostType(postType) {
+ switch (postType) {
+ case "article":
+ return "article";
+ case "photo":
+ case "video":
+ case "audio":
+ return "note";
+ case "reply":
+ return "note";
+ case "repost":
+ return "boost";
+ case "like":
+ case "bookmark":
+ return "note";
+ default:
+ return "note";
+ }
+}
+
+// ─── Normalization Helpers ────────────────────────────────────────────────────
+
+/**
+ * Normalize a value to an array of strings.
+ */
+function normalizeArray(value) {
+ if (!value) return [];
+ if (Array.isArray(value)) return value.map(String);
+ return [String(value)];
+}
+
+/**
+ * Normalize media values — resolves relative URLs to absolute.
+ *
+ * @param {*} value - String, object with url, or array thereof
+ * @param {string} siteUrl - Base site URL for resolving relative paths
+ */
+function normalizeMediaArray(value, siteUrl) {
+ if (!value) return [];
+ const arr = Array.isArray(value) ? value : [value];
+ return arr.map((item) => {
+ if (typeof item === "string") return resolveUrl(item, siteUrl);
+ if (typeof item === "object" && item.url) {
+ return { ...item, url: resolveUrl(item.url, siteUrl) };
+ }
+ return null;
+ }).filter(Boolean);
+}
+
+/**
+ * Resolve a URL — if relative, prepend the site URL.
+ */
+function resolveUrl(url, siteUrl) {
+ if (!url) return url;
+ if (url.startsWith("http://") || url.startsWith("https://")) return url;
+ if (url.startsWith("/")) return `${siteUrl}${url}`;
+ return `${siteUrl}/${url}`;
+}
+
+/**
+ * Escape HTML entities.
+ */
+function escapeHtml(str) {
+ return str
+ .replace(/&/g, "&")
+ .replace(//g, ">")
+ .replace(/"/g, """);
+}
+
+/**
+ * Escape regex special characters.
+ */
+function escapeRegex(str) {
+ return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
+}
diff --git a/lib/mastodon/entities/account.js b/lib/mastodon/entities/account.js
new file mode 100644
index 0000000..217ad38
--- /dev/null
+++ b/lib/mastodon/entities/account.js
@@ -0,0 +1,215 @@
+/**
+ * Account entity serializer for Mastodon Client API.
+ *
+ * Converts local profile (ap_profile) and remote actor objects
+ * (from timeline author, follower/following docs) into the
+ * Mastodon Account JSON shape that masto.js expects.
+ */
+import { accountId } from "../helpers/id-mapping.js";
+import { sanitizeHtml, stripHtml } from "./sanitize.js";
+import { getCachedAccountStats } from "../helpers/account-cache.js";
+
+/**
+ * Serialize an actor as a Mastodon Account entity.
+ *
+ * Handles two shapes:
+ * - Local profile: { _id, name, summary, url, icon, image, actorType,
+ * manuallyApprovesFollowers, attachments, createdAt, ... }
+ * - Remote author (from timeline): { name, url, photo, handle, emojis, bot }
+ * - Follower/following doc: { actorUrl, name, handle, avatar, ... }
+ *
+ * @param {object} actor - Actor document (profile, author, or follower)
+ * @param {object} options
+ * @param {string} options.baseUrl - Server base URL
+ * @param {boolean} [options.isLocal=false] - Whether this is the local user
+ * @param {string} [options.handle] - Local actor handle (for local accounts)
+ * @returns {object} Mastodon Account entity
+ */
+export function serializeAccount(actor, { baseUrl, isLocal = false, handle = "" }) {
+ if (!actor) {
+ return null;
+ }
+
+ const id = accountId(actor, isLocal);
+
+ // Resolve username and acct
+ let username;
+ let acct;
+ if (isLocal) {
+ username = handle || extractUsername(actor.url) || "user";
+ acct = username; // local accounts use bare username
+ } else {
+ // Remote: extract from handle (@user@domain) or URL
+ const remoteHandle = actor.handle || "";
+ if (remoteHandle.startsWith("@")) {
+ username = remoteHandle.split("@")[1] || "";
+ acct = remoteHandle.slice(1); // strip leading @
+ } else if (remoteHandle.includes("@")) {
+ username = remoteHandle.split("@")[0];
+ acct = remoteHandle;
+ } else {
+ username = extractUsername(actor.url || actor.actorUrl) || "unknown";
+ const domain = extractDomain(actor.url || actor.actorUrl);
+ acct = domain ? `${username}@${domain}` : username;
+ }
+ }
+
+ // Resolve display name
+ const displayName = actor.name || actor.displayName || username || "";
+
+ // Resolve URLs for avatar and header
+ const avatarUrl =
+ actor.icon || actor.avatarUrl || actor.photo || actor.avatar || "";
+ const headerUrl = actor.image || actor.bannerUrl || "";
+
+ // Resolve URL
+ const url = actor.url || actor.actorUrl || "";
+
+ // Resolve note/summary
+ const note = actor.summary || "";
+
+ // Bot detection
+ const bot =
+ actor.bot === true ||
+ actor.actorType === "Service" ||
+ actor.actorType === "Application";
+
+ // Profile fields from attachments
+ const fields = (actor.attachments || actor.fields || []).map((f) => ({
+ name: f.name || "",
+ value: sanitizeHtml(f.value || ""),
+ verified_at: null,
+ }));
+
+ // Custom emojis
+ const emojis = (actor.emojis || []).map((e) => ({
+ shortcode: e.shortcode || "",
+ url: e.url || "",
+ static_url: e.url || "",
+ visible_in_picker: true,
+ }));
+
+ return {
+ id,
+ username,
+ acct,
+ url,
+ display_name: displayName,
+ note: sanitizeHtml(note),
+ avatar: avatarUrl || `${baseUrl}/images/default-avatar.svg`,
+ avatar_static: avatarUrl || `${baseUrl}/images/default-avatar.svg`,
+ header: headerUrl || "",
+ header_static: headerUrl || "",
+ locked: actor.manuallyApprovesFollowers || false,
+ fields,
+ emojis,
+ bot,
+ group: actor.actorType === "Group" || false,
+ discoverable: true,
+ noindex: false,
+ created_at: actor.createdAt || new Date().toISOString(),
+ last_status_at: actor.lastStatusAt || null,
+ statuses_count: actor.statusesCount || 0,
+ followers_count: actor.followersCount || 0,
+ following_count: actor.followingCount || 0,
+ // Enrich from cache if counts are 0 (embedded accounts in statuses lack counts)
+ ...((!actor.statusesCount && !actor.followersCount && !isLocal)
+ ? (() => {
+ const cached = getCachedAccountStats(url);
+ return cached
+ ? {
+ statuses_count: cached.statusesCount || 0,
+ followers_count: cached.followersCount || 0,
+ following_count: cached.followingCount || 0,
+ created_at: cached.createdAt || actor.createdAt || new Date().toISOString(),
+ }
+ : {};
+ })()
+ : {}),
+ moved: actor.movedTo || null,
+ suspended: false,
+ limited: false,
+ memorial: false,
+ roles: [],
+ hide_collections: false,
+ };
+}
+
+/**
+ * Serialize the local profile as a CredentialAccount (includes source + role).
+ *
+ * @param {object} profile - ap_profile document
+ * @param {object} options
+ * @param {string} options.baseUrl - Server base URL
+ * @param {string} options.handle - Local actor handle
+ * @param {object} [options.counts] - { statuses, followers, following }
+ * @returns {object} Mastodon CredentialAccount entity
+ */
+export function serializeCredentialAccount(profile, { baseUrl, handle, counts = {} }) {
+ const account = serializeAccount(profile, {
+ baseUrl,
+ isLocal: true,
+ handle,
+ });
+
+ // Add counts if provided
+ account.statuses_count = counts.statuses || 0;
+ account.followers_count = counts.followers || 0;
+ account.following_count = counts.following || 0;
+
+ // CredentialAccount extensions
+ account.source = {
+ privacy: "public",
+ sensitive: false,
+ language: "",
+ note: stripHtml(profile.summary || ""),
+ fields: (profile.attachments || []).map((f) => ({
+ name: f.name || "",
+ value: f.value || "",
+ verified_at: null,
+ })),
+ follow_requests_count: 0,
+ };
+
+ account.role = {
+ id: "-99",
+ name: "",
+ permissions: "0",
+ color: "",
+ highlighted: false,
+ };
+
+ return account;
+}
+
+// ─── Helpers ─────────────────────────────────────────────────────────────────
+
+/**
+ * Extract username from a URL path.
+ * Handles /@username, /users/username patterns.
+ */
+function extractUsername(url) {
+ if (!url) return "";
+ try {
+ const { pathname } = new URL(url);
+ const atMatch = pathname.match(/\/@([^/]+)/);
+ if (atMatch) return atMatch[1];
+ const usersMatch = pathname.match(/\/users\/([^/]+)/);
+ if (usersMatch) return usersMatch[1];
+ return "";
+ } catch {
+ return "";
+ }
+}
+
+/**
+ * Extract domain from a URL.
+ */
+function extractDomain(url) {
+ if (!url) return "";
+ try {
+ return new URL(url).hostname;
+ } catch {
+ return "";
+ }
+}
diff --git a/lib/mastodon/entities/instance.js b/lib/mastodon/entities/instance.js
new file mode 100644
index 0000000..1e23f3b
--- /dev/null
+++ b/lib/mastodon/entities/instance.js
@@ -0,0 +1 @@
+// Instance v1/v2 serializer — implemented in Task 8
diff --git a/lib/mastodon/entities/media.js b/lib/mastodon/entities/media.js
new file mode 100644
index 0000000..25f3ce0
--- /dev/null
+++ b/lib/mastodon/entities/media.js
@@ -0,0 +1,38 @@
+/**
+ * MediaAttachment entity serializer for Mastodon Client API.
+ *
+ * Converts stored media metadata to Mastodon MediaAttachment shape.
+ */
+
+/**
+ * Serialize a MediaAttachment entity.
+ *
+ * @param {object} media - Media document from ap_media collection
+ * @returns {object} Mastodon MediaAttachment entity
+ */
+export function serializeMediaAttachment(media) {
+ const type = detectMediaType(media.contentType || media.type || "");
+
+ return {
+ id: media._id ? media._id.toString() : media.id || "",
+ type,
+ url: media.url || "",
+ preview_url: media.thumbnailUrl || media.url || "",
+ remote_url: null,
+ text_url: media.url || "",
+ meta: media.meta || {},
+ description: media.description || media.alt || null,
+ blurhash: media.blurhash || null,
+ };
+}
+
+/**
+ * Map MIME type or simple type string to Mastodon media type.
+ */
+function detectMediaType(contentType) {
+ if (contentType.startsWith("image/") || contentType === "image") return "image";
+ if (contentType.startsWith("video/") || contentType === "video") return "video";
+ if (contentType.startsWith("audio/") || contentType === "audio") return "audio";
+ if (contentType.startsWith("image/gif")) return "gifv";
+ return "unknown";
+}
diff --git a/lib/mastodon/entities/notification.js b/lib/mastodon/entities/notification.js
new file mode 100644
index 0000000..9ee0093
--- /dev/null
+++ b/lib/mastodon/entities/notification.js
@@ -0,0 +1,130 @@
+/**
+ * Notification entity serializer for Mastodon Client API.
+ *
+ * Converts ap_notifications documents into the Mastodon Notification JSON shape.
+ *
+ * Internal type -> Mastodon type mapping:
+ * like -> favourite
+ * boost -> reblog
+ * follow -> follow
+ * reply -> mention
+ * mention -> mention
+ * dm -> mention (status will have visibility: "direct")
+ */
+import { serializeAccount } from "./account.js";
+import { serializeStatus } from "./status.js";
+import { encodeCursor } from "../helpers/pagination.js";
+
+/**
+ * Map internal notification types to Mastodon API types.
+ */
+const TYPE_MAP = {
+ like: "favourite",
+ boost: "reblog",
+ follow: "follow",
+ follow_request: "follow_request",
+ reply: "mention",
+ mention: "mention",
+ dm: "mention",
+ report: "admin.report",
+};
+
+/**
+ * Serialize a notification document as a Mastodon Notification entity.
+ *
+ * @param {object} notif - ap_notifications document
+ * @param {object} options
+ * @param {string} options.baseUrl - Server base URL
+ * @param {Map} [options.statusMap] - Pre-fetched statuses keyed by targetUrl
+ * @param {object} [options.interactionState] - { favouritedIds, rebloggedIds, bookmarkedIds }
+ * @returns {object|null} Mastodon Notification entity
+ */
+export function serializeNotification(notif, { baseUrl, statusMap, interactionState }) {
+ if (!notif) return null;
+
+ const mastodonType = TYPE_MAP[notif.type] || notif.type;
+
+ // Build the actor account from notification fields
+ const account = serializeAccount(
+ {
+ name: notif.actorName,
+ url: notif.actorUrl,
+ photo: notif.actorPhoto,
+ handle: notif.actorHandle,
+ },
+ { baseUrl },
+ );
+
+ // Resolve the associated status (for favourite, reblog, mention types)
+ // For mention types, prefer the triggering post (notif.url) over the target post (notif.targetUrl)
+ // because targetUrl for replies points to the user's OWN post being replied to
+ let status = null;
+ if (statusMap) {
+ const isMentionType = mastodonType === "mention";
+ const lookupUrl = isMentionType
+ ? (notif.url || notif.targetUrl)
+ : (notif.targetUrl || notif.url);
+
+ if (lookupUrl) {
+ const timelineItem = statusMap.get(lookupUrl);
+ if (timelineItem) {
+ status = serializeStatus(timelineItem, {
+ baseUrl,
+ favouritedIds: interactionState?.favouritedIds || new Set(),
+ rebloggedIds: interactionState?.rebloggedIds || new Set(),
+ bookmarkedIds: interactionState?.bookmarkedIds || new Set(),
+ pinnedIds: new Set(),
+ });
+ }
+ }
+ }
+
+ // For mentions/replies that don't have a matching timeline item,
+ // construct a minimal status from the notification content
+ if (!status && notif.content && (mastodonType === "mention")) {
+ status = {
+ id: notif._id.toString(),
+ created_at: notif.published || notif.createdAt || new Date().toISOString(),
+ in_reply_to_id: null,
+ in_reply_to_account_id: null,
+ sensitive: false,
+ spoiler_text: "",
+ visibility: notif.type === "dm" ? "direct" : "public",
+ language: null,
+ uri: notif.uid || "",
+ url: notif.url || notif.targetUrl || notif.uid || "",
+ replies_count: 0,
+ reblogs_count: 0,
+ favourites_count: 0,
+ edited_at: null,
+ favourited: false,
+ reblogged: false,
+ muted: false,
+ bookmarked: false,
+ pinned: false,
+ content: notif.content?.html || notif.content?.text || "",
+ filtered: null,
+ reblog: null,
+ application: null,
+ account,
+ media_attachments: [],
+ mentions: [],
+ tags: [],
+ emojis: [],
+ card: null,
+ poll: null,
+ };
+ }
+
+ const createdAt = notif.published instanceof Date
+ ? notif.published.toISOString()
+ : notif.published || notif.createdAt || new Date().toISOString();
+
+ return {
+ id: encodeCursor(createdAt) || notif._id.toString(),
+ type: mastodonType,
+ created_at: createdAt,
+ account,
+ status,
+ };
+}
diff --git a/lib/mastodon/entities/relationship.js b/lib/mastodon/entities/relationship.js
new file mode 100644
index 0000000..df5aede
--- /dev/null
+++ b/lib/mastodon/entities/relationship.js
@@ -0,0 +1,38 @@
+/**
+ * Relationship entity serializer for Mastodon Client API.
+ *
+ * Represents the relationship between the authenticated user
+ * and another account.
+ */
+
+/**
+ * Serialize a Relationship entity.
+ *
+ * @param {string} id - Account ID
+ * @param {object} state - Relationship state
+ * @param {boolean} [state.following=false]
+ * @param {boolean} [state.followed_by=false]
+ * @param {boolean} [state.blocking=false]
+ * @param {boolean} [state.muting=false]
+ * @param {boolean} [state.requested=false]
+ * @returns {object} Mastodon Relationship entity
+ */
+export function serializeRelationship(id, state = {}) {
+ return {
+ id,
+ following: state.following || false,
+ showing_reblogs: state.following || false,
+ notifying: false,
+ languages: [],
+ followed_by: state.followed_by || false,
+ blocking: state.blocking || false,
+ blocked_by: false,
+ muting: state.muting || false,
+ muting_notifications: state.muting || false,
+ requested: state.requested || false,
+ requested_by: false,
+ domain_blocking: false,
+ endorsed: false,
+ note: "",
+ };
+}
diff --git a/lib/mastodon/entities/sanitize.js b/lib/mastodon/entities/sanitize.js
new file mode 100644
index 0000000..8c8da1a
--- /dev/null
+++ b/lib/mastodon/entities/sanitize.js
@@ -0,0 +1,111 @@
+/**
+ * XSS HTML sanitizer for Mastodon Client API responses.
+ *
+ * Strips dangerous HTML while preserving safe markup that
+ * Mastodon clients expect (links, paragraphs, line breaks,
+ * inline formatting, mentions, hashtags).
+ */
+
+/**
+ * Allowed HTML tags in Mastodon API content fields.
+ * Matches what Mastodon itself permits in status content.
+ */
+const ALLOWED_TAGS = new Set([
+ "a",
+ "br",
+ "p",
+ "span",
+ "strong",
+ "em",
+ "b",
+ "i",
+ "u",
+ "s",
+ "del",
+ "pre",
+ "code",
+ "blockquote",
+ "ul",
+ "ol",
+ "li",
+]);
+
+/**
+ * Allowed attributes per tag.
+ */
+const ALLOWED_ATTRS = {
+ a: new Set(["href", "rel", "class", "target"]),
+ span: new Set(["class"]),
+};
+
+/**
+ * Sanitize HTML content for safe inclusion in API responses.
+ *
+ * Strips all tags not in the allowlist and removes disallowed attributes.
+ * This is a lightweight sanitizer — for production, consider a
+ * battle-tested library like DOMPurify or sanitize-html.
+ *
+ * @param {string} html - Raw HTML string
+ * @returns {string} Sanitized HTML
+ */
+export function sanitizeHtml(html) {
+ if (!html || typeof html !== "string") return "";
+
+ return html.replace(/<\/?([a-z][a-z0-9]*)\b[^>]*>/gi, (match, tagName) => {
+ const tag = tagName.toLowerCase();
+
+ // Closing tag
+ if (match.startsWith("")) {
+ return ALLOWED_TAGS.has(tag) ? `${tag}>` : "";
+ }
+
+ // Opening tag — check if allowed
+ if (!ALLOWED_TAGS.has(tag)) return "";
+
+ // Self-closing br
+ if (tag === "br") return "
";
+
+ // Strip disallowed attributes
+ const allowedAttrs = ALLOWED_ATTRS[tag];
+ if (!allowedAttrs) return `<${tag}>`;
+
+ const attrs = [];
+ const attrRegex = /([a-z][a-z0-9-]*)(?:\s*=\s*(?:"([^"]*)"|'([^']*)'|(\S+)))?/gi;
+ let attrMatch;
+ while ((attrMatch = attrRegex.exec(match)) !== null) {
+ const attrName = attrMatch[1].toLowerCase();
+ if (attrName === tag) continue; // skip tag name itself
+ const attrValue = attrMatch[2] ?? attrMatch[3] ?? attrMatch[4] ?? "";
+ if (allowedAttrs.has(attrName)) {
+ // Block javascript: URIs in href
+ if (attrName === "href" && /^\s*javascript:/i.test(attrValue)) continue;
+ attrs.push(`${attrName}="${escapeAttr(attrValue)}"`);
+ }
+ }
+
+ return attrs.length > 0 ? `<${tag} ${attrs.join(" ")}>` : `<${tag}>`;
+ });
+}
+
+/**
+ * Escape HTML attribute value.
+ * @param {string} value
+ * @returns {string}
+ */
+function escapeAttr(value) {
+ return value
+ .replace(/&/g, "&")
+ .replace(/"/g, """)
+ .replace(//g, ">");
+}
+
+/**
+ * Strip all HTML tags, returning plain text.
+ * @param {string} html
+ * @returns {string}
+ */
+export function stripHtml(html) {
+ if (!html || typeof html !== "string") return "";
+ return html.replace(/<[^>]*>/g, "").trim();
+}
diff --git a/lib/mastodon/entities/status.js b/lib/mastodon/entities/status.js
new file mode 100644
index 0000000..069b92a
--- /dev/null
+++ b/lib/mastodon/entities/status.js
@@ -0,0 +1,312 @@
+/**
+ * Status entity serializer for Mastodon Client API.
+ *
+ * Converts ap_timeline documents into the Mastodon Status JSON shape.
+ *
+ * CORRECTED field mappings (based on actual extractObjectData output):
+ * content <- content.html (NOT contentHtml)
+ * uri <- uid (NOT activityUrl)
+ * account <- author { name, url, photo, handle, emojis, bot }
+ * media <- photo[] + video[] + audio[] (NOT single attachments[])
+ * card <- linkPreviews[0] (NOT single card)
+ * tags <- category[] (NOT tags[])
+ * counts <- counts.boosts, counts.likes, counts.replies
+ * boost <- type:"boost" + boostedBy (flat, NOT nested sharedItem)
+ */
+import { serializeAccount } from "./account.js";
+import { sanitizeHtml } from "./sanitize.js";
+import { encodeCursor } from "../helpers/pagination.js";
+
+// Module-level defaults set once at startup via setLocalIdentity()
+let _localPublicationUrl = "";
+let _localHandle = "";
+
+/**
+ * Set the local identity for own-post detection.
+ * Called once during plugin init.
+ * @param {string} publicationUrl - e.g. "https://rmendes.net/"
+ * @param {string} handle - e.g. "rick"
+ */
+export function setLocalIdentity(publicationUrl, handle) {
+ _localPublicationUrl = publicationUrl;
+ _localHandle = handle;
+}
+
+/**
+ * Serialize an ap_timeline document as a Mastodon Status entity.
+ *
+ * @param {object} item - ap_timeline document
+ * @param {object} options
+ * @param {string} options.baseUrl - Server base URL
+ * @param {Set} [options.favouritedIds] - UIDs the user has liked
+ * @param {Set} [options.rebloggedIds] - UIDs the user has boosted
+ * @param {Set} [options.bookmarkedIds] - UIDs the user has bookmarked
+ * @param {Set} [options.pinnedIds] - UIDs the user has pinned
+ * @returns {object} Mastodon Status entity
+ */
+export function serializeStatus(item, { baseUrl, favouritedIds, rebloggedIds, bookmarkedIds, pinnedIds }) {
+ if (!item) return null;
+
+ // Use published-based cursor as the status ID so pagination cursors
+ // (max_id/min_id) sort chronologically, not by insertion order.
+ const cursorDate = item.published || item.createdAt || item.boostedAt;
+ const id = encodeCursor(cursorDate) || item._id.toString();
+ const uid = item.uid || "";
+ const url = item.url || uid;
+
+ // Handle boosts — reconstruct nested reblog wrapper
+ if (item.type === "boost" && item.boostedBy) {
+ // The outer status represents the boost action
+ // The inner status is the original post (the item itself minus boost metadata)
+ const innerItem = { ...item, type: "note", boostedBy: undefined, boostedAt: undefined };
+ const innerStatus = serializeStatus(innerItem, {
+ baseUrl,
+ favouritedIds,
+ rebloggedIds,
+ bookmarkedIds,
+ pinnedIds,
+ });
+
+ return {
+ id,
+ created_at: item.boostedAt || item.createdAt || new Date().toISOString(),
+ in_reply_to_id: null,
+ in_reply_to_account_id: null,
+ sensitive: false,
+ spoiler_text: "",
+ visibility: item.visibility || "public",
+ language: null,
+ uri: uid,
+ url,
+ replies_count: 0,
+ reblogs_count: 0,
+ favourites_count: 0,
+ edited_at: null,
+ favourited: false,
+ reblogged: rebloggedIds?.has(uid) || false,
+ muted: false,
+ bookmarked: false,
+ pinned: false,
+ content: "",
+ filtered: null,
+ reblog: innerStatus,
+ application: null,
+ account: serializeAccount(item.boostedBy, { baseUrl }),
+ media_attachments: [],
+ mentions: [],
+ tags: [],
+ emojis: [],
+ card: null,
+ poll: null,
+ };
+ }
+
+ // Regular status (note, article, question)
+ const content = item.content?.html || item.content?.text || "";
+ const spoilerText = item.summary || "";
+ const sensitive = item.sensitive || false;
+ const visibility = item.visibility || "public";
+ const language = item.language || null;
+ const published = item.published || item.createdAt || new Date().toISOString();
+ const editedAt = item.updated || item.updatedAt || null;
+
+ // Media attachments — merge photo, video, audio arrays
+ const mediaAttachments = [];
+ let attachmentCounter = 0;
+
+ if (item.photo?.length > 0) {
+ for (const p of item.photo) {
+ mediaAttachments.push({
+ id: `${id}-${attachmentCounter++}`,
+ type: "image",
+ url: typeof p === "string" ? p : p.url,
+ preview_url: typeof p === "string" ? p : p.url,
+ remote_url: typeof p === "string" ? p : p.url,
+ text_url: null,
+ meta: buildImageMeta(p),
+ description: typeof p === "object" ? p.alt || "" : "",
+ blurhash: null,
+ });
+ }
+ }
+
+ if (item.video?.length > 0) {
+ for (const v of item.video) {
+ mediaAttachments.push({
+ id: `${id}-${attachmentCounter++}`,
+ type: "video",
+ url: typeof v === "string" ? v : v.url,
+ preview_url: typeof v === "string" ? v : v.url,
+ remote_url: typeof v === "string" ? v : v.url,
+ text_url: null,
+ meta: null,
+ description: typeof v === "object" ? v.alt || "" : "",
+ blurhash: null,
+ });
+ }
+ }
+
+ if (item.audio?.length > 0) {
+ for (const a of item.audio) {
+ mediaAttachments.push({
+ id: `${id}-${attachmentCounter++}`,
+ type: "audio",
+ url: typeof a === "string" ? a : a.url,
+ preview_url: typeof a === "string" ? a : a.url,
+ remote_url: typeof a === "string" ? a : a.url,
+ text_url: null,
+ meta: null,
+ description: typeof a === "object" ? a.alt || "" : "",
+ blurhash: null,
+ });
+ }
+ }
+
+ // Link preview -> card
+ const card = serializeCard(item.linkPreviews?.[0]);
+
+ // Tags from category[]
+ const tags = (item.category || []).map((tag) => ({
+ name: tag,
+ url: `${baseUrl}/tags/${encodeURIComponent(tag)}`,
+ }));
+
+ // Mentions
+ const mentions = (item.mentions || []).map((m) => ({
+ id: "0", // We don't have stable IDs for mentioned accounts
+ username: m.name || "",
+ url: m.url || "",
+ acct: m.name || "",
+ }));
+
+ // Custom emojis
+ const emojis = (item.emojis || []).map((e) => ({
+ shortcode: e.shortcode || "",
+ url: e.url || "",
+ static_url: e.url || "",
+ visible_in_picker: true,
+ }));
+
+ // Counts
+ const repliesCount = item.counts?.replies ?? 0;
+ const reblogsCount = item.counts?.boosts ?? 0;
+ const favouritesCount = item.counts?.likes ?? 0;
+
+ // Poll
+ const poll = serializePoll(item, id);
+
+ // Interaction state
+ const favourited = favouritedIds?.has(uid) || false;
+ const reblogged = rebloggedIds?.has(uid) || false;
+ const bookmarked = bookmarkedIds?.has(uid) || false;
+ const pinned = pinnedIds?.has(uid) || false;
+
+ return {
+ id,
+ created_at: published,
+ in_reply_to_id: item.inReplyTo ? null : null, // TODO: resolve to local ID
+ in_reply_to_account_id: null, // TODO: resolve
+ sensitive,
+ spoiler_text: spoilerText,
+ visibility,
+ language,
+ uri: uid,
+ url,
+ replies_count: repliesCount,
+ reblogs_count: reblogsCount,
+ favourites_count: favouritesCount,
+ edited_at: editedAt || null,
+ favourited,
+ reblogged,
+ muted: false,
+ bookmarked,
+ pinned,
+ content: sanitizeHtml(content),
+ filtered: null,
+ reblog: null,
+ application: null,
+ account: item.author
+ ? serializeAccount(item.author, {
+ baseUrl,
+ isLocal: !!(_localPublicationUrl && item.author.url === _localPublicationUrl),
+ handle: _localHandle,
+ })
+ : null,
+ media_attachments: mediaAttachments,
+ mentions,
+ tags,
+ emojis,
+ card,
+ poll,
+ };
+}
+
+// ─── Helpers ─────────────────────────────────────────────────────────────────
+
+/**
+ * Serialize a linkPreview object as a Mastodon PreviewCard.
+ */
+function serializeCard(preview) {
+ if (!preview) return null;
+
+ return {
+ url: preview.url || "",
+ title: preview.title || "",
+ description: preview.description || "",
+ type: "link",
+ author_name: "",
+ author_url: "",
+ provider_name: preview.domain || "",
+ provider_url: "",
+ html: "",
+ width: 0,
+ height: 0,
+ image: preview.image || null,
+ embed_url: "",
+ blurhash: null,
+ language: null,
+ published_at: null,
+ };
+}
+
+/**
+ * Build image meta object for media attachments.
+ */
+function buildImageMeta(photo) {
+ if (typeof photo === "string") return null;
+ if (!photo.width && !photo.height) return null;
+
+ return {
+ original: {
+ width: photo.width || 0,
+ height: photo.height || 0,
+ size: photo.width && photo.height ? `${photo.width}x${photo.height}` : null,
+ aspect: photo.width && photo.height ? photo.width / photo.height : null,
+ },
+ };
+}
+
+/**
+ * Serialize poll data from a timeline item.
+ */
+function serializePoll(item, statusId) {
+ if (!item.pollOptions?.length) return null;
+
+ const totalVotes = item.pollOptions.reduce((sum, o) => sum + (o.votes || 0), 0);
+
+ return {
+ id: statusId,
+ expires_at: item.pollEndTime || null,
+ expired: item.pollClosed || false,
+ multiple: false,
+ votes_count: totalVotes,
+ voters_count: item.votersCount || null,
+ options: item.pollOptions.map((o) => ({
+ title: o.name || "",
+ votes_count: o.votes || 0,
+ })),
+ emojis: [],
+ voted: false,
+ own_votes: [],
+ };
+}
diff --git a/lib/mastodon/helpers/account-cache.js b/lib/mastodon/helpers/account-cache.js
new file mode 100644
index 0000000..0407855
--- /dev/null
+++ b/lib/mastodon/helpers/account-cache.js
@@ -0,0 +1,51 @@
+/**
+ * In-memory cache for remote account stats (followers, following, statuses).
+ *
+ * Populated by resolveRemoteAccount() when a profile is fetched.
+ * Read by serializeAccount() to enrich embedded account objects in statuses.
+ *
+ * LRU-style with TTL — entries expire after 1 hour.
+ */
+
+const CACHE_TTL_MS = 60 * 60 * 1000; // 1 hour
+const MAX_ENTRIES = 500;
+
+// Map
+const cache = new Map();
+
+/**
+ * Store account stats in cache.
+ * @param {string} actorUrl - The actor's URL (cache key)
+ * @param {object} stats - { followersCount, followingCount, statusesCount, createdAt }
+ */
+export function cacheAccountStats(actorUrl, stats) {
+ if (!actorUrl) return;
+
+ // Evict oldest if at capacity
+ if (cache.size >= MAX_ENTRIES) {
+ const oldest = cache.keys().next().value;
+ cache.delete(oldest);
+ }
+
+ cache.set(actorUrl, { ...stats, cachedAt: Date.now() });
+}
+
+/**
+ * Get cached account stats.
+ * @param {string} actorUrl - The actor's URL
+ * @returns {object|null} Stats or null if not cached/expired
+ */
+export function getCachedAccountStats(actorUrl) {
+ if (!actorUrl) return null;
+
+ const entry = cache.get(actorUrl);
+ if (!entry) return null;
+
+ // Check TTL
+ if (Date.now() - entry.cachedAt > CACHE_TTL_MS) {
+ cache.delete(actorUrl);
+ return null;
+ }
+
+ return entry;
+}
diff --git a/lib/mastodon/helpers/id-mapping.js b/lib/mastodon/helpers/id-mapping.js
new file mode 100644
index 0000000..76e6d23
--- /dev/null
+++ b/lib/mastodon/helpers/id-mapping.js
@@ -0,0 +1,32 @@
+/**
+ * Deterministic ID mapping for Mastodon Client API.
+ *
+ * Local accounts use MongoDB _id.toString().
+ * Remote actors use sha256(actorUrl).slice(0, 24) for stable IDs
+ * without requiring a dedicated accounts collection.
+ */
+import crypto from "node:crypto";
+
+/**
+ * Generate a deterministic ID for a remote actor URL.
+ * @param {string} actorUrl - The remote actor's URL
+ * @returns {string} 24-character hex ID
+ */
+export function remoteActorId(actorUrl) {
+ return crypto.createHash("sha256").update(actorUrl).digest("hex").slice(0, 24);
+}
+
+/**
+ * Get the Mastodon API ID for an account.
+ * @param {object} actor - Actor object (local profile or remote author)
+ * @param {boolean} isLocal - Whether this is the local profile
+ * @returns {string}
+ */
+export function accountId(actor, isLocal = false) {
+ if (isLocal && actor._id) {
+ return actor._id.toString();
+ }
+ // Remote actors: use URL-based deterministic hash
+ const url = actor.url || actor.actorUrl || "";
+ return url ? remoteActorId(url) : "0";
+}
diff --git a/lib/mastodon/helpers/interactions.js b/lib/mastodon/helpers/interactions.js
new file mode 100644
index 0000000..3072b02
--- /dev/null
+++ b/lib/mastodon/helpers/interactions.js
@@ -0,0 +1,278 @@
+/**
+ * Shared interaction logic for like/unlike, boost/unboost, bookmark/unbookmark.
+ *
+ * Extracted from admin controllers (interactions-like.js, interactions-boost.js)
+ * so that both the admin UI and Mastodon Client API can reuse the same core logic.
+ *
+ * Each function accepts a context object instead of Express req/res,
+ * making them transport-agnostic.
+ */
+
+import { resolveAuthor } from "../../resolve-author.js";
+
+/**
+ * Like a post — send Like activity and track in ap_interactions.
+ *
+ * @param {object} params
+ * @param {string} params.targetUrl - URL of the post to like
+ * @param {object} params.federation - Fedify federation instance
+ * @param {string} params.handle - Local actor handle
+ * @param {string} params.publicationUrl - Publication base URL
+ * @param {object} params.collections - MongoDB collections (Map or object)
+ * @param {object} params.interactions - ap_interactions collection
+ * @returns {Promise<{ activityId: string }>}
+ */
+export async function likePost({ targetUrl, federation, handle, publicationUrl, collections, interactions }) {
+ const { Like } = await import("@fedify/fedify/vocab");
+ const ctx = federation.createContext(
+ new URL(publicationUrl),
+ { handle, publicationUrl },
+ );
+
+ const documentLoader = await ctx.getDocumentLoader({ identifier: handle });
+ const recipient = await resolveAuthor(targetUrl, ctx, documentLoader, collections);
+
+ const uuid = crypto.randomUUID();
+ const baseUrl = publicationUrl.replace(/\/$/, "");
+ const activityId = `${baseUrl}/activitypub/likes/${uuid}`;
+
+ const like = new Like({
+ id: new URL(activityId),
+ actor: ctx.getActorUri(handle),
+ object: new URL(targetUrl),
+ });
+
+ if (recipient) {
+ await ctx.sendActivity({ identifier: handle }, recipient, like, {
+ orderingKey: targetUrl,
+ });
+ }
+
+ if (interactions) {
+ await interactions.updateOne(
+ { objectUrl: targetUrl, type: "like" },
+ {
+ $set: {
+ objectUrl: targetUrl,
+ type: "like",
+ activityId,
+ recipientUrl: recipient?.id?.href || "",
+ createdAt: new Date().toISOString(),
+ },
+ },
+ { upsert: true },
+ );
+ }
+
+ return { activityId };
+}
+
+/**
+ * Unlike a post — send Undo(Like) activity and remove from ap_interactions.
+ *
+ * @param {object} params
+ * @param {string} params.targetUrl - URL of the post to unlike
+ * @param {object} params.federation - Fedify federation instance
+ * @param {string} params.handle - Local actor handle
+ * @param {string} params.publicationUrl - Publication base URL
+ * @param {object} params.collections - MongoDB collections
+ * @param {object} params.interactions - ap_interactions collection
+ * @returns {Promise}
+ */
+export async function unlikePost({ targetUrl, federation, handle, publicationUrl, collections, interactions }) {
+ const existing = interactions
+ ? await interactions.findOne({ objectUrl: targetUrl, type: "like" })
+ : null;
+
+ if (!existing) {
+ return;
+ }
+
+ const { Like, Undo } = await import("@fedify/fedify/vocab");
+ const ctx = federation.createContext(
+ new URL(publicationUrl),
+ { handle, publicationUrl },
+ );
+
+ const documentLoader = await ctx.getDocumentLoader({ identifier: handle });
+ const recipient = await resolveAuthor(targetUrl, ctx, documentLoader, collections);
+
+ if (recipient) {
+ const like = new Like({
+ id: existing.activityId ? new URL(existing.activityId) : undefined,
+ actor: ctx.getActorUri(handle),
+ object: new URL(targetUrl),
+ });
+
+ const undo = new Undo({
+ actor: ctx.getActorUri(handle),
+ object: like,
+ });
+
+ await ctx.sendActivity({ identifier: handle }, recipient, undo, {
+ orderingKey: targetUrl,
+ });
+ }
+
+ if (interactions) {
+ await interactions.deleteOne({ objectUrl: targetUrl, type: "like" });
+ }
+}
+
+/**
+ * Boost a post — send Announce activity and track in ap_interactions.
+ *
+ * @param {object} params
+ * @param {string} params.targetUrl - URL of the post to boost
+ * @param {object} params.federation - Fedify federation instance
+ * @param {string} params.handle - Local actor handle
+ * @param {string} params.publicationUrl - Publication base URL
+ * @param {object} params.collections - MongoDB collections
+ * @param {object} params.interactions - ap_interactions collection
+ * @returns {Promise<{ activityId: string }>}
+ */
+export async function boostPost({ targetUrl, federation, handle, publicationUrl, collections, interactions }) {
+ const { Announce } = await import("@fedify/fedify/vocab");
+ const ctx = federation.createContext(
+ new URL(publicationUrl),
+ { handle, publicationUrl },
+ );
+
+ const uuid = crypto.randomUUID();
+ const baseUrl = publicationUrl.replace(/\/$/, "");
+ const activityId = `${baseUrl}/activitypub/boosts/${uuid}`;
+
+ const publicAddress = new URL("https://www.w3.org/ns/activitystreams#Public");
+ const followersUri = ctx.getFollowersUri(handle);
+
+ const announce = new Announce({
+ id: new URL(activityId),
+ actor: ctx.getActorUri(handle),
+ object: new URL(targetUrl),
+ to: publicAddress,
+ cc: followersUri,
+ });
+
+ // Send to followers
+ await ctx.sendActivity({ identifier: handle }, "followers", announce, {
+ preferSharedInbox: true,
+ syncCollection: true,
+ orderingKey: targetUrl,
+ });
+
+ // Also send directly to the original post author
+ const documentLoader = await ctx.getDocumentLoader({ identifier: handle });
+ const recipient = await resolveAuthor(targetUrl, ctx, documentLoader, collections);
+ if (recipient) {
+ try {
+ await ctx.sendActivity({ identifier: handle }, recipient, announce, {
+ orderingKey: targetUrl,
+ });
+ } catch {
+ // Non-critical — follower delivery already happened
+ }
+ }
+
+ if (interactions) {
+ await interactions.updateOne(
+ { objectUrl: targetUrl, type: "boost" },
+ {
+ $set: {
+ objectUrl: targetUrl,
+ type: "boost",
+ activityId,
+ createdAt: new Date().toISOString(),
+ },
+ },
+ { upsert: true },
+ );
+ }
+
+ return { activityId };
+}
+
+/**
+ * Unboost a post — send Undo(Announce) activity and remove from ap_interactions.
+ *
+ * @param {object} params
+ * @param {string} params.targetUrl - URL of the post to unboost
+ * @param {object} params.federation - Fedify federation instance
+ * @param {string} params.handle - Local actor handle
+ * @param {string} params.publicationUrl - Publication base URL
+ * @param {object} params.interactions - ap_interactions collection
+ * @returns {Promise}
+ */
+export async function unboostPost({ targetUrl, federation, handle, publicationUrl, interactions }) {
+ const existing = interactions
+ ? await interactions.findOne({ objectUrl: targetUrl, type: "boost" })
+ : null;
+
+ if (!existing) {
+ return;
+ }
+
+ const { Announce, Undo } = await import("@fedify/fedify/vocab");
+ const ctx = federation.createContext(
+ new URL(publicationUrl),
+ { handle, publicationUrl },
+ );
+
+ const announce = new Announce({
+ id: existing.activityId ? new URL(existing.activityId) : undefined,
+ actor: ctx.getActorUri(handle),
+ object: new URL(targetUrl),
+ });
+
+ const undo = new Undo({
+ actor: ctx.getActorUri(handle),
+ object: announce,
+ });
+
+ await ctx.sendActivity({ identifier: handle }, "followers", undo, {
+ preferSharedInbox: true,
+ syncCollection: true,
+ orderingKey: targetUrl,
+ });
+
+ if (interactions) {
+ await interactions.deleteOne({ objectUrl: targetUrl, type: "boost" });
+ }
+}
+
+/**
+ * Bookmark a post — local-only, no federation.
+ *
+ * @param {object} params
+ * @param {string} params.targetUrl - URL of the post to bookmark
+ * @param {object} params.interactions - ap_interactions collection
+ * @returns {Promise}
+ */
+export async function bookmarkPost({ targetUrl, interactions }) {
+ if (!interactions) return;
+
+ await interactions.updateOne(
+ { objectUrl: targetUrl, type: "bookmark" },
+ {
+ $set: {
+ objectUrl: targetUrl,
+ type: "bookmark",
+ createdAt: new Date().toISOString(),
+ },
+ },
+ { upsert: true },
+ );
+}
+
+/**
+ * Remove a bookmark — local-only, no federation.
+ *
+ * @param {object} params
+ * @param {string} params.targetUrl - URL of the post to unbookmark
+ * @param {object} params.interactions - ap_interactions collection
+ * @returns {Promise}
+ */
+export async function unbookmarkPost({ targetUrl, interactions }) {
+ if (!interactions) return;
+
+ await interactions.deleteOne({ objectUrl: targetUrl, type: "bookmark" });
+}
diff --git a/lib/mastodon/helpers/pagination.js b/lib/mastodon/helpers/pagination.js
new file mode 100644
index 0000000..3f4da71
--- /dev/null
+++ b/lib/mastodon/helpers/pagination.js
@@ -0,0 +1,153 @@
+/**
+ * Mastodon-compatible cursor pagination helpers.
+ *
+ * Uses `published` date as cursor (chronologically correct) instead of
+ * MongoDB ObjectId. ObjectId reflects insertion order, not publication
+ * order — backfilled or syndicated posts get new ObjectIds at import
+ * time, breaking chronological sort. The `published` field matches the
+ * native reader's sort and produces a correct timeline.
+ *
+ * Cursor values are `published` ISO strings, but Mastodon clients pass
+ * them as opaque `max_id`/`min_id`/`since_id` strings. We encode the
+ * published date as a Mastodon-style snowflake-ish ID (milliseconds
+ * since epoch) so clients treat them as comparable integers.
+ *
+ * Emits RFC 8288 Link headers that masto.js / Phanpy parse.
+ */
+
+const DEFAULT_LIMIT = 20;
+const MAX_LIMIT = 40;
+
+/**
+ * Encode a published date string as a numeric cursor ID.
+ * Mastodon clients expect IDs to be numeric strings that sort chronologically.
+ * We use milliseconds since epoch — monotonic and comparable.
+ *
+ * @param {string|Date} published - ISO date string or Date object
+ * @returns {string} Numeric string (ms since epoch)
+ */
+export function encodeCursor(published) {
+ if (!published) return "0";
+ const ms = new Date(published).getTime();
+ return Number.isFinite(ms) ? String(ms) : "0";
+}
+
+/**
+ * Decode a numeric cursor ID back to an ISO date string.
+ *
+ * @param {string} cursor - Numeric cursor from client
+ * @returns {string|null} ISO date string, or null if invalid
+ */
+export function decodeCursor(cursor) {
+ if (!cursor) return null;
+ const ms = Number.parseInt(cursor, 10);
+ if (!Number.isFinite(ms) || ms <= 0) return null;
+ return new Date(ms).toISOString();
+}
+
+/**
+ * Parse and clamp the limit parameter.
+ *
+ * @param {string|number} raw - Raw limit value from query string
+ * @returns {number}
+ */
+export function parseLimit(raw) {
+ const n = Number.parseInt(String(raw), 10);
+ if (!Number.isFinite(n) || n < 1) return DEFAULT_LIMIT;
+ return Math.min(n, MAX_LIMIT);
+}
+
+/**
+ * Build a MongoDB filter object for cursor-based pagination.
+ *
+ * Mastodon cursor params (all optional, applied to `published`):
+ * max_id — return items older than this cursor (exclusive)
+ * min_id — return items newer than this cursor (exclusive), closest first
+ * since_id — return items newer than this cursor (exclusive), most recent first
+ *
+ * @param {object} baseFilter - Existing MongoDB filter to extend
+ * @param {object} cursors
+ * @param {string} [cursors.max_id] - Numeric cursor (ms since epoch)
+ * @param {string} [cursors.min_id] - Numeric cursor (ms since epoch)
+ * @param {string} [cursors.since_id] - Numeric cursor (ms since epoch)
+ * @returns {{ filter: object, sort: object, reverse: boolean }}
+ */
+export function buildPaginationQuery(baseFilter, { max_id, min_id, since_id } = {}) {
+ const filter = { ...baseFilter };
+ let sort = { published: -1 }; // newest first (default)
+ let reverse = false;
+
+ if (max_id) {
+ const date = decodeCursor(max_id);
+ if (date) {
+ filter.published = { ...filter.published, $lt: date };
+ }
+ }
+
+ if (since_id) {
+ const date = decodeCursor(since_id);
+ if (date) {
+ filter.published = { ...filter.published, $gt: date };
+ }
+ }
+
+ if (min_id) {
+ const date = decodeCursor(min_id);
+ if (date) {
+ filter.published = { ...filter.published, $gt: date };
+ // min_id returns results closest to the cursor, so sort ascending
+ // then reverse the results before returning
+ sort = { published: 1 };
+ reverse = true;
+ }
+ }
+
+ return { filter, sort, reverse };
+}
+
+/**
+ * Set the Link pagination header on an Express response.
+ *
+ * @param {object} res - Express response object
+ * @param {object} req - Express request object (for building URLs)
+ * @param {Array} items - Result items (must have `published`)
+ * @param {number} limit - The limit used for the query
+ */
+export function setPaginationHeaders(res, req, items, limit) {
+ if (!items?.length) return;
+
+ // Only emit Link if we got a full page (may have more)
+ if (items.length < limit) return;
+
+ const firstCursor = encodeCursor(items[0].published);
+ const lastCursor = encodeCursor(items[items.length - 1].published);
+
+ if (firstCursor === "0" || lastCursor === "0") return;
+
+ const baseUrl = `${req.protocol}://${req.get("host")}${req.path}`;
+
+ // Preserve existing query params (like types[] for notifications)
+ const existingParams = new URLSearchParams();
+ for (const [key, value] of Object.entries(req.query)) {
+ if (key === "max_id" || key === "min_id" || key === "since_id") continue;
+ if (Array.isArray(value)) {
+ for (const v of value) existingParams.append(key, v);
+ } else {
+ existingParams.set(key, String(value));
+ }
+ }
+
+ const links = [];
+
+ // rel="next" — older items (max_id = last item's cursor)
+ const nextParams = new URLSearchParams(existingParams);
+ nextParams.set("max_id", lastCursor);
+ links.push(`<${baseUrl}?${nextParams.toString()}>; rel="next"`);
+
+ // rel="prev" — newer items (min_id = first item's cursor)
+ const prevParams = new URLSearchParams(existingParams);
+ prevParams.set("min_id", firstCursor);
+ links.push(`<${baseUrl}?${prevParams.toString()}>; rel="prev"`);
+
+ res.set("Link", links.join(", "));
+}
diff --git a/lib/mastodon/helpers/resolve-account.js b/lib/mastodon/helpers/resolve-account.js
new file mode 100644
index 0000000..cd4cbe4
--- /dev/null
+++ b/lib/mastodon/helpers/resolve-account.js
@@ -0,0 +1,132 @@
+/**
+ * Resolve a remote account via WebFinger + ActivityPub actor fetch.
+ * Uses the Fedify federation instance to perform discovery.
+ *
+ * Shared by accounts.js (lookup) and search.js (resolve=true).
+ */
+import { serializeAccount } from "../entities/account.js";
+import { cacheAccountStats } from "./account-cache.js";
+
+/**
+ * @param {string} acct - Account identifier (user@domain or URL)
+ * @param {object} pluginOptions - Plugin options with federation, handle, publicationUrl
+ * @param {string} baseUrl - Server base URL
+ * @returns {Promise