From 24a2159638d69a46122b60f3a3adf8d3ed3c16b1 Mon Sep 17 00:00:00 2001 From: Ricardo Mendes Date: Sun, 1 Feb 2026 12:13:14 +0100 Subject: [PATCH 1/9] 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 e2bfd73dc0449b865fb2f96eae39563a2876cbac Mon Sep 17 00:00:00 2001 From: Paul Robert Lloyd Date: Sat, 4 Jul 2026 15:43:06 +0100 Subject: [PATCH 2/9] 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 70eadc6bd..2bf02c4c6 100644 --- a/indiekit.config.js +++ b/indiekit.config.js @@ -18,6 +18,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 b0638490266266819dd6d85aa8750ee3e45a1396 Mon Sep 17 00:00:00 2001 From: Paul Robert Lloyd Date: Sat, 4 Jul 2026 15:53:05 +0100 Subject: [PATCH 3/9] 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 0ee2e4228ecec41ca166cfac4a443fcf9bc91181 Mon Sep 17 00:00:00 2001 From: Ricardo Mendes Date: Sat, 15 Aug 2026 13:31:38 +0200 Subject: [PATCH 4/9] 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 e26f698ce62cf9667890bcdfa127bc2a30ce52ac Mon Sep 17 00:00:00 2001 From: Ricardo Mendes Date: Sat, 15 Aug 2026 13:31:47 +0200 Subject: [PATCH 5/9] 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 054ff482866bff4b2856beee8b9570e09d0434a2 Mon Sep 17 00:00:00 2001 From: Ricardo Mendes Date: Sat, 15 Aug 2026 13:31:56 +0200 Subject: [PATCH 6/9] 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 681d95c21eed7b07f8a3ce417230b0573ac5ec82 Mon Sep 17 00:00:00 2001 From: Ricardo Mendes Date: Sun, 16 Aug 2026 19:31:28 +0200 Subject: [PATCH 7/9] 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 ff07211ebc042c79470e005dfd13418764e653b5 Mon Sep 17 00:00:00 2001 From: Ricardo Date: Sat, 22 Aug 2026 14:08:29 +0200 Subject: [PATCH 8/9] 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 | 323 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 323 insertions(+) diff --git a/package-lock.json b/package-lock.json index 904951e15..7c8935663 100644 --- a/package-lock.json +++ b/package-lock.json @@ -4608,6 +4608,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 @@ -27072,6 +27076,325 @@ "integrity": "sha512-U3PppEkleoTnIfi8BozMx3yju3qc/L6SwqWo2Sw+54PX+PX0q9I+r1Um5HCmqD7n9VDX5/v3vQH/AjA6deDdtw==", "license": "MIT" }, + "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/body-parser/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/content-disposition": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.1.0.tgz", + "integrity": "sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g==", + "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.2.1", + "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", + "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", + "license": "MIT", + "dependencies": { + "accepts": "^2.0.0", + "body-parser": "^2.2.1", + "content-disposition": "^1.0.0", + "content-type": "^1.0.5", + "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.7", + "qs": "^6.14.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.0.1", + "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/negotiator/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/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-microsub/node_modules/type-is/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-posts": { "name": "@indiekit/endpoint-posts", "version": "1.0.0-beta.29", From 83593bfd7577bbe4f010a19a629759ae26763a27 Mon Sep 17 00:00:00 2001 From: Ricardo Date: Sat, 22 Aug 2026 18:59:59 +0200 Subject: [PATCH 9/9] 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 7c8935663..0140a7a7c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -27083,6 +27083,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}$/); } });