@rmdes/indiekit-endpoint-activitypub 3.5.9 → 3.6.1

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.
@@ -13,6 +13,7 @@
13
13
  */
14
14
  import { serializeAccount } from "./account.js";
15
15
  import { serializeStatus } from "./status.js";
16
+ import { encodeCursor } from "../helpers/pagination.js";
16
17
 
17
18
  /**
18
19
  * Map internal notification types to Mastodon API types.
@@ -115,12 +116,14 @@ export function serializeNotification(notif, { baseUrl, statusMap, interactionSt
115
116
  };
116
117
  }
117
118
 
119
+ const createdAt = notif.published instanceof Date
120
+ ? notif.published.toISOString()
121
+ : notif.published || notif.createdAt || new Date().toISOString();
122
+
118
123
  return {
119
- id: notif._id.toString(),
124
+ id: encodeCursor(createdAt) || notif._id.toString(),
120
125
  type: mastodonType,
121
- created_at: notif.published instanceof Date
122
- ? notif.published.toISOString()
123
- : notif.published || notif.createdAt || new Date().toISOString(),
126
+ created_at: createdAt,
124
127
  account,
125
128
  status,
126
129
  };
@@ -15,6 +15,7 @@
15
15
  */
16
16
  import { serializeAccount } from "./account.js";
17
17
  import { sanitizeHtml } from "./sanitize.js";
18
+ import { encodeCursor } from "../helpers/pagination.js";
18
19
 
19
20
  // Module-level defaults set once at startup via setLocalIdentity()
20
21
  let _localPublicationUrl = "";
