@rmdes/indiekit-endpoint-microsub 1.0.0-beta.8 → 1.0.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.
@@ -5,6 +5,7 @@
5
5
 
6
6
  import { IndiekitError } from "@indiekit/error";
7
7
 
8
+ import { proxyItemImages } from "../media/proxy.js";
8
9
  import { getChannel } from "../storage/channels.js";
9
10
  import {
10
11
  getTimelineItems,
@@ -12,6 +13,7 @@ import {
12
13
  markItemsUnread,
13
14
  removeItems,
14
15
  } from "../storage/items.js";
16
+ import { getUserId } from "../utils/auth.js";
15
17
  import {
16
18
  validateChannel,
17
19
  validateEntries,
@@ -26,7 +28,7 @@ import {
26
28
  */
27
29
  export async function get(request, response) {
28
30
  const { application } = request.app.locals;
29
- const userId = request.session?.userId;
31
+ const userId = getUserId(request);
30
32
  const { channel, before, after, limit } = request.query;
31
33
 
32
34
  validateChannel(channel);
@@ -46,6 +48,14 @@ export async function get(request, response) {
46
48
  userId,
47
49
  });
48
50
 
51
+ // Proxy images if application URL is available
52
+ const baseUrl = application.url;
53
+ if (baseUrl && timeline.items) {
54
+ timeline.items = timeline.items.map((item) =>
55
+ proxyItemImages(item, baseUrl),
56
+ );
57
+ }
58
+
49
59
  response.json(timeline);
50
60
  }
51
61
 
@@ -57,7 +67,7 @@ export async function get(request, response) {
57
67
  */
58
68
  export async function action(request, response) {
59
69
  const { application } = request.app.locals;
60
- const userId = request.session?.userId;
70
+ const userId = getUserId(request);
61
71
  const { method, channel } = request.body;
62
72
 
63
73
  validateChannel(channel);
@@ -0,0 +1,213 @@
1
+ /**
2
+ * Media proxy with caching
3
+ * @module media/proxy
4
+ */
5
+
6
+ import crypto from "node:crypto";
7
+
8
+ import { getCache, setCache } from "../cache/redis.js";
9
+
10
+ const MAX_SIZE = 2 * 1024 * 1024; // 2MB max image size
11
+ const CACHE_TTL = 4 * 60 * 60; // 4 hours
12
+ const ALLOWED_TYPES = [
13
+ "image/jpeg",
14
+ "image/png",
15
+ "image/gif",
16
+ "image/webp",
17
+ "image/svg+xml",
18
+ ];
19
+
20
+ /**
21
+ * Generate a hash for a URL to use as cache key
22
+ * @param {string} url - Original image URL
23
+ * @returns {string} URL-safe hash
24
+ */
25
+ export function hashUrl(url) {
26
+ return crypto.createHash("sha256").update(url).digest("hex").slice(0, 16);
27
+ }
28
+
29
+ /**
30
+ * Get the proxied URL for an image
31
+ * @param {string} baseUrl - Base URL of the Microsub endpoint
32
+ * @param {string} originalUrl - Original image URL
33
+ * @returns {string} Proxied URL
34
+ */
35
+ export function getProxiedUrl(baseUrl, originalUrl) {
36
+ if (!originalUrl || !baseUrl) {
37
+ return originalUrl;
38
+ }
39
+
40
+ // Skip data URLs
41
+ if (originalUrl.startsWith("data:")) {
42
+ return originalUrl;
43
+ }
44
+
45
+ // Skip already-proxied URLs
46
+ if (originalUrl.includes("/microsub/media/")) {
47
+ return originalUrl;
48
+ }
49
+
50
+ const hash = hashUrl(originalUrl);
51
+ return `${baseUrl}/microsub/media/${hash}?url=${encodeURIComponent(originalUrl)}`;
52
+ }
53
+
54
+ /**
55
+ * Rewrite image URLs in an item to use the proxy
56
+ * @param {object} item - JF2 item
57
+ * @param {string} baseUrl - Base URL for proxy
58
+ * @returns {object} Item with proxied URLs
59
+ */
60
+ export function proxyItemImages(item, baseUrl) {
61
+ if (!baseUrl || !item) {
62
+ return item;
63
+ }
64
+
65
+ const proxied = { ...item };
66
+
67
+ // Proxy photo URLs
68
+ if (proxied.photo) {
69
+ if (Array.isArray(proxied.photo)) {
70
+ proxied.photo = proxied.photo.map((p) => {
71
+ if (typeof p === "string") {
72
+ return getProxiedUrl(baseUrl, p);
73
+ }
74
+ if (p?.value) {
75
+ return { ...p, value: getProxiedUrl(baseUrl, p.value) };
76
+ }
77
+ return p;
78
+ });
79
+ } else if (typeof proxied.photo === "string") {
80
+ proxied.photo = getProxiedUrl(baseUrl, proxied.photo);
81
+ }
82
+ }
83
+
84
+ // Proxy author photo
85
+ if (proxied.author?.photo) {
86
+ proxied.author = {
87
+ ...proxied.author,
88
+ photo: getProxiedUrl(baseUrl, proxied.author.photo),
89
+ };
90
+ }
91
+
92
+ return proxied;
93
+ }
94
+
95
+ /**
96
+ * Fetch and cache an image
97
+ * @param {object} redis - Redis client
98
+ * @param {string} url - Image URL to fetch
99
+ * @returns {Promise<object|null>} Cached image data or null
100
+ */
101
+ export async function fetchImage(redis, url) {
102
+ const cacheKey = `media:${hashUrl(url)}`;
103
+
104
+ // Try cache first
105
+ if (redis) {
106
+ const cached = await getCache(redis, cacheKey);
107
+ if (cached) {
108
+ return cached;
109
+ }
110
+ }
111
+
112
+ try {
113
+ // Fetch the image
114
+ const response = await fetch(url, {
115
+ headers: {
116
+ "User-Agent": "Indiekit Microsub/1.0 (+https://getindiekit.com)",
117
+ Accept: "image/*",
118
+ },
119
+ signal: AbortSignal.timeout(10_000), // 10 second timeout
120
+ });
121
+
122
+ if (!response.ok) {
123
+ console.error(`[Microsub] Media proxy fetch failed: ${response.status} for ${url}`);
124
+ return null;
125
+ }
126
+
127
+ // Check content type
128
+ const contentType = response.headers.get("content-type")?.split(";")[0];
129
+ if (!ALLOWED_TYPES.includes(contentType)) {
130
+ console.error(`[Microsub] Media proxy rejected type: ${contentType} for ${url}`);
131
+ return null;
132
+ }
133
+
134
+ // Check content length
135
+ const contentLength = Number.parseInt(
136
+ response.headers.get("content-length") || "0",
137
+ 10,
138
+ );
139
+ if (contentLength > MAX_SIZE) {
140
+ console.error(`[Microsub] Media proxy rejected size: ${contentLength} for ${url}`);
141
+ return null;
142
+ }
143
+
144
+ // Read the body
145
+ const buffer = await response.arrayBuffer();
146
+ if (buffer.byteLength > MAX_SIZE) {
147
+ return null;
148
+ }
149
+
150
+ const imageData = {
151
+ contentType,
152
+ data: Buffer.from(buffer).toString("base64"),
153
+ size: buffer.byteLength,
154
+ };
155
+
156
+ // Cache in Redis
157
+ if (redis) {
158
+ await setCache(redis, cacheKey, imageData, CACHE_TTL);
159
+ }
160
+
161
+ return imageData;
162
+ } catch (error) {
163
+ console.error(`[Microsub] Media proxy error: ${error.message} for ${url}`);
164
+ return null;
165
+ }
166
+ }
167
+
168
+ /**
169
+ * Express route handler for media proxy
170
+ * @param {object} request - Express request
171
+ * @param {object} response - Express response
172
+ * @returns {Promise<void>}
173
+ */
174
+ export async function handleMediaProxy(request, response) {
175
+ const { url } = request.query;
176
+
177
+ if (!url) {
178
+ return response.status(400).send("Missing url parameter");
179
+ }
180
+
181
+ // Validate URL
182
+ try {
183
+ const parsed = new URL(url);
184
+ if (!["http:", "https:"].includes(parsed.protocol)) {
185
+ return response.status(400).send("Invalid URL protocol");
186
+ }
187
+ } catch {
188
+ return response.status(400).send("Invalid URL");
189
+ }
190
+
191
+ // Get Redis client from application
192
+ const { application } = request.app.locals;
193
+ const redis = application.redis;
194
+
195
+ // Fetch or get from cache
196
+ const imageData = await fetchImage(redis, url);
197
+
198
+ if (!imageData) {
199
+ // Redirect to original URL as fallback
200
+ return response.redirect(url);
201
+ }
202
+
203
+ // Set cache headers
204
+ response.set({
205
+ "Content-Type": imageData.contentType,
206
+ "Content-Length": imageData.size,
207
+ "Cache-Control": "public, max-age=14400", // 4 hours
208
+ "X-Proxied-From": url,
209
+ });
210
+
211
+ // Send the image
212
+ response.send(Buffer.from(imageData.data, "base64"));
213
+ }
@@ -9,6 +9,10 @@ import { getChannel } from "../storage/channels.js";
9
9
  import { updateFeedAfterFetch, updateFeedWebsub } from "../storage/feeds.js";
10
10
  import { passesRegexFilter, passesTypeFilter } from "../storage/filters.js";
11
11
  import { addItem } from "../storage/items.js";
12
+ import {
13
+ subscribe as websubSubscribe,
14
+ getCallbackUrl,
15
+ } from "../websub/subscriber.js";
12
16
 
13
17
  import { calculateNewTier } from "./tier.js";
14
18
 
@@ -123,13 +127,37 @@ export async function processFeed(application, feed) {
123
127
  updateData,
124
128
  );
125
129
 
126
- // Handle WebSub hub discovery
130
+ // Handle WebSub hub discovery and auto-subscription
127
131
  if (parsed.hub && (!feed.websub || feed.websub.hub !== parsed.hub)) {
128
132
  await updateFeedWebsub(application, feed._id, {
129
133
  hub: parsed.hub,
130
134
  topic: parsed.self || feed.url,
131
135
  });
132
- // TODO: Subscribe to hub
136
+
137
+ // Auto-subscribe to WebSub hub if we have a callback URL
138
+ const baseUrl = application.url;
139
+ if (baseUrl) {
140
+ const callbackUrl = getCallbackUrl(baseUrl, feed._id.toString());
141
+ const updatedFeed = {
142
+ ...feed,
143
+ websub: { hub: parsed.hub, topic: parsed.self || feed.url },
144
+ };
145
+
146
+ websubSubscribe(application, updatedFeed, callbackUrl)
147
+ .then((subscribed) => {
148
+ if (subscribed) {
149
+ console.info(
150
+ `[Microsub] WebSub subscription initiated for ${feed.url}`,
151
+ );
152
+ }
153
+ })
154
+ .catch((error) => {
155
+ console.error(
156
+ `[Microsub] WebSub subscription error for ${feed.url}:`,
157
+ error.message,
158
+ );
159
+ });
160
+ }
133
161
  }
134
162
 
135
163
  result.success = true;
@@ -5,6 +5,8 @@
5
5
 
6
6
  import { ObjectId } from "mongodb";
7
7
 
8
+ import { deleteFeedsForChannel } from "./feeds.js";
9
+ import { deleteItemsForChannel } from "./items.js";
8
10
  import { generateChannelUid } from "../utils/jf2.js";
9
11
 
10
12
  /**
@@ -184,7 +186,7 @@ export async function updateChannel(application, uid, updates, userId) {
184
186
  }
185
187
 
186
188
  /**
187
- * Delete a channel
189
+ * Delete a channel and all its feeds and items
188
190
  * @param {object} application - Indiekit application
189
191
  * @param {string} uid - Channel UID
190
192
  * @param {string} [userId] - User ID
@@ -200,7 +202,20 @@ export async function deleteChannel(application, uid, userId) {
200
202
  return false;
201
203
  }
202
204
 
203
- const result = await collection.deleteOne(query);
205
+ // Find the channel first to get its ObjectId
206
+ const channel = await collection.findOne(query);
207
+ if (!channel) {
208
+ return false;
209
+ }
210
+
211
+ // Cascade delete: items first, then feeds, then channel
212
+ const itemsDeleted = await deleteItemsForChannel(application, channel._id);
213
+ const feedsDeleted = await deleteFeedsForChannel(application, channel._id);
214
+ console.info(
215
+ `[Microsub] Deleted channel ${uid}: ${feedsDeleted} feeds, ${itemsDeleted} items`,
216
+ );
217
+
218
+ const result = await collection.deleteOne({ _id: channel._id });
204
219
  return result.deletedCount > 0;
205
220
  }
206
221
 
@@ -5,6 +5,8 @@
5
5
 
6
6
  import { ObjectId } from "mongodb";
7
7
 
8
+ import { deleteItemsForFeed } from "./items.js";
9
+
8
10
  /**
9
11
  * Get feeds collection from application
10
12
  * @param {object} application - Indiekit application
@@ -122,7 +124,7 @@ export async function updateFeed(application, id, updates) {
122
124
  }
123
125
 
124
126
  /**
125
- * Delete a feed subscription
127
+ * Delete a feed subscription and all its items
126
128
  * @param {object} application - Indiekit application
127
129
  * @param {ObjectId|string} channelId - Channel ObjectId
128
130
  * @param {string} url - Feed URL
@@ -133,7 +135,18 @@ export async function deleteFeed(application, channelId, url) {
133
135
  const objectId =
134
136
  typeof channelId === "string" ? new ObjectId(channelId) : channelId;
135
137
 
136
- const result = await collection.deleteOne({ channelId: objectId, url });
138
+ // Find the feed first to get its ID for cascade delete
139
+ const feed = await collection.findOne({ channelId: objectId, url });
140
+ if (!feed) {
141
+ return false;
142
+ }
143
+
144
+ // Delete all items from this feed
145
+ const itemsDeleted = await deleteItemsForFeed(application, feed._id);
146
+ console.info(`[Microsub] Deleted ${itemsDeleted} items from feed ${url}`);
147
+
148
+ // Delete the feed itself
149
+ const result = await collection.deleteOne({ _id: feed._id });
137
150
  return result.deletedCount > 0;
138
151
  }
139
152
 
@@ -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;
@@ -411,11 +446,34 @@ export async function deleteItemsByAuthorUrl(application, userId, authorUrl) {
411
446
  export async function createIndexes(application) {
412
447
  const collection = getCollection(application);
413
448
 
449
+ // Primary query indexes
414
450
  await collection.createIndex({ channelId: 1, published: -1 });
415
451
  await collection.createIndex({ channelId: 1, uid: 1 }, { unique: true });
416
452
  await collection.createIndex({ feedId: 1 });
453
+
454
+ // URL matching index for mark_read operations
455
+ await collection.createIndex({ channelId: 1, url: 1 });
456
+
457
+ // Full-text search index with weights
458
+ // Higher weight = more importance in relevance scoring
417
459
  await collection.createIndex(
418
- { name: "text", "content.text": "text", summary: "text" },
419
- { name: "text_search" },
460
+ {
461
+ name: "text",
462
+ "content.text": "text",
463
+ "content.html": "text",
464
+ summary: "text",
465
+ "author.name": "text",
466
+ },
467
+ {
468
+ name: "text_search",
469
+ weights: {
470
+ name: 10, // Titles most important
471
+ summary: 5, // Summaries second
472
+ "content.text": 3, // Content third
473
+ "content.html": 2, // HTML content lower
474
+ "author.name": 1, // Author names lowest
475
+ },
476
+ default_language: "english",
477
+ },
420
478
  );
421
479
  }
@@ -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/locales/en.json CHANGED
@@ -46,7 +46,8 @@
46
46
  "cancel": "Cancel",
47
47
  "replyTo": "Replying to",
48
48
  "likeOf": "Liking",
49
- "repostOf": "Reposting"
49
+ "repostOf": "Reposting",
50
+ "bookmarkOf": "Bookmarking"
50
51
  },
51
52
  "settings": {
52
53
  "title": "{{channel}} settings",
@@ -55,6 +56,10 @@
55
56
  "excludeRegex": "Exclude pattern",
56
57
  "excludeRegexHelp": "Regular expression to filter out matching content",
57
58
  "save": "Save settings",
59
+ "dangerZone": "Danger zone",
60
+ "deleteWarning": "Deleting this channel will permanently remove all feeds and items. This action cannot be undone.",
61
+ "deleteConfirm": "Are you sure you want to delete this channel and all its content?",
62
+ "delete": "Delete channel",
58
63
  "types": {
59
64
  "like": "Likes",
60
65
  "repost": "Reposts",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rmdes/indiekit-endpoint-microsub",
3
- "version": "1.0.0-beta.8",
3
+ "version": "1.0.0",
4
4
  "description": "Microsub endpoint for Indiekit. Enables subscribing to feeds and reading content using the Microsub protocol.",
5
5
  "keywords": [
6
6
  "indiekit",
@@ -21,6 +21,9 @@
21
21
  },
22
22
  "type": "module",
23
23
  "main": "index.js",
24
+ "scripts": {
25
+ "test": "node --test test/unit/*.js"
26
+ },
24
27
  "files": [
25
28
  "lib",
26
29
  "locales",