From e6b8f79c5c3a76e5b5a8bb9fc7d5bd5b03679ed4 Mon Sep 17 00:00:00 2001 From: Ricardo Mendes Date: Sun, 1 Feb 2026 12:13:14 +0100 Subject: [PATCH 01/17] feat(endpoint-microsub): add core Microsub server with channels and timeline This PR adds the foundational Microsub endpoint with: **Microsub API:** - GET/POST ?action=channels - list, create, update, delete, reorder channels - GET/POST ?action=timeline - list items, mark read/unread, remove **Storage:** - MongoDB collections for channels and items - Cursor-based pagination for timeline - Per-user channel ordering and read state tracking **Features:** - Follows Microsub spec for channel and timeline actions - Testable with existing Microsub clients (Monocle, Indigenous, etc.) - Multi-user support via userId from session/token This is PR 1 of 6 for the Microsub implementation. Future PRs will add: - PR 2: Feed discovery and subscription - PR 3: Feed fetching and parsing - PR 4: Reader UI - PR 5: Compose and Micropub integration - PR 6: Settings and filtering Co-Authored-By: Claude Opus 4.5 --- packages/endpoint-microsub/index.js | 63 +++++ .../lib/controllers/channels.js | 110 ++++++++ .../lib/controllers/microsub.js | 86 ++++++ .../lib/controllers/timeline.js | 119 ++++++++ .../endpoint-microsub/lib/storage/channels.js | 253 +++++++++++++++++ .../endpoint-microsub/lib/storage/items.js | 260 ++++++++++++++++++ packages/endpoint-microsub/lib/utils/auth.js | 35 +++ .../endpoint-microsub/lib/utils/pagination.js | 148 ++++++++++ packages/endpoint-microsub/lib/utils/uid.js | 17 ++ .../endpoint-microsub/lib/utils/validation.js | 129 +++++++++ packages/endpoint-microsub/locales/en.json | 15 + packages/endpoint-microsub/package.json | 51 ++++ 12 files changed, 1286 insertions(+) create mode 100644 packages/endpoint-microsub/index.js create mode 100644 packages/endpoint-microsub/lib/controllers/channels.js create mode 100644 packages/endpoint-microsub/lib/controllers/microsub.js create mode 100644 packages/endpoint-microsub/lib/controllers/timeline.js create mode 100644 packages/endpoint-microsub/lib/storage/channels.js create mode 100644 packages/endpoint-microsub/lib/storage/items.js create mode 100644 packages/endpoint-microsub/lib/utils/auth.js create mode 100644 packages/endpoint-microsub/lib/utils/pagination.js create mode 100644 packages/endpoint-microsub/lib/utils/uid.js create mode 100644 packages/endpoint-microsub/lib/utils/validation.js create mode 100644 packages/endpoint-microsub/locales/en.json create mode 100644 packages/endpoint-microsub/package.json diff --git a/packages/endpoint-microsub/index.js b/packages/endpoint-microsub/index.js new file mode 100644 index 000000000..a4e1d0248 --- /dev/null +++ b/packages/endpoint-microsub/index.js @@ -0,0 +1,63 @@ +import express from "express"; + +import { microsubController } from "./lib/controllers/microsub.js"; +import { createIndexes } from "./lib/storage/items.js"; + +const defaults = { + mountPath: "/microsub", +}; +const router = express.Router(); + +export default class MicrosubEndpoint { + name = "Microsub endpoint"; + + /** + * @param {object} options - Plugin options + * @param {string} [options.mountPath] - Path to mount Microsub endpoint + */ + constructor(options = {}) { + this.options = { ...defaults, ...options }; + this.mountPath = this.options.mountPath; + } + + /** + * Microsub API routes (authenticated) + * @returns {import("express").Router} Express router + */ + get routes() { + // Main Microsub endpoint - dispatches based on action parameter + router.get("/", microsubController.get); + router.post("/", microsubController.post); + + return router; + } + + /** + * Initialize plugin + * @param {object} indiekit - Indiekit instance + */ + init(indiekit) { + console.info("[Microsub] Initializing endpoint-microsub plugin"); + + // Register MongoDB collections + indiekit.addCollection("microsub_channels"); + indiekit.addCollection("microsub_items"); + + console.info("[Microsub] Registered MongoDB collections"); + + // Register endpoint + indiekit.addEndpoint(this); + + // Set microsub endpoint URL in config + if (!indiekit.config.application.microsubEndpoint) { + indiekit.config.application.microsubEndpoint = this.mountPath; + } + + // Create indexes for optimal performance (runs in background) + if (indiekit.database) { + createIndexes(indiekit).catch((error) => { + console.warn("[Microsub] Index creation failed:", error.message); + }); + } + } +} diff --git a/packages/endpoint-microsub/lib/controllers/channels.js b/packages/endpoint-microsub/lib/controllers/channels.js new file mode 100644 index 000000000..be861b0ad --- /dev/null +++ b/packages/endpoint-microsub/lib/controllers/channels.js @@ -0,0 +1,110 @@ +/** + * Channel management controller + * @module controllers/channels + */ + +import { IndiekitError } from "@indiekit/error"; + +import { + getChannels, + createChannel, + updateChannel, + deleteChannel, + reorderChannels, +} from "../storage/channels.js"; +import { getUserId } from "../utils/auth.js"; +import { + validateChannel, + validateChannelName, + parseArrayParameter, +} from "../utils/validation.js"; + +/** + * List all channels + * GET ?action=channels + * @param {object} request - Express request + * @param {object} response - Express response + */ +export async function list(request, response) { + const { application } = request.app.locals; + const userId = getUserId(request); + + const channels = await getChannels(application, userId); + + response.json({ channels }); +} + +/** + * Handle channel actions (create, update, delete, order) + * POST ?action=channels + * @param {object} request - Express request + * @param {object} response - Express response + * @returns {Promise} + */ +export async function action(request, response) { + const { application } = request.app.locals; + const userId = getUserId(request); + const { method, name, uid } = request.body; + + // Delete channel + if (method === "delete") { + validateChannel(uid); + + const deleted = await deleteChannel(application, uid, userId); + if (!deleted) { + throw new IndiekitError("Channel not found or cannot be deleted", { + status: 404, + }); + } + + return response.json({ deleted: uid }); + } + + // Reorder channels + if (method === "order") { + const channelUids = parseArrayParameter(request.body, "channels"); + if (channelUids.length === 0) { + throw new IndiekitError("Missing channels[] parameter", { + status: 400, + }); + } + + await reorderChannels(application, channelUids, userId); + + const channels = await getChannels(application, userId); + return response.json({ channels }); + } + + // Update existing channel + if (uid) { + validateChannel(uid); + + if (name) { + validateChannelName(name); + } + + const channel = await updateChannel(application, uid, { name }, userId); + if (!channel) { + throw new IndiekitError("Channel not found", { + status: 404, + }); + } + + return response.json({ + uid: channel.uid, + name: channel.name, + }); + } + + // Create new channel + validateChannelName(name); + + const channel = await createChannel(application, { name, userId }); + + response.status(201).json({ + uid: channel.uid, + name: channel.name, + }); +} + +export const channelsController = { list, action }; diff --git a/packages/endpoint-microsub/lib/controllers/microsub.js b/packages/endpoint-microsub/lib/controllers/microsub.js new file mode 100644 index 000000000..a25fd67dc --- /dev/null +++ b/packages/endpoint-microsub/lib/controllers/microsub.js @@ -0,0 +1,86 @@ +/** + * Main Microsub action router + * @module controllers/microsub + */ + +import { IndiekitError } from "@indiekit/error"; + +import { validateAction } from "../utils/validation.js"; + +import { list as listChannels, action as channelAction } from "./channels.js"; +import { get as getTimeline, action as timelineAction } from "./timeline.js"; + +/** + * Route GET requests to appropriate action handler + * @param {object} request - Express request + * @param {object} response - Express response + * @param {Function} next - Express next function + * @returns {Promise} + */ +export async function get(request, response, next) { + try { + const { action } = request.query; + + if (!action) { + // Return basic endpoint info + return response.json({ + type: "microsub", + actions: ["channels", "timeline"], + }); + } + + validateAction(action); + + switch (action) { + case "channels": { + return listChannels(request, response); + } + + case "timeline": { + return getTimeline(request, response); + } + + default: { + throw new IndiekitError(`Unsupported GET action: ${action}`, { + status: 400, + }); + } + } + } catch (error) { + next(error); + } +} + +/** + * Route POST requests to appropriate action handler + * @param {object} request - Express request + * @param {object} response - Express response + * @param {Function} next - Express next function + * @returns {Promise} + */ +export async function post(request, response, next) { + try { + const action = request.body.action || request.query.action; + validateAction(action); + + switch (action) { + case "channels": { + return channelAction(request, response); + } + + case "timeline": { + return timelineAction(request, response); + } + + default: { + throw new IndiekitError(`Unsupported POST action: ${action}`, { + status: 400, + }); + } + } + } catch (error) { + next(error); + } +} + +export const microsubController = { get, post }; diff --git a/packages/endpoint-microsub/lib/controllers/timeline.js b/packages/endpoint-microsub/lib/controllers/timeline.js new file mode 100644 index 000000000..8419d9a05 --- /dev/null +++ b/packages/endpoint-microsub/lib/controllers/timeline.js @@ -0,0 +1,119 @@ +/** + * Timeline controller + * @module controllers/timeline + */ + +import { IndiekitError } from "@indiekit/error"; + +import { getChannel } from "../storage/channels.js"; +import { + getTimelineItems, + markItemsRead, + markItemsUnread, + removeItems, +} from "../storage/items.js"; +import { getUserId } from "../utils/auth.js"; +import { + validateChannel, + validateEntries, + parseArrayParameter, +} from "../utils/validation.js"; + +/** + * Get timeline items for a channel + * GET ?action=timeline&channel= + * @param {object} request - Express request + * @param {object} response - Express response + */ +export async function get(request, response) { + const { application } = request.app.locals; + const userId = getUserId(request); + const { channel, before, after, limit } = request.query; + + validateChannel(channel); + + // Verify channel exists + const channelDocument = await getChannel(application, channel, userId); + if (!channelDocument) { + throw new IndiekitError("Channel not found", { + status: 404, + }); + } + + const timeline = await getTimelineItems(application, channelDocument._id, { + before, + after, + limit, + userId, + }); + + response.json(timeline); +} + +/** + * Handle timeline actions (mark_read, mark_unread, remove) + * POST ?action=timeline + * @param {object} request - Express request + * @param {object} response - Express response + * @returns {Promise} + */ +export async function action(request, response) { + const { application } = request.app.locals; + const userId = getUserId(request); + const { method, channel } = request.body; + + validateChannel(channel); + + // Verify channel exists + const channelDocument = await getChannel(application, channel, userId); + if (!channelDocument) { + throw new IndiekitError("Channel not found", { + status: 404, + }); + } + + // Get entry IDs from request + const entries = parseArrayParameter(request.body, "entry"); + + switch (method) { + case "mark_read": { + validateEntries(entries); + const count = await markItemsRead( + application, + channelDocument._id, + entries, + userId, + ); + return response.json({ result: "ok", updated: count }); + } + + case "mark_unread": { + validateEntries(entries); + const count = await markItemsUnread( + application, + channelDocument._id, + entries, + userId, + ); + return response.json({ result: "ok", updated: count }); + } + + case "remove": { + validateEntries(entries); + const count = await removeItems( + application, + channelDocument._id, + entries, + ); + return response.json({ result: "ok", removed: count }); + } + + default: { + throw new IndiekitError(`Invalid timeline method: ${method}`, { + status: 400, + }); + } + } +} + +export const timelineController = { get, action }; diff --git a/packages/endpoint-microsub/lib/storage/channels.js b/packages/endpoint-microsub/lib/storage/channels.js new file mode 100644 index 000000000..477f657f5 --- /dev/null +++ b/packages/endpoint-microsub/lib/storage/channels.js @@ -0,0 +1,253 @@ +/** + * Channel storage operations + * @module storage/channels + */ + +import { generateChannelUid } from "../utils/uid.js"; + +/** + * Get channels collection from application + * @param {object} application - Indiekit application + * @returns {object} MongoDB collection + */ +function getCollection(application) { + return application.collections.get("microsub_channels"); +} + +/** + * Get items collection for unread counts + * @param {object} application - Indiekit application + * @returns {object} MongoDB collection + */ +function getItemsCollection(application) { + return application.collections.get("microsub_items"); +} + +/** + * Create a new channel + * @param {object} application - Indiekit application + * @param {object} data - Channel data + * @param {string} data.name - Channel name + * @param {string} [data.userId] - User ID + * @returns {Promise} Created channel + */ +export async function createChannel(application, { name, userId }) { + const collection = getCollection(application); + + // Generate unique UID with retry on collision + let uid; + let attempts = 0; + const maxAttempts = 5; + + while (attempts < maxAttempts) { + uid = generateChannelUid(); + const existing = await collection.findOne({ uid }); + if (!existing) break; + attempts++; + } + + if (attempts >= maxAttempts) { + throw new Error("Failed to generate unique channel UID"); + } + + // Get max order for user + const maxOrderResult = await collection + .find({ userId }) + // eslint-disable-next-line unicorn/no-array-sort -- MongoDB cursor method + .sort({ order: -1 }) + .limit(1) + .toArray(); + + const order = maxOrderResult.length > 0 ? maxOrderResult[0].order + 1 : 0; + + const channel = { + uid, + name, + userId, + order, + createdAt: new Date(), + updatedAt: new Date(), + }; + + await collection.insertOne(channel); + + return channel; +} + +/** + * Get all channels for a user + * @param {object} application - Indiekit application + * @param {string} [userId] - User ID (optional for single-user mode) + * @returns {Promise} Array of channels with unread counts + */ +export async function getChannels(application, userId) { + const collection = getCollection(application); + const itemsCollection = getItemsCollection(application); + + const filter = userId ? { userId } : {}; + // eslint-disable-next-line unicorn/no-array-callback-reference, unicorn/no-array-sort -- MongoDB methods + const channels = await collection.find(filter).sort({ order: 1 }).toArray(); + + // Get unread counts for each channel + const channelsWithCounts = await Promise.all( + channels.map(async (channel) => { + const unreadCount = await itemsCollection.countDocuments({ + channelId: channel._id, + readBy: { $ne: userId }, + }); + + return { + uid: channel.uid, + name: channel.name, + unread: unreadCount > 0 ? unreadCount : false, + }; + }), + ); + + // Always include notifications channel first + const notificationsChannel = channelsWithCounts.find( + (c) => c.uid === "notifications", + ); + const otherChannels = channelsWithCounts.filter( + (c) => c.uid !== "notifications", + ); + + if (notificationsChannel) { + return [notificationsChannel, ...otherChannels]; + } + + return channelsWithCounts; +} + +/** + * Get a single channel by UID + * @param {object} application - Indiekit application + * @param {string} uid - Channel UID + * @param {string} [userId] - User ID + * @returns {Promise} Channel or null + */ +export async function getChannel(application, uid, userId) { + const collection = getCollection(application); + const query = { uid }; + if (userId) query.userId = userId; + + return collection.findOne(query); +} + +/** + * Update a channel + * @param {object} application - Indiekit application + * @param {string} uid - Channel UID + * @param {object} updates - Fields to update + * @param {string} [userId] - User ID + * @returns {Promise} Updated channel + */ +export async function updateChannel(application, uid, updates, userId) { + const collection = getCollection(application); + const query = { uid }; + if (userId) query.userId = userId; + + const result = await collection.findOneAndUpdate( + query, + { + $set: { + ...updates, + updatedAt: new Date(), + }, + }, + { returnDocument: "after" }, + ); + + return result; +} + +/** + * Delete a channel and all its items + * @param {object} application - Indiekit application + * @param {string} uid - Channel UID + * @param {string} [userId] - User ID + * @returns {Promise} True if deleted + */ +export async function deleteChannel(application, uid, userId) { + const collection = getCollection(application); + const itemsCollection = getItemsCollection(application); + const query = { uid }; + if (userId) query.userId = userId; + + // Don't allow deleting notifications channel + if (uid === "notifications") { + return false; + } + + // Find the channel first to get its ObjectId + const channel = await collection.findOne(query); + if (!channel) { + return false; + } + + // Delete all items in channel + const itemsDeleted = await itemsCollection.deleteMany({ + channelId: channel._id, + }); + console.info( + `[Microsub] Deleted channel ${uid}: ${itemsDeleted.deletedCount} items`, + ); + + const result = await collection.deleteOne({ _id: channel._id }); + return result.deletedCount > 0; +} + +/** + * Reorder channels + * @param {object} application - Indiekit application + * @param {Array} channelUids - Ordered array of channel UIDs + * @param {string} [userId] - User ID + * @returns {Promise} + */ +export async function reorderChannels(application, channelUids, userId) { + const collection = getCollection(application); + + // Update order for each channel + const operations = channelUids.map((uid, index) => ({ + updateOne: { + filter: userId ? { uid, userId } : { uid }, + update: { $set: { order: index, updatedAt: new Date() } }, + }, + })); + + if (operations.length > 0) { + await collection.bulkWrite(operations); + } +} + +/** + * Ensure notifications channel exists + * @param {object} application - Indiekit application + * @param {string} [userId] - User ID + * @returns {Promise} Notifications channel + */ +export async function ensureNotificationsChannel(application, userId) { + const collection = getCollection(application); + + const existing = await collection.findOne({ + uid: "notifications", + ...(userId && { userId }), + }); + + if (existing) { + return existing; + } + + // Create notifications channel + const channel = { + uid: "notifications", + name: "Notifications", + userId, + order: -1, // Always first + createdAt: new Date(), + updatedAt: new Date(), + }; + + await collection.insertOne(channel); + return channel; +} diff --git a/packages/endpoint-microsub/lib/storage/items.js b/packages/endpoint-microsub/lib/storage/items.js new file mode 100644 index 000000000..b80296a7d --- /dev/null +++ b/packages/endpoint-microsub/lib/storage/items.js @@ -0,0 +1,260 @@ +/** + * Timeline item storage operations + * @module storage/items + */ + +import { ObjectId } from "mongodb"; + +import { + buildPaginationQuery, + buildPaginationSort, + generatePagingCursors, + parseLimit, +} from "../utils/pagination.js"; + +/** + * Get items collection from application + * @param {object} application - Indiekit application + * @returns {object} MongoDB collection + */ +function getCollection(application) { + return application.collections.get("microsub_items"); +} + +/** + * Get timeline items for a channel + * @param {object} application - Indiekit application + * @param {ObjectId|string} channelId - Channel ObjectId + * @param {object} options - Query options + * @param {string} [options.before] - Before cursor + * @param {string} [options.after] - After cursor + * @param {number} [options.limit] - Items per page + * @param {string} [options.userId] - User ID for read state + * @returns {Promise} Timeline with items and paging + */ +export async function getTimelineItems(application, channelId, options = {}) { + const collection = getCollection(application); + const objectId = + typeof channelId === "string" ? new ObjectId(channelId) : channelId; + const limit = parseLimit(options.limit); + + const baseQuery = { channelId: objectId }; + + const query = buildPaginationQuery({ + before: options.before, + after: options.after, + baseQuery, + }); + + const sort = buildPaginationSort(options.before); + + // Fetch one extra to check if there are more + const items = await collection + // eslint-disable-next-line unicorn/no-array-callback-reference -- MongoDB query object + .find(query) + // eslint-disable-next-line unicorn/no-array-sort -- MongoDB cursor method + .sort(sort) + .limit(limit + 1) + .toArray(); + + const hasMore = items.length > limit; + if (hasMore) { + items.pop(); + } + + // Transform to jf2 format + const jf2Items = items.map((item) => transformToJf2(item, options.userId)); + + // Generate paging cursors + const paging = generatePagingCursors(items, limit, hasMore, options.before); + + return { + items: jf2Items, + paging, + }; +} + +/** + * Transform database item to jf2 format + * @param {object} item - Database item + * @param {string} [userId] - User ID for read state + * @returns {object} jf2 item + */ +function transformToJf2(item, userId) { + const jf2 = { + type: item.type, + uid: item.uid, + url: item.url, + published: item.published?.toISOString(), + _id: item._id.toString(), + _is_read: userId ? item.readBy?.includes(userId) : false, + }; + + // Optional fields + if (item.name) jf2.name = item.name; + if (item.content) jf2.content = item.content; + if (item.summary) jf2.summary = item.summary; + if (item.updated) jf2.updated = item.updated.toISOString(); + if (item.author) jf2.author = item.author; + if (item.category?.length > 0) jf2.category = item.category; + if (item.photo?.length > 0) jf2.photo = item.photo; + if (item.video?.length > 0) jf2.video = item.video; + if (item.audio?.length > 0) jf2.audio = item.audio; + + // Interaction types + if (item.likeOf?.length > 0) jf2["like-of"] = item.likeOf; + if (item.repostOf?.length > 0) jf2["repost-of"] = item.repostOf; + if (item.bookmarkOf?.length > 0) jf2["bookmark-of"] = item.bookmarkOf; + if (item.inReplyTo?.length > 0) jf2["in-reply-to"] = item.inReplyTo; + + // Source + if (item.source) jf2._source = item.source; + + return jf2; +} + +/** + * Mark items as read + * @param {object} application - Indiekit application + * @param {ObjectId|string} channelId - Channel ObjectId + * @param {Array} entryIds - Array of entry IDs to mark as read + * @param {string} userId - User ID + * @returns {Promise} Number of items updated + */ +export async function markItemsRead(application, channelId, entryIds, userId) { + const collection = getCollection(application); + const channelObjectId = + typeof channelId === "string" ? new ObjectId(channelId) : channelId; + + // Handle "last-read-entry" special value + if (entryIds.includes("last-read-entry")) { + const result = await collection.updateMany( + { channelId: channelObjectId }, + { $addToSet: { readBy: userId } }, + ); + return result.modifiedCount; + } + + // Convert string IDs to ObjectIds where possible + const objectIds = entryIds + .map((id) => { + try { + return new ObjectId(id); + } catch { + return; + } + }) + .filter(Boolean); + + // Match by _id, uid, or url + const result = await collection.updateMany( + { + channelId: channelObjectId, + $or: [ + ...(objectIds.length > 0 ? [{ _id: { $in: objectIds } }] : []), + { uid: { $in: entryIds } }, + { url: { $in: entryIds } }, + ], + }, + { $addToSet: { readBy: userId } }, + ); + + return result.modifiedCount; +} + +/** + * Mark items as unread + * @param {object} application - Indiekit application + * @param {ObjectId|string} channelId - Channel ObjectId + * @param {Array} entryIds - Array of entry IDs to mark as unread + * @param {string} userId - User ID + * @returns {Promise} Number of items updated + */ +export async function markItemsUnread( + application, + channelId, + entryIds, + userId, +) { + const collection = getCollection(application); + const channelObjectId = + typeof channelId === "string" ? new ObjectId(channelId) : channelId; + + // Convert string IDs to ObjectIds where possible + const objectIds = entryIds + .map((id) => { + try { + return new ObjectId(id); + } catch { + return; + } + }) + .filter(Boolean); + + // Match by _id, uid, or url + const result = await collection.updateMany( + { + channelId: channelObjectId, + $or: [ + ...(objectIds.length > 0 ? [{ _id: { $in: objectIds } }] : []), + { uid: { $in: entryIds } }, + { url: { $in: entryIds } }, + ], + }, + { $pull: { readBy: userId } }, + ); + + return result.modifiedCount; +} + +/** + * Remove items from channel + * @param {object} application - Indiekit application + * @param {ObjectId|string} channelId - Channel ObjectId + * @param {Array} entryIds - Array of entry IDs to remove + * @returns {Promise} Number of items removed + */ +export async function removeItems(application, channelId, entryIds) { + const collection = getCollection(application); + const channelObjectId = + typeof channelId === "string" ? new ObjectId(channelId) : channelId; + + // Convert string IDs to ObjectIds where possible + const objectIds = entryIds + .map((id) => { + try { + return new ObjectId(id); + } catch { + return; + } + }) + .filter(Boolean); + + // Match by _id, uid, or url + const result = await collection.deleteMany({ + channelId: channelObjectId, + $or: [ + ...(objectIds.length > 0 ? [{ _id: { $in: objectIds } }] : []), + { uid: { $in: entryIds } }, + { url: { $in: entryIds } }, + ], + }); + + return result.deletedCount; +} + +/** + * Create indexes for efficient queries + * @param {object} application - Indiekit application + * @returns {Promise} + */ +export async function createIndexes(application) { + const collection = getCollection(application); + + // Primary query indexes + await collection.createIndex({ channelId: 1, published: -1 }); + await collection.createIndex({ channelId: 1, uid: 1 }, { unique: true }); + + // URL matching index for mark_read operations + await collection.createIndex({ channelId: 1, url: 1 }); +} diff --git a/packages/endpoint-microsub/lib/utils/auth.js b/packages/endpoint-microsub/lib/utils/auth.js new file mode 100644 index 000000000..f052df42b --- /dev/null +++ b/packages/endpoint-microsub/lib/utils/auth.js @@ -0,0 +1,35 @@ +/** + * Authentication utilities for Microsub + * @module utils/auth + */ + +/** + * Get the user ID from request context + * + * In Indiekit, the userId can come from: + * 1. request.session.userId (if explicitly set) + * 2. request.session.me (from token introspection) + * 3. application.publication.me (single-user fallback) + * @param {object} request - Express request + * @returns {string} User ID + */ +export function getUserId(request) { + // Check session for explicit userId + if (request.session?.userId) { + return request.session.userId; + } + + // Check session for me URL from token introspection + if (request.session?.me) { + return request.session.me; + } + + // Fall back to publication me URL (single-user mode) + const { application } = request.app.locals; + if (application?.publication?.me) { + return application.publication.me; + } + + // Final fallback: use "default" as user ID for single-user instances + return "default"; +} diff --git a/packages/endpoint-microsub/lib/utils/pagination.js b/packages/endpoint-microsub/lib/utils/pagination.js new file mode 100644 index 000000000..96cc3dcfa --- /dev/null +++ b/packages/endpoint-microsub/lib/utils/pagination.js @@ -0,0 +1,148 @@ +/** + * Cursor-based pagination utilities for Microsub + * @module utils/pagination + */ + +import { ObjectId } from "mongodb"; + +/** + * Default pagination limit + */ +export const DEFAULT_LIMIT = 20; + +/** + * Maximum pagination limit + */ +export const MAX_LIMIT = 100; + +/** + * Encode a cursor from timestamp and ID + * @param {Date} timestamp - Item timestamp + * @param {string} id - Item ID + * @returns {string} Base64-encoded cursor + */ +export function encodeCursor(timestamp, id) { + const data = { + t: timestamp instanceof Date ? timestamp.toISOString() : timestamp, + i: id.toString(), + }; + return Buffer.from(JSON.stringify(data)).toString("base64url"); +} + +/** + * Decode a cursor string + * @param {string} cursor - Base64-encoded cursor + * @returns {object|undefined} Decoded cursor with timestamp and id + */ +export function decodeCursor(cursor) { + if (!cursor) return; + + try { + const decoded = Buffer.from(cursor, "base64url").toString("utf8"); + const data = JSON.parse(decoded); + return { + timestamp: new Date(data.t), + id: data.i, + }; + } catch { + return; + } +} + +/** + * Build MongoDB query for cursor-based pagination + * @param {object} options - Pagination options + * @param {string} [options.before] - Before cursor + * @param {string} [options.after] - After cursor + * @param {object} [options.baseQuery] - Base query to extend + * @returns {object} MongoDB query object + */ +export function buildPaginationQuery({ before, after, baseQuery = {} }) { + const query = { ...baseQuery }; + + if (before) { + const cursor = decodeCursor(before); + if (cursor) { + // Items newer than cursor (for scrolling up) + query.$or = [ + { published: { $gt: cursor.timestamp } }, + { + published: cursor.timestamp, + _id: { $gt: new ObjectId(cursor.id) }, + }, + ]; + } + } else if (after) { + const cursor = decodeCursor(after); + if (cursor) { + // Items older than cursor (for scrolling down) + query.$or = [ + { published: { $lt: cursor.timestamp } }, + { + published: cursor.timestamp, + _id: { $lt: new ObjectId(cursor.id) }, + }, + ]; + } + } + + return query; +} + +/** + * Build sort options for cursor pagination + * @param {string} [before] - Before cursor (ascending order) + * @returns {object} MongoDB sort object + */ +export function buildPaginationSort(before) { + if (before) { + return { published: 1, _id: 1 }; + } + return { published: -1, _id: -1 }; +} + +/** + * Generate pagination cursors from items + * @param {Array} items - Array of items + * @param {number} limit - Items per page + * @param {boolean} hasMore - Whether more items exist + * @param {string} [before] - Original before cursor + * @returns {object} Pagination object with before/after cursors + */ +export function generatePagingCursors(items, limit, hasMore, before) { + if (!items || items.length === 0) { + return {}; + } + + const paging = {}; + + if (before) { + items.reverse(); + paging.after = encodeCursor(items.at(-1).published, items.at(-1)._id); + if (hasMore) { + paging.before = encodeCursor(items[0].published, items[0]._id); + } + } else { + if (hasMore) { + paging.after = encodeCursor(items.at(-1).published, items.at(-1)._id); + } + if (items.length > 0) { + paging.before = encodeCursor(items[0].published, items[0]._id); + } + } + + return paging; +} + +/** + * Parse and validate limit parameter + * @param {string|number} limit - Requested limit + * @returns {number} Validated limit + */ +export function parseLimit(limit) { + const parsed = Number.parseInt(limit, 10); + if (Number.isNaN(parsed) || parsed < 1) { + return DEFAULT_LIMIT; + } + return Math.min(parsed, MAX_LIMIT); +} diff --git a/packages/endpoint-microsub/lib/utils/uid.js b/packages/endpoint-microsub/lib/utils/uid.js new file mode 100644 index 000000000..1b4eecd47 --- /dev/null +++ b/packages/endpoint-microsub/lib/utils/uid.js @@ -0,0 +1,17 @@ +/** + * UID generation utilities for Microsub + * @module utils/uid + */ + +/** + * Generate a random channel UID + * @returns {string} 24-character random string + */ +export function generateChannelUid() { + const chars = "abcdefghijklmnopqrstuvwxyz0123456789"; + let result = ""; + for (let index = 0; index < 24; index++) { + result += chars.charAt(Math.floor(Math.random() * chars.length)); + } + return result; +} diff --git a/packages/endpoint-microsub/lib/utils/validation.js b/packages/endpoint-microsub/lib/utils/validation.js new file mode 100644 index 000000000..ca1049b0c --- /dev/null +++ b/packages/endpoint-microsub/lib/utils/validation.js @@ -0,0 +1,129 @@ +/** + * Input validation utilities for Microsub + * @module utils/validation + */ + +import { IndiekitError } from "@indiekit/error"; + +/** + * Valid Microsub actions (PR 1: channels and timeline only) + */ +export const VALID_ACTIONS = ["channels", "timeline"]; + +/** + * Validate action parameter + * @param {string} action - Action to validate + * @throws {IndiekitError} If action is invalid + */ +export function validateAction(action) { + if (!action) { + throw new IndiekitError("Missing required parameter: action", { + status: 400, + }); + } + + if (!VALID_ACTIONS.includes(action)) { + throw new IndiekitError(`Invalid action: ${action}`, { + status: 400, + }); + } +} + +/** + * Validate channel UID + * @param {string} channel - Channel UID to validate + * @param {boolean} [required] - Whether channel is required + * @throws {IndiekitError} If channel is invalid + */ +export function validateChannel(channel, required = true) { + if (required && !channel) { + throw new IndiekitError("Missing required parameter: channel", { + status: 400, + }); + } + + if (channel && typeof channel !== "string") { + throw new IndiekitError("Invalid channel parameter", { + status: 400, + }); + } +} + +/** + * Validate entry/entries parameter + * @param {string|Array} entry - Entry ID(s) to validate + * @returns {Array} Array of entry IDs + * @throws {IndiekitError} If entry is invalid + */ +export function validateEntries(entry) { + if (!entry) { + throw new IndiekitError("Missing required parameter: entry", { + status: 400, + }); + } + + // Normalize to array + const entries = Array.isArray(entry) ? entry : [entry]; + + if (entries.length === 0) { + throw new IndiekitError("Entry parameter cannot be empty", { + status: 400, + }); + } + + return entries; +} + +/** + * Validate channel name + * @param {string} name - Channel name to validate + * @throws {IndiekitError} If name is invalid + */ +export function validateChannelName(name) { + if (!name || typeof name !== "string") { + throw new IndiekitError("Missing required parameter: name", { + status: 400, + }); + } + + if (name.length > 100) { + throw new IndiekitError("Channel name must be 100 characters or less", { + status: 400, + }); + } +} + +/** + * Parse array parameter from request + * Handles both array[] and array[0], array[1] formats + * @param {object} body - Request body + * @param {string} parameterName - Parameter name + * @returns {Array} Parsed array + */ +export function parseArrayParameter(body, parameterName) { + // Direct array + if (Array.isArray(body[parameterName])) { + return body[parameterName]; + } + + // Single value + if (body[parameterName]) { + return [body[parameterName]]; + } + + // Indexed values (param[0], param[1], ...) + const result = []; + let index = 0; + while (body[`${parameterName}[${index}]`] !== undefined) { + result.push(body[`${parameterName}[${index}]`]); + index++; + } + + // Array notation (param[]) + if (body[`${parameterName}[]`]) { + const values = body[`${parameterName}[]`]; + return Array.isArray(values) ? values : [values]; + } + + return result; +} diff --git a/packages/endpoint-microsub/locales/en.json b/packages/endpoint-microsub/locales/en.json new file mode 100644 index 000000000..9d1c0edbf --- /dev/null +++ b/packages/endpoint-microsub/locales/en.json @@ -0,0 +1,15 @@ +{ + "microsub": { + "title": "Microsub", + "channels": { + "title": "Channels" + }, + "timeline": { + "title": "Timeline" + }, + "error": { + "channelNotFound": "Channel not found", + "invalidAction": "Invalid action" + } + } +} diff --git a/packages/endpoint-microsub/package.json b/packages/endpoint-microsub/package.json new file mode 100644 index 000000000..e8632f5ce --- /dev/null +++ b/packages/endpoint-microsub/package.json @@ -0,0 +1,51 @@ +{ + "name": "@indiekit/endpoint-microsub", + "version": "1.0.0-alpha.1", + "description": "Microsub endpoint for Indiekit. Enables subscribing to feeds and reading content using the Microsub protocol.", + "keywords": [ + "indiekit", + "indiekit-plugin", + "indieweb", + "microsub", + "reader", + "social-reader" + ], + "homepage": "https://getindiekit.com", + "author": { + "name": "Paul Robert Lloyd", + "url": "https://paulrobertlloyd.com" + }, + "contributors": [ + { + "name": "Ricardo Mendes", + "url": "https://rmendes.net" + } + ], + "license": "MIT", + "engines": { + "node": ">=20" + }, + "type": "module", + "main": "index.js", + "files": [ + "lib", + "locales", + "index.js" + ], + "bugs": { + "url": "https://github.com/getindiekit/indiekit/issues" + }, + "repository": { + "type": "git", + "url": "https://github.com/getindiekit/indiekit.git", + "directory": "packages/endpoint-microsub" + }, + "dependencies": { + "@indiekit/error": "^1.0.0-beta.25", + "express": "^5.0.0", + "mongodb": "^6.0.0" + }, + "publishConfig": { + "access": "public" + } +} From cdd714a413006fbd5169d043a0cc564ce57afdd1 Mon Sep 17 00:00:00 2001 From: Paul Robert Lloyd Date: Sat, 4 Jul 2026 15:43:06 +0100 Subject: [PATCH 02/17] ci: add microsub endpoint to development config --- indiekit.config.js | 1 + 1 file changed, 1 insertion(+) diff --git a/indiekit.config.js b/indiekit.config.js index 67c2a8af3..1b90fe864 100644 --- a/indiekit.config.js +++ b/indiekit.config.js @@ -19,6 +19,7 @@ const config = { plugins: [ "@indiekit-test/frontend", "@indiekit/endpoint-json-feed", + "@indiekit/endpoint-microsub", "@indiekit/endpoint-webmention-io", "@indiekit/post-type-audio", "@indiekit/post-type-event", From 7d2c6a632456a96b5acb58b4635ce0ff6d57f94f Mon Sep 17 00:00:00 2001 From: Paul Robert Lloyd Date: Sat, 4 Jul 2026 15:53:05 +0100 Subject: [PATCH 03/17] feat(endpoint-microsub): add plug-in icon --- packages/endpoint-microsub/assets/icon.svg | 4 ++++ packages/endpoint-microsub/package.json | 1 + 2 files changed, 5 insertions(+) create mode 100644 packages/endpoint-microsub/assets/icon.svg diff --git a/packages/endpoint-microsub/assets/icon.svg b/packages/endpoint-microsub/assets/icon.svg new file mode 100644 index 000000000..787384a9b --- /dev/null +++ b/packages/endpoint-microsub/assets/icon.svg @@ -0,0 +1,4 @@ + + + + diff --git a/packages/endpoint-microsub/package.json b/packages/endpoint-microsub/package.json index e8632f5ce..09001a6e6 100644 --- a/packages/endpoint-microsub/package.json +++ b/packages/endpoint-microsub/package.json @@ -28,6 +28,7 @@ "type": "module", "main": "index.js", "files": [ + "assets", "lib", "locales", "index.js" From 0e41045d1a430dcd8a8b1c6b22f3eaf99ac8b552 Mon Sep 17 00:00:00 2001 From: Ricardo Mendes Date: Sat, 15 Aug 2026 13:31:38 +0200 Subject: [PATCH 04/17] fix(endpoint-microsub): use same mongodb version as indiekit The plug-in declared mongodb ^6.0.0 while indiekit declares ^7.4.0, so npm installed a nested copy of the driver. ObjectId values created by the plug-in came from bson 6 but were passed to collections served by bson 7, which threw BSONVersionError in markItemsRead, markItemsUnread and removeItems. --- packages/endpoint-microsub/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/endpoint-microsub/package.json b/packages/endpoint-microsub/package.json index 09001a6e6..2dca7e1bd 100644 --- a/packages/endpoint-microsub/package.json +++ b/packages/endpoint-microsub/package.json @@ -44,7 +44,7 @@ "dependencies": { "@indiekit/error": "^1.0.0-beta.25", "express": "^5.0.0", - "mongodb": "^6.0.0" + "mongodb": "^7.4.0" }, "publishConfig": { "access": "public" From 818707ad4d723367528e3e213a1ce0d9f9fccab0 Mon Sep 17 00:00:00 2001 From: Ricardo Mendes Date: Sat, 15 Aug 2026 13:31:47 +0200 Subject: [PATCH 05/17] style(endpoint-microsub): fix eslint errors Fixes unicorn/prefer-await, unicorn/prefer-number-coercion, unicorn/consistent-boolean-name, unicorn/no-computed-property-existence-check and jsdoc/reject-function-type, and removes unused eslint-disable directives. Satisfying unicorn/prefer-await means init() now awaits index creation rather than leaving it to run in the background, so plug-in initialisation waits for indexes to be created. Errors are still caught and warned about, and the plug-in loader already awaits init(). --- packages/endpoint-microsub/index.js | 10 +++++---- .../lib/controllers/microsub.js | 4 ++-- .../endpoint-microsub/lib/storage/channels.js | 3 +-- .../endpoint-microsub/lib/utils/pagination.js | 2 +- .../endpoint-microsub/lib/utils/validation.js | 22 ++++++++++--------- 5 files changed, 22 insertions(+), 19 deletions(-) diff --git a/packages/endpoint-microsub/index.js b/packages/endpoint-microsub/index.js index a4e1d0248..255bb1821 100644 --- a/packages/endpoint-microsub/index.js +++ b/packages/endpoint-microsub/index.js @@ -36,7 +36,7 @@ export default class MicrosubEndpoint { * Initialize plugin * @param {object} indiekit - Indiekit instance */ - init(indiekit) { + async init(indiekit) { console.info("[Microsub] Initializing endpoint-microsub plugin"); // Register MongoDB collections @@ -53,11 +53,13 @@ export default class MicrosubEndpoint { indiekit.config.application.microsubEndpoint = this.mountPath; } - // Create indexes for optimal performance (runs in background) + // Create indexes for optimal performance if (indiekit.database) { - createIndexes(indiekit).catch((error) => { + try { + await createIndexes(indiekit); + } catch (error) { console.warn("[Microsub] Index creation failed:", error.message); - }); + } } } } diff --git a/packages/endpoint-microsub/lib/controllers/microsub.js b/packages/endpoint-microsub/lib/controllers/microsub.js index a25fd67dc..1707c0e70 100644 --- a/packages/endpoint-microsub/lib/controllers/microsub.js +++ b/packages/endpoint-microsub/lib/controllers/microsub.js @@ -14,7 +14,7 @@ import { get as getTimeline, action as timelineAction } from "./timeline.js"; * Route GET requests to appropriate action handler * @param {object} request - Express request * @param {object} response - Express response - * @param {Function} next - Express next function + * @param {import("express").NextFunction} next - Express next function * @returns {Promise} */ export async function get(request, response, next) { @@ -55,7 +55,7 @@ export async function get(request, response, next) { * Route POST requests to appropriate action handler * @param {object} request - Express request * @param {object} response - Express response - * @param {Function} next - Express next function + * @param {import("express").NextFunction} next - Express next function * @returns {Promise} */ export async function post(request, response, next) { diff --git a/packages/endpoint-microsub/lib/storage/channels.js b/packages/endpoint-microsub/lib/storage/channels.js index 477f657f5..b9d8aa3df 100644 --- a/packages/endpoint-microsub/lib/storage/channels.js +++ b/packages/endpoint-microsub/lib/storage/channels.js @@ -53,7 +53,6 @@ export async function createChannel(application, { name, userId }) { // Get max order for user const maxOrderResult = await collection .find({ userId }) - // eslint-disable-next-line unicorn/no-array-sort -- MongoDB cursor method .sort({ order: -1 }) .limit(1) .toArray(); @@ -85,7 +84,7 @@ export async function getChannels(application, userId) { const itemsCollection = getItemsCollection(application); const filter = userId ? { userId } : {}; - // eslint-disable-next-line unicorn/no-array-callback-reference, unicorn/no-array-sort -- MongoDB methods + // eslint-disable-next-line unicorn/no-array-callback-reference -- MongoDB methods const channels = await collection.find(filter).sort({ order: 1 }).toArray(); // Get unread counts for each channel diff --git a/packages/endpoint-microsub/lib/utils/pagination.js b/packages/endpoint-microsub/lib/utils/pagination.js index 96cc3dcfa..2a8bdc57b 100644 --- a/packages/endpoint-microsub/lib/utils/pagination.js +++ b/packages/endpoint-microsub/lib/utils/pagination.js @@ -140,7 +140,7 @@ export function generatePagingCursors(items, limit, hasMore, before) { * @returns {number} Validated limit */ export function parseLimit(limit) { - const parsed = Number.parseInt(limit, 10); + const parsed = Math.trunc(Number(limit)); if (Number.isNaN(parsed) || parsed < 1) { return DEFAULT_LIMIT; } diff --git a/packages/endpoint-microsub/lib/utils/validation.js b/packages/endpoint-microsub/lib/utils/validation.js index ca1049b0c..1a77b6fae 100644 --- a/packages/endpoint-microsub/lib/utils/validation.js +++ b/packages/endpoint-microsub/lib/utils/validation.js @@ -32,11 +32,11 @@ export function validateAction(action) { /** * Validate channel UID * @param {string} channel - Channel UID to validate - * @param {boolean} [required] - Whether channel is required + * @param {boolean} [isRequired] - Whether channel is required * @throws {IndiekitError} If channel is invalid */ -export function validateChannel(channel, required = true) { - if (required && !channel) { +export function validateChannel(channel, isRequired = true) { + if (isRequired && !channel) { throw new IndiekitError("Missing required parameter: channel", { status: 400, }); @@ -101,14 +101,16 @@ export function validateChannelName(name) { * @returns {Array} Parsed array */ export function parseArrayParameter(body, parameterName) { + const value = body[parameterName]; + // Direct array - if (Array.isArray(body[parameterName])) { - return body[parameterName]; + if (Array.isArray(value)) { + return value; } // Single value - if (body[parameterName]) { - return [body[parameterName]]; + if (value) { + return [value]; } // Indexed values (param[0], param[1], ...) @@ -120,9 +122,9 @@ export function parseArrayParameter(body, parameterName) { } // Array notation (param[]) - if (body[`${parameterName}[]`]) { - const values = body[`${parameterName}[]`]; - return Array.isArray(values) ? values : [values]; + const bracketValues = body[`${parameterName}[]`]; + if (bracketValues) { + return Array.isArray(bracketValues) ? bracketValues : [bracketValues]; } return result; From eb32e2fcbc0dd87f81f5c0bc82c0cf2d16033bdb Mon Sep 17 00:00:00 2001 From: Ricardo Mendes Date: Sat, 15 Aug 2026 13:31:56 +0200 Subject: [PATCH 06/17] test(endpoint-microsub): add unit and integration tests Unit tests cover lib/utils and lib/storage, mirroring the structure of lib/. Controllers are covered by integration tests, as in other endpoint plug-ins. --- .../test/integration/200-get-channels.js | 63 ++++ .../test/integration/200-get-endpoint-info.js | 30 ++ .../test/integration/200-get-timeline.js | 95 +++++ .../integration/200-post-channel-delete.js | 89 +++++ .../integration/200-post-channel-update.js | 80 ++++ .../integration/200-post-channels-order.js | 79 ++++ .../test/integration/200-post-timeline.js | 162 ++++++++ .../integration/201-post-channel-create.js | 72 ++++ .../integration/302-get-unauthenticated.js | 33 ++ .../test/integration/400-invalid-action.js | 54 +++ .../integration/400-post-unauthenticated.js | 31 ++ .../test/unit/storage/channels.js | 319 ++++++++++++++++ .../test/unit/storage/items.js | 350 ++++++++++++++++++ .../endpoint-microsub/test/unit/utils/auth.js | 58 +++ .../test/unit/utils/pagination.js | 234 ++++++++++++ .../endpoint-microsub/test/unit/utils/uid.js | 30 ++ .../test/unit/utils/validation.js | 111 ++++++ 17 files changed, 1890 insertions(+) create mode 100644 packages/endpoint-microsub/test/integration/200-get-channels.js create mode 100644 packages/endpoint-microsub/test/integration/200-get-endpoint-info.js create mode 100644 packages/endpoint-microsub/test/integration/200-get-timeline.js create mode 100644 packages/endpoint-microsub/test/integration/200-post-channel-delete.js create mode 100644 packages/endpoint-microsub/test/integration/200-post-channel-update.js create mode 100644 packages/endpoint-microsub/test/integration/200-post-channels-order.js create mode 100644 packages/endpoint-microsub/test/integration/200-post-timeline.js create mode 100644 packages/endpoint-microsub/test/integration/201-post-channel-create.js create mode 100644 packages/endpoint-microsub/test/integration/302-get-unauthenticated.js create mode 100644 packages/endpoint-microsub/test/integration/400-invalid-action.js create mode 100644 packages/endpoint-microsub/test/integration/400-post-unauthenticated.js create mode 100644 packages/endpoint-microsub/test/unit/storage/channels.js create mode 100644 packages/endpoint-microsub/test/unit/storage/items.js create mode 100644 packages/endpoint-microsub/test/unit/utils/auth.js create mode 100644 packages/endpoint-microsub/test/unit/utils/pagination.js create mode 100644 packages/endpoint-microsub/test/unit/utils/uid.js create mode 100644 packages/endpoint-microsub/test/unit/utils/validation.js diff --git a/packages/endpoint-microsub/test/integration/200-get-channels.js b/packages/endpoint-microsub/test/integration/200-get-channels.js new file mode 100644 index 000000000..5042eae5a --- /dev/null +++ b/packages/endpoint-microsub/test/integration/200-get-channels.js @@ -0,0 +1,63 @@ +import { strict as assert } from "node:assert"; +import { after, before, describe, it } from "node:test"; + +import { testDatabase } from "@indiekit-test/database"; +import { testServer } from "@indiekit-test/server"; +import { testCookie } from "@indiekit-test/session"; +import supertest from "supertest"; + +const { client, mongoServer, mongoUri } = await testDatabase(); +const server = await testServer({ + application: { mongodbUrl: mongoUri }, + plugins: ["@indiekit/endpoint-microsub"], +}); +const request = supertest.agent(server); +const cookie = testCookie(); + +describe("endpoint-microsub GET /microsub?action=channels", () => { + before(async () => { + for (const name of ["Tech News", "Photos"]) { + await request + .post("/microsub") + .type("form") + .set("cookie", cookie) + .send({ action: "channels", name }); + } + }); + + it("Returns the list of channels", async () => { + const response = await request + .get("/microsub?action=channels") + .set("cookie", cookie); + + assert.equal(response.status, 200); + assert.deepEqual( + response.body.channels.map((channel) => channel.name), + ["Tech News", "Photos"], + ); + }); + + it("Reports channels with no items as read", async () => { + const response = await request + .get("/microsub?action=channels") + .set("cookie", cookie); + + assert.equal(response.body.channels[0].unread, false); + }); + + it("Returns a UID for each channel", async () => { + const response = await request + .get("/microsub?action=channels") + .set("cookie", cookie); + + for (const channel of response.body.channels) { + assert.match(channel.uid, /^[a-z0-9]{24}$/); + } + }); + + after(async () => { + await client.close(); + await mongoServer.stop(); + server.close((error) => process.exit(error ? 1 : 0)); + }); +}); diff --git a/packages/endpoint-microsub/test/integration/200-get-endpoint-info.js b/packages/endpoint-microsub/test/integration/200-get-endpoint-info.js new file mode 100644 index 000000000..ed12a6d1d --- /dev/null +++ b/packages/endpoint-microsub/test/integration/200-get-endpoint-info.js @@ -0,0 +1,30 @@ +import { strict as assert } from "node:assert"; +import { after, describe, it } from "node:test"; + +import { testDatabase } from "@indiekit-test/database"; +import { testServer } from "@indiekit-test/server"; +import { testCookie } from "@indiekit-test/session"; +import supertest from "supertest"; + +const { client, mongoServer, mongoUri } = await testDatabase(); +const server = await testServer({ + application: { mongodbUrl: mongoUri }, + plugins: ["@indiekit/endpoint-microsub"], +}); +const request = supertest.agent(server); + +describe("endpoint-microsub GET /microsub", () => { + it("Returns endpoint information when no action given", async () => { + const response = await request.get("/microsub").set("cookie", testCookie()); + + assert.equal(response.status, 200); + assert.equal(response.body.type, "microsub"); + assert.deepEqual(response.body.actions, ["channels", "timeline"]); + }); + + after(async () => { + await client.close(); + await mongoServer.stop(); + server.close((error) => process.exit(error ? 1 : 0)); + }); +}); diff --git a/packages/endpoint-microsub/test/integration/200-get-timeline.js b/packages/endpoint-microsub/test/integration/200-get-timeline.js new file mode 100644 index 000000000..d8503f769 --- /dev/null +++ b/packages/endpoint-microsub/test/integration/200-get-timeline.js @@ -0,0 +1,95 @@ +import { strict as assert } from "node:assert"; +import { after, before, describe, it } from "node:test"; + +import { testDatabase } from "@indiekit-test/database"; +import { testServer } from "@indiekit-test/server"; +import { testCookie } from "@indiekit-test/session"; +import supertest from "supertest"; + +const { client, mongoServer, mongoUri } = await testDatabase(); +const server = await testServer({ + application: { mongodbUrl: mongoUri }, + plugins: ["@indiekit/endpoint-microsub"], +}); +const request = supertest.agent(server); +const cookie = testCookie(); + +// Indiekit uses ‘indiekit’ as its default database, not ‘test’ +const database = client.db("indiekit"); + +const fixture = {}; + +describe("endpoint-microsub GET /microsub?action=timeline", () => { + before(async () => { + const created = await request + .post("/microsub") + .type("form") + .set("cookie", cookie) + .send({ action: "channels", name: "Tech News" }); + + fixture.channelUid = created.body.uid; + + const channel = await database + .collection("microsub_channels") + .findOne({ uid: fixture.channelUid }); + + await database.collection("microsub_items").insertMany( + Array.from({ length: 3 }, (_, index) => ({ + channelId: channel._id, + type: "entry", + uid: `item-${index}`, + url: `https://website.example/${index}`, + name: `Item ${index}`, + published: new Date(Date.UTC(2026, 0, index + 1)), + readBy: [], + })), + ); + }); + + it("Returns timeline items newest first", async () => { + const response = await request + .get(`/microsub?action=timeline&channel=${fixture.channelUid}`) + .set("cookie", cookie); + + assert.equal(response.status, 200); + assert.deepEqual( + response.body.items.map((item) => item.name), + ["Item 2", "Item 1", "Item 0"], + ); + }); + + it("Returns items in jf2 format", async () => { + const response = await request + .get(`/microsub?action=timeline&channel=${fixture.channelUid}`) + .set("cookie", cookie); + const [item] = response.body.items; + + assert.equal(item.type, "entry"); + assert.equal(item.url, "https://website.example/2"); + assert.equal(item._is_read, false); + }); + + it("Applies the limit parameter and returns paging cursors", async () => { + const response = await request + .get(`/microsub?action=timeline&channel=${fixture.channelUid}&limit=2`) + .set("cookie", cookie); + + assert.equal(response.body.items.length, 2); + assert.ok(response.body.paging.after); + }); + + it("Returns 400 when channel is missing", async () => { + const response = await request + .get("/microsub?action=timeline") + .set("cookie", cookie); + + assert.equal(response.status, 400); + assert.match(response.text, /Missing required parameter: channel/); + }); + + after(async () => { + await client.close(); + await mongoServer.stop(); + server.close((error) => process.exit(error ? 1 : 0)); + }); +}); diff --git a/packages/endpoint-microsub/test/integration/200-post-channel-delete.js b/packages/endpoint-microsub/test/integration/200-post-channel-delete.js new file mode 100644 index 000000000..2de7339f2 --- /dev/null +++ b/packages/endpoint-microsub/test/integration/200-post-channel-delete.js @@ -0,0 +1,89 @@ +import { strict as assert } from "node:assert"; +import { after, describe, it } from "node:test"; + +import { testDatabase } from "@indiekit-test/database"; +import { testServer } from "@indiekit-test/server"; +import { testCookie } from "@indiekit-test/session"; +import supertest from "supertest"; + +const { client, mongoServer, mongoUri } = await testDatabase(); +const server = await testServer({ + application: { mongodbUrl: mongoUri }, + plugins: ["@indiekit/endpoint-microsub"], +}); +const request = supertest.agent(server); +const cookie = testCookie(); + +/** + * Create a channel via the Microsub API + * @param {string} name - Channel name + * @returns {Promise} Created channel UID + */ +async function createChannel(name) { + const response = await request + .post("/microsub") + .type("form") + .set("cookie", cookie) + .send({ action: "channels", name }); + + return response.body.uid; +} + +describe("endpoint-microsub POST /microsub?action=channels (delete)", () => { + it("Deletes a channel", async () => { + const uid = await createChannel("Doomed"); + + const response = await request + .post("/microsub") + .type("form") + .set("cookie", cookie) + .send({ action: "channels", method: "delete", uid }); + + assert.equal(response.status, 200); + assert.equal(response.body.deleted, uid); + }); + + it("Removes the channel from the channel list", async () => { + const uid = await createChannel("Doomed too"); + + await request + .post("/microsub") + .type("form") + .set("cookie", cookie) + .send({ action: "channels", method: "delete", uid }); + + const response = await request + .get("/microsub?action=channels") + .set("cookie", cookie); + const uids = response.body.channels.map((channel) => channel.uid); + + assert.equal(uids.includes(uid), false); + }); + + it("Returns 404 for an unknown channel", async () => { + const response = await request + .post("/microsub") + .type("form") + .set("cookie", cookie) + .send({ action: "channels", method: "delete", uid: "nonexistent" }); + + assert.equal(response.status, 404); + assert.match(response.text, /Channel not found or cannot be deleted/); + }); + + it("Refuses to delete the notifications channel", async () => { + const response = await request + .post("/microsub") + .type("form") + .set("cookie", cookie) + .send({ action: "channels", method: "delete", uid: "notifications" }); + + assert.equal(response.status, 404); + }); + + after(async () => { + await client.close(); + await mongoServer.stop(); + server.close((error) => process.exit(error ? 1 : 0)); + }); +}); diff --git a/packages/endpoint-microsub/test/integration/200-post-channel-update.js b/packages/endpoint-microsub/test/integration/200-post-channel-update.js new file mode 100644 index 000000000..15377ad69 --- /dev/null +++ b/packages/endpoint-microsub/test/integration/200-post-channel-update.js @@ -0,0 +1,80 @@ +import { strict as assert } from "node:assert"; +import { after, describe, it } from "node:test"; + +import { testDatabase } from "@indiekit-test/database"; +import { testServer } from "@indiekit-test/server"; +import { testCookie } from "@indiekit-test/session"; +import supertest from "supertest"; + +const { client, mongoServer, mongoUri } = await testDatabase(); +const server = await testServer({ + application: { mongodbUrl: mongoUri }, + plugins: ["@indiekit/endpoint-microsub"], +}); +const request = supertest.agent(server); +const cookie = testCookie(); + +/** + * Create a channel via the Microsub API + * @param {string} name - Channel name + * @returns {Promise} Created channel UID + */ +async function createChannel(name) { + const response = await request + .post("/microsub") + .type("form") + .set("cookie", cookie) + .send({ action: "channels", name }); + + return response.body.uid; +} + +describe("endpoint-microsub POST /microsub?action=channels (update)", () => { + it("Renames a channel", async () => { + const uid = await createChannel("Old name"); + + const response = await request + .post("/microsub") + .type("form") + .set("cookie", cookie) + .send({ action: "channels", uid, name: "New name" }); + + assert.equal(response.status, 200); + assert.equal(response.body.uid, uid); + assert.equal(response.body.name, "New name"); + }); + + it("Persists the new name", async () => { + const uid = await createChannel("Before"); + + await request + .post("/microsub") + .type("form") + .set("cookie", cookie) + .send({ action: "channels", uid, name: "After" }); + + const response = await request + .get("/microsub?action=channels") + .set("cookie", cookie); + const channel = response.body.channels.find((c) => c.uid === uid); + + assert.equal(channel.name, "After"); + }); + + it("Returns 404 for an unknown channel", async () => { + const response = await request + .post("/microsub") + .type("form") + .set("cookie", cookie) + .send({ action: "channels", uid: "nonexistent", name: "New name" }); + + assert.equal(response.status, 404); + assert.match(response.text, /Channel not found/); + }); + + after(async () => { + await client.close(); + await mongoServer.stop(); + server.close((error) => process.exit(error ? 1 : 0)); + }); +}); diff --git a/packages/endpoint-microsub/test/integration/200-post-channels-order.js b/packages/endpoint-microsub/test/integration/200-post-channels-order.js new file mode 100644 index 000000000..4af0e468d --- /dev/null +++ b/packages/endpoint-microsub/test/integration/200-post-channels-order.js @@ -0,0 +1,79 @@ +import { strict as assert } from "node:assert"; +import { after, before, describe, it } from "node:test"; + +import { testDatabase } from "@indiekit-test/database"; +import { testServer } from "@indiekit-test/server"; +import { testCookie } from "@indiekit-test/session"; +import supertest from "supertest"; + +const { client, mongoServer, mongoUri } = await testDatabase(); +const server = await testServer({ + application: { mongodbUrl: mongoUri }, + plugins: ["@indiekit/endpoint-microsub"], +}); +const request = supertest.agent(server); +const cookie = testCookie(); + +const uids = {}; + +describe("endpoint-microsub POST /microsub?action=channels (order)", () => { + before(async () => { + for (const name of ["First", "Second", "Third"]) { + const response = await request + .post("/microsub") + .type("form") + .set("cookie", cookie) + .send({ action: "channels", name }); + + uids[name] = response.body.uid; + } + }); + + it("Reorders channels", async () => { + const response = await request + .post("/microsub") + .type("form") + .set("cookie", cookie) + .send({ + action: "channels", + method: "order", + "channels[0]": uids.Third, + "channels[1]": uids.First, + "channels[2]": uids.Second, + }); + + assert.equal(response.status, 200); + assert.deepEqual( + response.body.channels.map((channel) => channel.name), + ["Third", "First", "Second"], + ); + }); + + it("Persists the new order", async () => { + const response = await request + .get("/microsub?action=channels") + .set("cookie", cookie); + + assert.deepEqual( + response.body.channels.map((channel) => channel.name), + ["Third", "First", "Second"], + ); + }); + + it("Returns 400 when no channels are given", async () => { + const response = await request + .post("/microsub") + .type("form") + .set("cookie", cookie) + .send({ action: "channels", method: "order" }); + + assert.equal(response.status, 400); + assert.match(response.text, /Missing channels\[\] parameter/); + }); + + after(async () => { + await client.close(); + await mongoServer.stop(); + server.close((error) => process.exit(error ? 1 : 0)); + }); +}); diff --git a/packages/endpoint-microsub/test/integration/200-post-timeline.js b/packages/endpoint-microsub/test/integration/200-post-timeline.js new file mode 100644 index 000000000..69dbafa56 --- /dev/null +++ b/packages/endpoint-microsub/test/integration/200-post-timeline.js @@ -0,0 +1,162 @@ +import { strict as assert } from "node:assert"; +import { after, beforeEach, describe, it } from "node:test"; + +import { testDatabase } from "@indiekit-test/database"; +import { testServer } from "@indiekit-test/server"; +import { testCookie } from "@indiekit-test/session"; +import supertest from "supertest"; + +const { client, mongoServer, mongoUri } = await testDatabase(); +const server = await testServer({ + application: { mongodbUrl: mongoUri }, + plugins: ["@indiekit/endpoint-microsub"], +}); +const request = supertest.agent(server); +const cookie = testCookie(); + +// Indiekit uses ‘indiekit’ as its default database, not ‘test’ +const database = client.db("indiekit"); +const items = database.collection("microsub_items"); + +const fixture = {}; + +describe("endpoint-microsub POST /microsub?action=timeline", () => { + beforeEach(async () => { + await items.deleteMany({}); + + const created = await request + .post("/microsub") + .type("form") + .set("cookie", cookie) + .send({ action: "channels", name: "Tech News" }); + + fixture.channelUid = created.body.uid; + + const channel = await database + .collection("microsub_channels") + .findOne({ uid: fixture.channelUid }); + + await items.insertMany( + Array.from({ length: 3 }, (_, index) => ({ + channelId: channel._id, + type: "entry", + uid: `item-${index}`, + url: `https://website.example/${index}`, + published: new Date(Date.UTC(2026, 0, index + 1)), + readBy: [], + })), + ); + }); + + it("Marks entries as read", async () => { + const response = await request + .post("/microsub") + .type("form") + .set("cookie", cookie) + .send({ + action: "timeline", + method: "mark_read", + channel: fixture.channelUid, + "entry[0]": "item-0", + "entry[1]": "item-1", + }); + + assert.equal(response.status, 200); + assert.equal(response.body.result, "ok"); + assert.equal(response.body.updated, 2); + }); + + it("Reflects read state in the timeline", async () => { + await request.post("/microsub").type("form").set("cookie", cookie).send({ + action: "timeline", + method: "mark_read", + channel: fixture.channelUid, + entry: "item-2", + }); + + const response = await request + .get(`/microsub?action=timeline&channel=${fixture.channelUid}`) + .set("cookie", cookie); + const item = response.body.items.find((index) => index.uid === "item-2"); + + assert.equal(item._is_read, true); + }); + + it("Marks entries as unread", async () => { + await request.post("/microsub").type("form").set("cookie", cookie).send({ + action: "timeline", + method: "mark_read", + channel: fixture.channelUid, + entry: "item-0", + }); + + const response = await request + .post("/microsub") + .type("form") + .set("cookie", cookie) + .send({ + action: "timeline", + method: "mark_unread", + channel: fixture.channelUid, + entry: "item-0", + }); + + assert.equal(response.status, 200); + assert.equal(response.body.updated, 1); + }); + + it("Removes entries", async () => { + const response = await request + .post("/microsub") + .type("form") + .set("cookie", cookie) + .send({ + action: "timeline", + method: "remove", + channel: fixture.channelUid, + entry: "item-0", + }); + + assert.equal(response.status, 200); + assert.equal(response.body.removed, 1); + assert.equal(await items.countDocuments({ uid: "item-0" }), 0); + }); + + it("Returns 400 for an unknown method", async () => { + const response = await request + .post("/microsub") + .type("form") + .set("cookie", cookie) + .send({ + action: "timeline", + method: "bogus", + channel: fixture.channelUid, + entry: "item-0", + }); + + assert.equal(response.status, 400); + assert.match(response.text, /Invalid timeline method/); + }); + + it("Returns 404 for an unknown channel", async () => { + const response = await request + .post("/microsub") + .type("form") + .set("cookie", cookie) + .send({ + action: "timeline", + method: "mark_read", + channel: "nonexistent", + entry: "item-0", + }); + + assert.equal(response.status, 404); + assert.match(response.text, /Channel not found/); + }); + + after(async () => { + await client.close(); + await mongoServer.stop(); + server.close((error) => process.exit(error ? 1 : 0)); + }); +}); diff --git a/packages/endpoint-microsub/test/integration/201-post-channel-create.js b/packages/endpoint-microsub/test/integration/201-post-channel-create.js new file mode 100644 index 000000000..ccb709889 --- /dev/null +++ b/packages/endpoint-microsub/test/integration/201-post-channel-create.js @@ -0,0 +1,72 @@ +import { strict as assert } from "node:assert"; +import { after, describe, it } from "node:test"; + +import { testDatabase } from "@indiekit-test/database"; +import { testServer } from "@indiekit-test/server"; +import { testCookie } from "@indiekit-test/session"; +import supertest from "supertest"; + +const { client, mongoServer, mongoUri } = await testDatabase(); +const server = await testServer({ + application: { mongodbUrl: mongoUri }, + plugins: ["@indiekit/endpoint-microsub"], +}); +const request = supertest.agent(server); +const cookie = testCookie(); + +describe("endpoint-microsub POST /microsub?action=channels", () => { + it("Creates a channel", async () => { + const response = await request + .post("/microsub") + .type("form") + .set("cookie", cookie) + .send({ action: "channels", name: "Tech News" }); + + assert.equal(response.status, 201); + assert.equal(response.body.name, "Tech News"); + assert.match(response.body.uid, /^[a-z0-9]{24}$/); + }); + + it("Returns the created channel in the channel list", async () => { + const created = await request + .post("/microsub") + .type("form") + .set("cookie", cookie) + .send({ action: "channels", name: "Photos" }); + + const response = await request + .get("/microsub?action=channels") + .set("cookie", cookie); + const uids = response.body.channels.map((channel) => channel.uid); + + assert.ok(uids.includes(created.body.uid)); + }); + + it("Returns 400 when name is missing", async () => { + const response = await request + .post("/microsub") + .type("form") + .set("cookie", cookie) + .send({ action: "channels" }); + + assert.equal(response.status, 400); + assert.match(response.text, /Missing required parameter: name/); + }); + + it("Returns 400 when name exceeds 100 characters", async () => { + const response = await request + .post("/microsub") + .type("form") + .set("cookie", cookie) + .send({ action: "channels", name: "a".repeat(101) }); + + assert.equal(response.status, 400); + assert.match(response.text, /100 characters or less/); + }); + + after(async () => { + await client.close(); + await mongoServer.stop(); + server.close((error) => process.exit(error ? 1 : 0)); + }); +}); diff --git a/packages/endpoint-microsub/test/integration/302-get-unauthenticated.js b/packages/endpoint-microsub/test/integration/302-get-unauthenticated.js new file mode 100644 index 000000000..8e1ce2464 --- /dev/null +++ b/packages/endpoint-microsub/test/integration/302-get-unauthenticated.js @@ -0,0 +1,33 @@ +import { strict as assert } from "node:assert"; +import { after, describe, it } from "node:test"; + +import { testDatabase } from "@indiekit-test/database"; +import { testServer } from "@indiekit-test/server"; +import supertest from "supertest"; + +const { client, mongoServer, mongoUri } = await testDatabase(); +const server = await testServer({ + application: { mongodbUrl: mongoUri }, + plugins: ["@indiekit/endpoint-microsub"], +}); +const request = supertest.agent(server); + +describe("endpoint-microsub GET /microsub", () => { + it("Redirects to sign-in when unauthenticated", async () => { + const response = await request.get("/microsub?action=channels"); + + assert.equal(response.status, 302); + }); + + it("Redirects unauthenticated timeline requests", async () => { + const response = await request.get("/microsub?action=timeline&channel=abc"); + + assert.equal(response.status, 302); + }); + + after(async () => { + await client.close(); + await mongoServer.stop(); + server.close((error) => process.exit(error ? 1 : 0)); + }); +}); diff --git a/packages/endpoint-microsub/test/integration/400-invalid-action.js b/packages/endpoint-microsub/test/integration/400-invalid-action.js new file mode 100644 index 000000000..e5cc889d3 --- /dev/null +++ b/packages/endpoint-microsub/test/integration/400-invalid-action.js @@ -0,0 +1,54 @@ +import { strict as assert } from "node:assert"; +import { after, describe, it } from "node:test"; + +import { testDatabase } from "@indiekit-test/database"; +import { testServer } from "@indiekit-test/server"; +import { testCookie } from "@indiekit-test/session"; +import supertest from "supertest"; + +const { client, mongoServer, mongoUri } = await testDatabase(); +const server = await testServer({ + application: { mongodbUrl: mongoUri }, + plugins: ["@indiekit/endpoint-microsub"], +}); +const request = supertest.agent(server); +const cookie = testCookie(); + +describe("endpoint-microsub invalid action", () => { + it("Returns 400 for an unsupported GET action", async () => { + const response = await request + .get("/microsub?action=bogus") + .set("cookie", cookie); + + assert.equal(response.status, 400); + assert.match(response.text, /Invalid action/); + }); + + it("Returns 400 for an unsupported POST action", async () => { + const response = await request + .post("/microsub") + .type("form") + .set("cookie", cookie) + .send({ action: "bogus" }); + + assert.equal(response.status, 400); + assert.match(response.text, /Invalid action/); + }); + + it("Returns 400 when POST has no action", async () => { + const response = await request + .post("/microsub") + .type("form") + .set("cookie", cookie) + .send({ name: "Tech News" }); + + assert.equal(response.status, 400); + assert.match(response.text, /Missing required parameter: action/); + }); + + after(async () => { + await client.close(); + await mongoServer.stop(); + server.close((error) => process.exit(error ? 1 : 0)); + }); +}); diff --git a/packages/endpoint-microsub/test/integration/400-post-unauthenticated.js b/packages/endpoint-microsub/test/integration/400-post-unauthenticated.js new file mode 100644 index 000000000..d56a92ae4 --- /dev/null +++ b/packages/endpoint-microsub/test/integration/400-post-unauthenticated.js @@ -0,0 +1,31 @@ +import { strict as assert } from "node:assert"; +import { after, describe, it } from "node:test"; + +import { testDatabase } from "@indiekit-test/database"; +import { testServer } from "@indiekit-test/server"; +import supertest from "supertest"; + +const { client, mongoServer, mongoUri } = await testDatabase(); +const server = await testServer({ + application: { mongodbUrl: mongoUri }, + plugins: ["@indiekit/endpoint-microsub"], +}); +const request = supertest.agent(server); + +describe("endpoint-microsub POST /microsub", () => { + it("Rejects unauthenticated requests without a CSRF token", async () => { + const response = await request + .post("/microsub") + .type("form") + .send({ action: "channels", name: "Tech News" }); + + assert.equal(response.status, 400); + assert.match(response.text, /InvalidRequestError/); + }); + + after(async () => { + await client.close(); + await mongoServer.stop(); + server.close((error) => process.exit(error ? 1 : 0)); + }); +}); diff --git a/packages/endpoint-microsub/test/unit/storage/channels.js b/packages/endpoint-microsub/test/unit/storage/channels.js new file mode 100644 index 000000000..936b77987 --- /dev/null +++ b/packages/endpoint-microsub/test/unit/storage/channels.js @@ -0,0 +1,319 @@ +import { strict as assert } from "node:assert"; +import { after, beforeEach, describe, it, mock } from "node:test"; + +import { testDatabase } from "@indiekit-test/database"; + +import { + createChannel, + deleteChannel, + ensureNotificationsChannel, + getChannel, + getChannels, + reorderChannels, + updateChannel, +} from "../../../lib/storage/channels.js"; + +mock.method(console, "info", () => {}); // Disable console.info + +const { client, database, mongoServer } = await testDatabase(); +const channels = database.collection("microsub_channels"); +const items = database.collection("microsub_items"); +const application = { + collections: new Map([ + ["microsub_channels", channels], + ["microsub_items", items], + ]), +}; + +describe("endpoint-microsub/lib/storage/channels", () => { + beforeEach(async () => { + await channels.deleteMany({}); + await items.deleteMany({}); + }); + + after(async () => { + await client.close(); + await mongoServer.stop(); + }); + + describe("createChannel", () => { + it("Creates a channel with a generated UID", async () => { + const channel = await createChannel(application, { + name: "Tech News", + userId: "user-1", + }); + + assert.match(channel.uid, /^[a-z0-9]{24}$/); + assert.equal(channel.name, "Tech News"); + assert.equal(channel.userId, "user-1"); + assert.ok(channel.createdAt instanceof Date); + }); + + it("Persists the channel", async () => { + const channel = await createChannel(application, { + name: "Tech News", + userId: "user-1", + }); + + const stored = await channels.findOne({ uid: channel.uid }); + + assert.equal(stored.name, "Tech News"); + }); + + it("Assigns order 0 to a user's first channel", async () => { + const channel = await createChannel(application, { + name: "First", + userId: "user-1", + }); + + assert.equal(channel.order, 0); + }); + + it("Increments order for subsequent channels", async () => { + await createChannel(application, { name: "First", userId: "user-1" }); + const second = await createChannel(application, { + name: "Second", + userId: "user-1", + }); + + assert.equal(second.order, 1); + }); + + it("Tracks order separately for each user", async () => { + await createChannel(application, { name: "First", userId: "user-1" }); + const other = await createChannel(application, { + name: "Other", + userId: "user-2", + }); + + assert.equal(other.order, 0); + }); + }); + + describe("getChannels", () => { + it("Returns an empty array when no channels exist", async () => { + const result = await getChannels(application, "user-1"); + + assert.deepEqual(result, []); + }); + + it("Returns channels in order", async () => { + await createChannel(application, { name: "First", userId: "user-1" }); + await createChannel(application, { name: "Second", userId: "user-1" }); + + const result = await getChannels(application, "user-1"); + + assert.deepEqual( + result.map((channel) => channel.name), + ["First", "Second"], + ); + }); + + it("Returns only the requested user's channels", async () => { + await createChannel(application, { name: "Mine", userId: "user-1" }); + await createChannel(application, { name: "Theirs", userId: "user-2" }); + + const result = await getChannels(application, "user-1"); + + assert.equal(result.length, 1); + assert.equal(result[0].name, "Mine"); + }); + + it("Returns false as unread count when all items are read", async () => { + const channel = await createChannel(application, { + name: "Tech News", + userId: "user-1", + }); + const stored = await channels.findOne({ uid: channel.uid }); + await items.insertOne({ channelId: stored._id, readBy: ["user-1"] }); + + const result = await getChannels(application, "user-1"); + + assert.equal(result[0].unread, false); + }); + + it("Counts items not yet read by the user", async () => { + const channel = await createChannel(application, { + name: "Tech News", + userId: "user-1", + }); + const stored = await channels.findOne({ uid: channel.uid }); + await items.insertMany([ + { channelId: stored._id, readBy: [] }, + { channelId: stored._id, readBy: [] }, + { channelId: stored._id, readBy: ["user-1"] }, + ]); + + const result = await getChannels(application, "user-1"); + + assert.equal(result[0].unread, 2); + }); + + it("Lists the notifications channel first", async () => { + await createChannel(application, { name: "Tech News", userId: "user-1" }); + await ensureNotificationsChannel(application, "user-1"); + + const result = await getChannels(application, "user-1"); + + assert.equal(result[0].uid, "notifications"); + }); + }); + + describe("getChannel", () => { + it("Returns a channel by UID", async () => { + const channel = await createChannel(application, { + name: "Tech News", + userId: "user-1", + }); + + const result = await getChannel(application, channel.uid, "user-1"); + + assert.equal(result.name, "Tech News"); + }); + + it("Returns null for an unknown UID", async () => { + const result = await getChannel(application, "nonexistent", "user-1"); + + // eslint-disable-next-line unicorn/no-null -- MongoDB returns null + assert.equal(result, null); + }); + + it("Does not return another user's channel", async () => { + const channel = await createChannel(application, { + name: "Theirs", + userId: "user-2", + }); + + const result = await getChannel(application, channel.uid, "user-1"); + + // eslint-disable-next-line unicorn/no-null -- MongoDB returns null + assert.equal(result, null); + }); + }); + + describe("updateChannel", () => { + it("Updates the channel name", async () => { + const channel = await createChannel(application, { + name: "Old name", + userId: "user-1", + }); + + const result = await updateChannel( + application, + channel.uid, + { name: "New name" }, + "user-1", + ); + + assert.equal(result.name, "New name"); + }); + + it("Returns null for an unknown UID", async () => { + const result = await updateChannel( + application, + "nonexistent", + { name: "New name" }, + "user-1", + ); + + // eslint-disable-next-line unicorn/no-null -- MongoDB returns null + assert.equal(result, null); + }); + }); + + describe("deleteChannel", () => { + it("Deletes the channel", async () => { + const channel = await createChannel(application, { + name: "Tech News", + userId: "user-1", + }); + + const result = await deleteChannel(application, channel.uid, "user-1"); + + assert.equal(result, true); + assert.equal(await channels.countDocuments({ uid: channel.uid }), 0); + }); + + it("Deletes the channel's items", async () => { + const channel = await createChannel(application, { + name: "Tech News", + userId: "user-1", + }); + const stored = await channels.findOne({ uid: channel.uid }); + await items.insertOne({ channelId: stored._id }); + + await deleteChannel(application, channel.uid, "user-1"); + + assert.equal(await items.countDocuments({ channelId: stored._id }), 0); + }); + + it("Refuses to delete the notifications channel", async () => { + await ensureNotificationsChannel(application, "user-1"); + + const result = await deleteChannel( + application, + "notifications", + "user-1", + ); + + assert.equal(result, false); + assert.equal(await channels.countDocuments({ uid: "notifications" }), 1); + }); + + it("Returns false for an unknown UID", async () => { + const result = await deleteChannel(application, "nonexistent", "user-1"); + + assert.equal(result, false); + }); + }); + + describe("reorderChannels", () => { + it("Applies the given order", async () => { + const first = await createChannel(application, { + name: "First", + userId: "user-1", + }); + const second = await createChannel(application, { + name: "Second", + userId: "user-1", + }); + + await reorderChannels(application, [second.uid, first.uid], "user-1"); + + const result = await getChannels(application, "user-1"); + + assert.deepEqual( + result.map((channel) => channel.name), + ["Second", "First"], + ); + }); + + it("Does nothing when given an empty list", async () => { + await createChannel(application, { name: "First", userId: "user-1" }); + + await reorderChannels(application, [], "user-1"); + + const result = await getChannels(application, "user-1"); + + assert.equal(result.length, 1); + }); + }); + + describe("ensureNotificationsChannel", () => { + it("Creates the notifications channel", async () => { + const channel = await ensureNotificationsChannel(application, "user-1"); + + assert.equal(channel.uid, "notifications"); + assert.equal(channel.name, "Notifications"); + assert.equal(channel.order, -1); + }); + + it("Returns the existing channel without duplicating it", async () => { + const first = await ensureNotificationsChannel(application, "user-1"); + const second = await ensureNotificationsChannel(application, "user-1"); + + assert.equal(second._id.toString(), first._id.toString()); + assert.equal(await channels.countDocuments({ uid: "notifications" }), 1); + }); + }); +}); diff --git a/packages/endpoint-microsub/test/unit/storage/items.js b/packages/endpoint-microsub/test/unit/storage/items.js new file mode 100644 index 000000000..6b863caee --- /dev/null +++ b/packages/endpoint-microsub/test/unit/storage/items.js @@ -0,0 +1,350 @@ +import { strict as assert } from "node:assert"; +import { after, beforeEach, describe, it } from "node:test"; + +import { testDatabase } from "@indiekit-test/database"; +import { ObjectId } from "mongodb"; + +import { + createIndexes, + getTimelineItems, + markItemsRead, + markItemsUnread, + removeItems, +} from "../../../lib/storage/items.js"; + +const { client, database, mongoServer } = await testDatabase(); +const items = database.collection("microsub_items"); +const application = { + collections: new Map([["microsub_items", items]]), +}; + +const channelId = new ObjectId(); +const otherChannelId = new ObjectId(); + +/** + * Insert timeline items, oldest first + * @param {number} count - Number of items to insert + * @param {object} [overrides] - Fields to merge into each item + * @returns {Promise} Inserted item documents + */ +async function insertItems(count, overrides = {}) { + const documents = Array.from({ length: count }, (_, index) => ({ + channelId, + type: "entry", + uid: `item-${index}`, + url: `https://website.example/${index}`, + name: `Item ${index}`, + published: new Date(Date.UTC(2026, 0, index + 1)), + readBy: [], + ...overrides, + })); + + await items.insertMany(documents); + + return documents; +} + +describe("endpoint-microsub/lib/storage/items", () => { + beforeEach(async () => { + await items.deleteMany({}); + }); + + after(async () => { + await client.close(); + await mongoServer.stop(); + }); + + describe("getTimelineItems", () => { + it("Returns an empty timeline when the channel has no items", async () => { + const result = await getTimelineItems(application, channelId); + + assert.deepEqual(result.items, []); + assert.deepEqual(result.paging, {}); + }); + + it("Returns items newest first", async () => { + await insertItems(3); + + const result = await getTimelineItems(application, channelId); + + assert.deepEqual( + result.items.map((item) => item.name), + ["Item 2", "Item 1", "Item 0"], + ); + }); + + it("Excludes items from other channels", async () => { + await insertItems(2); + await items.insertOne({ + channelId: otherChannelId, + uid: "other", + published: new Date(), + }); + + const result = await getTimelineItems(application, channelId); + + assert.equal(result.items.length, 2); + }); + + it("Accepts a channel ID as a string", async () => { + await insertItems(2); + + const result = await getTimelineItems(application, channelId.toString()); + + assert.equal(result.items.length, 2); + }); + + it("Applies the requested limit", async () => { + await insertItems(5); + + const result = await getTimelineItems(application, channelId, { + limit: 2, + }); + + assert.equal(result.items.length, 2); + }); + + it("Returns an after cursor when more items remain", async () => { + await insertItems(5); + + const result = await getTimelineItems(application, channelId, { + limit: 2, + }); + + assert.ok(result.paging.after); + }); + + it("Pages through items using the after cursor", async () => { + await insertItems(4); + + const first = await getTimelineItems(application, channelId, { + limit: 2, + }); + const second = await getTimelineItems(application, channelId, { + limit: 2, + after: first.paging.after, + }); + + assert.deepEqual( + second.items.map((item) => item.name), + ["Item 1", "Item 0"], + ); + }); + + it("Transforms items to jf2", async () => { + await insertItems(1, { author: "Alice", category: ["indieweb"] }); + + const { items: result } = await getTimelineItems(application, channelId); + + assert.equal(result[0].type, "entry"); + assert.equal(result[0].uid, "item-0"); + assert.equal(result[0].author, "Alice"); + assert.deepEqual(result[0].category, ["indieweb"]); + assert.equal(typeof result[0].published, "string"); + assert.equal(typeof result[0]._id, "string"); + }); + + it("Omits optional fields that are absent", async () => { + await insertItems(1); + + const { items: result } = await getTimelineItems(application, channelId); + + assert.equal("author" in result[0], false); + assert.equal("category" in result[0], false); + }); + + it("Maps interaction properties to their jf2 names", async () => { + await insertItems(1, { + likeOf: ["https://website.example/liked"], + inReplyTo: ["https://website.example/replied"], + }); + + const { items: result } = await getTimelineItems(application, channelId); + + assert.deepEqual(result[0]["like-of"], ["https://website.example/liked"]); + assert.deepEqual(result[0]["in-reply-to"], [ + "https://website.example/replied", + ]); + }); + + it("Reports read state for the given user", async () => { + await insertItems(1, { readBy: ["user-1"] }); + + const { items: result } = await getTimelineItems(application, channelId, { + userId: "user-1", + }); + + assert.equal(result[0]._is_read, true); + }); + + it("Reports items as unread for a different user", async () => { + await insertItems(1, { readBy: ["user-2"] }); + + const { items: result } = await getTimelineItems(application, channelId, { + userId: "user-1", + }); + + assert.equal(result[0]._is_read, false); + }); + }); + + describe("markItemsRead", () => { + it("Marks the given items as read", async () => { + await insertItems(3); + + const count = await markItemsRead( + application, + channelId, + ["item-0", "item-1"], + "user-1", + ); + + assert.equal(count, 2); + assert.equal( + await items.countDocuments({ channelId, readBy: "user-1" }), + 2, + ); + }); + + it("Matches items by URL", async () => { + await insertItems(2); + + const count = await markItemsRead( + application, + channelId, + ["https://website.example/0"], + "user-1", + ); + + assert.equal(count, 1); + }); + + it("Matches items by ObjectId", async () => { + await insertItems(1); + const item = await items.findOne({ uid: "item-0" }); + + const count = await markItemsRead( + application, + channelId, + [item._id.toString()], + "user-1", + ); + + assert.equal(count, 1); + }); + + it("Marks the whole channel read for last-read-entry", async () => { + await insertItems(3); + + const count = await markItemsRead( + application, + channelId, + ["last-read-entry"], + "user-1", + ); + + assert.equal(count, 3); + }); + + it("Does not mark items in other channels", async () => { + await insertItems(1); + await items.insertOne({ + channelId: otherChannelId, + uid: "item-0", + readBy: [], + }); + + await markItemsRead(application, channelId, ["item-0"], "user-1"); + + const other = await items.findOne({ channelId: otherChannelId }); + + assert.deepEqual(other.readBy, []); + }); + + it("Does not add a duplicate user to readBy", async () => { + await insertItems(1, { readBy: ["user-1"] }); + + await markItemsRead(application, channelId, ["item-0"], "user-1"); + + const item = await items.findOne({ uid: "item-0" }); + + assert.deepEqual(item.readBy, ["user-1"]); + }); + }); + + describe("markItemsUnread", () => { + it("Removes the user from readBy", async () => { + await insertItems(2, { readBy: ["user-1"] }); + + const count = await markItemsUnread( + application, + channelId, + ["item-0"], + "user-1", + ); + + assert.equal(count, 1); + + const item = await items.findOne({ uid: "item-0" }); + + assert.deepEqual(item.readBy, []); + }); + + it("Leaves other users' read state intact", async () => { + await insertItems(1, { readBy: ["user-1", "user-2"] }); + + await markItemsUnread(application, channelId, ["item-0"], "user-1"); + + const item = await items.findOne({ uid: "item-0" }); + + assert.deepEqual(item.readBy, ["user-2"]); + }); + }); + + describe("removeItems", () => { + it("Deletes the given items", async () => { + await insertItems(3); + + const count = await removeItems(application, channelId, [ + "item-0", + "item-1", + ]); + + assert.equal(count, 2); + assert.equal(await items.countDocuments({ channelId }), 1); + }); + + it("Does not delete items in other channels", async () => { + await insertItems(1); + await items.insertOne({ channelId: otherChannelId, uid: "item-0" }); + + await removeItems(application, channelId, ["item-0"]); + + assert.equal( + await items.countDocuments({ channelId: otherChannelId }), + 1, + ); + }); + + it("Returns 0 when nothing matches", async () => { + await insertItems(1); + + const count = await removeItems(application, channelId, ["nonexistent"]); + + assert.equal(count, 0); + }); + }); + + describe("createIndexes", () => { + it("Creates the expected indexes", async () => { + await createIndexes(application); + + const indexes = await items.indexes(); + const keys = new Set(indexes.map((index) => JSON.stringify(index.key))); + + assert.ok(keys.has(JSON.stringify({ channelId: 1, published: -1 }))); + assert.ok(keys.has(JSON.stringify({ channelId: 1, uid: 1 }))); + assert.ok(keys.has(JSON.stringify({ channelId: 1, url: 1 }))); + }); + }); +}); diff --git a/packages/endpoint-microsub/test/unit/utils/auth.js b/packages/endpoint-microsub/test/unit/utils/auth.js new file mode 100644 index 000000000..79d1269cd --- /dev/null +++ b/packages/endpoint-microsub/test/unit/utils/auth.js @@ -0,0 +1,58 @@ +import { strict as assert } from "node:assert"; +import { describe, it } from "node:test"; + +import { getUserId } from "../../../lib/utils/auth.js"; + +describe("endpoint-microsub/lib/utils/auth", () => { + describe("getUserId", () => { + it("Returns userId from session if available", () => { + const request = { + session: { userId: "user-123" }, + app: { locals: { application: {} } }, + }; + + assert.equal(getUserId(request), "user-123"); + }); + + it("Returns me from session if userId not set", () => { + const request = { + session: { me: "https://example.com" }, + app: { locals: { application: {} } }, + }; + + assert.equal(getUserId(request), "https://example.com"); + }); + + it("Falls back to publication me URL", () => { + const request = { + session: {}, + app: { + locals: { + application: { + publication: { me: "https://mysite.com" }, + }, + }, + }, + }; + + assert.equal(getUserId(request), "https://mysite.com"); + }); + + it("Returns 'default' as final fallback", () => { + const request = { + session: {}, + app: { locals: { application: {} } }, + }; + + assert.equal(getUserId(request), "default"); + }); + + it("Handles undefined session gracefully", () => { + const request = { + app: { locals: { application: {} } }, + }; + + assert.equal(getUserId(request), "default"); + }); + }); +}); diff --git a/packages/endpoint-microsub/test/unit/utils/pagination.js b/packages/endpoint-microsub/test/unit/utils/pagination.js new file mode 100644 index 000000000..b64ef3987 --- /dev/null +++ b/packages/endpoint-microsub/test/unit/utils/pagination.js @@ -0,0 +1,234 @@ +import { strict as assert } from "node:assert"; +import { describe, it } from "node:test"; + +import { ObjectId } from "mongodb"; + +import { + buildPaginationQuery, + buildPaginationSort, + decodeCursor, + DEFAULT_LIMIT, + encodeCursor, + generatePagingCursors, + MAX_LIMIT, + parseLimit, +} from "../../../lib/utils/pagination.js"; + +/** + * Create mock items for testing + * @param {number} count - Number of items + * @returns {Array} Mock items + */ +function createMockItems(count) { + return Array.from({ length: count }, (_, index) => ({ + _id: new ObjectId(), + published: new Date(Date.now() - index * 1000), + })); +} + +describe("endpoint-microsub/lib/utils/pagination", () => { + describe("encodeCursor", () => { + it("Encodes timestamp and ID to base64url", () => { + const date = new Date("2024-01-15T10:30:00Z"); + const id = "507f1f77bcf86cd799439011"; + const cursor = encodeCursor(date, id); + + assert.ok(typeof cursor === "string"); + assert.ok(cursor.length > 0); + // Should be valid base64url (no +, /, or =) + assert.ok(!/[+/=]/.test(cursor)); + }); + + it("Handles string timestamp", () => { + const cursor = encodeCursor("2024-01-15T10:30:00Z", "abc123"); + assert.ok(typeof cursor === "string"); + }); + }); + + describe("decodeCursor", () => { + it("Decodes valid cursor", () => { + const date = new Date("2024-01-15T10:30:00Z"); + const id = "507f1f77bcf86cd799439011"; + const cursor = encodeCursor(date, id); + const decoded = decodeCursor(cursor); + + assert.ok(decoded); + assert.equal(decoded.timestamp.toISOString(), date.toISOString()); + assert.equal(decoded.id, id); + }); + + it("Returns undefined for null cursor", () => { + // eslint-disable-next-line unicorn/no-null -- Testing null input handling + const decoded = decodeCursor(null); + assert.equal(decoded, undefined); + }); + + it("Returns undefined for undefined cursor", () => { + const decoded = decodeCursor(); + assert.equal(decoded, undefined); + }); + + it("Returns undefined for empty string", () => { + const decoded = decodeCursor(""); + assert.equal(decoded, undefined); + }); + + it("Returns undefined for invalid base64", () => { + const decoded = decodeCursor("not-valid-base64!!!"); + assert.equal(decoded, undefined); + }); + + it("Returns undefined for valid base64 but invalid JSON", () => { + const invalidJson = Buffer.from("not json").toString("base64url"); + const decoded = decodeCursor(invalidJson); + assert.equal(decoded, undefined); + }); + }); + + describe("buildPaginationQuery", () => { + it("Returns base query when no cursors", () => { + const baseQuery = { userId: "user1" }; + const query = buildPaginationQuery({ baseQuery }); + assert.deepEqual(query, baseQuery); + }); + + it("Adds $or clause for before cursor", () => { + const date = new Date("2024-01-15T10:30:00Z"); + const id = "507f1f77bcf86cd799439011"; + const cursor = encodeCursor(date, id); + + const query = buildPaginationQuery({ before: cursor }); + + assert.ok(query.$or); + assert.equal(query.$or.length, 2); + // First condition: published > cursor.timestamp + assert.ok(query.$or[0].published.$gt); + // Second condition: same timestamp but greater ID + assert.ok(query.$or[1].published); + assert.ok(query.$or[1]._id.$gt); + }); + + it("Adds $or clause for after cursor", () => { + const date = new Date("2024-01-15T10:30:00Z"); + const id = "507f1f77bcf86cd799439011"; + const cursor = encodeCursor(date, id); + + const query = buildPaginationQuery({ after: cursor }); + + assert.ok(query.$or); + assert.equal(query.$or.length, 2); + // First condition: published < cursor.timestamp + assert.ok(query.$or[0].published.$lt); + }); + + it("Merges with base query", () => { + const date = new Date("2024-01-15T10:30:00Z"); + const id = "507f1f77bcf86cd799439011"; + const cursor = encodeCursor(date, id); + const baseQuery = { channelId: "ch1" }; + + const query = buildPaginationQuery({ after: cursor, baseQuery }); + + assert.equal(query.channelId, "ch1"); + assert.ok(query.$or); + }); + + it("Ignores invalid before cursor", () => { + const query = buildPaginationQuery({ before: "invalid" }); + assert.ok(!query.$or); + }); + }); + + describe("buildPaginationSort", () => { + it("Returns descending sort by default", () => { + const sort = buildPaginationSort(); + assert.deepEqual(sort, { published: -1, _id: -1 }); + }); + + it("Returns ascending sort when before cursor present", () => { + const sort = buildPaginationSort("some-cursor"); + assert.deepEqual(sort, { published: 1, _id: 1 }); + }); + }); + + describe("generatePagingCursors", () => { + it("Returns empty object for empty items", () => { + const cursors = generatePagingCursors([], 20, false); + assert.deepEqual(cursors, {}); + }); + + it("Returns empty object for null items", () => { + // eslint-disable-next-line unicorn/no-null -- Testing null input handling + const cursors = generatePagingCursors(null, 20, false); + assert.deepEqual(cursors, {}); + }); + + it("Returns after cursor when hasMore is true", () => { + const items = createMockItems(5); + const cursors = generatePagingCursors(items, 5, true); + + assert.ok(cursors.after); + assert.ok(cursors.before); + }); + + it("Returns only before cursor when hasMore is false", () => { + const items = createMockItems(5); + const cursors = generatePagingCursors(items, 10, false); + + assert.ok(cursors.before); + assert.ok(!cursors.after); + }); + + it("Reverses items and sets cursors when using before", () => { + const items = createMockItems(5); + const originalFirstId = items[0]._id.toString(); + + const cursors = generatePagingCursors(items, 5, true, "some-before"); + + // Items should be reversed + assert.equal(items.at(-1)._id.toString(), originalFirstId); + // Should have after cursor (older items exist) + assert.ok(cursors.after); + }); + }); + + describe("parseLimit", () => { + it("Returns parsed number for valid string", () => { + assert.equal(parseLimit("25"), 25); + }); + + it("Returns DEFAULT_LIMIT for invalid string", () => { + assert.equal(parseLimit("abc"), DEFAULT_LIMIT); + }); + + it("Returns DEFAULT_LIMIT for negative number", () => { + assert.equal(parseLimit("-5"), DEFAULT_LIMIT); + }); + + it("Returns DEFAULT_LIMIT for zero", () => { + assert.equal(parseLimit("0"), DEFAULT_LIMIT); + }); + + it("Clamps to MAX_LIMIT for large values", () => { + assert.equal(parseLimit("500"), MAX_LIMIT); + }); + + it("Returns DEFAULT_LIMIT for undefined", () => { + assert.equal(parseLimit(), DEFAULT_LIMIT); + }); + + it("Handles number input", () => { + assert.equal(parseLimit(30), 30); + }); + }); + + describe("Constants", () => { + it("DEFAULT_LIMIT is 20", () => { + assert.equal(DEFAULT_LIMIT, 20); + }); + + it("MAX_LIMIT is 100", () => { + assert.equal(MAX_LIMIT, 100); + }); + }); +}); diff --git a/packages/endpoint-microsub/test/unit/utils/uid.js b/packages/endpoint-microsub/test/unit/utils/uid.js new file mode 100644 index 000000000..928eee4de --- /dev/null +++ b/packages/endpoint-microsub/test/unit/utils/uid.js @@ -0,0 +1,30 @@ +import { strict as assert } from "node:assert"; +import { describe, it } from "node:test"; + +import { generateChannelUid } from "../../../lib/utils/uid.js"; + +describe("endpoint-microsub/lib/utils/uid", () => { + describe("generateChannelUid", () => { + it("Returns a 24-character string", () => { + const uid = generateChannelUid(); + + assert.equal(typeof uid, "string"); + assert.equal(uid.length, 24); + }); + + it("Uses only lowercase letters and digits", () => { + for (let index = 0; index < 100; index++) { + assert.match(generateChannelUid(), /^[a-z0-9]{24}$/); + } + }); + + it("Returns a different value on each call", () => { + const uids = new Set(); + for (let index = 0; index < 100; index++) { + uids.add(generateChannelUid()); + } + + assert.equal(uids.size, 100); + }); + }); +}); diff --git a/packages/endpoint-microsub/test/unit/utils/validation.js b/packages/endpoint-microsub/test/unit/utils/validation.js new file mode 100644 index 000000000..cefda22c4 --- /dev/null +++ b/packages/endpoint-microsub/test/unit/utils/validation.js @@ -0,0 +1,111 @@ +import { strict as assert } from "node:assert"; +import { describe, it } from "node:test"; + +import { + validateAction, + validateChannel, + validateEntries, + validateChannelName, + parseArrayParameter, +} from "../../../lib/utils/validation.js"; + +describe("endpoint-microsub/lib/utils/validation", () => { + describe("validateAction", () => { + it("Accepts valid actions", () => { + assert.doesNotThrow(() => validateAction("channels")); + assert.doesNotThrow(() => validateAction("timeline")); + }); + + it("Rejects missing action", () => { + assert.throws(() => validateAction(), { + message: /Missing required parameter: action/, + }); + // eslint-disable-next-line unicorn/no-null -- Testing null input handling + assert.throws(() => validateAction(null), { + message: /Missing required parameter: action/, + }); + }); + + it("Rejects invalid action", () => { + assert.throws(() => validateAction("invalid"), { + message: /Invalid action/, + }); + }); + }); + + describe("validateChannel", () => { + it("Accepts valid channel", () => { + assert.doesNotThrow(() => validateChannel("test-channel")); + }); + + it("Rejects missing channel when required", () => { + assert.throws(() => validateChannel(), { + message: /Missing required parameter: channel/, + }); + }); + + it("Allows missing channel when not required", () => { + assert.doesNotThrow(() => validateChannel(undefined, false)); + }); + }); + + describe("validateEntries", () => { + it("Returns array for single entry", () => { + const result = validateEntries("entry-1"); + assert.deepEqual(result, ["entry-1"]); + }); + + it("Returns array for array of entries", () => { + const result = validateEntries(["entry-1", "entry-2"]); + assert.deepEqual(result, ["entry-1", "entry-2"]); + }); + + it("Rejects missing entries", () => { + assert.throws(() => validateEntries(), { + message: /Missing required parameter: entry/, + }); + }); + }); + + describe("validateChannelName", () => { + it("Accepts valid name", () => { + assert.doesNotThrow(() => validateChannelName("My Channel")); + }); + + it("Rejects empty name", () => { + assert.throws(() => validateChannelName(""), { + message: /Missing required parameter: name/, + }); + }); + + it("Rejects name over 100 characters", () => { + const longName = "a".repeat(101); + assert.throws(() => validateChannelName(longName), { + message: /100 characters or less/, + }); + }); + }); + + describe("parseArrayParameter", () => { + it("Handles direct array", () => { + const result = parseArrayParameter({ items: ["a", "b"] }, "items"); + assert.deepEqual(result, ["a", "b"]); + }); + + it("Handles single value", () => { + const result = parseArrayParameter({ item: "single" }, "item"); + assert.deepEqual(result, ["single"]); + }); + + it("Handles indexed values", () => { + const body = { "item[0]": "first", "item[1]": "second" }; + const result = parseArrayParameter(body, "item"); + assert.deepEqual(result, ["first", "second"]); + }); + + it("Returns empty array for missing parameter", () => { + const result = parseArrayParameter({}, "missing"); + assert.deepEqual(result, []); + }); + }); +}); From 96036f8d9a1620b105c921ed9438af44e23097f6 Mon Sep 17 00:00:00 2001 From: Ricardo Mendes Date: Sun, 16 Aug 2026 19:31:28 +0200 Subject: [PATCH 07/17] refactor(endpoint-microsub): use getObjectId from @indiekit/util MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces the direct mongodb import with @indiekit/util's getObjectId, as suggested in review. The plug-in no longer declares mongodb at all, so its driver version can't drift from the host's — @indiekit/util owns that pin. This supersedes the earlier version bump, which fixed the same mismatch by matching the pin by hand and would have needed maintaining. --- .../endpoint-microsub/lib/storage/items.js | 24 +++++++++---------- .../endpoint-microsub/lib/utils/pagination.js | 6 ++--- packages/endpoint-microsub/package.json | 4 ++-- .../test/unit/storage/items.js | 6 ++--- .../test/unit/utils/pagination.js | 4 ++-- 5 files changed, 22 insertions(+), 22 deletions(-) diff --git a/packages/endpoint-microsub/lib/storage/items.js b/packages/endpoint-microsub/lib/storage/items.js index b80296a7d..3e7d441a9 100644 --- a/packages/endpoint-microsub/lib/storage/items.js +++ b/packages/endpoint-microsub/lib/storage/items.js @@ -3,7 +3,7 @@ * @module storage/items */ -import { ObjectId } from "mongodb"; +import { getObjectId } from "@indiekit/util"; import { buildPaginationQuery, @@ -24,7 +24,7 @@ function getCollection(application) { /** * Get timeline items for a channel * @param {object} application - Indiekit application - * @param {ObjectId|string} channelId - Channel ObjectId + * @param {object|string} channelId - Channel ObjectId or its string form * @param {object} options - Query options * @param {string} [options.before] - Before cursor * @param {string} [options.after] - After cursor @@ -35,7 +35,7 @@ function getCollection(application) { export async function getTimelineItems(application, channelId, options = {}) { const collection = getCollection(application); const objectId = - typeof channelId === "string" ? new ObjectId(channelId) : channelId; + typeof channelId === "string" ? getObjectId(channelId) : channelId; const limit = parseLimit(options.limit); const baseQuery = { channelId: objectId }; @@ -116,7 +116,7 @@ function transformToJf2(item, userId) { /** * Mark items as read * @param {object} application - Indiekit application - * @param {ObjectId|string} channelId - Channel ObjectId + * @param {object|string} channelId - Channel ObjectId or its string form * @param {Array} entryIds - Array of entry IDs to mark as read * @param {string} userId - User ID * @returns {Promise} Number of items updated @@ -124,7 +124,7 @@ function transformToJf2(item, userId) { export async function markItemsRead(application, channelId, entryIds, userId) { const collection = getCollection(application); const channelObjectId = - typeof channelId === "string" ? new ObjectId(channelId) : channelId; + typeof channelId === "string" ? getObjectId(channelId) : channelId; // Handle "last-read-entry" special value if (entryIds.includes("last-read-entry")) { @@ -139,7 +139,7 @@ export async function markItemsRead(application, channelId, entryIds, userId) { const objectIds = entryIds .map((id) => { try { - return new ObjectId(id); + return getObjectId(id); } catch { return; } @@ -165,7 +165,7 @@ export async function markItemsRead(application, channelId, entryIds, userId) { /** * Mark items as unread * @param {object} application - Indiekit application - * @param {ObjectId|string} channelId - Channel ObjectId + * @param {object|string} channelId - Channel ObjectId or its string form * @param {Array} entryIds - Array of entry IDs to mark as unread * @param {string} userId - User ID * @returns {Promise} Number of items updated @@ -178,13 +178,13 @@ export async function markItemsUnread( ) { const collection = getCollection(application); const channelObjectId = - typeof channelId === "string" ? new ObjectId(channelId) : channelId; + typeof channelId === "string" ? getObjectId(channelId) : channelId; // Convert string IDs to ObjectIds where possible const objectIds = entryIds .map((id) => { try { - return new ObjectId(id); + return getObjectId(id); } catch { return; } @@ -210,20 +210,20 @@ export async function markItemsUnread( /** * Remove items from channel * @param {object} application - Indiekit application - * @param {ObjectId|string} channelId - Channel ObjectId + * @param {object|string} channelId - Channel ObjectId or its string form * @param {Array} entryIds - Array of entry IDs to remove * @returns {Promise} Number of items removed */ export async function removeItems(application, channelId, entryIds) { const collection = getCollection(application); const channelObjectId = - typeof channelId === "string" ? new ObjectId(channelId) : channelId; + typeof channelId === "string" ? getObjectId(channelId) : channelId; // Convert string IDs to ObjectIds where possible const objectIds = entryIds .map((id) => { try { - return new ObjectId(id); + return getObjectId(id); } catch { return; } diff --git a/packages/endpoint-microsub/lib/utils/pagination.js b/packages/endpoint-microsub/lib/utils/pagination.js index 2a8bdc57b..1efe4ef50 100644 --- a/packages/endpoint-microsub/lib/utils/pagination.js +++ b/packages/endpoint-microsub/lib/utils/pagination.js @@ -3,7 +3,7 @@ * @module utils/pagination */ -import { ObjectId } from "mongodb"; +import { getObjectId } from "@indiekit/util"; /** * Default pagination limit @@ -68,7 +68,7 @@ export function buildPaginationQuery({ before, after, baseQuery = {} }) { { published: { $gt: cursor.timestamp } }, { published: cursor.timestamp, - _id: { $gt: new ObjectId(cursor.id) }, + _id: { $gt: getObjectId(cursor.id) }, }, ]; } @@ -80,7 +80,7 @@ export function buildPaginationQuery({ before, after, baseQuery = {} }) { { published: { $lt: cursor.timestamp } }, { published: cursor.timestamp, - _id: { $lt: new ObjectId(cursor.id) }, + _id: { $lt: getObjectId(cursor.id) }, }, ]; } diff --git a/packages/endpoint-microsub/package.json b/packages/endpoint-microsub/package.json index 2dca7e1bd..84dc9f3cf 100644 --- a/packages/endpoint-microsub/package.json +++ b/packages/endpoint-microsub/package.json @@ -43,8 +43,8 @@ }, "dependencies": { "@indiekit/error": "^1.0.0-beta.25", - "express": "^5.0.0", - "mongodb": "^7.4.0" + "@indiekit/util": "^1.0.0-beta.28", + "express": "^5.0.0" }, "publishConfig": { "access": "public" diff --git a/packages/endpoint-microsub/test/unit/storage/items.js b/packages/endpoint-microsub/test/unit/storage/items.js index 6b863caee..76871f7d1 100644 --- a/packages/endpoint-microsub/test/unit/storage/items.js +++ b/packages/endpoint-microsub/test/unit/storage/items.js @@ -1,8 +1,8 @@ import { strict as assert } from "node:assert"; import { after, beforeEach, describe, it } from "node:test"; +import { getObjectId } from "@indiekit/util"; import { testDatabase } from "@indiekit-test/database"; -import { ObjectId } from "mongodb"; import { createIndexes, @@ -18,8 +18,8 @@ const application = { collections: new Map([["microsub_items", items]]), }; -const channelId = new ObjectId(); -const otherChannelId = new ObjectId(); +const channelId = getObjectId(); +const otherChannelId = getObjectId(); /** * Insert timeline items, oldest first diff --git a/packages/endpoint-microsub/test/unit/utils/pagination.js b/packages/endpoint-microsub/test/unit/utils/pagination.js index b64ef3987..1f5c0cce5 100644 --- a/packages/endpoint-microsub/test/unit/utils/pagination.js +++ b/packages/endpoint-microsub/test/unit/utils/pagination.js @@ -1,7 +1,7 @@ import { strict as assert } from "node:assert"; import { describe, it } from "node:test"; -import { ObjectId } from "mongodb"; +import { getObjectId } from "@indiekit/util"; import { buildPaginationQuery, @@ -21,7 +21,7 @@ import { */ function createMockItems(count) { return Array.from({ length: count }, (_, index) => ({ - _id: new ObjectId(), + _id: getObjectId(), published: new Date(Date.now() - index * 1000), })); } From 182ec81591711c3be4545a257170e78b020a55d0 Mon Sep 17 00:00:00 2001 From: Ricardo Date: Sat, 22 Aug 2026 14:08:29 +0200 Subject: [PATCH 08/17] chore: update lockfile for endpoint-microsub dependency change Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01WGHR7MuyvBaDbAFfAGUxeT --- package-lock.json | 376 +++++++++++++++++++++++++++++++++++++++++----- 1 file changed, 337 insertions(+), 39 deletions(-) diff --git a/package-lock.json b/package-lock.json index 8e2bcd7dc..741f8570e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -4692,6 +4692,10 @@ "resolved": "packages/endpoint-micropub", "link": true }, + "node_modules/@indiekit/endpoint-microsub": { + "resolved": "packages/endpoint-microsub", + "link": true + }, "node_modules/@indiekit/endpoint-posts": { "resolved": "packages/endpoint-posts", "link": true @@ -5398,7 +5402,7 @@ "version": "8.0.2", "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", "integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==", - "devOptional": true, + "dev": true, "license": "ISC", "dependencies": { "string-width": "^5.1.2", @@ -5416,7 +5420,7 @@ "version": "6.4.0", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.4.0.tgz", "integrity": "sha512-KzTVk2tCWAHtYrvvvaP8bJKJq2pVinhLcGEQdtLIYPbmNGNyYe8QwNaTUYQp2J7/vIsUKt5QCqAfUkYyG9DkOw==", - "devOptional": true, + "dev": true, "license": "MIT", "engines": { "node": ">=12" @@ -5429,7 +5433,7 @@ "version": "6.2.3", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", - "devOptional": true, + "dev": true, "license": "MIT", "engines": { "node": ">=12" @@ -5442,14 +5446,14 @@ "version": "9.2.2", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", - "devOptional": true, + "dev": true, "license": "MIT" }, "node_modules/@isaacs/cliui/node_modules/string-width": { "version": "5.1.2", "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "eastasianwidth": "^0.2.0", @@ -5467,7 +5471,7 @@ "version": "7.2.0", "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "ansi-regex": "^6.2.2" @@ -5483,7 +5487,7 @@ "version": "8.1.0", "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz", "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "ansi-styles": "^6.1.0", @@ -8410,6 +8414,7 @@ "version": "0.11.0", "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", "integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==", + "dev": true, "license": "MIT", "optional": true, "engines": { @@ -11252,7 +11257,7 @@ "version": "9.3.1", "resolved": "https://registry.npmjs.org/bignumber.js/-/bignumber.js-9.3.1.tgz", "integrity": "sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ==", - "devOptional": true, + "dev": true, "license": "MIT", "engines": { "node": "*" @@ -12502,7 +12507,7 @@ "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "path-key": "^3.1.0", @@ -12567,7 +12572,7 @@ "version": "4.0.1", "resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-4.0.1.tgz", "integrity": "sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==", - "devOptional": true, + "dev": true, "license": "MIT", "engines": { "node": ">= 12" @@ -13092,7 +13097,7 @@ "version": "0.2.0", "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==", - "devOptional": true, + "dev": true, "license": "MIT" }, "node_modules/easymde": { @@ -14137,7 +14142,7 @@ "version": "3.0.2", "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==", - "devOptional": true, + "dev": true, "license": "MIT" }, "node_modules/fast-deep-equal": { @@ -14315,7 +14320,7 @@ "version": "3.2.0", "resolved": "https://registry.npmjs.org/fetch-blob/-/fetch-blob-3.2.0.tgz", "integrity": "sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==", - "devOptional": true, + "dev": true, "funding": [ { "type": "github", @@ -14556,7 +14561,7 @@ "version": "3.3.1", "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", "integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==", - "devOptional": true, + "dev": true, "license": "ISC", "dependencies": { "cross-spawn": "^7.0.6", @@ -14619,7 +14624,7 @@ "version": "4.0.10", "resolved": "https://registry.npmjs.org/formdata-polyfill/-/formdata-polyfill-4.0.10.tgz", "integrity": "sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "fetch-blob": "^3.1.2" @@ -14773,7 +14778,7 @@ "version": "7.1.3", "resolved": "https://registry.npmjs.org/gaxios/-/gaxios-7.1.3.tgz", "integrity": "sha512-YGGyuEdVIjqxkxVH1pUTMY/XtmmsApXrCVv5EU25iX6inEPbV+VakJfLealkBtJN69AQmh1eGOdCl9Sm1UP6XQ==", - "devOptional": true, + "dev": true, "license": "Apache-2.0", "dependencies": { "extend": "^3.0.2", @@ -14789,7 +14794,7 @@ "version": "7.1.4", "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", - "devOptional": true, + "dev": true, "license": "MIT", "engines": { "node": ">= 14" @@ -14799,7 +14804,7 @@ "version": "7.0.6", "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "agent-base": "^7.1.2", @@ -15143,7 +15148,7 @@ "version": "1.1.3", "resolved": "https://registry.npmjs.org/google-logging-utils/-/google-logging-utils-1.1.3.tgz", "integrity": "sha512-eAmLkjDjAFCVXg7A1unxHsLf961m6y17QFqXqAXGj/gVkKFrEICfStRfwUlGNfeCEjNRa32JEWOUTlYXPyyKvA==", - "devOptional": true, + "dev": true, "license": "Apache-2.0", "engines": { "node": ">=14" @@ -16542,7 +16547,7 @@ "version": "3.4.3", "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz", "integrity": "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==", - "devOptional": true, + "dev": true, "license": "BlueOak-1.0.0", "dependencies": { "@isaacs/cliui": "^8.0.2" @@ -16690,7 +16695,7 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/json-bigint/-/json-bigint-1.0.0.tgz", "integrity": "sha512-SiPv/8VpZuWbvLSMtTDU8hEfrZWg/mH/nV/b4o0CYbSxu1UIQPLdwKOCIyLQX+VIPO5vrLX3i8qtqFyhdPSUSQ==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "bignumber.js": "^9.0.0" @@ -17552,7 +17557,7 @@ "version": "10.4.3", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", - "devOptional": true, + "dev": true, "license": "ISC" }, "node_modules/magic-string": { @@ -18756,7 +18761,7 @@ "resolved": "https://registry.npmjs.org/node-domexception/-/node-domexception-1.0.0.tgz", "integrity": "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==", "deprecated": "Use your platform's native DOMException instead", - "devOptional": true, + "dev": true, "funding": [ { "type": "github", @@ -18805,7 +18810,7 @@ "version": "3.3.2", "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-3.3.2.tgz", "integrity": "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "data-uri-to-buffer": "^4.0.0", @@ -19844,7 +19849,7 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==", - "devOptional": true, + "dev": true, "license": "BlueOak-1.0.0" }, "node_modules/pacote": { @@ -21285,7 +21290,7 @@ "version": "5.0.10", "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-5.0.10.tgz", "integrity": "sha512-l0OE8wL34P4nJH/H2ffoaniAokM2qSmrtXHmlpvYr5AVVX8msAyW0l8NVJFDxlSK4u3Uh/f41cQheDVdnYijwQ==", - "devOptional": true, + "dev": true, "license": "ISC", "dependencies": { "glob": "^10.3.7" @@ -21301,14 +21306,14 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", - "devOptional": true, + "dev": true, "license": "MIT" }, "node_modules/rimraf/node_modules/brace-expansion": { "version": "2.1.7", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.7.tgz", "integrity": "sha512-uZbew1NqdmPDTMJ8ah1y+b+9QEJrfkXFk3RcTQw3X0jW/xRUvFKsg1CfQdSYGdTbXZWExtU3J3ccxtnfw1Fi0g==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "balanced-match": "^1.0.0" @@ -21319,7 +21324,7 @@ "resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz", "integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==", "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", - "devOptional": true, + "dev": true, "license": "ISC", "dependencies": { "foreground-child": "^3.1.0", @@ -21340,7 +21345,7 @@ "version": "9.0.9", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", - "devOptional": true, + "dev": true, "license": "ISC", "dependencies": { "brace-expansion": "^2.0.2" @@ -21356,7 +21361,7 @@ "version": "1.11.1", "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz", "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==", - "devOptional": true, + "dev": true, "license": "BlueOak-1.0.0", "dependencies": { "lru-cache": "^10.2.0", @@ -21942,7 +21947,7 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "shebang-regex": "^3.0.0" @@ -21955,7 +21960,7 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", - "devOptional": true, + "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -22559,7 +22564,7 @@ "version": "4.2.3", "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "emoji-regex": "^8.0.0", @@ -22689,7 +22694,7 @@ "version": "6.0.1", "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "ansi-regex": "^5.0.1" @@ -24265,7 +24270,7 @@ "version": "3.3.3", "resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-3.3.3.tgz", "integrity": "sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==", - "devOptional": true, + "dev": true, "license": "MIT", "engines": { "node": ">= 8" @@ -24310,7 +24315,7 @@ "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", - "devOptional": true, + "dev": true, "license": "ISC", "dependencies": { "isexe": "^2.0.0" @@ -24464,7 +24469,7 @@ "version": "7.0.0", "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "ansi-styles": "^4.0.0", @@ -24482,7 +24487,7 @@ "version": "4.2.3", "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "emoji-regex": "^8.0.0", @@ -26458,6 +26463,299 @@ "url": "https://opencollective.com/express" } }, + "packages/endpoint-microsub": { + "name": "@indiekit/endpoint-microsub", + "version": "1.0.0-alpha.1", + "license": "MIT", + "dependencies": { + "@indiekit/error": "^1.0.0-beta.25", + "@indiekit/util": "^1.0.0-beta.28", + "express": "^5.0.0" + }, + "engines": { + "node": ">=20" + } + }, + "packages/endpoint-microsub/node_modules/accepts": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", + "integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==", + "license": "MIT", + "dependencies": { + "mime-types": "^3.0.0", + "negotiator": "^1.0.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "packages/endpoint-microsub/node_modules/body-parser": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.3.0.tgz", + "integrity": "sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw==", + "license": "MIT", + "dependencies": { + "bytes": "^3.1.2", + "content-type": "^2.0.0", + "debug": "^4.4.3", + "http-errors": "^2.0.1", + "iconv-lite": "^0.7.2", + "on-finished": "^2.4.1", + "qs": "^6.15.2", + "raw-body": "^3.0.2", + "type-is": "^2.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "packages/endpoint-microsub/node_modules/content-disposition": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-2.0.1.tgz", + "integrity": "sha512-e+H0ZXHSWYrENhQzw1LPuP4oF5MzVKmDU6d3hxlvaPEYLLg62MxtQNPRx4SYSuYJSBUgnQIG4HIN2tEtNv7Dog==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "packages/endpoint-microsub/node_modules/content-type": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.1.0.tgz", + "integrity": "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "packages/endpoint-microsub/node_modules/cookie-signature": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz", + "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==", + "license": "MIT", + "engines": { + "node": ">=6.6.0" + } + }, + "packages/endpoint-microsub/node_modules/express": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/express/-/express-5.3.0.tgz", + "integrity": "sha512-qCqy1xSoaugxOD4IVHb1ZWdkvCF2W3aE2okKouWj8VwJ+ztskSiXoBnCSJqfKT+cVPJhuJqe9YLbY425+Zh00g==", + "license": "MIT", + "dependencies": { + "accepts": "^2.0.0", + "body-parser": "^2.3.0", + "content-disposition": "^2.0.1", + "content-type": "^2.0.0", + "cookie": "^0.7.1", + "cookie-signature": "^1.2.1", + "debug": "^4.4.0", + "depd": "^2.0.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "finalhandler": "^2.1.0", + "fresh": "^2.0.0", + "http-errors": "^2.0.0", + "merge-descriptors": "^2.0.0", + "mime-types": "^3.0.0", + "on-finished": "^2.4.1", + "once": "^1.4.0", + "parseurl": "^1.3.3", + "proxy-addr": "^2.0.8", + "qs": "^6.16.0", + "range-parser": "^1.2.1", + "router": "^2.2.0", + "send": "^1.1.0", + "serve-static": "^2.2.0", + "statuses": "^2.0.1", + "type-is": "^2.1.0", + "vary": "^1.1.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "packages/endpoint-microsub/node_modules/finalhandler": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz", + "integrity": "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "on-finished": "^2.4.1", + "parseurl": "^1.3.3", + "statuses": "^2.0.1" + }, + "engines": { + "node": ">= 18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "packages/endpoint-microsub/node_modules/fresh": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz", + "integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "packages/endpoint-microsub/node_modules/iconv-lite": { + "version": "0.7.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.3.tgz", + "integrity": "sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "packages/endpoint-microsub/node_modules/media-typer": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.1.tgz", + "integrity": "sha512-yz3xRaG20c6/BOzvYoDaGtPmGscs7YivItZEEqe6GbwNfHuxu9YNmvnEkMzKldAGY4/80pRcQRZSEnhquk9XuQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "packages/endpoint-microsub/node_modules/merge-descriptors": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz", + "integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "packages/endpoint-microsub/node_modules/negotiator": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.1.0.tgz", + "integrity": "sha512-NMPBRMJgiQHjbd8phG3Vebdx4kZ1H121rbl5IkMqeOsahptB9BKo/d7oJ3zTXqTgagn2bWlNSXkh0QUGM31RYg==", + "license": "MIT", + "dependencies": { + "content-type": "^2.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "packages/endpoint-microsub/node_modules/raw-body": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.2.tgz", + "integrity": "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.7.0", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.10" + } + }, + "packages/endpoint-microsub/node_modules/send": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/send/-/send-1.2.1.tgz", + "integrity": "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.3", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "fresh": "^2.0.0", + "http-errors": "^2.0.1", + "mime-types": "^3.0.2", + "ms": "^2.1.3", + "on-finished": "^2.4.1", + "range-parser": "^1.2.1", + "statuses": "^2.0.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "packages/endpoint-microsub/node_modules/serve-static": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.1.tgz", + "integrity": "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==", + "license": "MIT", + "dependencies": { + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "parseurl": "^1.3.3", + "send": "^1.2.0" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "packages/endpoint-microsub/node_modules/type-is": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.1.0.tgz", + "integrity": "sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==", + "license": "MIT", + "dependencies": { + "content-type": "^2.0.0", + "media-typer": "^1.1.0", + "mime-types": "^3.0.0" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "packages/endpoint-posts": { "name": "@indiekit/endpoint-posts", "version": "1.0.0-beta.29", From 76c927dfde65157d4dcce59f746eea398639b74f Mon Sep 17 00:00:00 2001 From: Ricardo Date: Sat, 22 Aug 2026 18:59:59 +0200 Subject: [PATCH 09/17] refactor(endpoint-microsub): use utility methods for uid and logging MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `generateChannelUid` built its own string from `Math.random()`. `randomString` from `@indiekit/util` does the same job with `randomBytes`, which is what a channel identifier should be using. That changes the alphabet from `[a-z0-9]` to base64url, so the tests asserting lowercase now assert URL-safe characters instead — that was the actual requirement, since a uid appears in Microsub request URLs. Replaces the one `console.info` in the package with `debug`, matching endpoint-micropub and endpoint-media, and declares the dependency. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01WGHR7MuyvBaDbAFfAGUxeT --- package-lock.json | 1 + packages/endpoint-microsub/lib/storage/channels.js | 8 +++++--- packages/endpoint-microsub/lib/utils/uid.js | 9 +++------ packages/endpoint-microsub/package.json | 1 + .../test/integration/200-get-channels.js | 2 +- .../test/integration/201-post-channel-create.js | 2 +- packages/endpoint-microsub/test/unit/storage/channels.js | 2 +- packages/endpoint-microsub/test/unit/utils/uid.js | 6 ++++-- 8 files changed, 17 insertions(+), 14 deletions(-) diff --git a/package-lock.json b/package-lock.json index 741f8570e..fa8bc6648 100644 --- a/package-lock.json +++ b/package-lock.json @@ -26470,6 +26470,7 @@ "dependencies": { "@indiekit/error": "^1.0.0-beta.25", "@indiekit/util": "^1.0.0-beta.28", + "debug": "^4.4.3", "express": "^5.0.0" }, "engines": { diff --git a/packages/endpoint-microsub/lib/storage/channels.js b/packages/endpoint-microsub/lib/storage/channels.js index b9d8aa3df..c5ad20bc4 100644 --- a/packages/endpoint-microsub/lib/storage/channels.js +++ b/packages/endpoint-microsub/lib/storage/channels.js @@ -3,8 +3,12 @@ * @module storage/channels */ +import makeDebug from "debug"; + import { generateChannelUid } from "../utils/uid.js"; +const debug = makeDebug("indiekit:endpoint-microsub"); + /** * Get channels collection from application * @param {object} application - Indiekit application @@ -188,9 +192,7 @@ export async function deleteChannel(application, uid, userId) { const itemsDeleted = await itemsCollection.deleteMany({ channelId: channel._id, }); - console.info( - `[Microsub] Deleted channel ${uid}: ${itemsDeleted.deletedCount} items`, - ); + debug(`Deleted channel ${uid}: ${itemsDeleted.deletedCount} items`); const result = await collection.deleteOne({ _id: channel._id }); return result.deletedCount > 0; diff --git a/packages/endpoint-microsub/lib/utils/uid.js b/packages/endpoint-microsub/lib/utils/uid.js index 1b4eecd47..2aa1fa024 100644 --- a/packages/endpoint-microsub/lib/utils/uid.js +++ b/packages/endpoint-microsub/lib/utils/uid.js @@ -3,15 +3,12 @@ * @module utils/uid */ +import { randomString } from "@indiekit/util"; + /** * Generate a random channel UID * @returns {string} 24-character random string */ export function generateChannelUid() { - const chars = "abcdefghijklmnopqrstuvwxyz0123456789"; - let result = ""; - for (let index = 0; index < 24; index++) { - result += chars.charAt(Math.floor(Math.random() * chars.length)); - } - return result; + return randomString(24); } diff --git a/packages/endpoint-microsub/package.json b/packages/endpoint-microsub/package.json index 84dc9f3cf..c4f72adca 100644 --- a/packages/endpoint-microsub/package.json +++ b/packages/endpoint-microsub/package.json @@ -44,6 +44,7 @@ "dependencies": { "@indiekit/error": "^1.0.0-beta.25", "@indiekit/util": "^1.0.0-beta.28", + "debug": "^4.4.3", "express": "^5.0.0" }, "publishConfig": { diff --git a/packages/endpoint-microsub/test/integration/200-get-channels.js b/packages/endpoint-microsub/test/integration/200-get-channels.js index 5042eae5a..32c8ea564 100644 --- a/packages/endpoint-microsub/test/integration/200-get-channels.js +++ b/packages/endpoint-microsub/test/integration/200-get-channels.js @@ -51,7 +51,7 @@ describe("endpoint-microsub GET /microsub?action=channels", () => { .set("cookie", cookie); for (const channel of response.body.channels) { - assert.match(channel.uid, /^[a-z0-9]{24}$/); + assert.match(channel.uid, /^[\w-]{24}$/); } }); diff --git a/packages/endpoint-microsub/test/integration/201-post-channel-create.js b/packages/endpoint-microsub/test/integration/201-post-channel-create.js index ccb709889..d1ddbe9d9 100644 --- a/packages/endpoint-microsub/test/integration/201-post-channel-create.js +++ b/packages/endpoint-microsub/test/integration/201-post-channel-create.js @@ -24,7 +24,7 @@ describe("endpoint-microsub POST /microsub?action=channels", () => { assert.equal(response.status, 201); assert.equal(response.body.name, "Tech News"); - assert.match(response.body.uid, /^[a-z0-9]{24}$/); + assert.match(response.body.uid, /^[\w-]{24}$/); }); it("Returns the created channel in the channel list", async () => { diff --git a/packages/endpoint-microsub/test/unit/storage/channels.js b/packages/endpoint-microsub/test/unit/storage/channels.js index 936b77987..4b01c5326 100644 --- a/packages/endpoint-microsub/test/unit/storage/channels.js +++ b/packages/endpoint-microsub/test/unit/storage/channels.js @@ -43,7 +43,7 @@ describe("endpoint-microsub/lib/storage/channels", () => { userId: "user-1", }); - assert.match(channel.uid, /^[a-z0-9]{24}$/); + assert.match(channel.uid, /^[\w-]{24}$/); assert.equal(channel.name, "Tech News"); assert.equal(channel.userId, "user-1"); assert.ok(channel.createdAt instanceof Date); diff --git a/packages/endpoint-microsub/test/unit/utils/uid.js b/packages/endpoint-microsub/test/unit/utils/uid.js index 928eee4de..3b81888d4 100644 --- a/packages/endpoint-microsub/test/unit/utils/uid.js +++ b/packages/endpoint-microsub/test/unit/utils/uid.js @@ -12,9 +12,11 @@ describe("endpoint-microsub/lib/utils/uid", () => { assert.equal(uid.length, 24); }); - it("Uses only lowercase letters and digits", () => { + // A channel uid appears in Microsub request URLs, so it has to be + // URL-safe. `randomString` returns base64url, which is. + it("Uses only URL-safe characters", () => { for (let index = 0; index < 100; index++) { - assert.match(generateChannelUid(), /^[a-z0-9]{24}$/); + assert.match(generateChannelUid(), /^[\w-]{24}$/); } }); From 3a2af4fa125ecd5bafd8647e354e45cd774282b6 Mon Sep 17 00:00:00 2001 From: Ricardo Date: Fri, 9 Oct 2026 08:11:09 +0200 Subject: [PATCH 10/17] test(endpoint-microsub): drop the constant parameter eslint rejects --- .../endpoint-microsub/test/unit/utils/pagination.js | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/packages/endpoint-microsub/test/unit/utils/pagination.js b/packages/endpoint-microsub/test/unit/utils/pagination.js index 1f5c0cce5..8922800d7 100644 --- a/packages/endpoint-microsub/test/unit/utils/pagination.js +++ b/packages/endpoint-microsub/test/unit/utils/pagination.js @@ -15,12 +15,11 @@ import { } from "../../../lib/utils/pagination.js"; /** - * Create mock items for testing - * @param {number} count - Number of items + * Create five mock items for testing, newest first * @returns {Array} Mock items */ -function createMockItems(count) { - return Array.from({ length: count }, (_, index) => ({ +function createMockItems() { + return Array.from({ length: 5 }, (_, index) => ({ _id: getObjectId(), published: new Date(Date.now() - index * 1000), })); @@ -164,7 +163,7 @@ describe("endpoint-microsub/lib/utils/pagination", () => { }); it("Returns after cursor when hasMore is true", () => { - const items = createMockItems(5); + const items = createMockItems(); const cursors = generatePagingCursors(items, 5, true); assert.ok(cursors.after); @@ -172,7 +171,7 @@ describe("endpoint-microsub/lib/utils/pagination", () => { }); it("Returns only before cursor when hasMore is false", () => { - const items = createMockItems(5); + const items = createMockItems(); const cursors = generatePagingCursors(items, 10, false); assert.ok(cursors.before); @@ -180,7 +179,7 @@ describe("endpoint-microsub/lib/utils/pagination", () => { }); it("Reverses items and sets cursors when using before", () => { - const items = createMockItems(5); + const items = createMockItems(); const originalFirstId = items[0]._id.toString(); const cursors = generatePagingCursors(items, 5, true, "some-before"); From 4bd122eb69111198ff3fb961edd941374d5ff4e0 Mon Sep 17 00:00:00 2001 From: Ricardo Date: Fri, 9 Oct 2026 21:15:47 +0200 Subject: [PATCH 11/17] chore(endpoint-microsub): satisfy strictNullChecks and the new lint rules --- packages/endpoint-microsub/index.js | 5 ++++- packages/endpoint-microsub/lib/storage/channels.js | 2 +- packages/endpoint-microsub/lib/utils/pagination.js | 8 ++++---- packages/endpoint-microsub/lib/utils/validation.js | 6 +++--- packages/endpoint-microsub/test/unit/storage/items.js | 4 ++-- packages/endpoint-microsub/test/unit/utils/pagination.js | 2 +- 6 files changed, 15 insertions(+), 12 deletions(-) diff --git a/packages/endpoint-microsub/index.js b/packages/endpoint-microsub/index.js index 255bb1821..92bb47d2c 100644 --- a/packages/endpoint-microsub/index.js +++ b/packages/endpoint-microsub/index.js @@ -58,7 +58,10 @@ export default class MicrosubEndpoint { try { await createIndexes(indiekit); } catch (error) { - console.warn("[Microsub] Index creation failed:", error.message); + console.warn( + "[Microsub] Index creation failed:", + error instanceof Error ? error.message : String(error), + ); } } } diff --git a/packages/endpoint-microsub/lib/storage/channels.js b/packages/endpoint-microsub/lib/storage/channels.js index c5ad20bc4..044135df1 100644 --- a/packages/endpoint-microsub/lib/storage/channels.js +++ b/packages/endpoint-microsub/lib/storage/channels.js @@ -102,7 +102,7 @@ export async function getChannels(application, userId) { return { uid: channel.uid, name: channel.name, - unread: unreadCount > 0 ? unreadCount : false, + unread: unreadCount > 0 && unreadCount, }; }), ); diff --git a/packages/endpoint-microsub/lib/utils/pagination.js b/packages/endpoint-microsub/lib/utils/pagination.js index 1efe4ef50..e1a581e85 100644 --- a/packages/endpoint-microsub/lib/utils/pagination.js +++ b/packages/endpoint-microsub/lib/utils/pagination.js @@ -17,7 +17,7 @@ export const MAX_LIMIT = 100; /** * Encode a cursor from timestamp and ID - * @param {Date} timestamp - Item timestamp + * @param {Date|string} timestamp - Item timestamp * @param {string} id - Item ID * @returns {string} Base64-encoded cursor */ @@ -31,7 +31,7 @@ export function encodeCursor(timestamp, id) { /** * Decode a cursor string - * @param {string} cursor - Base64-encoded cursor + * @param {string|null} [cursor] - Base64-encoded cursor * @returns {object|undefined} Decoded cursor with timestamp and id */ export function decodeCursor(cursor) { @@ -103,7 +103,7 @@ export function buildPaginationSort(before) { /** * Generate pagination cursors from items - * @param {Array} items - Array of items + * @param {Array|null} items - Array of items * @param {number} limit - Items per page * @param {boolean} hasMore - Whether more items exist * @param {string} [before] - Original before cursor @@ -136,7 +136,7 @@ export function generatePagingCursors(items, limit, hasMore, before) { /** * Parse and validate limit parameter - * @param {string|number} limit - Requested limit + * @param {string|number} [limit] - Requested limit * @returns {number} Validated limit */ export function parseLimit(limit) { diff --git a/packages/endpoint-microsub/lib/utils/validation.js b/packages/endpoint-microsub/lib/utils/validation.js index 1a77b6fae..68afda221 100644 --- a/packages/endpoint-microsub/lib/utils/validation.js +++ b/packages/endpoint-microsub/lib/utils/validation.js @@ -12,7 +12,7 @@ export const VALID_ACTIONS = ["channels", "timeline"]; /** * Validate action parameter - * @param {string} action - Action to validate + * @param {string|null} [action] - Action to validate * @throws {IndiekitError} If action is invalid */ export function validateAction(action) { @@ -31,7 +31,7 @@ export function validateAction(action) { /** * Validate channel UID - * @param {string} channel - Channel UID to validate + * @param {string} [channel] - Channel UID to validate * @param {boolean} [isRequired] - Whether channel is required * @throws {IndiekitError} If channel is invalid */ @@ -51,7 +51,7 @@ export function validateChannel(channel, isRequired = true) { /** * Validate entry/entries parameter - * @param {string|Array} entry - Entry ID(s) to validate + * @param {string|Array} [entry] - Entry ID(s) to validate * @returns {Array} Array of entry IDs * @throws {IndiekitError} If entry is invalid */ diff --git a/packages/endpoint-microsub/test/unit/storage/items.js b/packages/endpoint-microsub/test/unit/storage/items.js index 76871f7d1..8092b4eee 100644 --- a/packages/endpoint-microsub/test/unit/storage/items.js +++ b/packages/endpoint-microsub/test/unit/storage/items.js @@ -18,8 +18,8 @@ const application = { collections: new Map([["microsub_items", items]]), }; -const channelId = getObjectId(); -const otherChannelId = getObjectId(); +const channelId = getObjectId("000000000000000000000001"); +const otherChannelId = getObjectId("000000000000000000000002"); /** * Insert timeline items, oldest first diff --git a/packages/endpoint-microsub/test/unit/utils/pagination.js b/packages/endpoint-microsub/test/unit/utils/pagination.js index 8922800d7..847a89279 100644 --- a/packages/endpoint-microsub/test/unit/utils/pagination.js +++ b/packages/endpoint-microsub/test/unit/utils/pagination.js @@ -20,7 +20,7 @@ import { */ function createMockItems() { return Array.from({ length: 5 }, (_, index) => ({ - _id: getObjectId(), + _id: getObjectId(String(index + 1).padStart(24, "0")), published: new Date(Date.now() - index * 1000), })); } From 7992fbe7c71406af682ae5ce6eba9b5dd3fa2695 Mon Sep 17 00:00:00 2001 From: Ricardo Date: Sat, 10 Oct 2026 08:46:35 +0200 Subject: [PATCH 12/17] refactor(endpoint-microsub): use mongodb's ObjectId now that util no longer exports getObjectId --- package-lock.json | 3 ++- packages/endpoint-microsub/lib/storage/items.js | 16 ++++++++-------- .../endpoint-microsub/lib/utils/pagination.js | 6 +++--- packages/endpoint-microsub/package.json | 3 ++- .../endpoint-microsub/test/unit/storage/items.js | 6 +++--- .../test/unit/utils/pagination.js | 4 ++-- 6 files changed, 20 insertions(+), 18 deletions(-) diff --git a/package-lock.json b/package-lock.json index fa8bc6648..eedbda042 100644 --- a/package-lock.json +++ b/package-lock.json @@ -26471,7 +26471,8 @@ "@indiekit/error": "^1.0.0-beta.25", "@indiekit/util": "^1.0.0-beta.28", "debug": "^4.4.3", - "express": "^5.0.0" + "express": "^5.0.0", + "mongodb": "^7.6.0" }, "engines": { "node": ">=20" diff --git a/packages/endpoint-microsub/lib/storage/items.js b/packages/endpoint-microsub/lib/storage/items.js index 3e7d441a9..7a84e9204 100644 --- a/packages/endpoint-microsub/lib/storage/items.js +++ b/packages/endpoint-microsub/lib/storage/items.js @@ -3,7 +3,7 @@ * @module storage/items */ -import { getObjectId } from "@indiekit/util"; +import { ObjectId } from "mongodb"; import { buildPaginationQuery, @@ -35,7 +35,7 @@ function getCollection(application) { export async function getTimelineItems(application, channelId, options = {}) { const collection = getCollection(application); const objectId = - typeof channelId === "string" ? getObjectId(channelId) : channelId; + typeof channelId === "string" ? new ObjectId(channelId) : channelId; const limit = parseLimit(options.limit); const baseQuery = { channelId: objectId }; @@ -124,7 +124,7 @@ function transformToJf2(item, userId) { export async function markItemsRead(application, channelId, entryIds, userId) { const collection = getCollection(application); const channelObjectId = - typeof channelId === "string" ? getObjectId(channelId) : channelId; + typeof channelId === "string" ? new ObjectId(channelId) : channelId; // Handle "last-read-entry" special value if (entryIds.includes("last-read-entry")) { @@ -139,7 +139,7 @@ export async function markItemsRead(application, channelId, entryIds, userId) { const objectIds = entryIds .map((id) => { try { - return getObjectId(id); + return new ObjectId(id); } catch { return; } @@ -178,13 +178,13 @@ export async function markItemsUnread( ) { const collection = getCollection(application); const channelObjectId = - typeof channelId === "string" ? getObjectId(channelId) : channelId; + typeof channelId === "string" ? new ObjectId(channelId) : channelId; // Convert string IDs to ObjectIds where possible const objectIds = entryIds .map((id) => { try { - return getObjectId(id); + return new ObjectId(id); } catch { return; } @@ -217,13 +217,13 @@ export async function markItemsUnread( export async function removeItems(application, channelId, entryIds) { const collection = getCollection(application); const channelObjectId = - typeof channelId === "string" ? getObjectId(channelId) : channelId; + typeof channelId === "string" ? new ObjectId(channelId) : channelId; // Convert string IDs to ObjectIds where possible const objectIds = entryIds .map((id) => { try { - return getObjectId(id); + return new ObjectId(id); } catch { return; } diff --git a/packages/endpoint-microsub/lib/utils/pagination.js b/packages/endpoint-microsub/lib/utils/pagination.js index e1a581e85..32842fbc1 100644 --- a/packages/endpoint-microsub/lib/utils/pagination.js +++ b/packages/endpoint-microsub/lib/utils/pagination.js @@ -3,7 +3,7 @@ * @module utils/pagination */ -import { getObjectId } from "@indiekit/util"; +import { ObjectId } from "mongodb"; /** * Default pagination limit @@ -68,7 +68,7 @@ export function buildPaginationQuery({ before, after, baseQuery = {} }) { { published: { $gt: cursor.timestamp } }, { published: cursor.timestamp, - _id: { $gt: getObjectId(cursor.id) }, + _id: { $gt: new ObjectId(cursor.id) }, }, ]; } @@ -80,7 +80,7 @@ export function buildPaginationQuery({ before, after, baseQuery = {} }) { { published: { $lt: cursor.timestamp } }, { published: cursor.timestamp, - _id: { $lt: getObjectId(cursor.id) }, + _id: { $lt: new ObjectId(cursor.id) }, }, ]; } diff --git a/packages/endpoint-microsub/package.json b/packages/endpoint-microsub/package.json index c4f72adca..0880013fc 100644 --- a/packages/endpoint-microsub/package.json +++ b/packages/endpoint-microsub/package.json @@ -45,7 +45,8 @@ "@indiekit/error": "^1.0.0-beta.25", "@indiekit/util": "^1.0.0-beta.28", "debug": "^4.4.3", - "express": "^5.0.0" + "express": "^5.0.0", + "mongodb": "^7.6.0" }, "publishConfig": { "access": "public" diff --git a/packages/endpoint-microsub/test/unit/storage/items.js b/packages/endpoint-microsub/test/unit/storage/items.js index 8092b4eee..5bde73653 100644 --- a/packages/endpoint-microsub/test/unit/storage/items.js +++ b/packages/endpoint-microsub/test/unit/storage/items.js @@ -1,8 +1,8 @@ import { strict as assert } from "node:assert"; import { after, beforeEach, describe, it } from "node:test"; -import { getObjectId } from "@indiekit/util"; import { testDatabase } from "@indiekit-test/database"; +import { ObjectId } from "mongodb"; import { createIndexes, @@ -18,8 +18,8 @@ const application = { collections: new Map([["microsub_items", items]]), }; -const channelId = getObjectId("000000000000000000000001"); -const otherChannelId = getObjectId("000000000000000000000002"); +const channelId = new ObjectId("000000000000000000000001"); +const otherChannelId = new ObjectId("000000000000000000000002"); /** * Insert timeline items, oldest first diff --git a/packages/endpoint-microsub/test/unit/utils/pagination.js b/packages/endpoint-microsub/test/unit/utils/pagination.js index 847a89279..da88af15e 100644 --- a/packages/endpoint-microsub/test/unit/utils/pagination.js +++ b/packages/endpoint-microsub/test/unit/utils/pagination.js @@ -1,7 +1,7 @@ import { strict as assert } from "node:assert"; import { describe, it } from "node:test"; -import { getObjectId } from "@indiekit/util"; +import { ObjectId } from "mongodb"; import { buildPaginationQuery, @@ -20,7 +20,7 @@ import { */ function createMockItems() { return Array.from({ length: 5 }, (_, index) => ({ - _id: getObjectId(String(index + 1).padStart(24, "0")), + _id: new ObjectId(String(index + 1).padStart(24, "0")), published: new Date(Date.now() - index * 1000), })); } From a022176bf0b36ced7ce1b1bf22a032bc3aeafb55 Mon Sep 17 00:00:00 2001 From: Ricardo Date: Sat, 10 Oct 2026 09:11:28 +0200 Subject: [PATCH 13/17] feat(endpoint-microsub): page the timeline with the shared cursor Timeline items get an `id`, a UUIDv7 stamped with their publication date, listed and paged through util's `getCursor` like posts and media are since #975. The cursor gains two options for it: a filter every page is confined to (the channel) and the key path to order on. Clients see that id as the Microsub `_id` and can mark or remove items by it; the Mongo `_id` no longer leaves the storage layer. The endpoint's own cursor encoding and query builders go. --- .../endpoint-microsub/lib/storage/items.js | 111 ++++------- .../endpoint-microsub/lib/utils/pagination.js | 123 +----------- packages/endpoint-microsub/lib/utils/uid.js | 21 ++ .../test/integration/200-get-timeline.js | 25 ++- .../test/integration/200-post-timeline.js | 23 ++- .../test/unit/storage/items.js | 58 ++++-- .../test/unit/utils/pagination.js | 183 ------------------ .../endpoint-microsub/test/unit/utils/uid.js | 31 ++- packages/util/lib/mongodb.js | 46 +++-- packages/util/test/unit/mongodb.js | 38 ++++ 10 files changed, 233 insertions(+), 426 deletions(-) diff --git a/packages/endpoint-microsub/lib/storage/items.js b/packages/endpoint-microsub/lib/storage/items.js index 7a84e9204..45844b61e 100644 --- a/packages/endpoint-microsub/lib/storage/items.js +++ b/packages/endpoint-microsub/lib/storage/items.js @@ -3,14 +3,10 @@ * @module storage/items */ +import { getCursor } from "@indiekit/util"; import { ObjectId } from "mongodb"; -import { - buildPaginationQuery, - buildPaginationSort, - generatePagingCursors, - parseLimit, -} from "../utils/pagination.js"; +import { parseLimit } from "../utils/pagination.js"; /** * Get items collection from application @@ -38,40 +34,31 @@ export async function getTimelineItems(application, channelId, options = {}) { typeof channelId === "string" ? new ObjectId(channelId) : channelId; const limit = parseLimit(options.limit); - const baseQuery = { channelId: objectId }; - - const query = buildPaginationQuery({ - before: options.before, - after: options.after, - baseQuery, - }); - - const sort = buildPaginationSort(options.before); + // Items are listed and paged by their `id`, a UUIDv7 stamped with the + // publication date, through the same cursor posts and media use. + const cursor = await getCursor( + collection, + options.after, + options.before, + limit, + { filter: { channelId: objectId }, key: "id" }, + ); - // Fetch one extra to check if there are more - const items = await collection - // eslint-disable-next-line unicorn/no-array-callback-reference -- MongoDB query object - .find(query) - // eslint-disable-next-line unicorn/no-array-sort -- MongoDB cursor method - .sort(sort) - .limit(limit + 1) - .toArray(); + const items = cursor.items.map((item) => + transformToJf2(item, options.userId), + ); - const hasMore = items.length > limit; - if (hasMore) { - items.pop(); + // Microsub paging: `after` continues down to older items, `before` back + // up to newer ones + const paging = {}; + if (cursor.hasNext) { + paging.after = cursor.lastItem; + } + if (cursor.hasPrev) { + paging.before = cursor.firstItem; } - // Transform to jf2 format - const jf2Items = items.map((item) => transformToJf2(item, options.userId)); - - // Generate paging cursors - const paging = generatePagingCursors(items, limit, hasMore, options.before); - - return { - items: jf2Items, - paging, - }; + return { items, paging }; } /** @@ -86,7 +73,7 @@ function transformToJf2(item, userId) { uid: item.uid, url: item.url, published: item.published?.toISOString(), - _id: item._id.toString(), + _id: item.id, _is_read: userId ? item.readBy?.includes(userId) : false, }; @@ -135,23 +122,12 @@ export async function markItemsRead(application, channelId, entryIds, userId) { return result.modifiedCount; } - // Convert string IDs to ObjectIds where possible - const objectIds = entryIds - .map((id) => { - try { - return new ObjectId(id); - } catch { - return; - } - }) - .filter(Boolean); - - // Match by _id, uid, or url + // Match by the id clients see, the feed's own uid, or url const result = await collection.updateMany( { channelId: channelObjectId, $or: [ - ...(objectIds.length > 0 ? [{ _id: { $in: objectIds } }] : []), + { id: { $in: entryIds } }, { uid: { $in: entryIds } }, { url: { $in: entryIds } }, ], @@ -180,23 +156,12 @@ export async function markItemsUnread( const channelObjectId = typeof channelId === "string" ? new ObjectId(channelId) : channelId; - // Convert string IDs to ObjectIds where possible - const objectIds = entryIds - .map((id) => { - try { - return new ObjectId(id); - } catch { - return; - } - }) - .filter(Boolean); - - // Match by _id, uid, or url + // Match by the id clients see, the feed's own uid, or url const result = await collection.updateMany( { channelId: channelObjectId, $or: [ - ...(objectIds.length > 0 ? [{ _id: { $in: objectIds } }] : []), + { id: { $in: entryIds } }, { uid: { $in: entryIds } }, { url: { $in: entryIds } }, ], @@ -219,22 +184,11 @@ export async function removeItems(application, channelId, entryIds) { const channelObjectId = typeof channelId === "string" ? new ObjectId(channelId) : channelId; - // Convert string IDs to ObjectIds where possible - const objectIds = entryIds - .map((id) => { - try { - return new ObjectId(id); - } catch { - return; - } - }) - .filter(Boolean); - - // Match by _id, uid, or url + // Match by the id clients see, the feed's own uid, or url const result = await collection.deleteMany({ channelId: channelObjectId, $or: [ - ...(objectIds.length > 0 ? [{ _id: { $in: objectIds } }] : []), + { id: { $in: entryIds } }, { uid: { $in: entryIds } }, { url: { $in: entryIds } }, ], @@ -251,8 +205,9 @@ export async function removeItems(application, channelId, entryIds) { export async function createIndexes(application) { const collection = getCollection(application); - // Primary query indexes - await collection.createIndex({ channelId: 1, published: -1 }); + // Primary query indexes: `id` orders and pages the timeline, `uid` is the + // feed's own identifier and keeps an item from being stored twice + await collection.createIndex({ channelId: 1, id: 1 }, { unique: true }); await collection.createIndex({ channelId: 1, uid: 1 }, { unique: true }); // URL matching index for mark_read operations diff --git a/packages/endpoint-microsub/lib/utils/pagination.js b/packages/endpoint-microsub/lib/utils/pagination.js index 32842fbc1..ca73d876d 100644 --- a/packages/endpoint-microsub/lib/utils/pagination.js +++ b/packages/endpoint-microsub/lib/utils/pagination.js @@ -1,10 +1,8 @@ /** - * Cursor-based pagination utilities for Microsub + * Timeline limit handling * @module utils/pagination */ -import { ObjectId } from "mongodb"; - /** * Default pagination limit */ @@ -15,125 +13,6 @@ export const DEFAULT_LIMIT = 20; */ export const MAX_LIMIT = 100; -/** - * Encode a cursor from timestamp and ID - * @param {Date|string} timestamp - Item timestamp - * @param {string} id - Item ID - * @returns {string} Base64-encoded cursor - */ -export function encodeCursor(timestamp, id) { - const data = { - t: timestamp instanceof Date ? timestamp.toISOString() : timestamp, - i: id.toString(), - }; - return Buffer.from(JSON.stringify(data)).toString("base64url"); -} - -/** - * Decode a cursor string - * @param {string|null} [cursor] - Base64-encoded cursor - * @returns {object|undefined} Decoded cursor with timestamp and id - */ -export function decodeCursor(cursor) { - if (!cursor) return; - - try { - const decoded = Buffer.from(cursor, "base64url").toString("utf8"); - const data = JSON.parse(decoded); - return { - timestamp: new Date(data.t), - id: data.i, - }; - } catch { - return; - } -} - -/** - * Build MongoDB query for cursor-based pagination - * @param {object} options - Pagination options - * @param {string} [options.before] - Before cursor - * @param {string} [options.after] - After cursor - * @param {object} [options.baseQuery] - Base query to extend - * @returns {object} MongoDB query object - */ -export function buildPaginationQuery({ before, after, baseQuery = {} }) { - const query = { ...baseQuery }; - - if (before) { - const cursor = decodeCursor(before); - if (cursor) { - // Items newer than cursor (for scrolling up) - query.$or = [ - { published: { $gt: cursor.timestamp } }, - { - published: cursor.timestamp, - _id: { $gt: new ObjectId(cursor.id) }, - }, - ]; - } - } else if (after) { - const cursor = decodeCursor(after); - if (cursor) { - // Items older than cursor (for scrolling down) - query.$or = [ - { published: { $lt: cursor.timestamp } }, - { - published: cursor.timestamp, - _id: { $lt: new ObjectId(cursor.id) }, - }, - ]; - } - } - - return query; -} - -/** - * Build sort options for cursor pagination - * @param {string} [before] - Before cursor (ascending order) - * @returns {object} MongoDB sort object - */ -export function buildPaginationSort(before) { - if (before) { - return { published: 1, _id: 1 }; - } - return { published: -1, _id: -1 }; -} - -/** - * Generate pagination cursors from items - * @param {Array|null} items - Array of items - * @param {number} limit - Items per page - * @param {boolean} hasMore - Whether more items exist - * @param {string} [before] - Original before cursor - * @returns {object} Pagination object with before/after cursors - */ -export function generatePagingCursors(items, limit, hasMore, before) { - if (!items || items.length === 0) { - return {}; - } - - const paging = {}; - - if (before) { - items.reverse(); - paging.after = encodeCursor(items.at(-1).published, items.at(-1)._id); - if (hasMore) { - paging.before = encodeCursor(items[0].published, items[0]._id); - } - } else { - if (hasMore) { - paging.after = encodeCursor(items.at(-1).published, items.at(-1)._id); - } - if (items.length > 0) { - paging.before = encodeCursor(items[0].published, items[0]._id); - } - } - - return paging; -} - /** * Parse and validate limit parameter * @param {string|number} [limit] - Requested limit diff --git a/packages/endpoint-microsub/lib/utils/uid.js b/packages/endpoint-microsub/lib/utils/uid.js index 2aa1fa024..74fff35c3 100644 --- a/packages/endpoint-microsub/lib/utils/uid.js +++ b/packages/endpoint-microsub/lib/utils/uid.js @@ -3,6 +3,8 @@ * @module utils/uid */ +import { randomBytes } from "node:crypto"; + import { randomString } from "@indiekit/util"; /** @@ -12,3 +14,22 @@ import { randomString } from "@indiekit/util"; export function generateChannelUid() { return randomString(24); } + +/** + * Generate a UUIDv7 whose timestamp is the given date + * + * Timeline items are listed and paged by this identifier, the same way posts + * are paged by their uid. Node's `randomUUIDv7()` stamps the current time; + * an item is ordered by when it was published, so the stamp is set here. + * @param {Date} date - Time to encode in the first 48 bits + * @returns {string} UUIDv7 + */ +export function uuidv7At(date) { + const bytes = randomBytes(16); + bytes.writeUIntBE(date.getTime(), 0, 6); + bytes[6] = (bytes[6] & 0x0f) | 0x70; // version 7 + bytes[8] = (bytes[8] & 0x3f) | 0x80; // variant 10 + const hex = bytes.toString("hex"); + + return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`; +} diff --git a/packages/endpoint-microsub/test/integration/200-get-timeline.js b/packages/endpoint-microsub/test/integration/200-get-timeline.js index d8503f769..903ec9489 100644 --- a/packages/endpoint-microsub/test/integration/200-get-timeline.js +++ b/packages/endpoint-microsub/test/integration/200-get-timeline.js @@ -6,6 +6,8 @@ import { testServer } from "@indiekit-test/server"; import { testCookie } from "@indiekit-test/session"; import supertest from "supertest"; +import { uuidv7At } from "../../lib/utils/uid.js"; + const { client, mongoServer, mongoUri } = await testDatabase(); const server = await testServer({ application: { mongodbUrl: mongoUri }, @@ -34,15 +36,20 @@ describe("endpoint-microsub GET /microsub?action=timeline", () => { .findOne({ uid: fixture.channelUid }); await database.collection("microsub_items").insertMany( - Array.from({ length: 3 }, (_, index) => ({ - channelId: channel._id, - type: "entry", - uid: `item-${index}`, - url: `https://website.example/${index}`, - name: `Item ${index}`, - published: new Date(Date.UTC(2026, 0, index + 1)), - readBy: [], - })), + Array.from({ length: 3 }, (_, index) => { + const published = new Date(Date.UTC(2026, 0, index + 1)); + + return { + channelId: channel._id, + id: uuidv7At(published), + type: "entry", + uid: `item-${index}`, + url: `https://website.example/${index}`, + name: `Item ${index}`, + published, + readBy: [], + }; + }), ); }); diff --git a/packages/endpoint-microsub/test/integration/200-post-timeline.js b/packages/endpoint-microsub/test/integration/200-post-timeline.js index 69dbafa56..95b627e47 100644 --- a/packages/endpoint-microsub/test/integration/200-post-timeline.js +++ b/packages/endpoint-microsub/test/integration/200-post-timeline.js @@ -6,6 +6,8 @@ import { testServer } from "@indiekit-test/server"; import { testCookie } from "@indiekit-test/session"; import supertest from "supertest"; +import { uuidv7At } from "../../lib/utils/uid.js"; + const { client, mongoServer, mongoUri } = await testDatabase(); const server = await testServer({ application: { mongodbUrl: mongoUri }, @@ -37,14 +39,19 @@ describe("endpoint-microsub POST /microsub?action=timeline", () => { .findOne({ uid: fixture.channelUid }); await items.insertMany( - Array.from({ length: 3 }, (_, index) => ({ - channelId: channel._id, - type: "entry", - uid: `item-${index}`, - url: `https://website.example/${index}`, - published: new Date(Date.UTC(2026, 0, index + 1)), - readBy: [], - })), + Array.from({ length: 3 }, (_, index) => { + const published = new Date(Date.UTC(2026, 0, index + 1)); + + return { + channelId: channel._id, + id: uuidv7At(published), + type: "entry", + uid: `item-${index}`, + url: `https://website.example/${index}`, + published, + readBy: [], + }; + }), ); }); diff --git a/packages/endpoint-microsub/test/unit/storage/items.js b/packages/endpoint-microsub/test/unit/storage/items.js index 5bde73653..19a02df7a 100644 --- a/packages/endpoint-microsub/test/unit/storage/items.js +++ b/packages/endpoint-microsub/test/unit/storage/items.js @@ -11,6 +11,7 @@ import { markItemsUnread, removeItems, } from "../../../lib/storage/items.js"; +import { uuidv7At } from "../../../lib/utils/uid.js"; const { client, database, mongoServer } = await testDatabase(); const items = database.collection("microsub_items"); @@ -28,16 +29,21 @@ const otherChannelId = new ObjectId("000000000000000000000002"); * @returns {Promise} Inserted item documents */ async function insertItems(count, overrides = {}) { - const documents = Array.from({ length: count }, (_, index) => ({ - channelId, - type: "entry", - uid: `item-${index}`, - url: `https://website.example/${index}`, - name: `Item ${index}`, - published: new Date(Date.UTC(2026, 0, index + 1)), - readBy: [], - ...overrides, - })); + const documents = Array.from({ length: count }, (_, index) => { + const published = new Date(Date.UTC(2026, 0, index + 1)); + + return { + channelId, + id: uuidv7At(published), + type: "entry", + uid: `item-${index}`, + url: `https://website.example/${index}`, + name: `Item ${index}`, + published, + readBy: [], + ...overrides, + }; + }); await items.insertMany(documents); @@ -77,6 +83,7 @@ describe("endpoint-microsub/lib/storage/items", () => { await insertItems(2); await items.insertOne({ channelId: otherChannelId, + id: uuidv7At(new Date()), uid: "other", published: new Date(), }); @@ -114,6 +121,28 @@ describe("endpoint-microsub/lib/storage/items", () => { assert.ok(result.paging.after); }); + it("Pages back to newer items using the before cursor", async () => { + await insertItems(4); + + const first = await getTimelineItems(application, channelId, { + limit: 2, + }); + const second = await getTimelineItems(application, channelId, { + limit: 2, + after: first.paging.after, + }); + const back = await getTimelineItems(application, channelId, { + limit: 2, + before: second.paging.before, + }); + + assert.deepEqual( + back.items.map((item) => item.name), + ["Item 3", "Item 2"], + ); + assert.equal("before" in back.paging, false); + }); + it("Pages through items using the after cursor", async () => { await insertItems(4); @@ -141,7 +170,8 @@ describe("endpoint-microsub/lib/storage/items", () => { assert.equal(result[0].author, "Alice"); assert.deepEqual(result[0].category, ["indieweb"]); assert.equal(typeof result[0].published, "string"); - assert.equal(typeof result[0]._id, "string"); + const stored = await items.findOne({ uid: "item-0" }); + assert.equal(result[0]._id, stored.id); }); it("Omits optional fields that are absent", async () => { @@ -219,14 +249,14 @@ describe("endpoint-microsub/lib/storage/items", () => { assert.equal(count, 1); }); - it("Matches items by ObjectId", async () => { + it("Matches items by the id clients see", async () => { await insertItems(1); const item = await items.findOne({ uid: "item-0" }); const count = await markItemsRead( application, channelId, - [item._id.toString()], + [item.id], "user-1", ); @@ -342,7 +372,7 @@ describe("endpoint-microsub/lib/storage/items", () => { const indexes = await items.indexes(); const keys = new Set(indexes.map((index) => JSON.stringify(index.key))); - assert.ok(keys.has(JSON.stringify({ channelId: 1, published: -1 }))); + assert.ok(keys.has(JSON.stringify({ channelId: 1, id: 1 }))); assert.ok(keys.has(JSON.stringify({ channelId: 1, uid: 1 }))); assert.ok(keys.has(JSON.stringify({ channelId: 1, url: 1 }))); }); diff --git a/packages/endpoint-microsub/test/unit/utils/pagination.js b/packages/endpoint-microsub/test/unit/utils/pagination.js index da88af15e..0e9995e48 100644 --- a/packages/endpoint-microsub/test/unit/utils/pagination.js +++ b/packages/endpoint-microsub/test/unit/utils/pagination.js @@ -1,196 +1,13 @@ import { strict as assert } from "node:assert"; import { describe, it } from "node:test"; -import { ObjectId } from "mongodb"; - import { - buildPaginationQuery, - buildPaginationSort, - decodeCursor, DEFAULT_LIMIT, - encodeCursor, - generatePagingCursors, MAX_LIMIT, parseLimit, } from "../../../lib/utils/pagination.js"; -/** - * Create five mock items for testing, newest first - * @returns {Array} Mock items - */ -function createMockItems() { - return Array.from({ length: 5 }, (_, index) => ({ - _id: new ObjectId(String(index + 1).padStart(24, "0")), - published: new Date(Date.now() - index * 1000), - })); -} - describe("endpoint-microsub/lib/utils/pagination", () => { - describe("encodeCursor", () => { - it("Encodes timestamp and ID to base64url", () => { - const date = new Date("2024-01-15T10:30:00Z"); - const id = "507f1f77bcf86cd799439011"; - const cursor = encodeCursor(date, id); - - assert.ok(typeof cursor === "string"); - assert.ok(cursor.length > 0); - // Should be valid base64url (no +, /, or =) - assert.ok(!/[+/=]/.test(cursor)); - }); - - it("Handles string timestamp", () => { - const cursor = encodeCursor("2024-01-15T10:30:00Z", "abc123"); - assert.ok(typeof cursor === "string"); - }); - }); - - describe("decodeCursor", () => { - it("Decodes valid cursor", () => { - const date = new Date("2024-01-15T10:30:00Z"); - const id = "507f1f77bcf86cd799439011"; - const cursor = encodeCursor(date, id); - const decoded = decodeCursor(cursor); - - assert.ok(decoded); - assert.equal(decoded.timestamp.toISOString(), date.toISOString()); - assert.equal(decoded.id, id); - }); - - it("Returns undefined for null cursor", () => { - // eslint-disable-next-line unicorn/no-null -- Testing null input handling - const decoded = decodeCursor(null); - assert.equal(decoded, undefined); - }); - - it("Returns undefined for undefined cursor", () => { - const decoded = decodeCursor(); - assert.equal(decoded, undefined); - }); - - it("Returns undefined for empty string", () => { - const decoded = decodeCursor(""); - assert.equal(decoded, undefined); - }); - - it("Returns undefined for invalid base64", () => { - const decoded = decodeCursor("not-valid-base64!!!"); - assert.equal(decoded, undefined); - }); - - it("Returns undefined for valid base64 but invalid JSON", () => { - const invalidJson = Buffer.from("not json").toString("base64url"); - const decoded = decodeCursor(invalidJson); - assert.equal(decoded, undefined); - }); - }); - - describe("buildPaginationQuery", () => { - it("Returns base query when no cursors", () => { - const baseQuery = { userId: "user1" }; - const query = buildPaginationQuery({ baseQuery }); - assert.deepEqual(query, baseQuery); - }); - - it("Adds $or clause for before cursor", () => { - const date = new Date("2024-01-15T10:30:00Z"); - const id = "507f1f77bcf86cd799439011"; - const cursor = encodeCursor(date, id); - - const query = buildPaginationQuery({ before: cursor }); - - assert.ok(query.$or); - assert.equal(query.$or.length, 2); - // First condition: published > cursor.timestamp - assert.ok(query.$or[0].published.$gt); - // Second condition: same timestamp but greater ID - assert.ok(query.$or[1].published); - assert.ok(query.$or[1]._id.$gt); - }); - - it("Adds $or clause for after cursor", () => { - const date = new Date("2024-01-15T10:30:00Z"); - const id = "507f1f77bcf86cd799439011"; - const cursor = encodeCursor(date, id); - - const query = buildPaginationQuery({ after: cursor }); - - assert.ok(query.$or); - assert.equal(query.$or.length, 2); - // First condition: published < cursor.timestamp - assert.ok(query.$or[0].published.$lt); - }); - - it("Merges with base query", () => { - const date = new Date("2024-01-15T10:30:00Z"); - const id = "507f1f77bcf86cd799439011"; - const cursor = encodeCursor(date, id); - const baseQuery = { channelId: "ch1" }; - - const query = buildPaginationQuery({ after: cursor, baseQuery }); - - assert.equal(query.channelId, "ch1"); - assert.ok(query.$or); - }); - - it("Ignores invalid before cursor", () => { - const query = buildPaginationQuery({ before: "invalid" }); - assert.ok(!query.$or); - }); - }); - - describe("buildPaginationSort", () => { - it("Returns descending sort by default", () => { - const sort = buildPaginationSort(); - assert.deepEqual(sort, { published: -1, _id: -1 }); - }); - - it("Returns ascending sort when before cursor present", () => { - const sort = buildPaginationSort("some-cursor"); - assert.deepEqual(sort, { published: 1, _id: 1 }); - }); - }); - - describe("generatePagingCursors", () => { - it("Returns empty object for empty items", () => { - const cursors = generatePagingCursors([], 20, false); - assert.deepEqual(cursors, {}); - }); - - it("Returns empty object for null items", () => { - // eslint-disable-next-line unicorn/no-null -- Testing null input handling - const cursors = generatePagingCursors(null, 20, false); - assert.deepEqual(cursors, {}); - }); - - it("Returns after cursor when hasMore is true", () => { - const items = createMockItems(); - const cursors = generatePagingCursors(items, 5, true); - - assert.ok(cursors.after); - assert.ok(cursors.before); - }); - - it("Returns only before cursor when hasMore is false", () => { - const items = createMockItems(); - const cursors = generatePagingCursors(items, 10, false); - - assert.ok(cursors.before); - assert.ok(!cursors.after); - }); - - it("Reverses items and sets cursors when using before", () => { - const items = createMockItems(); - const originalFirstId = items[0]._id.toString(); - - const cursors = generatePagingCursors(items, 5, true, "some-before"); - - // Items should be reversed - assert.equal(items.at(-1)._id.toString(), originalFirstId); - // Should have after cursor (older items exist) - assert.ok(cursors.after); - }); - }); - describe("parseLimit", () => { it("Returns parsed number for valid string", () => { assert.equal(parseLimit("25"), 25); diff --git a/packages/endpoint-microsub/test/unit/utils/uid.js b/packages/endpoint-microsub/test/unit/utils/uid.js index 3b81888d4..66c8ec8f6 100644 --- a/packages/endpoint-microsub/test/unit/utils/uid.js +++ b/packages/endpoint-microsub/test/unit/utils/uid.js @@ -1,9 +1,38 @@ import { strict as assert } from "node:assert"; import { describe, it } from "node:test"; -import { generateChannelUid } from "../../../lib/utils/uid.js"; +import { generateChannelUid, uuidv7At } from "../../../lib/utils/uid.js"; describe("endpoint-microsub/lib/utils/uid", () => { + describe("uuidv7At", () => { + it("Encodes the given time in the first 48 bits", () => { + const uid = uuidv7At(new Date("2026-01-02T00:00:00.000Z")); + + assert.match( + uid, + /^[\da-f]{8}-[\da-f]{4}-7[\da-f]{3}-[89ab][\da-f]{3}-[\da-f]{12}$/, + ); + const milliseconds = Number.parseInt( + uid.slice(0, 13).replace("-", ""), + 16, + ); + assert.equal(milliseconds, Date.UTC(2026, 0, 2)); + }); + + it("Sorts by the given time, newest last", () => { + const earlier = uuidv7At(new Date(Date.UTC(2026, 0, 1))); + const later = uuidv7At(new Date(Date.UTC(2026, 0, 2))); + + assert.ok(earlier < later); + }); + + it("Differs between two items published in the same millisecond", () => { + const when = new Date(Date.UTC(2026, 0, 1)); + + assert.notEqual(uuidv7At(when), uuidv7At(when)); + }); + }); + describe("generateChannelUid", () => { it("Returns a 24-character string", () => { const uid = generateChannelUid(); diff --git a/packages/util/lib/mongodb.js b/packages/util/lib/mongodb.js index 4e1d33773..a298bda45 100644 --- a/packages/util/lib/mongodb.js +++ b/packages/util/lib/mongodb.js @@ -27,7 +27,8 @@ const getBoundary = (value) => { /** * Get pagination cursor * - * Items are ordered by `properties.uid`. A UUIDv7 leads with a 48-bit + * Items are ordered by `properties.uid`, unless another key is given, and a + * query can confine every page to a subset. A UUIDv7 leads with a 48-bit * millisecond timestamp, so comparing two of them as strings compares when * they were created — the ordering `_id` gave, now carried by a property of * the item itself rather than by the database. Cursor values are the same @@ -40,15 +41,38 @@ const getBoundary = (value) => { * @param {string|string[]} [after] - Items created after item with this uid * @param {string|string[]} [before] - Items created before item with this uid * @param {number} [limit] - Number of items to return within cursor + * @param {object} [options] - Options + * @param {object} [options.filter] - Query every page is confined to + * @param {string} [options.key] - Path of the time-ordered identifier the + * items are listed and paged by * @returns {Promise} Pagination cursor */ -export const getCursor = async (collection, after, before, limit) => { +export const getCursor = async ( + collection, + after, + before, + limit, + { filter = {}, key = "properties.uid" } = {}, +) => { const cursor = { items: [], hasNext: false, hasPrev: false, }; + /** + * @param {object} item - Database document + * @returns {string} The item's identifier at `key` + */ + const identifier = (item) => { + let value = item; + for (const segment of key.split(".")) { + value = value?.[segment]; + } + + return value; + }; + // `before` wins when both are given const boundaryValue = before || after; const boundary = boundaryValue ? getBoundary(boundaryValue) : undefined; @@ -60,11 +84,9 @@ export const getCursor = async (collection, after, before, limit) => { /** * @type {Record} */ - const query = { "properties.uid": { $type: "string" } }; + const query = { ...filter, [key]: { $type: "string" } }; if (boundary) { - query["properties.uid"] = isPagingBackwards - ? { $gt: boundary } - : { $lt: boundary }; + query[key] = isPagingBackwards ? { $gt: boundary } : { $lt: boundary }; } const options = { @@ -73,7 +95,7 @@ export const getCursor = async (collection, after, before, limit) => { // the smallest uids above it. Taking them in the listing’s own descending // order would instead take the largest — the newest items in the // collection — so that going back from page three landed on page one. - sort: { "properties.uid": isPagingBackwards ? 1 : -1 }, + sort: { [key]: isPagingBackwards ? 1 : -1 }, }; const items = await collection.find(query, options).toArray(); @@ -85,16 +107,18 @@ export const getCursor = async (collection, after, before, limit) => { if (items.length > 0) { cursor.items = items; - cursor.lastItem = items.at(-1).properties.uid; - cursor.firstItem = items[0].properties.uid; + cursor.lastItem = identifier(items.at(-1)); + cursor.firstItem = identifier(items[0]); cursor.hasNext = Boolean( await collection.findOne({ - "properties.uid": { $lt: cursor.lastItem }, + ...filter, + [key]: { $lt: cursor.lastItem }, }), ); cursor.hasPrev = Boolean( await collection.findOne({ - "properties.uid": { $gt: cursor.firstItem }, + ...filter, + [key]: { $gt: cursor.firstItem }, }), ); } diff --git a/packages/util/test/unit/mongodb.js b/packages/util/test/unit/mongodb.js index 3bcc50694..f1c69aafe 100644 --- a/packages/util/test/unit/mongodb.js +++ b/packages/util/test/unit/mongodb.js @@ -187,6 +187,44 @@ describe("util/lib/mongodb", async () => { assert.deepEqual(names(result), ["item-1", "item-0"]); }); + it("Pages within a filter", async () => { + await seed(5); + // item-3 and item-1 match, newest first; item-4 never counts + const filter = { "properties.name": { $in: ["item-1", "item-3"] } }; + const page = await getCursor(items, undefined, undefined, 1, { filter }); + assert.deepEqual(names(page), ["item-3"]); + assert.equal(page.hasNext, true); + assert.equal(page.hasPrev, false); + + const next = await getCursor(items, page.lastItem, undefined, 1, { + filter, + }); + assert.deepEqual(names(next), ["item-1"]); + assert.equal(next.hasNext, false); + assert.equal(next.hasPrev, true); + }); + + it("Orders and pages on another key", async () => { + await items.insertMany( + Array.from({ length: 3 }, (_, index) => ({ + id: uidAt(index), + properties: { name: `item-${index}` }, + })), + ); + + const page = await getCursor(items, undefined, undefined, 2, { + key: "id", + }); + assert.deepEqual(names(page), ["item-2", "item-1"]); + assert.equal(page.lastItem, uidAt(1)); + + const next = await getCursor(items, page.lastItem, undefined, 2, { + key: "id", + }); + assert.deepEqual(names(next), ["item-0"]); + assert.equal(next.hasPrev, true); + }); + it("Omits items that have no uid", async () => { await seed(5); await items.insertOne({ properties: { name: "item-x" } }); From 615154aa026b318b5092ec2427ec0878839fef05 Mon Sep 17 00:00:00 2001 From: Ricardo Date: Sat, 10 Oct 2026 16:38:41 +0200 Subject: [PATCH 14/17] refactor(endpoint-microsub): reference channels by uid, not by Mongo id Items point at their channel by the channel's uid, the string clients already use, so nothing in the package needs an ObjectId any more: no casting, no mongodb dependency, and indexes on strings only, in line with paginating and indexing on properties.uid elsewhere. --- package-lock.json | 3 +- .../lib/controllers/timeline.js | 8 +- .../endpoint-microsub/lib/storage/channels.js | 14 ++- .../endpoint-microsub/lib/storage/items.js | 46 ++++------ packages/endpoint-microsub/package.json | 3 +- .../test/integration/200-get-timeline.js | 6 +- .../test/integration/200-post-timeline.js | 6 +- .../test/unit/storage/channels.js | 12 +-- .../test/unit/storage/items.js | 90 ++++++++----------- 9 files changed, 74 insertions(+), 114 deletions(-) diff --git a/package-lock.json b/package-lock.json index eedbda042..fa8bc6648 100644 --- a/package-lock.json +++ b/package-lock.json @@ -26471,8 +26471,7 @@ "@indiekit/error": "^1.0.0-beta.25", "@indiekit/util": "^1.0.0-beta.28", "debug": "^4.4.3", - "express": "^5.0.0", - "mongodb": "^7.6.0" + "express": "^5.0.0" }, "engines": { "node": ">=20" diff --git a/packages/endpoint-microsub/lib/controllers/timeline.js b/packages/endpoint-microsub/lib/controllers/timeline.js index 8419d9a05..d2a9356bb 100644 --- a/packages/endpoint-microsub/lib/controllers/timeline.js +++ b/packages/endpoint-microsub/lib/controllers/timeline.js @@ -40,7 +40,7 @@ export async function get(request, response) { }); } - const timeline = await getTimelineItems(application, channelDocument._id, { + const timeline = await getTimelineItems(application, channelDocument.uid, { before, after, limit, @@ -80,7 +80,7 @@ export async function action(request, response) { validateEntries(entries); const count = await markItemsRead( application, - channelDocument._id, + channelDocument.uid, entries, userId, ); @@ -91,7 +91,7 @@ export async function action(request, response) { validateEntries(entries); const count = await markItemsUnread( application, - channelDocument._id, + channelDocument.uid, entries, userId, ); @@ -102,7 +102,7 @@ export async function action(request, response) { validateEntries(entries); const count = await removeItems( application, - channelDocument._id, + channelDocument.uid, entries, ); return response.json({ result: "ok", removed: count }); diff --git a/packages/endpoint-microsub/lib/storage/channels.js b/packages/endpoint-microsub/lib/storage/channels.js index 044135df1..e70c49266 100644 --- a/packages/endpoint-microsub/lib/storage/channels.js +++ b/packages/endpoint-microsub/lib/storage/channels.js @@ -95,7 +95,7 @@ export async function getChannels(application, userId) { const channelsWithCounts = await Promise.all( channels.map(async (channel) => { const unreadCount = await itemsCollection.countDocuments({ - channelId: channel._id, + channel: channel.uid, readBy: { $ne: userId }, }); @@ -182,20 +182,16 @@ export async function deleteChannel(application, uid, userId) { return false; } - // Find the channel first to get its ObjectId - const channel = await collection.findOne(query); - if (!channel) { + const result = await collection.deleteOne(query); + if (result.deletedCount === 0) { return false; } // Delete all items in channel - const itemsDeleted = await itemsCollection.deleteMany({ - channelId: channel._id, - }); + const itemsDeleted = await itemsCollection.deleteMany({ channel: uid }); debug(`Deleted channel ${uid}: ${itemsDeleted.deletedCount} items`); - const result = await collection.deleteOne({ _id: channel._id }); - return result.deletedCount > 0; + return true; } /** diff --git a/packages/endpoint-microsub/lib/storage/items.js b/packages/endpoint-microsub/lib/storage/items.js index 45844b61e..74cff7a57 100644 --- a/packages/endpoint-microsub/lib/storage/items.js +++ b/packages/endpoint-microsub/lib/storage/items.js @@ -4,7 +4,6 @@ */ import { getCursor } from "@indiekit/util"; -import { ObjectId } from "mongodb"; import { parseLimit } from "../utils/pagination.js"; @@ -20,7 +19,7 @@ function getCollection(application) { /** * Get timeline items for a channel * @param {object} application - Indiekit application - * @param {object|string} channelId - Channel ObjectId or its string form + * @param {string} channel - Channel uid * @param {object} options - Query options * @param {string} [options.before] - Before cursor * @param {string} [options.after] - After cursor @@ -28,10 +27,8 @@ function getCollection(application) { * @param {string} [options.userId] - User ID for read state * @returns {Promise} Timeline with items and paging */ -export async function getTimelineItems(application, channelId, options = {}) { +export async function getTimelineItems(application, channel, options = {}) { const collection = getCollection(application); - const objectId = - typeof channelId === "string" ? new ObjectId(channelId) : channelId; const limit = parseLimit(options.limit); // Items are listed and paged by their `id`, a UUIDv7 stamped with the @@ -41,7 +38,7 @@ export async function getTimelineItems(application, channelId, options = {}) { options.after, options.before, limit, - { filter: { channelId: objectId }, key: "id" }, + { filter: { channel }, key: "id" }, ); const items = cursor.items.map((item) => @@ -103,20 +100,18 @@ function transformToJf2(item, userId) { /** * Mark items as read * @param {object} application - Indiekit application - * @param {object|string} channelId - Channel ObjectId or its string form + * @param {string} channel - Channel uid * @param {Array} entryIds - Array of entry IDs to mark as read * @param {string} userId - User ID * @returns {Promise} Number of items updated */ -export async function markItemsRead(application, channelId, entryIds, userId) { +export async function markItemsRead(application, channel, entryIds, userId) { const collection = getCollection(application); - const channelObjectId = - typeof channelId === "string" ? new ObjectId(channelId) : channelId; // Handle "last-read-entry" special value if (entryIds.includes("last-read-entry")) { const result = await collection.updateMany( - { channelId: channelObjectId }, + { channel }, { $addToSet: { readBy: userId } }, ); return result.modifiedCount; @@ -125,7 +120,7 @@ export async function markItemsRead(application, channelId, entryIds, userId) { // Match by the id clients see, the feed's own uid, or url const result = await collection.updateMany( { - channelId: channelObjectId, + channel, $or: [ { id: { $in: entryIds } }, { uid: { $in: entryIds } }, @@ -141,25 +136,18 @@ export async function markItemsRead(application, channelId, entryIds, userId) { /** * Mark items as unread * @param {object} application - Indiekit application - * @param {object|string} channelId - Channel ObjectId or its string form + * @param {string} channel - Channel uid * @param {Array} entryIds - Array of entry IDs to mark as unread * @param {string} userId - User ID * @returns {Promise} Number of items updated */ -export async function markItemsUnread( - application, - channelId, - entryIds, - userId, -) { +export async function markItemsUnread(application, channel, entryIds, userId) { const collection = getCollection(application); - const channelObjectId = - typeof channelId === "string" ? new ObjectId(channelId) : channelId; // Match by the id clients see, the feed's own uid, or url const result = await collection.updateMany( { - channelId: channelObjectId, + channel, $or: [ { id: { $in: entryIds } }, { uid: { $in: entryIds } }, @@ -175,18 +163,16 @@ export async function markItemsUnread( /** * Remove items from channel * @param {object} application - Indiekit application - * @param {object|string} channelId - Channel ObjectId or its string form + * @param {string} channel - Channel uid * @param {Array} entryIds - Array of entry IDs to remove * @returns {Promise} Number of items removed */ -export async function removeItems(application, channelId, entryIds) { +export async function removeItems(application, channel, entryIds) { const collection = getCollection(application); - const channelObjectId = - typeof channelId === "string" ? new ObjectId(channelId) : channelId; // Match by the id clients see, the feed's own uid, or url const result = await collection.deleteMany({ - channelId: channelObjectId, + channel, $or: [ { id: { $in: entryIds } }, { uid: { $in: entryIds } }, @@ -207,9 +193,9 @@ export async function createIndexes(application) { // Primary query indexes: `id` orders and pages the timeline, `uid` is the // feed's own identifier and keeps an item from being stored twice - await collection.createIndex({ channelId: 1, id: 1 }, { unique: true }); - await collection.createIndex({ channelId: 1, uid: 1 }, { unique: true }); + await collection.createIndex({ channel: 1, id: 1 }, { unique: true }); + await collection.createIndex({ channel: 1, uid: 1 }, { unique: true }); // URL matching index for mark_read operations - await collection.createIndex({ channelId: 1, url: 1 }); + await collection.createIndex({ channel: 1, url: 1 }); } diff --git a/packages/endpoint-microsub/package.json b/packages/endpoint-microsub/package.json index 0880013fc..c4f72adca 100644 --- a/packages/endpoint-microsub/package.json +++ b/packages/endpoint-microsub/package.json @@ -45,8 +45,7 @@ "@indiekit/error": "^1.0.0-beta.25", "@indiekit/util": "^1.0.0-beta.28", "debug": "^4.4.3", - "express": "^5.0.0", - "mongodb": "^7.6.0" + "express": "^5.0.0" }, "publishConfig": { "access": "public" diff --git a/packages/endpoint-microsub/test/integration/200-get-timeline.js b/packages/endpoint-microsub/test/integration/200-get-timeline.js index 903ec9489..9b336cccd 100644 --- a/packages/endpoint-microsub/test/integration/200-get-timeline.js +++ b/packages/endpoint-microsub/test/integration/200-get-timeline.js @@ -31,16 +31,12 @@ describe("endpoint-microsub GET /microsub?action=timeline", () => { fixture.channelUid = created.body.uid; - const channel = await database - .collection("microsub_channels") - .findOne({ uid: fixture.channelUid }); - await database.collection("microsub_items").insertMany( Array.from({ length: 3 }, (_, index) => { const published = new Date(Date.UTC(2026, 0, index + 1)); return { - channelId: channel._id, + channel: fixture.channelUid, id: uuidv7At(published), type: "entry", uid: `item-${index}`, diff --git a/packages/endpoint-microsub/test/integration/200-post-timeline.js b/packages/endpoint-microsub/test/integration/200-post-timeline.js index 95b627e47..caf7e7352 100644 --- a/packages/endpoint-microsub/test/integration/200-post-timeline.js +++ b/packages/endpoint-microsub/test/integration/200-post-timeline.js @@ -34,16 +34,12 @@ describe("endpoint-microsub POST /microsub?action=timeline", () => { fixture.channelUid = created.body.uid; - const channel = await database - .collection("microsub_channels") - .findOne({ uid: fixture.channelUid }); - await items.insertMany( Array.from({ length: 3 }, (_, index) => { const published = new Date(Date.UTC(2026, 0, index + 1)); return { - channelId: channel._id, + channel: fixture.channelUid, id: uuidv7At(published), type: "entry", uid: `item-${index}`, diff --git a/packages/endpoint-microsub/test/unit/storage/channels.js b/packages/endpoint-microsub/test/unit/storage/channels.js index 4b01c5326..15d4c7c53 100644 --- a/packages/endpoint-microsub/test/unit/storage/channels.js +++ b/packages/endpoint-microsub/test/unit/storage/channels.js @@ -125,7 +125,7 @@ describe("endpoint-microsub/lib/storage/channels", () => { userId: "user-1", }); const stored = await channels.findOne({ uid: channel.uid }); - await items.insertOne({ channelId: stored._id, readBy: ["user-1"] }); + await items.insertOne({ channel: stored.uid, readBy: ["user-1"] }); const result = await getChannels(application, "user-1"); @@ -139,9 +139,9 @@ describe("endpoint-microsub/lib/storage/channels", () => { }); const stored = await channels.findOne({ uid: channel.uid }); await items.insertMany([ - { channelId: stored._id, readBy: [] }, - { channelId: stored._id, readBy: [] }, - { channelId: stored._id, readBy: ["user-1"] }, + { channel: stored.uid, readBy: [] }, + { channel: stored.uid, readBy: [] }, + { channel: stored.uid, readBy: ["user-1"] }, ]); const result = await getChannels(application, "user-1"); @@ -240,11 +240,11 @@ describe("endpoint-microsub/lib/storage/channels", () => { userId: "user-1", }); const stored = await channels.findOne({ uid: channel.uid }); - await items.insertOne({ channelId: stored._id }); + await items.insertOne({ channel: stored.uid }); await deleteChannel(application, channel.uid, "user-1"); - assert.equal(await items.countDocuments({ channelId: stored._id }), 0); + assert.equal(await items.countDocuments({ channel: stored.uid }), 0); }); it("Refuses to delete the notifications channel", async () => { diff --git a/packages/endpoint-microsub/test/unit/storage/items.js b/packages/endpoint-microsub/test/unit/storage/items.js index 19a02df7a..726bee146 100644 --- a/packages/endpoint-microsub/test/unit/storage/items.js +++ b/packages/endpoint-microsub/test/unit/storage/items.js @@ -2,7 +2,6 @@ import { strict as assert } from "node:assert"; import { after, beforeEach, describe, it } from "node:test"; import { testDatabase } from "@indiekit-test/database"; -import { ObjectId } from "mongodb"; import { createIndexes, @@ -19,8 +18,8 @@ const application = { collections: new Map([["microsub_items", items]]), }; -const channelId = new ObjectId("000000000000000000000001"); -const otherChannelId = new ObjectId("000000000000000000000002"); +const channel = "channel-1"; +const otherChannel = "channel-2"; /** * Insert timeline items, oldest first @@ -33,7 +32,7 @@ async function insertItems(count, overrides = {}) { const published = new Date(Date.UTC(2026, 0, index + 1)); return { - channelId, + channel, id: uuidv7At(published), type: "entry", uid: `item-${index}`, @@ -62,7 +61,7 @@ describe("endpoint-microsub/lib/storage/items", () => { describe("getTimelineItems", () => { it("Returns an empty timeline when the channel has no items", async () => { - const result = await getTimelineItems(application, channelId); + const result = await getTimelineItems(application, channel); assert.deepEqual(result.items, []); assert.deepEqual(result.paging, {}); @@ -71,7 +70,7 @@ describe("endpoint-microsub/lib/storage/items", () => { it("Returns items newest first", async () => { await insertItems(3); - const result = await getTimelineItems(application, channelId); + const result = await getTimelineItems(application, channel); assert.deepEqual( result.items.map((item) => item.name), @@ -82,21 +81,13 @@ describe("endpoint-microsub/lib/storage/items", () => { it("Excludes items from other channels", async () => { await insertItems(2); await items.insertOne({ - channelId: otherChannelId, + channel: otherChannel, id: uuidv7At(new Date()), uid: "other", published: new Date(), }); - const result = await getTimelineItems(application, channelId); - - assert.equal(result.items.length, 2); - }); - - it("Accepts a channel ID as a string", async () => { - await insertItems(2); - - const result = await getTimelineItems(application, channelId.toString()); + const result = await getTimelineItems(application, channel); assert.equal(result.items.length, 2); }); @@ -104,7 +95,7 @@ describe("endpoint-microsub/lib/storage/items", () => { it("Applies the requested limit", async () => { await insertItems(5); - const result = await getTimelineItems(application, channelId, { + const result = await getTimelineItems(application, channel, { limit: 2, }); @@ -114,7 +105,7 @@ describe("endpoint-microsub/lib/storage/items", () => { it("Returns an after cursor when more items remain", async () => { await insertItems(5); - const result = await getTimelineItems(application, channelId, { + const result = await getTimelineItems(application, channel, { limit: 2, }); @@ -124,14 +115,14 @@ describe("endpoint-microsub/lib/storage/items", () => { it("Pages back to newer items using the before cursor", async () => { await insertItems(4); - const first = await getTimelineItems(application, channelId, { + const first = await getTimelineItems(application, channel, { limit: 2, }); - const second = await getTimelineItems(application, channelId, { + const second = await getTimelineItems(application, channel, { limit: 2, after: first.paging.after, }); - const back = await getTimelineItems(application, channelId, { + const back = await getTimelineItems(application, channel, { limit: 2, before: second.paging.before, }); @@ -146,10 +137,10 @@ describe("endpoint-microsub/lib/storage/items", () => { it("Pages through items using the after cursor", async () => { await insertItems(4); - const first = await getTimelineItems(application, channelId, { + const first = await getTimelineItems(application, channel, { limit: 2, }); - const second = await getTimelineItems(application, channelId, { + const second = await getTimelineItems(application, channel, { limit: 2, after: first.paging.after, }); @@ -163,7 +154,7 @@ describe("endpoint-microsub/lib/storage/items", () => { it("Transforms items to jf2", async () => { await insertItems(1, { author: "Alice", category: ["indieweb"] }); - const { items: result } = await getTimelineItems(application, channelId); + const { items: result } = await getTimelineItems(application, channel); assert.equal(result[0].type, "entry"); assert.equal(result[0].uid, "item-0"); @@ -177,7 +168,7 @@ describe("endpoint-microsub/lib/storage/items", () => { it("Omits optional fields that are absent", async () => { await insertItems(1); - const { items: result } = await getTimelineItems(application, channelId); + const { items: result } = await getTimelineItems(application, channel); assert.equal("author" in result[0], false); assert.equal("category" in result[0], false); @@ -189,7 +180,7 @@ describe("endpoint-microsub/lib/storage/items", () => { inReplyTo: ["https://website.example/replied"], }); - const { items: result } = await getTimelineItems(application, channelId); + const { items: result } = await getTimelineItems(application, channel); assert.deepEqual(result[0]["like-of"], ["https://website.example/liked"]); assert.deepEqual(result[0]["in-reply-to"], [ @@ -200,7 +191,7 @@ describe("endpoint-microsub/lib/storage/items", () => { it("Reports read state for the given user", async () => { await insertItems(1, { readBy: ["user-1"] }); - const { items: result } = await getTimelineItems(application, channelId, { + const { items: result } = await getTimelineItems(application, channel, { userId: "user-1", }); @@ -210,7 +201,7 @@ describe("endpoint-microsub/lib/storage/items", () => { it("Reports items as unread for a different user", async () => { await insertItems(1, { readBy: ["user-2"] }); - const { items: result } = await getTimelineItems(application, channelId, { + const { items: result } = await getTimelineItems(application, channel, { userId: "user-1", }); @@ -224,14 +215,14 @@ describe("endpoint-microsub/lib/storage/items", () => { const count = await markItemsRead( application, - channelId, + channel, ["item-0", "item-1"], "user-1", ); assert.equal(count, 2); assert.equal( - await items.countDocuments({ channelId, readBy: "user-1" }), + await items.countDocuments({ channel, readBy: "user-1" }), 2, ); }); @@ -241,7 +232,7 @@ describe("endpoint-microsub/lib/storage/items", () => { const count = await markItemsRead( application, - channelId, + channel, ["https://website.example/0"], "user-1", ); @@ -255,7 +246,7 @@ describe("endpoint-microsub/lib/storage/items", () => { const count = await markItemsRead( application, - channelId, + channel, [item.id], "user-1", ); @@ -268,7 +259,7 @@ describe("endpoint-microsub/lib/storage/items", () => { const count = await markItemsRead( application, - channelId, + channel, ["last-read-entry"], "user-1", ); @@ -279,14 +270,14 @@ describe("endpoint-microsub/lib/storage/items", () => { it("Does not mark items in other channels", async () => { await insertItems(1); await items.insertOne({ - channelId: otherChannelId, + channel: otherChannel, uid: "item-0", readBy: [], }); - await markItemsRead(application, channelId, ["item-0"], "user-1"); + await markItemsRead(application, channel, ["item-0"], "user-1"); - const other = await items.findOne({ channelId: otherChannelId }); + const other = await items.findOne({ channel: otherChannel }); assert.deepEqual(other.readBy, []); }); @@ -294,7 +285,7 @@ describe("endpoint-microsub/lib/storage/items", () => { it("Does not add a duplicate user to readBy", async () => { await insertItems(1, { readBy: ["user-1"] }); - await markItemsRead(application, channelId, ["item-0"], "user-1"); + await markItemsRead(application, channel, ["item-0"], "user-1"); const item = await items.findOne({ uid: "item-0" }); @@ -308,7 +299,7 @@ describe("endpoint-microsub/lib/storage/items", () => { const count = await markItemsUnread( application, - channelId, + channel, ["item-0"], "user-1", ); @@ -323,7 +314,7 @@ describe("endpoint-microsub/lib/storage/items", () => { it("Leaves other users' read state intact", async () => { await insertItems(1, { readBy: ["user-1", "user-2"] }); - await markItemsUnread(application, channelId, ["item-0"], "user-1"); + await markItemsUnread(application, channel, ["item-0"], "user-1"); const item = await items.findOne({ uid: "item-0" }); @@ -335,31 +326,28 @@ describe("endpoint-microsub/lib/storage/items", () => { it("Deletes the given items", async () => { await insertItems(3); - const count = await removeItems(application, channelId, [ + const count = await removeItems(application, channel, [ "item-0", "item-1", ]); assert.equal(count, 2); - assert.equal(await items.countDocuments({ channelId }), 1); + assert.equal(await items.countDocuments({ channel }), 1); }); it("Does not delete items in other channels", async () => { await insertItems(1); - await items.insertOne({ channelId: otherChannelId, uid: "item-0" }); + await items.insertOne({ channel: otherChannel, uid: "item-0" }); - await removeItems(application, channelId, ["item-0"]); + await removeItems(application, channel, ["item-0"]); - assert.equal( - await items.countDocuments({ channelId: otherChannelId }), - 1, - ); + assert.equal(await items.countDocuments({ channel: otherChannel }), 1); }); it("Returns 0 when nothing matches", async () => { await insertItems(1); - const count = await removeItems(application, channelId, ["nonexistent"]); + const count = await removeItems(application, channel, ["nonexistent"]); assert.equal(count, 0); }); @@ -372,9 +360,9 @@ describe("endpoint-microsub/lib/storage/items", () => { const indexes = await items.indexes(); const keys = new Set(indexes.map((index) => JSON.stringify(index.key))); - assert.ok(keys.has(JSON.stringify({ channelId: 1, id: 1 }))); - assert.ok(keys.has(JSON.stringify({ channelId: 1, uid: 1 }))); - assert.ok(keys.has(JSON.stringify({ channelId: 1, url: 1 }))); + assert.ok(keys.has(JSON.stringify({ channel: 1, id: 1 }))); + assert.ok(keys.has(JSON.stringify({ channel: 1, uid: 1 }))); + assert.ok(keys.has(JSON.stringify({ channel: 1, url: 1 }))); }); }); }); From 83322966955bf4bf647e67c4e7a0accaa587fe7e Mon Sep 17 00:00:00 2001 From: Ricardo Date: Sat, 10 Oct 2026 17:03:31 +0200 Subject: [PATCH 15/17] refactor(endpoint-microsub): address review - Errors use the shared localisable strings (BadRequestError.missingParameter, invalidValue, missingProperty) through the localiser the validators now take; channel not found and the name limit are package-scoped locale keys, and the unused title keys are gone. - uuidv7At moves to @indiekit/util, shared by the uid backfill and the Microsub timeline; the channel uid wrapper is a direct randomString(24). - One collections module replaces the two duplicated collection getters. - The user is publication.me: a session carries only a token and a scope, so the fallback chain never found anything else. --- .../lib/controllers/channels.js | 33 ++++----- .../lib/controllers/microsub.js | 16 ++--- .../lib/controllers/timeline.js | 35 +++++----- .../endpoint-microsub/lib/storage/channels.js | 37 +++------- .../lib/storage/collections.js | 20 ++++++ .../endpoint-microsub/lib/storage/items.js | 19 ++---- packages/endpoint-microsub/lib/utils/auth.js | 35 ---------- packages/endpoint-microsub/lib/utils/uid.js | 35 ---------- .../endpoint-microsub/lib/utils/validation.js | 67 +++++++++++-------- packages/endpoint-microsub/locales/en.json | 9 +-- .../test/integration/200-get-timeline.js | 7 +- .../integration/200-post-channel-delete.js | 2 +- .../integration/200-post-channels-order.js | 2 +- .../test/integration/200-post-timeline.js | 10 +-- .../integration/201-post-channel-create.js | 2 +- .../test/integration/400-invalid-action.js | 12 +++- .../test/unit/storage/items.js | 6 +- .../endpoint-microsub/test/unit/utils/auth.js | 58 ---------------- .../endpoint-microsub/test/unit/utils/uid.js | 61 ----------------- .../test/unit/utils/validation.js | 50 ++++++++------ packages/indiekit/lib/migrate-uid.js | 26 +------ packages/indiekit/test/unit/migrate-uid.js | 3 +- packages/util/index.js | 1 + packages/util/lib/uid.js | 27 ++++++++ packages/util/test/unit/uid.js | 36 ++++++++++ 25 files changed, 233 insertions(+), 376 deletions(-) create mode 100644 packages/endpoint-microsub/lib/storage/collections.js delete mode 100644 packages/endpoint-microsub/lib/utils/auth.js delete mode 100644 packages/endpoint-microsub/lib/utils/uid.js delete mode 100644 packages/endpoint-microsub/test/unit/utils/auth.js delete mode 100644 packages/endpoint-microsub/test/unit/utils/uid.js create mode 100644 packages/util/lib/uid.js create mode 100644 packages/util/test/unit/uid.js diff --git a/packages/endpoint-microsub/lib/controllers/channels.js b/packages/endpoint-microsub/lib/controllers/channels.js index be861b0ad..8928cf27c 100644 --- a/packages/endpoint-microsub/lib/controllers/channels.js +++ b/packages/endpoint-microsub/lib/controllers/channels.js @@ -12,7 +12,6 @@ import { deleteChannel, reorderChannels, } from "../storage/channels.js"; -import { getUserId } from "../utils/auth.js"; import { validateChannel, validateChannelName, @@ -26,10 +25,9 @@ import { * @param {object} response - Express response */ export async function list(request, response) { - const { application } = request.app.locals; - const userId = getUserId(request); + const { application, publication } = request.app.locals; - const channels = await getChannels(application, userId); + const channels = await getChannels(application, publication.me); response.json({ channels }); } @@ -42,19 +40,18 @@ export async function list(request, response) { * @returns {Promise} */ export async function action(request, response) { - const { application } = request.app.locals; - const userId = getUserId(request); + const { application, publication } = request.app.locals; + const { __ } = response.locals; + const userId = publication.me; const { method, name, uid } = request.body; // Delete channel if (method === "delete") { - validateChannel(uid); + validateChannel(__, uid); const deleted = await deleteChannel(application, uid, userId); if (!deleted) { - throw new IndiekitError("Channel not found or cannot be deleted", { - status: 404, - }); + throw IndiekitError.notFound(__("microsub.error.channelNotFound")); } return response.json({ deleted: uid }); @@ -64,9 +61,9 @@ export async function action(request, response) { if (method === "order") { const channelUids = parseArrayParameter(request.body, "channels"); if (channelUids.length === 0) { - throw new IndiekitError("Missing channels[] parameter", { - status: 400, - }); + throw IndiekitError.badRequest( + __("BadRequestError.missingParameter", "channels"), + ); } await reorderChannels(application, channelUids, userId); @@ -77,17 +74,15 @@ export async function action(request, response) { // Update existing channel if (uid) { - validateChannel(uid); + validateChannel(__, uid); if (name) { - validateChannelName(name); + validateChannelName(__, name); } const channel = await updateChannel(application, uid, { name }, userId); if (!channel) { - throw new IndiekitError("Channel not found", { - status: 404, - }); + throw IndiekitError.notFound(__("microsub.error.channelNotFound")); } return response.json({ @@ -97,7 +92,7 @@ export async function action(request, response) { } // Create new channel - validateChannelName(name); + validateChannelName(__, name); const channel = await createChannel(application, { name, userId }); diff --git a/packages/endpoint-microsub/lib/controllers/microsub.js b/packages/endpoint-microsub/lib/controllers/microsub.js index 1707c0e70..c4dada3a3 100644 --- a/packages/endpoint-microsub/lib/controllers/microsub.js +++ b/packages/endpoint-microsub/lib/controllers/microsub.js @@ -29,7 +29,7 @@ export async function get(request, response, next) { }); } - validateAction(action); + validateAction(response.locals.__, action); switch (action) { case "channels": { @@ -41,9 +41,9 @@ export async function get(request, response, next) { } default: { - throw new IndiekitError(`Unsupported GET action: ${action}`, { - status: 400, - }); + throw IndiekitError.badRequest( + response.locals.__("BadRequestError.invalidValue", "action"), + ); } } } catch (error) { @@ -61,7 +61,7 @@ export async function get(request, response, next) { export async function post(request, response, next) { try { const action = request.body.action || request.query.action; - validateAction(action); + validateAction(response.locals.__, action); switch (action) { case "channels": { @@ -73,9 +73,9 @@ export async function post(request, response, next) { } default: { - throw new IndiekitError(`Unsupported POST action: ${action}`, { - status: 400, - }); + throw IndiekitError.badRequest( + response.locals.__("BadRequestError.invalidValue", "action"), + ); } } } catch (error) { diff --git a/packages/endpoint-microsub/lib/controllers/timeline.js b/packages/endpoint-microsub/lib/controllers/timeline.js index d2a9356bb..cb1ffabcb 100644 --- a/packages/endpoint-microsub/lib/controllers/timeline.js +++ b/packages/endpoint-microsub/lib/controllers/timeline.js @@ -12,7 +12,6 @@ import { markItemsUnread, removeItems, } from "../storage/items.js"; -import { getUserId } from "../utils/auth.js"; import { validateChannel, validateEntries, @@ -26,18 +25,17 @@ import { * @param {object} response - Express response */ export async function get(request, response) { - const { application } = request.app.locals; - const userId = getUserId(request); + const { application, publication } = request.app.locals; + const { __ } = response.locals; + const userId = publication.me; const { channel, before, after, limit } = request.query; - validateChannel(channel); + validateChannel(__, channel); // Verify channel exists const channelDocument = await getChannel(application, channel, userId); if (!channelDocument) { - throw new IndiekitError("Channel not found", { - status: 404, - }); + throw IndiekitError.notFound(__("microsub.error.channelNotFound")); } const timeline = await getTimelineItems(application, channelDocument.uid, { @@ -58,18 +56,17 @@ export async function get(request, response) { * @returns {Promise} */ export async function action(request, response) { - const { application } = request.app.locals; - const userId = getUserId(request); + const { application, publication } = request.app.locals; + const { __ } = response.locals; + const userId = publication.me; const { method, channel } = request.body; - validateChannel(channel); + validateChannel(__, channel); // Verify channel exists const channelDocument = await getChannel(application, channel, userId); if (!channelDocument) { - throw new IndiekitError("Channel not found", { - status: 404, - }); + throw IndiekitError.notFound(__("microsub.error.channelNotFound")); } // Get entry IDs from request @@ -77,7 +74,7 @@ export async function action(request, response) { switch (method) { case "mark_read": { - validateEntries(entries); + validateEntries(__, entries); const count = await markItemsRead( application, channelDocument.uid, @@ -88,7 +85,7 @@ export async function action(request, response) { } case "mark_unread": { - validateEntries(entries); + validateEntries(__, entries); const count = await markItemsUnread( application, channelDocument.uid, @@ -99,7 +96,7 @@ export async function action(request, response) { } case "remove": { - validateEntries(entries); + validateEntries(__, entries); const count = await removeItems( application, channelDocument.uid, @@ -109,9 +106,9 @@ export async function action(request, response) { } default: { - throw new IndiekitError(`Invalid timeline method: ${method}`, { - status: 400, - }); + throw IndiekitError.badRequest( + __("BadRequestError.invalidValue", "method"), + ); } } } diff --git a/packages/endpoint-microsub/lib/storage/channels.js b/packages/endpoint-microsub/lib/storage/channels.js index e70c49266..b9184e2e5 100644 --- a/packages/endpoint-microsub/lib/storage/channels.js +++ b/packages/endpoint-microsub/lib/storage/channels.js @@ -3,30 +3,13 @@ * @module storage/channels */ +import { randomString } from "@indiekit/util"; import makeDebug from "debug"; -import { generateChannelUid } from "../utils/uid.js"; +import { getChannelsCollection, getItemsCollection } from "./collections.js"; const debug = makeDebug("indiekit:endpoint-microsub"); -/** - * Get channels collection from application - * @param {object} application - Indiekit application - * @returns {object} MongoDB collection - */ -function getCollection(application) { - return application.collections.get("microsub_channels"); -} - -/** - * Get items collection for unread counts - * @param {object} application - Indiekit application - * @returns {object} MongoDB collection - */ -function getItemsCollection(application) { - return application.collections.get("microsub_items"); -} - /** * Create a new channel * @param {object} application - Indiekit application @@ -36,7 +19,7 @@ function getItemsCollection(application) { * @returns {Promise} Created channel */ export async function createChannel(application, { name, userId }) { - const collection = getCollection(application); + const collection = getChannelsCollection(application); // Generate unique UID with retry on collision let uid; @@ -44,7 +27,7 @@ export async function createChannel(application, { name, userId }) { const maxAttempts = 5; while (attempts < maxAttempts) { - uid = generateChannelUid(); + uid = randomString(24); const existing = await collection.findOne({ uid }); if (!existing) break; attempts++; @@ -84,7 +67,7 @@ export async function createChannel(application, { name, userId }) { * @returns {Promise} Array of channels with unread counts */ export async function getChannels(application, userId) { - const collection = getCollection(application); + const collection = getChannelsCollection(application); const itemsCollection = getItemsCollection(application); const filter = userId ? { userId } : {}; @@ -130,7 +113,7 @@ export async function getChannels(application, userId) { * @returns {Promise} Channel or null */ export async function getChannel(application, uid, userId) { - const collection = getCollection(application); + const collection = getChannelsCollection(application); const query = { uid }; if (userId) query.userId = userId; @@ -146,7 +129,7 @@ export async function getChannel(application, uid, userId) { * @returns {Promise} Updated channel */ export async function updateChannel(application, uid, updates, userId) { - const collection = getCollection(application); + const collection = getChannelsCollection(application); const query = { uid }; if (userId) query.userId = userId; @@ -172,7 +155,7 @@ export async function updateChannel(application, uid, updates, userId) { * @returns {Promise} True if deleted */ export async function deleteChannel(application, uid, userId) { - const collection = getCollection(application); + const collection = getChannelsCollection(application); const itemsCollection = getItemsCollection(application); const query = { uid }; if (userId) query.userId = userId; @@ -202,7 +185,7 @@ export async function deleteChannel(application, uid, userId) { * @returns {Promise} */ export async function reorderChannels(application, channelUids, userId) { - const collection = getCollection(application); + const collection = getChannelsCollection(application); // Update order for each channel const operations = channelUids.map((uid, index) => ({ @@ -224,7 +207,7 @@ export async function reorderChannels(application, channelUids, userId) { * @returns {Promise} Notifications channel */ export async function ensureNotificationsChannel(application, userId) { - const collection = getCollection(application); + const collection = getChannelsCollection(application); const existing = await collection.findOne({ uid: "notifications", diff --git a/packages/endpoint-microsub/lib/storage/collections.js b/packages/endpoint-microsub/lib/storage/collections.js new file mode 100644 index 000000000..7b1c05281 --- /dev/null +++ b/packages/endpoint-microsub/lib/storage/collections.js @@ -0,0 +1,20 @@ +/** + * Database collections the endpoint keeps + * @module storage/collections + */ + +/** + * Get the channels collection + * @param {object} application - Indiekit application + * @returns {object} MongoDB collection + */ +export const getChannelsCollection = (application) => + application.collections.get("microsub_channels"); + +/** + * Get the timeline items collection + * @param {object} application - Indiekit application + * @returns {object} MongoDB collection + */ +export const getItemsCollection = (application) => + application.collections.get("microsub_items"); diff --git a/packages/endpoint-microsub/lib/storage/items.js b/packages/endpoint-microsub/lib/storage/items.js index 74cff7a57..ed0a8d151 100644 --- a/packages/endpoint-microsub/lib/storage/items.js +++ b/packages/endpoint-microsub/lib/storage/items.js @@ -7,14 +7,7 @@ import { getCursor } from "@indiekit/util"; import { parseLimit } from "../utils/pagination.js"; -/** - * Get items collection from application - * @param {object} application - Indiekit application - * @returns {object} MongoDB collection - */ -function getCollection(application) { - return application.collections.get("microsub_items"); -} +import { getItemsCollection } from "./collections.js"; /** * Get timeline items for a channel @@ -28,7 +21,7 @@ function getCollection(application) { * @returns {Promise} Timeline with items and paging */ export async function getTimelineItems(application, channel, options = {}) { - const collection = getCollection(application); + const collection = getItemsCollection(application); const limit = parseLimit(options.limit); // Items are listed and paged by their `id`, a UUIDv7 stamped with the @@ -106,7 +99,7 @@ function transformToJf2(item, userId) { * @returns {Promise} Number of items updated */ export async function markItemsRead(application, channel, entryIds, userId) { - const collection = getCollection(application); + const collection = getItemsCollection(application); // Handle "last-read-entry" special value if (entryIds.includes("last-read-entry")) { @@ -142,7 +135,7 @@ export async function markItemsRead(application, channel, entryIds, userId) { * @returns {Promise} Number of items updated */ export async function markItemsUnread(application, channel, entryIds, userId) { - const collection = getCollection(application); + const collection = getItemsCollection(application); // Match by the id clients see, the feed's own uid, or url const result = await collection.updateMany( @@ -168,7 +161,7 @@ export async function markItemsUnread(application, channel, entryIds, userId) { * @returns {Promise} Number of items removed */ export async function removeItems(application, channel, entryIds) { - const collection = getCollection(application); + const collection = getItemsCollection(application); // Match by the id clients see, the feed's own uid, or url const result = await collection.deleteMany({ @@ -189,7 +182,7 @@ export async function removeItems(application, channel, entryIds) { * @returns {Promise} */ export async function createIndexes(application) { - const collection = getCollection(application); + const collection = getItemsCollection(application); // Primary query indexes: `id` orders and pages the timeline, `uid` is the // feed's own identifier and keeps an item from being stored twice diff --git a/packages/endpoint-microsub/lib/utils/auth.js b/packages/endpoint-microsub/lib/utils/auth.js deleted file mode 100644 index f052df42b..000000000 --- a/packages/endpoint-microsub/lib/utils/auth.js +++ /dev/null @@ -1,35 +0,0 @@ -/** - * Authentication utilities for Microsub - * @module utils/auth - */ - -/** - * Get the user ID from request context - * - * In Indiekit, the userId can come from: - * 1. request.session.userId (if explicitly set) - * 2. request.session.me (from token introspection) - * 3. application.publication.me (single-user fallback) - * @param {object} request - Express request - * @returns {string} User ID - */ -export function getUserId(request) { - // Check session for explicit userId - if (request.session?.userId) { - return request.session.userId; - } - - // Check session for me URL from token introspection - if (request.session?.me) { - return request.session.me; - } - - // Fall back to publication me URL (single-user mode) - const { application } = request.app.locals; - if (application?.publication?.me) { - return application.publication.me; - } - - // Final fallback: use "default" as user ID for single-user instances - return "default"; -} diff --git a/packages/endpoint-microsub/lib/utils/uid.js b/packages/endpoint-microsub/lib/utils/uid.js deleted file mode 100644 index 74fff35c3..000000000 --- a/packages/endpoint-microsub/lib/utils/uid.js +++ /dev/null @@ -1,35 +0,0 @@ -/** - * UID generation utilities for Microsub - * @module utils/uid - */ - -import { randomBytes } from "node:crypto"; - -import { randomString } from "@indiekit/util"; - -/** - * Generate a random channel UID - * @returns {string} 24-character random string - */ -export function generateChannelUid() { - return randomString(24); -} - -/** - * Generate a UUIDv7 whose timestamp is the given date - * - * Timeline items are listed and paged by this identifier, the same way posts - * are paged by their uid. Node's `randomUUIDv7()` stamps the current time; - * an item is ordered by when it was published, so the stamp is set here. - * @param {Date} date - Time to encode in the first 48 bits - * @returns {string} UUIDv7 - */ -export function uuidv7At(date) { - const bytes = randomBytes(16); - bytes.writeUIntBE(date.getTime(), 0, 6); - bytes[6] = (bytes[6] & 0x0f) | 0x70; // version 7 - bytes[8] = (bytes[8] & 0x3f) | 0x80; // variant 10 - const hex = bytes.toString("hex"); - - return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`; -} diff --git a/packages/endpoint-microsub/lib/utils/validation.js b/packages/endpoint-microsub/lib/utils/validation.js index 68afda221..0b48676ed 100644 --- a/packages/endpoint-microsub/lib/utils/validation.js +++ b/packages/endpoint-microsub/lib/utils/validation.js @@ -10,65 +10,73 @@ import { IndiekitError } from "@indiekit/error"; */ export const VALID_ACTIONS = ["channels", "timeline"]; +/** + * Longest channel name accepted + */ +export const MAX_NAME_LENGTH = 100; + /** * Validate action parameter + * @param {Function} __ - Localisation function * @param {string|null} [action] - Action to validate * @throws {IndiekitError} If action is invalid */ -export function validateAction(action) { +export function validateAction(__, action) { if (!action) { - throw new IndiekitError("Missing required parameter: action", { - status: 400, - }); + throw IndiekitError.badRequest( + __("BadRequestError.missingParameter", "action"), + ); } if (!VALID_ACTIONS.includes(action)) { - throw new IndiekitError(`Invalid action: ${action}`, { - status: 400, - }); + throw IndiekitError.badRequest( + __("BadRequestError.invalidValue", "action"), + ); } } /** * Validate channel UID + * @param {Function} __ - Localisation function * @param {string} [channel] - Channel UID to validate * @param {boolean} [isRequired] - Whether channel is required * @throws {IndiekitError} If channel is invalid */ -export function validateChannel(channel, isRequired = true) { +export function validateChannel(__, channel, isRequired = true) { if (isRequired && !channel) { - throw new IndiekitError("Missing required parameter: channel", { - status: 400, - }); + throw IndiekitError.badRequest( + __("BadRequestError.missingParameter", "channel"), + ); } if (channel && typeof channel !== "string") { - throw new IndiekitError("Invalid channel parameter", { - status: 400, - }); + throw IndiekitError.badRequest( + __("BadRequestError.invalidValue", "channel"), + ); } } /** * Validate entry/entries parameter + * @param {Function} __ - Localisation function * @param {string|Array} [entry] - Entry ID(s) to validate * @returns {Array} Array of entry IDs * @throws {IndiekitError} If entry is invalid */ -export function validateEntries(entry) { +export function validateEntries(__, entry) { if (!entry) { - throw new IndiekitError("Missing required parameter: entry", { - status: 400, - }); + throw IndiekitError.badRequest( + __("BadRequestError.missingParameter", "entry"), + ); } // Normalize to array const entries = Array.isArray(entry) ? entry : [entry]; if (entries.length === 0) { - throw new IndiekitError("Entry parameter cannot be empty", { - status: 400, - }); + throw IndiekitError.badRequest( + __("BadRequestError.missingProperty", "entry"), + ); } return entries; @@ -76,20 +84,21 @@ export function validateEntries(entry) { /** * Validate channel name + * @param {Function} __ - Localisation function * @param {string} name - Channel name to validate * @throws {IndiekitError} If name is invalid */ -export function validateChannelName(name) { +export function validateChannelName(__, name) { if (!name || typeof name !== "string") { - throw new IndiekitError("Missing required parameter: name", { - status: 400, - }); + throw IndiekitError.badRequest( + __("BadRequestError.missingParameter", "name"), + ); } - if (name.length > 100) { - throw new IndiekitError("Channel name must be 100 characters or less", { - status: 400, - }); + if (name.length > MAX_NAME_LENGTH) { + throw IndiekitError.badRequest( + __("microsub.error.nameTooLong", MAX_NAME_LENGTH), + ); } } diff --git a/packages/endpoint-microsub/locales/en.json b/packages/endpoint-microsub/locales/en.json index 9d1c0edbf..cbbaf458b 100644 --- a/packages/endpoint-microsub/locales/en.json +++ b/packages/endpoint-microsub/locales/en.json @@ -1,15 +1,8 @@ { "microsub": { - "title": "Microsub", - "channels": { - "title": "Channels" - }, - "timeline": { - "title": "Timeline" - }, "error": { "channelNotFound": "Channel not found", - "invalidAction": "Invalid action" + "nameTooLong": "Channel name must be %s characters or less" } } } diff --git a/packages/endpoint-microsub/test/integration/200-get-timeline.js b/packages/endpoint-microsub/test/integration/200-get-timeline.js index 9b336cccd..ecab5ed46 100644 --- a/packages/endpoint-microsub/test/integration/200-get-timeline.js +++ b/packages/endpoint-microsub/test/integration/200-get-timeline.js @@ -1,13 +1,12 @@ import { strict as assert } from "node:assert"; import { after, before, describe, it } from "node:test"; +import { uuidv7At } from "@indiekit/util"; import { testDatabase } from "@indiekit-test/database"; import { testServer } from "@indiekit-test/server"; import { testCookie } from "@indiekit-test/session"; import supertest from "supertest"; -import { uuidv7At } from "../../lib/utils/uid.js"; - const { client, mongoServer, mongoUri } = await testDatabase(); const server = await testServer({ application: { mongodbUrl: mongoUri }, @@ -37,7 +36,7 @@ describe("endpoint-microsub GET /microsub?action=timeline", () => { return { channel: fixture.channelUid, - id: uuidv7At(published), + id: uuidv7At(published.getTime()), type: "entry", uid: `item-${index}`, url: `https://website.example/${index}`, @@ -87,7 +86,7 @@ describe("endpoint-microsub GET /microsub?action=timeline", () => { .set("cookie", cookie); assert.equal(response.status, 400); - assert.match(response.text, /Missing required parameter: channel/); + assert.match(response.text, /Missing parameter: channel<\/code>/); }); after(async () => { diff --git a/packages/endpoint-microsub/test/integration/200-post-channel-delete.js b/packages/endpoint-microsub/test/integration/200-post-channel-delete.js index 2de7339f2..5626f1e23 100644 --- a/packages/endpoint-microsub/test/integration/200-post-channel-delete.js +++ b/packages/endpoint-microsub/test/integration/200-post-channel-delete.js @@ -68,7 +68,7 @@ describe("endpoint-microsub POST /microsub?action=channels (delete)", () => { .send({ action: "channels", method: "delete", uid: "nonexistent" }); assert.equal(response.status, 404); - assert.match(response.text, /Channel not found or cannot be deleted/); + assert.match(response.text, /Channel not found/); }); it("Refuses to delete the notifications channel", async () => { diff --git a/packages/endpoint-microsub/test/integration/200-post-channels-order.js b/packages/endpoint-microsub/test/integration/200-post-channels-order.js index 4af0e468d..6ccb16cfe 100644 --- a/packages/endpoint-microsub/test/integration/200-post-channels-order.js +++ b/packages/endpoint-microsub/test/integration/200-post-channels-order.js @@ -68,7 +68,7 @@ describe("endpoint-microsub POST /microsub?action=channels (order)", () => { .send({ action: "channels", method: "order" }); assert.equal(response.status, 400); - assert.match(response.text, /Missing channels\[\] parameter/); + assert.match(response.text, /Missing parameter: channels<\/code>/); }); after(async () => { diff --git a/packages/endpoint-microsub/test/integration/200-post-timeline.js b/packages/endpoint-microsub/test/integration/200-post-timeline.js index caf7e7352..efbe2db07 100644 --- a/packages/endpoint-microsub/test/integration/200-post-timeline.js +++ b/packages/endpoint-microsub/test/integration/200-post-timeline.js @@ -1,13 +1,12 @@ import { strict as assert } from "node:assert"; import { after, beforeEach, describe, it } from "node:test"; +import { uuidv7At } from "@indiekit/util"; import { testDatabase } from "@indiekit-test/database"; import { testServer } from "@indiekit-test/server"; import { testCookie } from "@indiekit-test/session"; import supertest from "supertest"; -import { uuidv7At } from "../../lib/utils/uid.js"; - const { client, mongoServer, mongoUri } = await testDatabase(); const server = await testServer({ application: { mongodbUrl: mongoUri }, @@ -40,7 +39,7 @@ describe("endpoint-microsub POST /microsub?action=timeline", () => { return { channel: fixture.channelUid, - id: uuidv7At(published), + id: uuidv7At(published.getTime()), type: "entry", uid: `item-${index}`, url: `https://website.example/${index}`, @@ -138,7 +137,10 @@ describe("endpoint-microsub POST /microsub?action=timeline", () => { }); assert.equal(response.status, 400); - assert.match(response.text, /Invalid timeline method/); + assert.match( + response.text, + /Invalid value provided for: method<\/code>/, + ); }); it("Returns 404 for an unknown channel", async () => { diff --git a/packages/endpoint-microsub/test/integration/201-post-channel-create.js b/packages/endpoint-microsub/test/integration/201-post-channel-create.js index d1ddbe9d9..8023b8d93 100644 --- a/packages/endpoint-microsub/test/integration/201-post-channel-create.js +++ b/packages/endpoint-microsub/test/integration/201-post-channel-create.js @@ -50,7 +50,7 @@ describe("endpoint-microsub POST /microsub?action=channels", () => { .send({ action: "channels" }); assert.equal(response.status, 400); - assert.match(response.text, /Missing required parameter: name/); + assert.match(response.text, /Missing parameter: name<\/code>/); }); it("Returns 400 when name exceeds 100 characters", async () => { diff --git a/packages/endpoint-microsub/test/integration/400-invalid-action.js b/packages/endpoint-microsub/test/integration/400-invalid-action.js index e5cc889d3..bc4a282cb 100644 --- a/packages/endpoint-microsub/test/integration/400-invalid-action.js +++ b/packages/endpoint-microsub/test/integration/400-invalid-action.js @@ -21,7 +21,10 @@ describe("endpoint-microsub invalid action", () => { .set("cookie", cookie); assert.equal(response.status, 400); - assert.match(response.text, /Invalid action/); + assert.match( + response.text, + /Invalid value provided for: action<\/code>/, + ); }); it("Returns 400 for an unsupported POST action", async () => { @@ -32,7 +35,10 @@ describe("endpoint-microsub invalid action", () => { .send({ action: "bogus" }); assert.equal(response.status, 400); - assert.match(response.text, /Invalid action/); + assert.match( + response.text, + /Invalid value provided for: action<\/code>/, + ); }); it("Returns 400 when POST has no action", async () => { @@ -43,7 +49,7 @@ describe("endpoint-microsub invalid action", () => { .send({ name: "Tech News" }); assert.equal(response.status, 400); - assert.match(response.text, /Missing required parameter: action/); + assert.match(response.text, /Missing parameter: action<\/code>/); }); after(async () => { diff --git a/packages/endpoint-microsub/test/unit/storage/items.js b/packages/endpoint-microsub/test/unit/storage/items.js index 726bee146..c7e43b81c 100644 --- a/packages/endpoint-microsub/test/unit/storage/items.js +++ b/packages/endpoint-microsub/test/unit/storage/items.js @@ -1,6 +1,7 @@ import { strict as assert } from "node:assert"; import { after, beforeEach, describe, it } from "node:test"; +import { uuidv7At } from "@indiekit/util"; import { testDatabase } from "@indiekit-test/database"; import { @@ -10,7 +11,6 @@ import { markItemsUnread, removeItems, } from "../../../lib/storage/items.js"; -import { uuidv7At } from "../../../lib/utils/uid.js"; const { client, database, mongoServer } = await testDatabase(); const items = database.collection("microsub_items"); @@ -33,7 +33,7 @@ async function insertItems(count, overrides = {}) { return { channel, - id: uuidv7At(published), + id: uuidv7At(published.getTime()), type: "entry", uid: `item-${index}`, url: `https://website.example/${index}`, @@ -82,7 +82,7 @@ describe("endpoint-microsub/lib/storage/items", () => { await insertItems(2); await items.insertOne({ channel: otherChannel, - id: uuidv7At(new Date()), + id: uuidv7At(Date.now()), uid: "other", published: new Date(), }); diff --git a/packages/endpoint-microsub/test/unit/utils/auth.js b/packages/endpoint-microsub/test/unit/utils/auth.js deleted file mode 100644 index 79d1269cd..000000000 --- a/packages/endpoint-microsub/test/unit/utils/auth.js +++ /dev/null @@ -1,58 +0,0 @@ -import { strict as assert } from "node:assert"; -import { describe, it } from "node:test"; - -import { getUserId } from "../../../lib/utils/auth.js"; - -describe("endpoint-microsub/lib/utils/auth", () => { - describe("getUserId", () => { - it("Returns userId from session if available", () => { - const request = { - session: { userId: "user-123" }, - app: { locals: { application: {} } }, - }; - - assert.equal(getUserId(request), "user-123"); - }); - - it("Returns me from session if userId not set", () => { - const request = { - session: { me: "https://example.com" }, - app: { locals: { application: {} } }, - }; - - assert.equal(getUserId(request), "https://example.com"); - }); - - it("Falls back to publication me URL", () => { - const request = { - session: {}, - app: { - locals: { - application: { - publication: { me: "https://mysite.com" }, - }, - }, - }, - }; - - assert.equal(getUserId(request), "https://mysite.com"); - }); - - it("Returns 'default' as final fallback", () => { - const request = { - session: {}, - app: { locals: { application: {} } }, - }; - - assert.equal(getUserId(request), "default"); - }); - - it("Handles undefined session gracefully", () => { - const request = { - app: { locals: { application: {} } }, - }; - - assert.equal(getUserId(request), "default"); - }); - }); -}); diff --git a/packages/endpoint-microsub/test/unit/utils/uid.js b/packages/endpoint-microsub/test/unit/utils/uid.js deleted file mode 100644 index 66c8ec8f6..000000000 --- a/packages/endpoint-microsub/test/unit/utils/uid.js +++ /dev/null @@ -1,61 +0,0 @@ -import { strict as assert } from "node:assert"; -import { describe, it } from "node:test"; - -import { generateChannelUid, uuidv7At } from "../../../lib/utils/uid.js"; - -describe("endpoint-microsub/lib/utils/uid", () => { - describe("uuidv7At", () => { - it("Encodes the given time in the first 48 bits", () => { - const uid = uuidv7At(new Date("2026-01-02T00:00:00.000Z")); - - assert.match( - uid, - /^[\da-f]{8}-[\da-f]{4}-7[\da-f]{3}-[89ab][\da-f]{3}-[\da-f]{12}$/, - ); - const milliseconds = Number.parseInt( - uid.slice(0, 13).replace("-", ""), - 16, - ); - assert.equal(milliseconds, Date.UTC(2026, 0, 2)); - }); - - it("Sorts by the given time, newest last", () => { - const earlier = uuidv7At(new Date(Date.UTC(2026, 0, 1))); - const later = uuidv7At(new Date(Date.UTC(2026, 0, 2))); - - assert.ok(earlier < later); - }); - - it("Differs between two items published in the same millisecond", () => { - const when = new Date(Date.UTC(2026, 0, 1)); - - assert.notEqual(uuidv7At(when), uuidv7At(when)); - }); - }); - - describe("generateChannelUid", () => { - it("Returns a 24-character string", () => { - const uid = generateChannelUid(); - - assert.equal(typeof uid, "string"); - assert.equal(uid.length, 24); - }); - - // A channel uid appears in Microsub request URLs, so it has to be - // URL-safe. `randomString` returns base64url, which is. - it("Uses only URL-safe characters", () => { - for (let index = 0; index < 100; index++) { - assert.match(generateChannelUid(), /^[\w-]{24}$/); - } - }); - - it("Returns a different value on each call", () => { - const uids = new Set(); - for (let index = 0; index < 100; index++) { - uids.add(generateChannelUid()); - } - - assert.equal(uids.size, 100); - }); - }); -}); diff --git a/packages/endpoint-microsub/test/unit/utils/validation.js b/packages/endpoint-microsub/test/unit/utils/validation.js index cefda22c4..ee9ac3afb 100644 --- a/packages/endpoint-microsub/test/unit/utils/validation.js +++ b/packages/endpoint-microsub/test/unit/utils/validation.js @@ -9,79 +9,87 @@ import { parseArrayParameter, } from "../../../lib/utils/validation.js"; +/** + * Stand-in for the localiser: the key and its values, space-separated + * @param {string} key - Locale key + * @param {...string} values - Values + * @returns {string} Message + */ +const __ = (key, ...values) => [key, ...values].join(" "); + describe("endpoint-microsub/lib/utils/validation", () => { describe("validateAction", () => { it("Accepts valid actions", () => { - assert.doesNotThrow(() => validateAction("channels")); - assert.doesNotThrow(() => validateAction("timeline")); + assert.doesNotThrow(() => validateAction(__, "channels")); + assert.doesNotThrow(() => validateAction(__, "timeline")); }); it("Rejects missing action", () => { - assert.throws(() => validateAction(), { - message: /Missing required parameter: action/, + assert.throws(() => validateAction(__), { + message: /missingParameter action/, }); // eslint-disable-next-line unicorn/no-null -- Testing null input handling - assert.throws(() => validateAction(null), { - message: /Missing required parameter: action/, + assert.throws(() => validateAction(__, null), { + message: /missingParameter action/, }); }); it("Rejects invalid action", () => { - assert.throws(() => validateAction("invalid"), { - message: /Invalid action/, + assert.throws(() => validateAction(__, "invalid"), { + message: /invalidValue action/, }); }); }); describe("validateChannel", () => { it("Accepts valid channel", () => { - assert.doesNotThrow(() => validateChannel("test-channel")); + assert.doesNotThrow(() => validateChannel(__, "test-channel")); }); it("Rejects missing channel when required", () => { - assert.throws(() => validateChannel(), { - message: /Missing required parameter: channel/, + assert.throws(() => validateChannel(__), { + message: /missingParameter channel/, }); }); it("Allows missing channel when not required", () => { - assert.doesNotThrow(() => validateChannel(undefined, false)); + assert.doesNotThrow(() => validateChannel(__, undefined, false)); }); }); describe("validateEntries", () => { it("Returns array for single entry", () => { - const result = validateEntries("entry-1"); + const result = validateEntries(__, "entry-1"); assert.deepEqual(result, ["entry-1"]); }); it("Returns array for array of entries", () => { - const result = validateEntries(["entry-1", "entry-2"]); + const result = validateEntries(__, ["entry-1", "entry-2"]); assert.deepEqual(result, ["entry-1", "entry-2"]); }); it("Rejects missing entries", () => { - assert.throws(() => validateEntries(), { - message: /Missing required parameter: entry/, + assert.throws(() => validateEntries(__), { + message: /missingParameter entry/, }); }); }); describe("validateChannelName", () => { it("Accepts valid name", () => { - assert.doesNotThrow(() => validateChannelName("My Channel")); + assert.doesNotThrow(() => validateChannelName(__, "My Channel")); }); it("Rejects empty name", () => { - assert.throws(() => validateChannelName(""), { - message: /Missing required parameter: name/, + assert.throws(() => validateChannelName(__, ""), { + message: /missingParameter name/, }); }); it("Rejects name over 100 characters", () => { const longName = "a".repeat(101); - assert.throws(() => validateChannelName(longName), { - message: /100 characters or less/, + assert.throws(() => validateChannelName(__, longName), { + message: /nameTooLong 100/, }); }); }); diff --git a/packages/indiekit/lib/migrate-uid.js b/packages/indiekit/lib/migrate-uid.js index 29f03ec13..cff9914f1 100644 --- a/packages/indiekit/lib/migrate-uid.js +++ b/packages/indiekit/lib/migrate-uid.js @@ -1,28 +1,4 @@ -import { randomBytes } from "node:crypto"; - -/** - * A UUIDv7 for a known point in time - * - * `crypto.randomUUIDv7()` always stamps the current time, so it cannot give an - * existing post an identifier that sorts by when the post was created. RFC 9562 - * lays the value out as a 48-bit big-endian millisecond timestamp, four version - * bits, twelve free bits, two variant bits, then random. `seq` goes in the free - * bits so that documents sharing a timestamp keep the order they arrive in. - * @param {number} msecs - Milliseconds since the epoch - * @param {number} seq - Tiebreaker within one millisecond, 0-4095 - * @returns {string} UUIDv7 - */ -export const uuidv7At = (msecs, seq) => { - const bytes = randomBytes(16); - - bytes.writeUIntBE(msecs, 0, 6); - bytes.writeUInt16BE(0x70_00 | (seq & 0x0f_ff), 6); - bytes[8] = (bytes[8] & 0x3f) | 0x80; - - return bytes - .toString("hex") - .replace(/(.{8})(.{4})(.{4})(.{4})(.{12})/, "$1-$2-$3-$4-$5"); -}; +import { uuidv7At } from "@indiekit/util"; /** * Give every document in a collection a `properties.uid` diff --git a/packages/indiekit/test/unit/migrate-uid.js b/packages/indiekit/test/unit/migrate-uid.js index 20b5f8726..26271c89d 100644 --- a/packages/indiekit/test/unit/migrate-uid.js +++ b/packages/indiekit/test/unit/migrate-uid.js @@ -1,9 +1,10 @@ import { strict as assert } from "node:assert"; import { after, before, describe, it, mock } from "node:test"; +import { uuidv7At } from "@indiekit/util"; import { testDatabase } from "@indiekit-test/database"; -import { backfillUids, uuidv7At } from "../../lib/migrate-uid.js"; +import { backfillUids } from "../../lib/migrate-uid.js"; // UUIDs sort as strings; a plain `.sort()` would coerce and compare lexically // by default anyway, but the compare function keeps `unicorn/require-array-sort-compare` happy. diff --git a/packages/util/index.js b/packages/util/index.js index 701e74f6d..26853bb78 100644 --- a/packages/util/index.js +++ b/packages/util/index.js @@ -18,5 +18,6 @@ export { slugify, supplant, } from "./lib/string.js"; +export { uuidv7At } from "./lib/uid.js"; export { getCanonicalUrl, isSameOrigin } from "./lib/url.js"; export { isRequired } from "./lib/validation-schema.js"; diff --git a/packages/util/lib/uid.js b/packages/util/lib/uid.js new file mode 100644 index 000000000..af3266282 --- /dev/null +++ b/packages/util/lib/uid.js @@ -0,0 +1,27 @@ +import { randomBytes } from "node:crypto"; + +/** + * A UUIDv7 for a known point in time + * + * `crypto.randomUUIDv7()` always stamps the current time, so it cannot give a + * document an identifier that sorts by when it was created or published. RFC + * 9562 lays the value out as a 48-bit big-endian millisecond timestamp, four + * version bits, twelve free bits, two variant bits, then random. `seq` goes in + * the free bits so that documents sharing a timestamp keep the order they + * arrive in; left out, those bits stay random. + * @param {number} msecs - Milliseconds since the epoch + * @param {number} [seq] - Tiebreaker within one millisecond, 0-4095 + * @returns {string} UUIDv7 + */ +export const uuidv7At = (msecs, seq) => { + const bytes = randomBytes(16); + const sequence = seq ?? bytes.readUInt16BE(6) & 0x0f_ff; + + bytes.writeUIntBE(msecs, 0, 6); + bytes.writeUInt16BE(0x70_00 | (sequence & 0x0f_ff), 6); + bytes[8] = (bytes[8] & 0x3f) | 0x80; + + return bytes + .toString("hex") + .replace(/(.{8})(.{4})(.{4})(.{4})(.{12})/, "$1-$2-$3-$4-$5"); +}; diff --git a/packages/util/test/unit/uid.js b/packages/util/test/unit/uid.js new file mode 100644 index 000000000..4786b6783 --- /dev/null +++ b/packages/util/test/unit/uid.js @@ -0,0 +1,36 @@ +import { strict as assert } from "node:assert"; +import { describe, it } from "node:test"; + +import { uuidv7At } from "../../lib/uid.js"; + +describe("util/lib/uid", () => { + it("Encodes the given time in the first 48 bits", () => { + const uid = uuidv7At(Date.UTC(2026, 0, 2)); + + assert.match( + uid, + /^[\da-f]{8}-[\da-f]{4}-7[\da-f]{3}-[89ab][\da-f]{3}-[\da-f]{12}$/, + ); + const milliseconds = Number.parseInt(uid.slice(0, 13).replace("-", ""), 16); + assert.equal(milliseconds, Date.UTC(2026, 0, 2)); + }); + + it("Sorts by the given time", () => { + assert.ok(uuidv7At(Date.UTC(2026, 0, 1)) < uuidv7At(Date.UTC(2026, 0, 2))); + }); + + it("Keeps the given sequence within a millisecond", () => { + const first = uuidv7At(Date.UTC(2026, 0, 1), 1); + const second = uuidv7At(Date.UTC(2026, 0, 1), 2); + + assert.ok(first < second); + assert.equal(first.slice(14, 18), "7001"); + }); + + it("Differs between two calls for the same millisecond", () => { + assert.notEqual( + uuidv7At(Date.UTC(2026, 0, 1)), + uuidv7At(Date.UTC(2026, 0, 1)), + ); + }); +}); From 1270f8889b5fd37bcd2439138979c7331615167d Mon Sep 17 00:00:00 2001 From: Ricardo Date: Sat, 10 Oct 2026 18:15:05 +0200 Subject: [PATCH 16/17] refactor(endpoint-microsub): type the localiser, drop the uid retry, name the channel variables --- .../endpoint-microsub/lib/storage/channels.js | 22 +++++-------------- .../endpoint-microsub/lib/utils/validation.js | 8 +++---- 2 files changed, 9 insertions(+), 21 deletions(-) diff --git a/packages/endpoint-microsub/lib/storage/channels.js b/packages/endpoint-microsub/lib/storage/channels.js index b9184e2e5..5d1256fa3 100644 --- a/packages/endpoint-microsub/lib/storage/channels.js +++ b/packages/endpoint-microsub/lib/storage/channels.js @@ -21,21 +21,9 @@ const debug = makeDebug("indiekit:endpoint-microsub"); export async function createChannel(application, { name, userId }) { const collection = getChannelsCollection(application); - // Generate unique UID with retry on collision - let uid; - let attempts = 0; - const maxAttempts = 5; - - while (attempts < maxAttempts) { - uid = randomString(24); - const existing = await collection.findOne({ uid }); - if (!existing) break; - attempts++; - } - - if (attempts >= maxAttempts) { - throw new Error("Failed to generate unique channel UID"); - } + // 24 base64url characters are 144 random bits: a collision is not a case + // to handle, and the uid is what clients and URLs name the channel by + const uid = randomString(24); // Get max order for user const maxOrderResult = await collection @@ -92,10 +80,10 @@ export async function getChannels(application, userId) { // Always include notifications channel first const notificationsChannel = channelsWithCounts.find( - (c) => c.uid === "notifications", + (channel) => channel.uid === "notifications", ); const otherChannels = channelsWithCounts.filter( - (c) => c.uid !== "notifications", + (channel) => channel.uid !== "notifications", ); if (notificationsChannel) { diff --git a/packages/endpoint-microsub/lib/utils/validation.js b/packages/endpoint-microsub/lib/utils/validation.js index 0b48676ed..e6b419f54 100644 --- a/packages/endpoint-microsub/lib/utils/validation.js +++ b/packages/endpoint-microsub/lib/utils/validation.js @@ -17,7 +17,7 @@ export const MAX_NAME_LENGTH = 100; /** * Validate action parameter - * @param {Function} __ - Localisation function + * @param {(key: string, ...values: Array) => string} __ - Localisation function * @param {string|null} [action] - Action to validate * @throws {IndiekitError} If action is invalid */ @@ -37,7 +37,7 @@ export function validateAction(__, action) { /** * Validate channel UID - * @param {Function} __ - Localisation function + * @param {(key: string, ...values: Array) => string} __ - Localisation function * @param {string} [channel] - Channel UID to validate * @param {boolean} [isRequired] - Whether channel is required * @throws {IndiekitError} If channel is invalid @@ -58,7 +58,7 @@ export function validateChannel(__, channel, isRequired = true) { /** * Validate entry/entries parameter - * @param {Function} __ - Localisation function + * @param {(key: string, ...values: Array) => string} __ - Localisation function * @param {string|Array} [entry] - Entry ID(s) to validate * @returns {Array} Array of entry IDs * @throws {IndiekitError} If entry is invalid @@ -84,7 +84,7 @@ export function validateEntries(__, entry) { /** * Validate channel name - * @param {Function} __ - Localisation function + * @param {(key: string, ...values: Array) => string} __ - Localisation function * @param {string} name - Channel name to validate * @throws {IndiekitError} If name is invalid */ From 66802a0811a1621389569144855d4bc4f2ed2f8d Mon Sep 17 00:00:00 2001 From: Ricardo Date: Sat, 10 Oct 2026 18:24:38 +0200 Subject: [PATCH 17/17] chore(endpoint-microsub): no init logging, README and docs page, engines like its siblings Other endpoints announce nothing at init and core already logs each collection it adds under debug, so the console lines go; index creation throws like core's does rather than warn and carry on. --- docs/.vitepress/config.js | 4 +++ docs/plugins/endpoints/index.md | 1 + docs/plugins/endpoints/microsub.md | 1 + package-lock.json | 10 +++--- packages/endpoint-microsub/README.md | 35 +++++++++++++++++++ packages/endpoint-microsub/index.js | 15 +------- .../endpoint-microsub/lib/utils/validation.js | 2 +- packages/endpoint-microsub/package.json | 4 +-- 8 files changed, 50 insertions(+), 22 deletions(-) create mode 100644 docs/plugins/endpoints/microsub.md create mode 100644 packages/endpoint-microsub/README.md diff --git a/docs/.vitepress/config.js b/docs/.vitepress/config.js index 2b8b1efea..e8bd94089 100644 --- a/docs/.vitepress/config.js +++ b/docs/.vitepress/config.js @@ -135,6 +135,10 @@ const sidebarPlugins = [ text: "Micropub media", link: "/plugins/endpoints/media", }, + { + text: "Microsub", + link: "/plugins/endpoints/microsub", + }, { text: "Posts", link: "/plugins/endpoints/posts", diff --git a/docs/plugins/endpoints/index.md b/docs/plugins/endpoints/index.md index cf0e3bc6e..64152be70 100644 --- a/docs/plugins/endpoints/index.md +++ b/docs/plugins/endpoints/index.md @@ -8,6 +8,7 @@ An [endpoint](../../concepts#endpoint) is a path on your Indiekit server that ap - [Files](files.md) `@indiekit/endpoint-files` - [Image resizing](image.md) `@indiekit/endpoint-image` - [Micropub](micropub.md) `@indiekit/endpoint-micropub` +- [Microsub](microsub.md) `@indiekit/endpoint-microsub` - [Media](media.md) `@indiekit/endpoint-media` - [Posts](posts.md) `@indiekit/endpoint-posts` - [Share](share.md) `@indiekit/endpoint-share` diff --git a/docs/plugins/endpoints/microsub.md b/docs/plugins/endpoints/microsub.md new file mode 100644 index 000000000..d0327d7d4 --- /dev/null +++ b/docs/plugins/endpoints/microsub.md @@ -0,0 +1 @@ + diff --git a/package-lock.json b/package-lock.json index fa8bc6648..8aead908a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -20632,9 +20632,9 @@ } }, "node_modules/process-warning": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/process-warning/-/process-warning-5.1.0.tgz", - "integrity": "sha512-jQSaVHsPgtyw60e1rQ/A+/ArPEj/S8pS/vFnyGa/gYFXrKk/6RuDkoqVDQ5NI5MmS01698ltlAk0NoDBNLujRw==", + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/process-warning/-/process-warning-5.1.1.tgz", + "integrity": "sha512-oCkNVj4OKnKEcocVBcDfiIugN1/cE6rgfT3nCtzeqZPA8+rIxMaQ0xXzIkGP8Gj2qUaP1UR7uhJ1VD8lUCpzCg==", "dev": true, "funding": [ { @@ -26471,10 +26471,10 @@ "@indiekit/error": "^1.0.0-beta.25", "@indiekit/util": "^1.0.0-beta.28", "debug": "^4.4.3", - "express": "^5.0.0" + "express": "^5.3.0" }, "engines": { - "node": ">=20" + "node": ">=24.17" } }, "packages/endpoint-microsub/node_modules/accepts": { diff --git a/packages/endpoint-microsub/README.md b/packages/endpoint-microsub/README.md new file mode 100644 index 000000000..7bb8c8ca7 --- /dev/null +++ b/packages/endpoint-microsub/README.md @@ -0,0 +1,35 @@ +# @indiekit/endpoint-microsub + +Microsub endpoint for Indiekit. Lets a Microsub client, such as a social reader, manage channels and read their timelines. + +## Installation + +`npm install @indiekit/endpoint-microsub` + +## Usage + +Add `@indiekit/endpoint-microsub` to your list of plug-ins, specifying options as required: + +```json +{ + "plugins": ["@indiekit/endpoint-microsub"], + "@indiekit/endpoint-microsub": { + "mountPath": "/reader" + } +} +``` + +## Options + +| Option | Type | Description | +| :---------- | :------- | :------------------------------------------------------------------------ | +| `mountPath` | `string` | Path to listen to Microsub requests. _Optional_, defaults to `/microsub`. | + +## Supported actions + +- Channels: `/microsub?action=channels` lists them; `POST` with `method` set to `create`, `update`, `delete` or `order` changes them. +- Timeline: `/microsub?action=timeline&channel=UID` lists a channel's items, newest first, paged with `after` and `before`; `POST` with `method` set to `mark_read`, `mark_unread` or `remove` changes them. + +Following, muting, blocking, search and preview are not supported yet. + +This endpoint requires a database. diff --git a/packages/endpoint-microsub/index.js b/packages/endpoint-microsub/index.js index 92bb47d2c..c15d0a5d3 100644 --- a/packages/endpoint-microsub/index.js +++ b/packages/endpoint-microsub/index.js @@ -37,14 +37,9 @@ export default class MicrosubEndpoint { * @param {object} indiekit - Indiekit instance */ async init(indiekit) { - console.info("[Microsub] Initializing endpoint-microsub plugin"); - - // Register MongoDB collections indiekit.addCollection("microsub_channels"); indiekit.addCollection("microsub_items"); - console.info("[Microsub] Registered MongoDB collections"); - // Register endpoint indiekit.addEndpoint(this); @@ -53,16 +48,8 @@ export default class MicrosubEndpoint { indiekit.config.application.microsubEndpoint = this.mountPath; } - // Create indexes for optimal performance if (indiekit.database) { - try { - await createIndexes(indiekit); - } catch (error) { - console.warn( - "[Microsub] Index creation failed:", - error instanceof Error ? error.message : String(error), - ); - } + await createIndexes(indiekit); } } } diff --git a/packages/endpoint-microsub/lib/utils/validation.js b/packages/endpoint-microsub/lib/utils/validation.js index e6b419f54..832b91be0 100644 --- a/packages/endpoint-microsub/lib/utils/validation.js +++ b/packages/endpoint-microsub/lib/utils/validation.js @@ -6,7 +6,7 @@ import { IndiekitError } from "@indiekit/error"; /** - * Valid Microsub actions (PR 1: channels and timeline only) + * Microsub actions this endpoint supports */ export const VALID_ACTIONS = ["channels", "timeline"]; diff --git a/packages/endpoint-microsub/package.json b/packages/endpoint-microsub/package.json index c4f72adca..8f7c15b66 100644 --- a/packages/endpoint-microsub/package.json +++ b/packages/endpoint-microsub/package.json @@ -23,7 +23,7 @@ ], "license": "MIT", "engines": { - "node": ">=20" + "node": ">=24.17" }, "type": "module", "main": "index.js", @@ -45,7 +45,7 @@ "@indiekit/error": "^1.0.0-beta.25", "@indiekit/util": "^1.0.0-beta.28", "debug": "^4.4.3", - "express": "^5.0.0" + "express": "^5.3.0" }, "publishConfig": { "access": "public"