@rmdes/indiekit-endpoint-activitypub 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/inbox.js ADDED
@@ -0,0 +1,291 @@
1
+ /**
2
+ * Inbox activity processors.
3
+ *
4
+ * Each handler receives a parsed ActivityStreams activity, the MongoDB
5
+ * collections, and a context object with delivery capabilities.
6
+ * Activities are auto-accepted (Follow) and logged for the admin UI.
7
+ */
8
+
9
+ /**
10
+ * Dispatch an incoming activity to the appropriate handler.
11
+ *
12
+ * @param {object} activity - Parsed ActivityStreams activity
13
+ * @param {object} collections - MongoDB collections (ap_followers, ap_following, ap_activities)
14
+ * @param {object} context - { actorUrl, deliverActivity(activity, inboxUrl), storeRawActivities }
15
+ */
16
+ export async function processInboxActivity(activity, collections, context) {
17
+ const type = activity.type;
18
+
19
+ switch (type) {
20
+ case "Follow":
21
+ return handleFollow(activity, collections, context);
22
+ case "Undo":
23
+ return handleUndo(activity, collections, context);
24
+ case "Like":
25
+ return handleLike(activity, collections, context);
26
+ case "Announce":
27
+ return handleAnnounce(activity, collections, context);
28
+ case "Create":
29
+ return handleCreate(activity, collections, context);
30
+ case "Delete":
31
+ return handleDelete(activity, collections);
32
+ case "Move":
33
+ return handleMove(activity, collections, context);
34
+ default:
35
+ await logActivity(collections, context, {
36
+ direction: "inbound",
37
+ type,
38
+ actorUrl: resolveActorUrl(activity.actor),
39
+ summary: `Received unhandled activity: ${type}`,
40
+ raw: activity,
41
+ });
42
+ }
43
+ }
44
+
45
+ /**
46
+ * Handle Follow — store follower, send Accept back.
47
+ */
48
+ async function handleFollow(activity, collections, context) {
49
+ const followerActorUrl = resolveActorUrl(activity.actor);
50
+
51
+ // Fetch remote actor profile for display info
52
+ const profile = await fetchActorProfile(followerActorUrl);
53
+
54
+ // Upsert follower record
55
+ await collections.ap_followers.updateOne(
56
+ { actorUrl: followerActorUrl },
57
+ {
58
+ $set: {
59
+ actorUrl: followerActorUrl,
60
+ handle: profile.preferredUsername || "",
61
+ name:
62
+ profile.name || profile.preferredUsername || followerActorUrl,
63
+ avatar: profile.icon?.url || "",
64
+ inbox: profile.inbox || "",
65
+ sharedInbox: profile.endpoints?.sharedInbox || "",
66
+ followedAt: new Date(),
67
+ },
68
+ },
69
+ { upsert: true },
70
+ );
71
+
72
+ // Send Accept(Follow) back to the follower's inbox
73
+ const acceptActivity = {
74
+ "@context": "https://www.w3.org/ns/activitystreams",
75
+ type: "Accept",
76
+ actor: context.actorUrl,
77
+ object: activity,
78
+ };
79
+
80
+ const targetInbox = profile.inbox || `${followerActorUrl}inbox`;
81
+ await context.deliverActivity(acceptActivity, targetInbox);
82
+
83
+ await logActivity(collections, context, {
84
+ direction: "inbound",
85
+ type: "Follow",
86
+ actorUrl: followerActorUrl,
87
+ actorName:
88
+ profile.name || profile.preferredUsername || followerActorUrl,
89
+ summary: `${profile.name || followerActorUrl} followed you`,
90
+ raw: activity,
91
+ });
92
+ }
93
+
94
+ /**
95
+ * Handle Undo — dispatch based on the inner activity type.
96
+ */
97
+ async function handleUndo(activity, collections, context) {
98
+ const inner =
99
+ typeof activity.object === "string" ? { type: "unknown" } : activity.object;
100
+ const actorUrl = resolveActorUrl(activity.actor);
101
+
102
+ switch (inner.type) {
103
+ case "Follow":
104
+ await collections.ap_followers.deleteOne({ actorUrl });
105
+ await logActivity(collections, context, {
106
+ direction: "inbound",
107
+ type: "Undo(Follow)",
108
+ actorUrl,
109
+ summary: `${actorUrl} unfollowed you`,
110
+ raw: activity,
111
+ });
112
+ break;
113
+
114
+ case "Like":
115
+ await collections.ap_activities.deleteOne({
116
+ type: "Like",
117
+ actorUrl,
118
+ objectUrl: resolveObjectUrl(inner.object),
119
+ });
120
+ break;
121
+
122
+ case "Announce":
123
+ await collections.ap_activities.deleteOne({
124
+ type: "Announce",
125
+ actorUrl,
126
+ objectUrl: resolveObjectUrl(inner.object),
127
+ });
128
+ break;
129
+
130
+ default:
131
+ await logActivity(collections, context, {
132
+ direction: "inbound",
133
+ type: `Undo(${inner.type})`,
134
+ actorUrl,
135
+ summary: `${actorUrl} undid ${inner.type}`,
136
+ raw: activity,
137
+ });
138
+ }
139
+ }
140
+
141
+ /**
142
+ * Handle Like — log for admin display.
143
+ */
144
+ async function handleLike(activity, collections, context) {
145
+ const actorUrl = resolveActorUrl(activity.actor);
146
+ const objectUrl = resolveObjectUrl(activity.object);
147
+ const profile = await fetchActorProfile(actorUrl);
148
+
149
+ await logActivity(collections, context, {
150
+ direction: "inbound",
151
+ type: "Like",
152
+ actorUrl,
153
+ actorName: profile.name || profile.preferredUsername || actorUrl,
154
+ objectUrl,
155
+ summary: `${profile.name || actorUrl} liked ${objectUrl}`,
156
+ raw: activity,
157
+ });
158
+ }
159
+
160
+ /**
161
+ * Handle Announce (boost) — log for admin display.
162
+ */
163
+ async function handleAnnounce(activity, collections, context) {
164
+ const actorUrl = resolveActorUrl(activity.actor);
165
+ const objectUrl = resolveObjectUrl(activity.object);
166
+ const profile = await fetchActorProfile(actorUrl);
167
+
168
+ await logActivity(collections, context, {
169
+ direction: "inbound",
170
+ type: "Announce",
171
+ actorUrl,
172
+ actorName: profile.name || profile.preferredUsername || actorUrl,
173
+ objectUrl,
174
+ summary: `${profile.name || actorUrl} boosted ${objectUrl}`,
175
+ raw: activity,
176
+ });
177
+ }
178
+
179
+ /**
180
+ * Handle Create — if it's a reply to one of our posts, log it.
181
+ */
182
+ async function handleCreate(activity, collections, context) {
183
+ const object =
184
+ typeof activity.object === "string" ? { id: activity.object } : activity.object;
185
+ const inReplyTo = object.inReplyTo;
186
+
187
+ // Only log replies to our posts (inReplyTo is set)
188
+ if (!inReplyTo) return;
189
+
190
+ const actorUrl = resolveActorUrl(activity.actor);
191
+ const profile = await fetchActorProfile(actorUrl);
192
+
193
+ await logActivity(collections, context, {
194
+ direction: "inbound",
195
+ type: "Reply",
196
+ actorUrl,
197
+ actorName: profile.name || profile.preferredUsername || actorUrl,
198
+ objectUrl: object.id || object.url || "",
199
+ summary: `${profile.name || actorUrl} replied to ${inReplyTo}`,
200
+ raw: activity,
201
+ });
202
+ }
203
+
204
+ /**
205
+ * Handle Delete — remove activity records for deleted objects.
206
+ */
207
+ async function handleDelete(activity, collections) {
208
+ const objectUrl = resolveObjectUrl(activity.object);
209
+ if (objectUrl) {
210
+ await collections.ap_activities.deleteMany({ objectUrl });
211
+ }
212
+ }
213
+
214
+ /**
215
+ * Handle Move — update follower record if actor moved to a new account.
216
+ * This is part of the Mastodon migration flow: after a Move, followers
217
+ * are expected to re-follow the new account.
218
+ */
219
+ async function handleMove(activity, collections, context) {
220
+ const oldActorUrl = resolveActorUrl(activity.actor);
221
+ const newActorUrl = resolveObjectUrl(activity.target || activity.object);
222
+
223
+ if (oldActorUrl && newActorUrl) {
224
+ await collections.ap_followers.updateOne(
225
+ { actorUrl: oldActorUrl },
226
+ { $set: { actorUrl: newActorUrl, movedFrom: oldActorUrl } },
227
+ );
228
+ }
229
+
230
+ await logActivity(collections, context, {
231
+ direction: "inbound",
232
+ type: "Move",
233
+ actorUrl: oldActorUrl,
234
+ objectUrl: newActorUrl,
235
+ summary: `${oldActorUrl} moved to ${newActorUrl}`,
236
+ raw: activity,
237
+ });
238
+ }
239
+
240
+ // --- Helpers ---
241
+
242
+ /**
243
+ * Extract actor URL from an activity's actor field.
244
+ * The actor can be a string URL or an object with an id field.
245
+ */
246
+ function resolveActorUrl(actor) {
247
+ if (typeof actor === "string") return actor;
248
+ return actor?.id || "";
249
+ }
250
+
251
+ /**
252
+ * Extract object URL from an activity's object field.
253
+ */
254
+ function resolveObjectUrl(object) {
255
+ if (typeof object === "string") return object;
256
+ return object?.id || object?.url || "";
257
+ }
258
+
259
+ /**
260
+ * Fetch a remote actor's profile document for display info.
261
+ * Returns an empty object on failure — federation should be resilient
262
+ * to unreachable remote servers.
263
+ */
264
+ async function fetchActorProfile(actorUrl) {
265
+ try {
266
+ const response = await fetch(actorUrl, {
267
+ headers: { Accept: "application/activity+json" },
268
+ signal: AbortSignal.timeout(10_000),
269
+ });
270
+ if (response.ok) {
271
+ return await response.json();
272
+ }
273
+ } catch {
274
+ // Remote server unreachable — proceed without profile
275
+ }
276
+ return {};
277
+ }
278
+
279
+ /**
280
+ * Write an activity record to the ap_activities collection.
281
+ * Strips the raw JSON field unless storeRawActivities is enabled,
282
+ * keeping the activity log lightweight for backups.
283
+ */
284
+ async function logActivity(collections, context, record) {
285
+ const { raw, ...rest } = record;
286
+ await collections.ap_activities.insertOne({
287
+ ...rest,
288
+ ...(context.storeRawActivities ? { raw } : {}),
289
+ receivedAt: new Date(),
290
+ });
291
+ }
@@ -0,0 +1,191 @@
1
+ /**
2
+ * Convert Indiekit JF2 post properties to ActivityStreams 2.0 objects.
3
+ *
4
+ * JF2 is the simplified Microformats2 JSON format used by Indiekit internally.
5
+ * ActivityStreams 2.0 (AS2) is the JSON-LD format used by ActivityPub for federation.
6
+ *
7
+ * @param {object} properties - JF2 post properties from Indiekit's posts collection
8
+ * @param {string} actorUrl - This actor's URL (e.g. "https://rmendes.net/")
9
+ * @param {string} publicationUrl - Publication base URL with trailing slash
10
+ * @returns {object} ActivityStreams activity (Create, Like, or Announce)
11
+ */
12
+ export function jf2ToActivityStreams(properties, actorUrl, publicationUrl) {
13
+ const postType = properties["post-type"];
14
+
15
+ // Like — not wrapped in Create, stands alone
16
+ if (postType === "like") {
17
+ return {
18
+ "@context": "https://www.w3.org/ns/activitystreams",
19
+ type: "Like",
20
+ actor: actorUrl,
21
+ object: properties["like-of"],
22
+ };
23
+ }
24
+
25
+ // Repost/boost — Announce activity
26
+ if (postType === "repost") {
27
+ return {
28
+ "@context": "https://www.w3.org/ns/activitystreams",
29
+ type: "Announce",
30
+ actor: actorUrl,
31
+ object: properties["repost-of"],
32
+ };
33
+ }
34
+
35
+ // Everything else is wrapped in a Create activity
36
+ const isArticle = postType === "article" && properties.name;
37
+ const postUrl = resolvePostUrl(properties.url, publicationUrl);
38
+
39
+ const object = {
40
+ type: isArticle ? "Article" : "Note",
41
+ id: postUrl,
42
+ attributedTo: actorUrl,
43
+ published: properties.published,
44
+ url: postUrl,
45
+ to: ["https://www.w3.org/ns/activitystreams#Public"],
46
+ cc: [`${actorUrl.replace(/\/$/, "")}/activitypub/followers`],
47
+ };
48
+
49
+ // Content — bookmarks get special treatment
50
+ if (postType === "bookmark") {
51
+ const bookmarkUrl = properties["bookmark-of"];
52
+ const commentary = properties.content?.html || properties.content || "";
53
+ object.content = commentary
54
+ ? `${commentary}<br><br>\u{1F516} <a href="${bookmarkUrl}">${bookmarkUrl}</a>`
55
+ : `\u{1F516} <a href="${bookmarkUrl}">${bookmarkUrl}</a>`;
56
+ object.tag = [
57
+ {
58
+ type: "Hashtag",
59
+ name: "#bookmark",
60
+ href: `${publicationUrl}categories/bookmark`,
61
+ },
62
+ ];
63
+ } else {
64
+ object.content = properties.content?.html || properties.content || "";
65
+ }
66
+
67
+ if (isArticle) {
68
+ object.name = properties.name;
69
+ if (properties.summary) {
70
+ object.summary = properties.summary;
71
+ }
72
+ }
73
+
74
+ // Reply
75
+ if (properties["in-reply-to"]) {
76
+ object.inReplyTo = properties["in-reply-to"];
77
+ }
78
+
79
+ // Media attachments
80
+ const attachments = [];
81
+
82
+ if (properties.photo) {
83
+ const photos = Array.isArray(properties.photo)
84
+ ? properties.photo
85
+ : [properties.photo];
86
+ for (const photo of photos) {
87
+ const url = typeof photo === "string" ? photo : photo.url;
88
+ const alt = typeof photo === "string" ? "" : photo.alt || "";
89
+ attachments.push({
90
+ type: "Image",
91
+ mediaType: guessMediaType(url),
92
+ url: resolveMediaUrl(url, publicationUrl),
93
+ name: alt,
94
+ });
95
+ }
96
+ }
97
+
98
+ if (properties.video) {
99
+ const videos = Array.isArray(properties.video)
100
+ ? properties.video
101
+ : [properties.video];
102
+ for (const video of videos) {
103
+ const url = typeof video === "string" ? video : video.url;
104
+ attachments.push({
105
+ type: "Video",
106
+ url: resolveMediaUrl(url, publicationUrl),
107
+ name: "",
108
+ });
109
+ }
110
+ }
111
+
112
+ if (properties.audio) {
113
+ const audios = Array.isArray(properties.audio)
114
+ ? properties.audio
115
+ : [properties.audio];
116
+ for (const audio of audios) {
117
+ const url = typeof audio === "string" ? audio : audio.url;
118
+ attachments.push({
119
+ type: "Audio",
120
+ url: resolveMediaUrl(url, publicationUrl),
121
+ name: "",
122
+ });
123
+ }
124
+ }
125
+
126
+ if (attachments.length > 0) {
127
+ object.attachment = attachments;
128
+ }
129
+
130
+ // Categories → hashtags
131
+ if (properties.category) {
132
+ const categories = Array.isArray(properties.category)
133
+ ? properties.category
134
+ : [properties.category];
135
+ object.tag = [
136
+ ...(object.tag || []),
137
+ ...categories.map((cat) => ({
138
+ type: "Hashtag",
139
+ name: `#${cat.replace(/\s+/g, "")}`,
140
+ href: `${publicationUrl}categories/${encodeURIComponent(cat)}`,
141
+ })),
142
+ ];
143
+ }
144
+
145
+ return {
146
+ "@context": "https://www.w3.org/ns/activitystreams",
147
+ type: "Create",
148
+ actor: actorUrl,
149
+ object,
150
+ };
151
+ }
152
+
153
+ /**
154
+ * Resolve a post URL, ensuring it's absolute.
155
+ * @param {string} url - Post URL (may be relative or absolute)
156
+ * @param {string} publicationUrl - Base publication URL
157
+ * @returns {string} Absolute URL
158
+ */
159
+ export function resolvePostUrl(url, publicationUrl) {
160
+ if (!url) return "";
161
+ if (url.startsWith("http")) return url;
162
+ const base = publicationUrl.replace(/\/$/, "");
163
+ return `${base}/${url.replace(/^\//, "")}`;
164
+ }
165
+
166
+ /**
167
+ * Resolve a media URL, ensuring it's absolute.
168
+ */
169
+ function resolveMediaUrl(url, publicationUrl) {
170
+ if (!url) return "";
171
+ if (url.startsWith("http")) return url;
172
+ const base = publicationUrl.replace(/\/$/, "");
173
+ return `${base}/${url.replace(/^\//, "")}`;
174
+ }
175
+
176
+ /**
177
+ * Guess MIME type from file extension.
178
+ */
179
+ function guessMediaType(url) {
180
+ const ext = url.split(".").pop()?.toLowerCase();
181
+ const types = {
182
+ jpg: "image/jpeg",
183
+ jpeg: "image/jpeg",
184
+ png: "image/png",
185
+ gif: "image/gif",
186
+ webp: "image/webp",
187
+ svg: "image/svg+xml",
188
+ avif: "image/avif",
189
+ };
190
+ return types[ext] || "image/jpeg";
191
+ }
package/lib/keys.js ADDED
@@ -0,0 +1,39 @@
1
+ import { generateKeyPair } from "node:crypto";
2
+ import { promisify } from "node:util";
3
+
4
+ const generateKeyPairAsync = promisify(generateKeyPair);
5
+
6
+ /**
7
+ * Get or create an RSA 2048-bit key pair for the ActivityPub actor.
8
+ * Keys are stored in the ap_keys MongoDB collection so they persist
9
+ * across server restarts — a stable key pair is essential for federation
10
+ * since remote servers cache the public key for signature verification.
11
+ *
12
+ * @param {Collection} collection - MongoDB ap_keys collection
13
+ * @param {string} actorUrl - Actor URL (used as the key document identifier)
14
+ * @returns {Promise<{publicKeyPem: string, privateKeyPem: string}>}
15
+ */
16
+ export async function getOrCreateKeyPair(collection, actorUrl) {
17
+ const existing = await collection.findOne({ actorUrl });
18
+ if (existing) {
19
+ return {
20
+ publicKeyPem: existing.publicKeyPem,
21
+ privateKeyPem: existing.privateKeyPem,
22
+ };
23
+ }
24
+
25
+ const { publicKey, privateKey } = await generateKeyPairAsync("rsa", {
26
+ modulusLength: 2048,
27
+ publicKeyEncoding: { type: "spki", format: "pem" },
28
+ privateKeyEncoding: { type: "pkcs8", format: "pem" },
29
+ });
30
+
31
+ await collection.insertOne({
32
+ actorUrl,
33
+ publicKeyPem: publicKey,
34
+ privateKeyPem: privateKey,
35
+ createdAt: new Date(),
36
+ });
37
+
38
+ return { publicKeyPem: publicKey, privateKeyPem: privateKey };
39
+ }
@@ -0,0 +1,184 @@
1
+ /**
2
+ * Mastodon migration utilities.
3
+ *
4
+ * Parses Mastodon data export CSVs and resolves handles via WebFinger
5
+ * to import followers/following into the ActivityPub collections.
6
+ */
7
+
8
+ /**
9
+ * Parse Mastodon's following_accounts.csv export.
10
+ * Format: "Account address,Show boosts,Notify on new posts,Languages"
11
+ * First row is the header.
12
+ *
13
+ * @param {string} csvText - Raw CSV text
14
+ * @returns {string[]} Array of handles (e.g. ["user@instance.social"])
15
+ */
16
+ export function parseMastodonFollowingCsv(csvText) {
17
+ const lines = csvText.trim().split("\n");
18
+ // Skip header row
19
+ return lines
20
+ .slice(1)
21
+ .map((line) => line.split(",")[0].trim())
22
+ .filter((handle) => handle.length > 0 && handle.includes("@"));
23
+ }
24
+
25
+ /**
26
+ * Parse Mastodon's followers CSV or JSON export.
27
+ * Accepts the same CSV format as following, or a JSON array of actor URLs.
28
+ *
29
+ * @param {string} text - Raw CSV or JSON text
30
+ * @returns {string[]} Array of handles or actor URLs
31
+ */
32
+ export function parseMastodonFollowersList(text) {
33
+ const trimmed = text.trim();
34
+
35
+ // Try JSON first (array of actor URLs)
36
+ if (trimmed.startsWith("[")) {
37
+ try {
38
+ const parsed = JSON.parse(trimmed);
39
+ return Array.isArray(parsed) ? parsed.filter(Boolean) : [];
40
+ } catch {
41
+ // Fall through to CSV parsing
42
+ }
43
+ }
44
+
45
+ // CSV format — same as following
46
+ return parseMastodonFollowingCsv(trimmed);
47
+ }
48
+
49
+ /**
50
+ * Resolve a fediverse handle (user@instance) to an actor URL via WebFinger.
51
+ *
52
+ * @param {string} handle - Handle like "user@instance.social"
53
+ * @returns {Promise<{actorUrl: string, inbox: string, sharedInbox: string, name: string, handle: string} | null>}
54
+ */
55
+ export async function resolveHandleViaWebFinger(handle) {
56
+ const [user, domain] = handle.split("@");
57
+ if (!user || !domain) return null;
58
+
59
+ try {
60
+ // WebFinger lookup
61
+ const wfUrl = `https://${domain}/.well-known/webfinger?resource=acct:${encodeURIComponent(handle)}`;
62
+ const wfResponse = await fetch(wfUrl, {
63
+ headers: { Accept: "application/jrd+json" },
64
+ signal: AbortSignal.timeout(10_000),
65
+ });
66
+
67
+ if (!wfResponse.ok) return null;
68
+
69
+ const jrd = await wfResponse.json();
70
+ const selfLink = jrd.links?.find(
71
+ (l) => l.rel === "self" && l.type === "application/activity+json",
72
+ );
73
+
74
+ if (!selfLink?.href) return null;
75
+
76
+ // Fetch actor document for inbox and profile
77
+ const actorResponse = await fetch(selfLink.href, {
78
+ headers: { Accept: "application/activity+json" },
79
+ signal: AbortSignal.timeout(10_000),
80
+ });
81
+
82
+ if (!actorResponse.ok) return null;
83
+
84
+ const actor = await actorResponse.json();
85
+ return {
86
+ actorUrl: actor.id || selfLink.href,
87
+ inbox: actor.inbox || "",
88
+ sharedInbox: actor.endpoints?.sharedInbox || "",
89
+ name: actor.name || actor.preferredUsername || handle,
90
+ handle: actor.preferredUsername || user,
91
+ };
92
+ } catch {
93
+ return null;
94
+ }
95
+ }
96
+
97
+ /**
98
+ * Import a list of handles into the ap_following collection.
99
+ *
100
+ * @param {string[]} handles - Array of handles to import
101
+ * @param {Collection} collection - MongoDB ap_following collection
102
+ * @returns {Promise<{imported: number, failed: number}>}
103
+ */
104
+ export async function bulkImportFollowing(handles, collection) {
105
+ let imported = 0;
106
+ let failed = 0;
107
+
108
+ for (const handle of handles) {
109
+ const resolved = await resolveHandleViaWebFinger(handle);
110
+ if (!resolved) {
111
+ failed++;
112
+ continue;
113
+ }
114
+
115
+ await collection.updateOne(
116
+ { actorUrl: resolved.actorUrl },
117
+ {
118
+ $set: {
119
+ actorUrl: resolved.actorUrl,
120
+ handle: resolved.handle,
121
+ name: resolved.name,
122
+ inbox: resolved.inbox,
123
+ sharedInbox: resolved.sharedInbox,
124
+ followedAt: new Date(),
125
+ source: "import",
126
+ },
127
+ },
128
+ { upsert: true },
129
+ );
130
+ imported++;
131
+ }
132
+
133
+ return { imported, failed };
134
+ }
135
+
136
+ /**
137
+ * Import a list of handles/URLs into the ap_followers collection.
138
+ * These are "pending" followers — they'll become real when they
139
+ * re-follow after the Mastodon Move activity.
140
+ *
141
+ * @param {string[]} entries - Array of handles or actor URLs
142
+ * @param {Collection} collection - MongoDB ap_followers collection
143
+ * @returns {Promise<{imported: number, failed: number}>}
144
+ */
145
+ export async function bulkImportFollowers(entries, collection) {
146
+ let imported = 0;
147
+ let failed = 0;
148
+
149
+ for (const entry of entries) {
150
+ // If it's a URL, store directly; if it's a handle, resolve via WebFinger
151
+ const isUrl = entry.startsWith("http");
152
+ let actorData;
153
+
154
+ if (isUrl) {
155
+ actorData = { actorUrl: entry, handle: "", name: entry, inbox: "", sharedInbox: "" };
156
+ } else {
157
+ actorData = await resolveHandleViaWebFinger(entry);
158
+ }
159
+
160
+ if (!actorData) {
161
+ failed++;
162
+ continue;
163
+ }
164
+
165
+ await collection.updateOne(
166
+ { actorUrl: actorData.actorUrl },
167
+ {
168
+ $set: {
169
+ actorUrl: actorData.actorUrl,
170
+ handle: actorData.handle,
171
+ name: actorData.name,
172
+ inbox: actorData.inbox,
173
+ sharedInbox: actorData.sharedInbox,
174
+ followedAt: new Date(),
175
+ pending: true, // Will be confirmed when they re-follow after Move
176
+ },
177
+ },
178
+ { upsert: true },
179
+ );
180
+ imported++;
181
+ }
182
+
183
+ return { imported, failed };
184
+ }