@rmdes/indiekit-endpoint-microsub 1.0.0-beta.12 → 1.0.0-beta.13

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.
@@ -4,6 +4,7 @@
4
4
  */
5
5
 
6
6
  import { deleteItemsByAuthorUrl } from "../storage/items.js";
7
+ import { getUserId } from "../utils/auth.js";
7
8
  import { validateUrl } from "../utils/validation.js";
8
9
 
9
10
  /**
@@ -23,7 +24,7 @@ function getCollection(application) {
23
24
  */
24
25
  export async function list(request, response) {
25
26
  const { application } = request.app.locals;
26
- const userId = request.session?.userId;
27
+ const userId = getUserId(request);
27
28
 
28
29
  const collection = getCollection(application);
29
30
  const blocked = await collection.find({ userId }).toArray();
@@ -40,7 +41,7 @@ export async function list(request, response) {
40
41
  */
41
42
  export async function block(request, response) {
42
43
  const { application } = request.app.locals;
43
- const userId = request.session?.userId;
44
+ const userId = getUserId(request);
44
45
  const { url } = request.body;
45
46
 
46
47
  validateUrl(url);
@@ -71,7 +72,7 @@ export async function block(request, response) {
71
72
  */
72
73
  export async function unblock(request, response) {
73
74
  const { application } = request.app.locals;
74
- const userId = request.session?.userId;
75
+ const userId = getUserId(request);
75
76
  const { url } = request.body;
76
77
 
77
78
  validateUrl(url);
@@ -13,6 +13,7 @@ import {
13
13
  deleteChannel,
14
14
  reorderChannels,
15
15
  } from "../storage/channels.js";
16
+ import { getUserId } from "../utils/auth.js";
16
17
  import {
17
18
  validateChannel,
18
19
  validateChannelName,
@@ -27,7 +28,7 @@ import {
27
28
  */
28
29
  export async function list(request, response) {
29
30
  const { application } = request.app.locals;
30
- const userId = request.session?.userId;
31
+ const userId = getUserId(request);
31
32
 
32
33
  const channels = await getChannels(application, userId);
33
34
 
@@ -42,7 +43,7 @@ export async function list(request, response) {
42
43
  */
43
44
  export async function action(request, response) {
44
45
  const { application } = request.app.locals;
45
- const userId = request.session?.userId;
46
+ const userId = getUserId(request);
46
47
  const { method, name, uid } = request.body;
47
48
 
48
49
  // Delete channel
@@ -113,7 +114,7 @@ export async function action(request, response) {
113
114
  */
114
115
  export async function get(request, response) {
115
116
  const { application } = request.app.locals;
116
- const userId = request.session?.userId;
117
+ const userId = getUserId(request);
117
118
  const { uid } = request.params;
118
119
 
119
120
  validateChannel(uid);
@@ -9,6 +9,7 @@ import {
9
9
  sendEvent,
10
10
  subscribeClient,
11
11
  } from "../realtime/broker.js";
12
+ import { getUserId } from "../utils/auth.js";
12
13
 
13
14
  /**
14
15
  * SSE stream endpoint
@@ -18,7 +19,7 @@ import {
18
19
  */
19
20
  export async function stream(request, response) {
20
21
  const { application } = request.app.locals;
21
- const userId = request.session?.userId;
22
+ const userId = getUserId(request);
22
23
 
23
24
  // Set SSE headers
24
25
  response.setHeader("Content-Type", "text/event-stream");
@@ -13,6 +13,7 @@ import {
13
13
  getFeedsForChannel,
14
14
  } from "../storage/feeds.js";
15
15
  import { createFeedResponse } from "../utils/jf2.js";
16
+ import { getUserId } from "../utils/auth.js";
16
17
  import { validateChannel, validateUrl } from "../utils/validation.js";
17
18
 
18
19
  /**
@@ -23,7 +24,7 @@ import { validateChannel, validateUrl } from "../utils/validation.js";
23
24
  */
24
25
  export async function list(request, response) {
25
26
  const { application } = request.app.locals;
26
- const userId = request.session?.userId;
27
+ const userId = getUserId(request);
27
28
  const { channel } = request.query;
28
29
 
29
30
  validateChannel(channel);
@@ -47,7 +48,7 @@ export async function list(request, response) {
47
48
  */
48
49
  export async function follow(request, response) {
49
50
  const { application } = request.app.locals;
50
- const userId = request.session?.userId;
51
+ const userId = getUserId(request);
51
52
  const { channel, url } = request.body;
52
53
 
53
54
  validateChannel(channel);
@@ -84,7 +85,7 @@ export async function follow(request, response) {
84
85
  */
85
86
  export async function unfollow(request, response) {
86
87
  const { application } = request.app.locals;
87
- const userId = request.session?.userId;
88
+ const userId = getUserId(request);
88
89
  const { channel, url } = request.body;
89
90
 
90
91
  validateChannel(channel);
@@ -5,6 +5,7 @@
5
5
 
6
6
  import { IndiekitError } from "@indiekit/error";
7
7
 
8
+ import { getUserId } from "../utils/auth.js";
8
9
  import { validateChannel, validateUrl } from "../utils/validation.js";
9
10
 
10
11
  /**
@@ -24,7 +25,7 @@ function getCollection(application) {
24
25
  */
25
26
  export async function list(request, response) {
26
27
  const { application } = request.app.locals;
27
- const userId = request.session?.userId;
28
+ const userId = getUserId(request);
28
29
  const { channel } = request.query;
29
30
 
30
31
  // Channel can be "global" or a specific channel UID
@@ -58,7 +59,7 @@ export async function list(request, response) {
58
59
  */
59
60
  export async function mute(request, response) {
60
61
  const { application } = request.app.locals;
61
- const userId = request.session?.userId;
62
+ const userId = getUserId(request);
62
63
  const { channel, url } = request.body;
63
64
 
64
65
  validateUrl(url);
@@ -99,7 +100,7 @@ export async function mute(request, response) {
99
100
  */
100
101
  export async function unmute(request, response) {
101
102
  const { application } = request.app.locals;
102
- const userId = request.session?.userId;
103
+ const userId = getUserId(request);
103
104
  const { channel, url } = request.body;
104
105
 
105
106
  validateUrl(url);
@@ -18,6 +18,7 @@ import {
18
18
  deleteFeed,
19
19
  } from "../storage/feeds.js";
20
20
  import { getTimelineItems, getItemById } from "../storage/items.js";
21
+ import { getUserId } from "../utils/auth.js";
21
22
  import {
22
23
  validateChannelName,
23
24
  validateExcludeTypes,
@@ -40,7 +41,7 @@ export async function index(request, response) {
40
41
  */
41
42
  export async function channels(request, response) {
42
43
  const { application } = request.app.locals;
43
- const userId = request.session?.userId;
44
+ const userId = getUserId(request);
44
45
 
45
46
  const channelList = await getChannels(application, userId);
46
47
 
@@ -70,7 +71,7 @@ export async function newChannel(request, response) {
70
71
  */
71
72
  export async function createChannelAction(request, response) {
72
73
  const { application } = request.app.locals;
73
- const userId = request.session?.userId;
74
+ const userId = getUserId(request);
74
75
  const { name } = request.body;
75
76
 
76
77
  validateChannelName(name);
@@ -88,7 +89,7 @@ export async function createChannelAction(request, response) {
88
89
  */
89
90
  export async function channel(request, response) {
90
91
  const { application } = request.app.locals;
91
- const userId = request.session?.userId;
92
+ const userId = getUserId(request);
92
93
  const { uid } = request.params;
93
94
  const { before, after } = request.query;
94
95
 
@@ -120,7 +121,7 @@ export async function channel(request, response) {
120
121
  */
121
122
  export async function settings(request, response) {
122
123
  const { application } = request.app.locals;
123
- const userId = request.session?.userId;
124
+ const userId = getUserId(request);
124
125
  const { uid } = request.params;
125
126
 
126
127
  const channelDocument = await getChannel(application, uid, userId);
@@ -145,7 +146,7 @@ export async function settings(request, response) {
145
146
  */
146
147
  export async function updateSettings(request, response) {
147
148
  const { application } = request.app.locals;
148
- const userId = request.session?.userId;
149
+ const userId = getUserId(request);
149
150
  const { uid } = request.params;
150
151
  const { excludeTypes, excludeRegex } = request.body;
151
152
 
@@ -180,7 +181,7 @@ export async function updateSettings(request, response) {
180
181
  */
181
182
  export async function deleteChannelAction(request, response) {
182
183
  const { application } = request.app.locals;
183
- const userId = request.session?.userId;
184
+ const userId = getUserId(request);
184
185
  const { uid } = request.params;
185
186
 
186
187
  // Don't allow deleting notifications channel
@@ -206,7 +207,7 @@ export async function deleteChannelAction(request, response) {
206
207
  */
207
208
  export async function feeds(request, response) {
208
209
  const { application } = request.app.locals;
209
- const userId = request.session?.userId;
210
+ const userId = getUserId(request);
210
211
  const { uid } = request.params;
211
212
 
212
213
  const channelDocument = await getChannel(application, uid, userId);
@@ -232,7 +233,7 @@ export async function feeds(request, response) {
232
233
  */
233
234
  export async function addFeed(request, response) {
234
235
  const { application } = request.app.locals;
235
- const userId = request.session?.userId;
236
+ const userId = getUserId(request);
236
237
  const { uid } = request.params;
237
238
  const { url } = request.body;
238
239
 
@@ -265,7 +266,7 @@ export async function addFeed(request, response) {
265
266
  */
266
267
  export async function removeFeed(request, response) {
267
268
  const { application } = request.app.locals;
268
- const userId = request.session?.userId;
269
+ const userId = getUserId(request);
269
270
  const { uid } = request.params;
270
271
  const { url } = request.body;
271
272
 
@@ -287,7 +288,7 @@ export async function removeFeed(request, response) {
287
288
  */
288
289
  export async function item(request, response) {
289
290
  const { application } = request.app.locals;
290
- const userId = request.session?.userId;
291
+ const userId = getUserId(request);
291
292
  const { id } = request.params;
292
293
 
293
294
  const itemDocument = await getItemById(application, id, userId);
@@ -338,7 +339,7 @@ export async function submitCompose(request, response) {
338
339
  */
339
340
  export async function searchPage(request, response) {
340
341
  const { application } = request.app.locals;
341
- const userId = request.session?.userId;
342
+ const userId = getUserId(request);
342
343
 
343
344
  const channelList = await getChannels(application, userId);
344
345
 
@@ -357,7 +358,7 @@ export async function searchPage(request, response) {
357
358
  */
358
359
  export async function searchFeeds(request, response) {
359
360
  const { application } = request.app.locals;
360
- const userId = request.session?.userId;
361
+ const userId = getUserId(request);
361
362
  const { query } = request.body;
362
363
 
363
364
  const channelList = await getChannels(application, userId);
@@ -389,7 +390,7 @@ export async function searchFeeds(request, response) {
389
390
  */
390
391
  export async function subscribe(request, response) {
391
392
  const { application } = request.app.locals;
392
- const userId = request.session?.userId;
393
+ const userId = getUserId(request);
393
394
  const { url, channel: channelUid } = request.body;
394
395
 
395
396
  const channelDocument = await getChannel(application, channelUid, userId);
@@ -8,6 +8,7 @@ import { IndiekitError } from "@indiekit/error";
8
8
  import { discoverFeeds } from "../feeds/hfeed.js";
9
9
  import { searchWithFallback } from "../search/query.js";
10
10
  import { getChannel } from "../storage/channels.js";
11
+ import { getUserId } from "../utils/auth.js";
11
12
  import { validateChannel, validateUrl } from "../utils/validation.js";
12
13
 
13
14
  /**
@@ -77,7 +78,7 @@ export async function discover(request, response) {
77
78
  */
78
79
  export async function search(request, response) {
79
80
  const { application } = request.app.locals;
80
- const userId = request.session?.userId;
81
+ const userId = getUserId(request);
81
82
  const { query, channel } = request.body;
82
83
 
83
84
  if (!query) {
@@ -12,6 +12,7 @@ import {
12
12
  markItemsUnread,
13
13
  removeItems,
14
14
  } from "../storage/items.js";
15
+ import { getUserId } from "../utils/auth.js";
15
16
  import {
16
17
  validateChannel,
17
18
  validateEntries,
@@ -26,7 +27,7 @@ import {
26
27
  */
27
28
  export async function get(request, response) {
28
29
  const { application } = request.app.locals;
29
- const userId = request.session?.userId;
30
+ const userId = getUserId(request);
30
31
  const { channel, before, after, limit } = request.query;
31
32
 
32
33
  validateChannel(channel);
@@ -57,7 +58,7 @@ export async function get(request, response) {
57
58
  */
58
59
  export async function action(request, response) {
59
60
  const { application } = request.app.locals;
60
- const userId = request.session?.userId;
61
+ const userId = getUserId(request);
61
62
  const { method, channel } = request.body;
62
63
 
63
64
  validateChannel(channel);
@@ -195,7 +195,7 @@ export async function getItemsByUids(application, uids, userId) {
195
195
  * Mark items as read
196
196
  * @param {object} application - Indiekit application
197
197
  * @param {ObjectId|string} channelId - Channel ObjectId
198
- * @param {Array} entryIds - Array of entry IDs to mark as read
198
+ * @param {Array} entryIds - Array of entry IDs to mark as read (can be ObjectId, uid, or URL)
199
199
  * @param {string} userId - User ID
200
200
  * @returns {Promise<number>} Number of items updated
201
201
  */
@@ -204,6 +204,12 @@ export async function markItemsRead(application, channelId, entryIds, userId) {
204
204
  const channelObjectId =
205
205
  typeof channelId === "string" ? new ObjectId(channelId) : channelId;
206
206
 
207
+ console.info(
208
+ `[Microsub] markItemsRead called for channel ${channelId}, entries:`,
209
+ entryIds,
210
+ `userId: ${userId}`,
211
+ );
212
+
207
213
  // Handle "last-read-entry" special value
208
214
  if (entryIds.includes("last-read-entry")) {
209
215
  // Mark all items in channel as read
@@ -211,26 +217,39 @@ export async function markItemsRead(application, channelId, entryIds, userId) {
211
217
  { channelId: channelObjectId },
212
218
  { $addToSet: { readBy: userId } },
213
219
  );
220
+ console.info(
221
+ `[Microsub] Marked all items as read: ${result.modifiedCount} updated`,
222
+ );
214
223
  return result.modifiedCount;
215
224
  }
216
225
 
217
226
  // Convert string IDs to ObjectIds where possible
218
- const objectIds = entryIds.map((id) => {
219
- try {
220
- return new ObjectId(id);
221
- } catch {
222
- return id;
223
- }
224
- });
227
+ const objectIds = entryIds
228
+ .map((id) => {
229
+ try {
230
+ return new ObjectId(id);
231
+ } catch {
232
+ return undefined;
233
+ }
234
+ })
235
+ .filter(Boolean);
225
236
 
237
+ // Build query to match by _id, uid, or url (Microsub spec uses URLs as entry identifiers)
226
238
  const result = await collection.updateMany(
227
239
  {
228
240
  channelId: channelObjectId,
229
- $or: [{ _id: { $in: objectIds } }, { uid: { $in: entryIds } }],
241
+ $or: [
242
+ ...(objectIds.length > 0 ? [{ _id: { $in: objectIds } }] : []),
243
+ { uid: { $in: entryIds } },
244
+ { url: { $in: entryIds } },
245
+ ],
230
246
  },
231
247
  { $addToSet: { readBy: userId } },
232
248
  );
233
249
 
250
+ console.info(
251
+ `[Microsub] markItemsRead result: ${result.modifiedCount} items updated`,
252
+ );
234
253
  return result.modifiedCount;
235
254
  }
236
255
 
@@ -238,7 +257,7 @@ export async function markItemsRead(application, channelId, entryIds, userId) {
238
257
  * Mark items as unread
239
258
  * @param {object} application - Indiekit application
240
259
  * @param {ObjectId|string} channelId - Channel ObjectId
241
- * @param {Array} entryIds - Array of entry IDs to mark as unread
260
+ * @param {Array} entryIds - Array of entry IDs to mark as unread (can be ObjectId, uid, or URL)
242
261
  * @param {string} userId - User ID
243
262
  * @returns {Promise<number>} Number of items updated
244
263
  */
@@ -252,18 +271,26 @@ export async function markItemsUnread(
252
271
  const channelObjectId =
253
272
  typeof channelId === "string" ? new ObjectId(channelId) : channelId;
254
273
 
255
- const objectIds = entryIds.map((id) => {
256
- try {
257
- return new ObjectId(id);
258
- } catch {
259
- return id;
260
- }
261
- });
274
+ // Convert string IDs to ObjectIds where possible
275
+ const objectIds = entryIds
276
+ .map((id) => {
277
+ try {
278
+ return new ObjectId(id);
279
+ } catch {
280
+ return undefined;
281
+ }
282
+ })
283
+ .filter(Boolean);
262
284
 
285
+ // Match by _id, uid, or url
263
286
  const result = await collection.updateMany(
264
287
  {
265
288
  channelId: channelObjectId,
266
- $or: [{ _id: { $in: objectIds } }, { uid: { $in: entryIds } }],
289
+ $or: [
290
+ ...(objectIds.length > 0 ? [{ _id: { $in: objectIds } }] : []),
291
+ { uid: { $in: entryIds } },
292
+ { url: { $in: entryIds } },
293
+ ],
267
294
  },
268
295
  { $pull: { readBy: userId } },
269
296
  );
@@ -275,7 +302,7 @@ export async function markItemsUnread(
275
302
  * Remove items from channel
276
303
  * @param {object} application - Indiekit application
277
304
  * @param {ObjectId|string} channelId - Channel ObjectId
278
- * @param {Array} entryIds - Array of entry IDs to remove
305
+ * @param {Array} entryIds - Array of entry IDs to remove (can be ObjectId, uid, or URL)
279
306
  * @returns {Promise<number>} Number of items removed
280
307
  */
281
308
  export async function removeItems(application, channelId, entryIds) {
@@ -283,17 +310,25 @@ export async function removeItems(application, channelId, entryIds) {
283
310
  const channelObjectId =
284
311
  typeof channelId === "string" ? new ObjectId(channelId) : channelId;
285
312
 
286
- const objectIds = entryIds.map((id) => {
287
- try {
288
- return new ObjectId(id);
289
- } catch {
290
- return id;
291
- }
292
- });
313
+ // Convert string IDs to ObjectIds where possible
314
+ const objectIds = entryIds
315
+ .map((id) => {
316
+ try {
317
+ return new ObjectId(id);
318
+ } catch {
319
+ return undefined;
320
+ }
321
+ })
322
+ .filter(Boolean);
293
323
 
324
+ // Match by _id, uid, or url
294
325
  const result = await collection.deleteMany({
295
326
  channelId: channelObjectId,
296
- $or: [{ _id: { $in: objectIds } }, { uid: { $in: entryIds } }],
327
+ $or: [
328
+ ...(objectIds.length > 0 ? [{ _id: { $in: objectIds } }] : []),
329
+ { uid: { $in: entryIds } },
330
+ { url: { $in: entryIds } },
331
+ ],
297
332
  });
298
333
 
299
334
  return result.deletedCount;
@@ -0,0 +1,37 @@
1
+ /**
2
+ * Authentication utilities for Microsub
3
+ * @module utils/auth
4
+ */
5
+
6
+ /**
7
+ * Get the user ID from request context
8
+ *
9
+ * In Indiekit, the userId can come from:
10
+ * 1. request.session.userId (if explicitly set)
11
+ * 2. request.session.me (from token introspection)
12
+ * 3. application.publication.me (single-user fallback)
13
+ *
14
+ * @param {object} request - Express request
15
+ * @returns {string|undefined} User ID
16
+ */
17
+ export function getUserId(request) {
18
+ // Check session for explicit userId
19
+ if (request.session?.userId) {
20
+ return request.session.userId;
21
+ }
22
+
23
+ // Check session for me URL from token introspection
24
+ if (request.session?.me) {
25
+ return request.session.me;
26
+ }
27
+
28
+ // Fall back to publication me URL (single-user mode)
29
+ const { application } = request.app.locals;
30
+ if (application?.publication?.me) {
31
+ return application.publication.me;
32
+ }
33
+
34
+ // Final fallback: use "default" as user ID for single-user instances
35
+ // This ensures read state is tracked even without explicit user identity
36
+ return "default";
37
+ }
@@ -3,6 +3,7 @@
3
3
  * @module webmention/receiver
4
4
  */
5
5
 
6
+ import { getUserId } from "../utils/auth.js";
6
7
  import { processWebmention } from "./processor.js";
7
8
 
8
9
  /**
@@ -33,7 +34,7 @@ export async function receive(request, response) {
33
34
  }
34
35
 
35
36
  const { application } = request.app.locals;
36
- const userId = request.session?.userId;
37
+ const userId = getUserId(request);
37
38
 
38
39
  // Return 202 Accepted immediately (processing asynchronously)
39
40
  response.status(202).json({
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rmdes/indiekit-endpoint-microsub",
3
- "version": "1.0.0-beta.12",
3
+ "version": "1.0.0-beta.13",
4
4
  "description": "Microsub endpoint for Indiekit. Enables subscribing to feeds and reading content using the Microsub protocol.",
5
5
  "keywords": [
6
6
  "indiekit",