@@ -46,7 +47,10 @@ export function setLocalIdentity(publicationUrl, handle) {
46
47
  export function serializeStatus(item, { baseUrl, favouritedIds, rebloggedIds, bookmarkedIds, pinnedIds }) {
47
48
  if (!item) return null;
48
49
 
49
- const id = item._id.toString();
50
+ // Use published-based cursor as the status ID so pagination cursors
51
+ // (max_id/min_id) sort chronologically, not by insertion order.
52
+ const cursorDate = item.published || item.createdAt || item.boostedAt;
53
+ const id = encodeCursor(cursorDate) || item._id.toString();
50
54
  const uid = item.uid || "";
51
55
  const url = item.url || uid;
52
56
 
@@ -1,14 +1,50 @@
1
1
  /**
2
2
  * Mastodon-compatible cursor pagination helpers.
3
3
  *
4
- * Uses MongoDB ObjectId as cursor (chronologically ordered).
4
+ * Uses `published` date as cursor (chronologically correct) instead of
5
+ * MongoDB ObjectId. ObjectId reflects insertion order, not publication
6
+ * order — backfilled or syndicated posts get new ObjectIds at import
7
+ * time, breaking chronological sort. The `published` field matches the
8
+ * native reader's sort and produces a correct timeline.
9
+ *
10
+ * Cursor values are `published` ISO strings, but Mastodon clients pass
11
+ * them as opaque `max_id`/`min_id`/`since_id` strings. We encode the
12
+ * published date as a Mastodon-style snowflake-ish ID (milliseconds
13
+ * since epoch) so clients treat them as comparable integers.
14
+ *
5
15
  * Emits RFC 8288 Link headers that masto.js / Phanpy parse.
6
16
  */
7
- import { ObjectId } from "mongodb";
8
17
 
9
18
  const DEFAULT_LIMIT = 20;
10
19
  const MAX_LIMIT = 40;
11
20
 
21
+ /**
22
+ * Encode a published date string as a numeric cursor ID.
23
+ * Mastodon clients expect IDs to be numeric strings that sort chronologically.
24
+ * We use milliseconds since epoch — monotonic and comparable.
25
+ *
26
+ * @param {string|Date} published - ISO date string or Date object
27
+ * @returns {string} Numeric string (ms since epoch)
28
+ */
29
+ export function encodeCursor(published) {
30
+ if (!published) return "0";
31
+ const ms = new Date(published).getTime();
32
+ return Number.isFinite(ms) ? String(ms) : "0";
33
+ }
34
+
35
+ /**
36
+ * Decode a numeric cursor ID back to an ISO date string.
37
+ *
38
+ * @param {string} cursor - Numeric cursor from client
39
+ * @returns {string|null} ISO date string, or null if invalid
40
+ */
41
+ export function decodeCursor(cursor) {
42
+ if (!cursor) return null;
43
+ const ms = Number.parseInt(cursor, 10);
44
+ if (!Number.isFinite(ms) || ms <= 0) return null;
45
+ return new Date(ms).toISOString();
46
+ }
47
+
12
48
  /**
13
49
  * Parse and clamp the limit parameter.
14
50
  *
@@ -24,48 +60,45 @@ export function parseLimit(raw) {
24
60
  /**
25
61
  * Build a MongoDB filter object for cursor-based pagination.
26
62
  *
27
- * Mastodon cursor params (all optional, applied to `_id`):
28
- * max_id — return items older than this ID (exclusive)
29
- * min_id — return items newer than this ID (exclusive), closest first
30
- * since_id — return items newer than this ID (exclusive), most recent first
63
+ * Mastodon cursor params (all optional, applied to `published`):
64
+ * max_id — return items older than this cursor (exclusive)
65
+ * min_id — return items newer than this cursor (exclusive), closest first
66
+ * since_id — return items newer than this cursor (exclusive), most recent first
31
67
  *
32
68
  * @param {object} baseFilter - Existing MongoDB filter to extend
33
69
  * @param {object} cursors
34
- * @param {string} [cursors.max_id]
35
- * @param {string} [cursors.min_id]
36
- * @param {string} [cursors.since_id]
70
+ * @param {string} [cursors.max_id] - Numeric cursor (ms since epoch)
71
+ * @param {string} [cursors.min_id] - Numeric cursor (ms since epoch)
72
+ * @param {string} [cursors.since_id] - Numeric cursor (ms since epoch)
37
73
  * @returns {{ filter: object, sort: object, reverse: boolean }}
38
74
  */
39
75
  export function buildPaginationQuery(baseFilter, { max_id, min_id, since_id } = {}) {
40
76
  const filter = { ...baseFilter };
41
- let sort = { _id: -1 }; // newest first (default)
77
+ let sort = { published: -1 }; // newest first (default)
42
78
  let reverse = false;
43
79
 
44
80
  if (max_id) {
45
- try {
46
- filter._id = { ...filter._id, $lt: new ObjectId(max_id) };
47
- } catch {
48
- // Invalid ObjectId — ignore
81
+ const date = decodeCursor(max_id);
82
+ if (date) {
83
+ filter.published = { ...filter.published, $lt: date };
49
84
  }
50
85
  }
51
86
 
52
87
  if (since_id) {
53
- try {
54
- filter._id = { ...filter._id, $gt: new ObjectId(since_id) };
55
- } catch {
56
- // Invalid ObjectId — ignore
88
+ const date = decodeCursor(since_id);
89
+ if (date) {
90
+ filter.published = { ...filter.published, $gt: date };
57
91
  }
58
92
  }
59
93
 
60
94
  if (min_id) {
61
- try {
62
- filter._id = { ...filter._id, $gt: new ObjectId(min_id) };
95
+ const date = decodeCursor(min_id);
96
+ if (date) {
97
+ filter.published = { ...filter.published, $gt: date };
63
98
  // min_id returns results closest to the cursor, so sort ascending
64
99
  // then reverse the results before returning
65
- sort = { _id: 1 };
100
+ sort = { published: 1 };
66
101
  reverse = true;
67
- } catch {
68
- // Invalid ObjectId — ignore
69
102
  }
70
103
  }
71
104
 
@@ -77,7 +110,7 @@ export function buildPaginationQuery(baseFilter, { max_id, min_id, since_id } =
77
110
  *
78
111
  * @param {object} res - Express response object
79
112
  * @param {object} req - Express request object (for building URLs)
80
- * @param {Array} items - Result items (must have `_id` or `id`)
113
+ * @param {Array} items - Result items (must have `published`)
81
114
  * @param {number} limit - The limit used for the query
82
115
  */
83
116
  export function setPaginationHeaders(res, req, items, limit) {
@@ -86,10 +119,10 @@ export function setPaginationHeaders(res, req, items, limit) {
86
119
  // Only emit Link if we got a full page (may have more)
87
120
  if (items.length < limit) return;
88
121
 
89
- const firstId = itemId(items[0]);
90
- const lastId = itemId(items[items.length - 1]);
122
+ const firstCursor = encodeCursor(items[0].published);
123
+ const lastCursor = encodeCursor(items[items.length - 1].published);
91
124
 
92
- if (!firstId || !lastId) return;
125
+ if (firstCursor === "0" || lastCursor === "0") return;
93
126
 
94
127
  const baseUrl = `${req.protocol}://${req.get("host")}${req.path}`;
95
128
 
@@ -106,25 +139,15 @@ export function setPaginationHeaders(res, req, items, limit) {
106
139
 
107
140
  const links = [];
108
141
 
109
- // rel="next" — older items (max_id = last item's ID)
142
+ // rel="next" — older items (max_id = last item's cursor)
110
143
  const nextParams = new URLSearchParams(existingParams);
111
- nextParams.set("max_id", lastId);
144
+ nextParams.set("max_id", lastCursor);
112
145
  links.push(`<${baseUrl}?${nextParams.toString()}>; rel="next"`);
113
146
 
114
- // rel="prev" — newer items (min_id = first item's ID)
147
+ // rel="prev" — newer items (min_id = first item's cursor)
115
148
  const prevParams = new URLSearchParams(existingParams);
116
- prevParams.set("min_id", firstId);
149
+ prevParams.set("min_id", firstCursor);
117
150
  links.push(`<${baseUrl}?${prevParams.toString()}>; rel="prev"`);
118
151
 
119
152
  res.set("Link", links.join(", "));
120
153
  }
121
-
122
- /**
123
- * Extract the string ID from an item.
124
- */
125
- function itemId(item) {
126
- if (!item) return null;
127
- if (item._id) return item._id.toString();
128
- if (item.id) return String(item.id);
129
- return null;
130
- }
@@ -15,6 +15,7 @@
15
15
  import express from "express";
16
16
  import { ObjectId } from "mongodb";
17
17
  import { serializeStatus } from "../entities/status.js";
18
+ import { decodeCursor } from "../helpers/pagination.js";
18
19
  import {
19
20
  likePost, unlikePost,
20
21
  boostPost, unboostPost,
@@ -32,14 +33,7 @@ router.get("/api/v1/statuses/:id", async (req, res, next) => {
32
33
  const collections = req.app.locals.mastodonCollections;
33
34
  const baseUrl = `${req.protocol}://${req.get("host")}`;
34
35
 
35
- let objectId;
36
- try {
37
- objectId = new ObjectId(id);
38
- } catch {
39
- return res.status(404).json({ error: "Record not found" });
40
- }
41
-
42
- const item = await collections.ap_timeline.findOne({ _id: objectId });
36
+ const item = await findTimelineItemById(collections.ap_timeline, id);
43
37
  if (!item) {
44
38
  return res.status(404).json({ error: "Record not found" });
45
39
  }
@@ -67,14 +61,7 @@ router.get("/api/v1/statuses/:id/context", async (req, res, next) => {
67
61
  const collections = req.app.locals.mastodonCollections;
68
62
  const baseUrl = `${req.protocol}://${req.get("host")}`;
69
63
 
70
- let objectId;
71
- try {
72
- objectId = new ObjectId(id);
73
- } catch {
74
- return res.status(404).json({ error: "Record not found" });
75
- }
76
-
77
- const item = await collections.ap_timeline.findOne({ _id: objectId });
64
+ const item = await findTimelineItemById(collections.ap_timeline, id);
78
65
  if (!item) {
79
66
  return res.status(404).json({ error: "Record not found" });
80
67
  }
@@ -173,18 +160,12 @@ router.post("/api/v1/statuses", async (req, res, next) => {
173
160
  return res.status(422).json({ error: "Validation failed: Text content is required" });
174
161
  }
175
162
 
176
- // Resolve in_reply_to URL from timeline ObjectId
163
+ // Resolve in_reply_to URL from status ID (cursor or ObjectId)
177
164
  let inReplyTo = null;
178
165
  if (inReplyToId) {
179
- try {
180
- const replyItem = await collections.ap_timeline.findOne({
181
- _id: new ObjectId(inReplyToId),
182
- });
183
- if (replyItem) {
184
- inReplyTo = replyItem.uid || replyItem.url;
185
- }
186
- } catch {
187
- // Invalid ObjectId — ignore
166
+ const replyItem = await findTimelineItemById(collections.ap_timeline, inReplyToId);
167
+ if (replyItem) {
168
+ inReplyTo = replyItem.uid || replyItem.url;
188
169
  }
189
170
  }
190
171
 
@@ -300,14 +281,7 @@ router.delete("/api/v1/statuses/:id", async (req, res, next) => {
300
281
  const collections = req.app.locals.mastodonCollections;
301
282
  const baseUrl = `${req.protocol}://${req.get("host")}`;
302
283
 
303
- let objectId;
304
- try {
305
- objectId = new ObjectId(id);
306
- } catch {
307
- return res.status(404).json({ error: "Record not found" });
308
- }
309
-
310
- const item = await collections.ap_timeline.findOne({ _id: objectId });
284
+ const item = await findTimelineItemById(collections.ap_timeline, id);
311
285
  if (!item) {
312
286
  return res.status(404).json({ error: "Record not found" });
313
287
  }
@@ -553,20 +527,38 @@ router.post("/api/v1/statuses/:id/unbookmark", async (req, res, next) => {
553
527
  // ─── Helpers ─────────────────────────────────────────────────────────────────
554
528
 
555
529
  /**
556
- * Resolve a timeline item from the :id param, plus common context.
530
+ * Find a timeline item by cursor ID (published-based) or ObjectId (legacy).
531
+ * Status IDs are now encodeCursor(published) — milliseconds since epoch.
532
+ * Falls back to ObjectId lookup for backwards compatibility.
533
+ *
534
+ * @param {object} collection - ap_timeline collection
535
+ * @param {string} id - Status ID from client
536
+ * @returns {Promise<object|null>} Timeline document or null
557
537
  */
558
- async function resolveStatusForInteraction(req) {
559
- const collections = req.app.locals.mastodonCollections;
560
- const baseUrl = `${req.protocol}://${req.get("host")}`;
538
+ async function findTimelineItemById(collection, id) {
539
+ // Try cursor-based lookup first (published date from ms-since-epoch)
540
+ const publishedDate = decodeCursor(id);
541
+ if (publishedDate) {
542
+ const item = await collection.findOne({ published: publishedDate });
543
+ if (item) return item;
544
+ }
561
545
 
562
- let objectId;
546
+ // Fall back to ObjectId lookup (legacy IDs)
563
547
  try {
564
- objectId = new ObjectId(req.params.id);
548
+ return await collection.findOne({ _id: new ObjectId(id) });
565
549
  } catch {
566
- return { item: null, collections, baseUrl };
550
+ return null;
567
551
  }
552
+ }
553
+
554
+ /**
555
+ * Resolve a timeline item from the :id param, plus common context.
556
+ */
557
+ async function resolveStatusForInteraction(req) {
558
+ const collections = req.app.locals.mastodonCollections;
559
+ const baseUrl = `${req.protocol}://${req.get("host")}`;
568
560
 
569
- const item = await collections.ap_timeline.findOne({ _id: objectId });
561
+ const item = await findTimelineItemById(collections.ap_timeline, req.params.id);
570
562
  return { item, collections, baseUrl };
571
563
  }
572
564
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rmdes/indiekit-endpoint-activitypub",
3
- "version": "3.5.9",
3
+ "version": "3.6.1",
4
4
  "description": "ActivityPub federation endpoint for Indiekit via Fedify. Adds full fediverse support: actor, inbox, outbox, followers, following, syndication, and Mastodon migration.",
5
5
  "keywords": [
6
6
  "indiekit",