@rmdes/indiekit-endpoint-activitypub 3.11.7 → 3.12.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.
@@ -13,7 +13,6 @@
13
13
  */
14
14
  import { serializeAccount } from "./account.js";
15
15
  import { serializeStatus } from "./status.js";
16
- import { encodeCursor } from "../helpers/pagination.js";
17
16
 
18
17
  /**
19
18
  * Map internal notification types to Mastodon API types.
@@ -121,7 +120,7 @@ export function serializeNotification(notif, { baseUrl, statusMap, interactionSt
121
120
  : notif.published || notif.createdAt || new Date().toISOString();
122
121
 
123
122
  return {
124
- id: encodeCursor(createdAt) || notif._id.toString(),
123
+ id: notif._id.toString(),
125
124
  type: mastodonType,
126
125
  created_at: createdAt,
127
126
  account,
@@ -15,7 +15,6 @@
15
15
  */
16
16
  import { serializeAccount } from "./account.js";
17
17
  import { sanitizeHtml } from "./sanitize.js";
18
- import { encodeCursor } from "../helpers/pagination.js";
19
18
 
20
19
  // Module-level defaults set once at startup via setLocalIdentity()
21
20
  let _localPublicationUrl = "";
@@ -47,10 +46,8 @@ export function setLocalIdentity(publicationUrl, handle) {
47
46
  export function serializeStatus(item, { baseUrl, favouritedIds, rebloggedIds, bookmarkedIds, pinnedIds, replyIdMap, replyAccountIdMap } = {}) {
48
47
  if (!item) return null;
49
48
 
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();
49
+ // Use MongoDB ObjectId as the status ID unique and chronologically sortable.
50
+ const id = item._id.toString();
54
51
  const uid = item.uid || "";
55
52
  const url = item.url || uid;
56
53
 
@@ -1,50 +1,17 @@
1
1
  /**
2
- * Mastodon-compatible cursor pagination helpers.
2
+ * Mastodon-compatible pagination helpers using MongoDB ObjectId.
3
3
  *
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.
4
+ * ObjectIds are 12-byte values with a 4-byte timestamp prefix, making
5
+ * them chronologically sortable. Status IDs are _id.toString() unique,
6
+ * sortable, and directly usable as pagination cursors.
9
7
  *
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
- *
15
- * Emits RFC 8288 Link headers that masto.js / Phanpy parse.
8
+ * Emits RFC 8288 Link headers that Phanpy/Elk/Moshidon parse.
16
9
  */
10
+ import { ObjectId } from "mongodb";
17
11
 
18
12
  const DEFAULT_LIMIT = 20;
19
13
  const MAX_LIMIT = 40;
20
14
 
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
-
48
15
  /**
49
16
  * Parse and clamp the limit parameter.
50
17
  *
@@ -58,46 +25,60 @@ export function parseLimit(raw) {
58
25
  }
59
26
 
60
27
  /**
61
- * Build a MongoDB filter object for cursor-based pagination.
28
+ * Try to parse a cursor string as an ObjectId.
29
+ * Returns null if invalid.
62
30
  *
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
+ * @param {string} cursor - ObjectId hex string from client
32
+ * @returns {ObjectId|null}
33
+ */
34
+ function parseCursor(cursor) {
35
+ if (!cursor || typeof cursor !== "string") return null;
36
+ try {
37
+ return new ObjectId(cursor);
38
+ } catch {
39
+ return null;
40
+ }
41
+ }
42
+
43
+ /**
44
+ * Build a MongoDB filter object for ObjectId-based pagination.
45
+ *
46
+ * Mastodon cursor params (all optional, applied to `_id`):
47
+ * max_id — return items older than this ID (exclusive)
48
+ * min_id — return items newer than this ID (exclusive), closest first
49
+ * since_id — return items newer than this ID (exclusive), most recent first
67
50
  *
68
51
  * @param {object} baseFilter - Existing MongoDB filter to extend
69
52
  * @param {object} cursors
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)
53
+ * @param {string} [cursors.max_id] - ObjectId hex string
54
+ * @param {string} [cursors.min_id] - ObjectId hex string
55
+ * @param {string} [cursors.since_id] - ObjectId hex string
73
56
  * @returns {{ filter: object, sort: object, reverse: boolean }}
74
57
  */
75
58
  export function buildPaginationQuery(baseFilter, { max_id, min_id, since_id } = {}) {
76
59
  const filter = { ...baseFilter };
77
- let sort = { published: -1 }; // newest first (default)
60
+ let sort = { _id: -1 }; // newest first (default)
78
61
  let reverse = false;
79
62
 
80
63
  if (max_id) {
81
- const date = decodeCursor(max_id);
82
- if (date) {
83
- filter.published = { ...filter.published, $lt: date };
64
+ const oid = parseCursor(max_id);
65
+ if (oid) {
66
+ filter._id = { ...filter._id, $lt: oid };
84
67
  }
85
68
  }
86
69
 
87
70
  if (since_id) {
88
- const date = decodeCursor(since_id);
89
- if (date) {
90
- filter.published = { ...filter.published, $gt: date };
71
+ const oid = parseCursor(since_id);
72
+ if (oid) {
73
+ filter._id = { ...filter._id, $gt: oid };
91
74
  }
92
75
  }
93
76
 
94
77
  if (min_id) {
95
- const date = decodeCursor(min_id);
96
- if (date) {
97
- filter.published = { ...filter.published, $gt: date };
98
- // min_id returns results closest to the cursor, so sort ascending
99
- // then reverse the results before returning
100
- sort = { published: 1 };
78
+ const oid = parseCursor(min_id);
79
+ if (oid) {
80
+ filter._id = { ...filter._id, $gt: oid };
81
+ sort = { _id: 1 };
101
82
  reverse = true;
102
83
  }
103
84
  }
@@ -110,7 +91,7 @@ export function buildPaginationQuery(baseFilter, { max_id, min_id, since_id } =
110
91
  *
111
92
  * @param {object} res - Express response object
112
93
  * @param {object} req - Express request object (for building URLs)
113
- * @param {Array} items - Result items (must have `published`)
94
+ * @param {Array} items - Result items (must have `_id`)
114
95
  * @param {number} limit - The limit used for the query
115
96
  */
116
97
  export function setPaginationHeaders(res, req, items, limit) {
@@ -119,10 +100,8 @@ export function setPaginationHeaders(res, req, items, limit) {
119
100
  // Only emit Link if we got a full page (may have more)
120
101
  if (items.length < limit) return;
121
102
 
122
- const firstCursor = encodeCursor(items[0].published);
123
- const lastCursor = encodeCursor(items[items.length - 1].published);
124
-
125
- if (firstCursor === "0" || lastCursor === "0") return;
103
+ const firstId = items[0]._id.toString();
104
+ const lastId = items[items.length - 1]._id.toString();
126
105
 
127
106
  const baseUrl = `${req.protocol}://${req.get("host")}${req.path}`;
128
107
 
@@ -139,14 +118,14 @@ export function setPaginationHeaders(res, req, items, limit) {
139
118
 
140
119
  const links = [];
141
120
 
142
- // rel="next" — older items (max_id = last item's cursor)
121
+ // rel="next" — older items (max_id = last item's ID)
143
122
  const nextParams = new URLSearchParams(existingParams);
144
- nextParams.set("max_id", lastCursor);
123
+ nextParams.set("max_id", lastId);
145
124
  links.push(`<${baseUrl}?${nextParams.toString()}>; rel="next"`);
146
125
 
147
- // rel="prev" — newer items (min_id = first item's cursor)
126
+ // rel="prev" — newer items (min_id = first item's ID)
148
127
  const prevParams = new URLSearchParams(existingParams);
149
- prevParams.set("min_id", firstCursor);
128
+ prevParams.set("min_id", firstId);
150
129
  links.push(`<${baseUrl}?${prevParams.toString()}>; rel="prev"`);
151
130
 
152
131
  res.set("Link", links.join(", "));
@@ -1,17 +1,14 @@
1
1
  /**
2
- * Batch-resolve inReplyTo URLs to Mastodon cursor IDs and account IDs.
2
+ * Batch-resolve inReplyTo URLs to ObjectId strings and account IDs.
3
3
  *
4
4
  * Looks up parent posts in ap_timeline by uid/url and returns two Maps:
5
- * - replyIdMap: inReplyTo URL → cursor ID (status ID)
6
- * - replyAccountIdMap: inReplyTo URL → author account ID
7
- *
8
- * Used by route handlers before calling serializeStatus().
5
+ * - replyIdMap: inReplyTo URL → parent _id.toString()
6
+ * - replyAccountIdMap: inReplyTo URL → parent author account ID
9
7
  *
10
8
  * @param {object} collection - ap_timeline MongoDB collection
11
9
  * @param {Array<object>} items - Timeline items with optional inReplyTo
12
10
  * @returns {Promise<{replyIdMap: Map<string, string>, replyAccountIdMap: Map<string, string>}>}
13
11
  */
14
- import { encodeCursor } from "./pagination.js";
15
12
  import { remoteActorId } from "./id-mapping.js";
16
13
 
17
14
  export async function resolveReplyIds(collection, items) {
@@ -19,29 +16,25 @@ export async function resolveReplyIds(collection, items) {
19
16
  const replyAccountIdMap = new Map();
20
17
  if (!collection || !items?.length) return { replyIdMap, replyAccountIdMap };
21
18
 
22
- // Collect unique inReplyTo URLs
23
19
  const urls = [
24
20
  ...new Set(
25
- items
26
- .map((item) => item.inReplyTo)
27
- .filter(Boolean),
21
+ items.map((item) => item.inReplyTo).filter(Boolean),
28
22
  ),
29
23
  ];
30
24
  if (urls.length === 0) return { replyIdMap, replyAccountIdMap };
31
25
 
32
- // Batch lookup parents by uid or url
33
26
  const parents = await collection
34
27
  .find({ $or: [{ uid: { $in: urls } }, { url: { $in: urls } }] })
35
- .project({ uid: 1, url: 1, published: 1, "author.url": 1 })
28
+ .project({ uid: 1, url: 1, "author.url": 1 })
36
29
  .toArray();
37
30
 
38
31
  for (const parent of parents) {
39
- const cursorId = encodeCursor(parent.published);
32
+ const parentId = parent._id.toString();
40
33
  const authorUrl = parent.author?.url;
41
34
  const authorAccountId = authorUrl ? remoteActorId(authorUrl) : null;
42
35
 
43
36
  const setMaps = (key) => {
44
- if (cursorId && cursorId !== "0") replyIdMap.set(key, cursorId);
37
+ replyIdMap.set(key, parentId);
45
38
  if (authorAccountId) replyAccountIdMap.set(key, authorAccountId);
46
39
  };
47
40
 
@@ -17,7 +17,6 @@
17
17
  import express from "express";
18
18
  import { ObjectId } from "mongodb";
19
19
  import { serializeStatus } from "../entities/status.js";
20
- import { decodeCursor } from "../helpers/pagination.js";
21
20
  import { resolveReplyIds } from "../helpers/resolve-reply-ids.js";
22
21
  import {
23
22
  likePost, unlikePost,
@@ -844,32 +843,13 @@ router.get("/api/v1/statuses/:id/card", async (req, res, next) => {
844
843
  // ─── Helpers ─────────────────────────────────────────────────────────────────
845
844
 
846
845
  /**
847
- * Find a timeline item by cursor ID (published-based) or ObjectId (legacy).
848
- * Status IDs are now encodeCursor(published) — milliseconds since epoch.
849
- * Falls back to ObjectId lookup for backwards compatibility.
846
+ * Find a timeline item by ObjectId.
850
847
  *
851
848
  * @param {object} collection - ap_timeline collection
852
- * @param {string} id - Status ID from client
849
+ * @param {string} id - MongoDB ObjectId string
853
850
  * @returns {Promise<object|null>} Timeline document or null
854
851
  */
855
852
  async function findTimelineItemById(collection, id) {
856
- // Try cursor-based lookup first (published date from ms-since-epoch)
857
- const publishedDate = decodeCursor(id);
858
- if (publishedDate) {
859
- // Try exact match first (with .000Z suffix from toISOString)
860
- let item = await collection.findOne({ published: publishedDate });
861
- if (item) return item;
862
-
863
- // Try without milliseconds — stored dates often lack .000Z
864
- // e.g., "2026-03-21T15:33:50Z" vs "2026-03-21T15:33:50.000Z"
865
- const withoutMs = publishedDate.replace(/\.000Z$/, "Z");
866
- if (withoutMs !== publishedDate) {
867
- item = await collection.findOne({ published: withoutMs });
868
- if (item) return item;
869
- }
870
- }
871
-
872
- // Fall back to ObjectId lookup (legacy IDs)
873
853
  try {
874
854
  return await collection.findOne({ _id: new ObjectId(id) });
875
855
  } catch {
@@ -169,7 +169,7 @@ router.get("/api/v1/timelines/public", async (req, res, next) => {
169
169
  ));
170
170
  }
171
171
 
172
- const replyIdMap = await resolveReplyIds(collections.ap_timeline, items);
172
+ const { replyIdMap: rIdMap, replyAccountIdMap: rAcctMap } = await resolveReplyIds(collections.ap_timeline, items);
173
173
 
174
174
  const statuses = items.map((item) =>
175
175
  serializeStatus(item, {
@@ -178,7 +178,8 @@ router.get("/api/v1/timelines/public", async (req, res, next) => {
178
178
  rebloggedIds,
179
179
  bookmarkedIds,
180
180
  pinnedIds: new Set(),
181
- replyIdMap,
181
+ replyIdMap: rIdMap,
182
+ replyAccountIdMap: rAcctMap,
182
183
  }),
183
184
  );
184
185
 
@@ -235,7 +236,7 @@ router.get("/api/v1/timelines/tag/:hashtag", async (req, res, next) => {
235
236
  ));
236
237
  }
237
238
 
238
- const replyIdMap = await resolveReplyIds(collections.ap_timeline, items);
239
+ const { replyIdMap: rIdMap, replyAccountIdMap: rAcctMap } = await resolveReplyIds(collections.ap_timeline, items);
239
240
 
240
241
  const statuses = items.map((item) =>
241
242
  serializeStatus(item, {
@@ -244,7 +245,8 @@ router.get("/api/v1/timelines/tag/:hashtag", async (req, res, next) => {
244
245
  rebloggedIds,
245
246
  bookmarkedIds,
246
247
  pinnedIds: new Set(),
247
- replyIdMap,
248
+ replyIdMap: rIdMap,
249
+ replyAccountIdMap: rAcctMap,
248
250
  }),
249
251
  );
250
252
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rmdes/indiekit-endpoint-activitypub",
3
- "version": "3.11.7",
3
+ "version": "3.12.0",
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",