From d085b05e766671f714fdd854e98c0fb712622046 Mon Sep 17 00:00:00 2001 From: Ricardo Mendes Date: Sun, 1 Feb 2026 12:13:14 +0100 Subject: [PATCH 1/3] 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 ce14bef4c71227ff9286053fc36589cbe4135e15 Mon Sep 17 00:00:00 2001 From: Paul Robert Lloyd Date: Sat, 4 Jul 2026 15:43:06 +0100 Subject: [PATCH 2/3] ci: add microsub endpoint to development config --- indiekit.config.js | 1 + package-lock.json | 446 +++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 447 insertions(+) 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", diff --git a/package-lock.json b/package-lock.json index a96624f18..9f159c6de 100644 --- a/package-lock.json +++ b/package-lock.json @@ -4692,6 +4692,10 @@ "resolved": "packages/endpoint-micropub", "link": true }, + "node_modules/@indiekit/endpoint-microsub": { + "resolved": "packages/endpoint-microsub", + "link": true + }, "node_modules/@indiekit/endpoint-posts": { "resolved": "packages/endpoint-posts", "link": true @@ -25415,6 +25419,448 @@ "url": "https://opencollective.com/express" } }, + "packages/endpoint-microsub": { + "name": "@indiekit/endpoint-microsub", + "version": "1.0.0-alpha.1", + "license": "MIT", + "dependencies": { + "@indiekit/error": "^1.0.0-beta.25", + "express": "^5.0.0", + "mongodb": "^6.0.0" + }, + "engines": { + "node": ">=20" + } + }, + "packages/endpoint-microsub/node_modules/@types/whatwg-url": { + "version": "11.0.5", + "resolved": "https://registry.npmjs.org/@types/whatwg-url/-/whatwg-url-11.0.5.tgz", + "integrity": "sha512-coYR071JRaHa+xoEvvYqvnIHaVqaYrLPbsufM9BF63HkwI5Lgmy2QR8Q5K/lYDYo5AK82wOvSOS0UsLTpTG7uQ==", + "license": "MIT", + "dependencies": { + "@types/webidl-conversions": "*" + } + }, + "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.0.0", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.0.0.tgz", + "integrity": "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "packages/endpoint-microsub/node_modules/bson": { + "version": "6.10.4", + "resolved": "https://registry.npmjs.org/bson/-/bson-6.10.4.tgz", + "integrity": "sha512-WIsKqkSC0ABoBJuT1LEX+2HEvNmNKKgnTAyd0fL8qzK4SH2i9NXg+t08YtdZp/V9IZ33cxe3iV4yM0qg8lMQng==", + "license": "Apache-2.0", + "engines": { + "node": ">=16.20.1" + } + }, + "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/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "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.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz", + "integrity": "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "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/mime-types": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", + "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", + "license": "MIT", + "dependencies": { + "mime-db": "^1.54.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "packages/endpoint-microsub/node_modules/mongodb": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/mongodb/-/mongodb-6.21.0.tgz", + "integrity": "sha512-URyb/VXMjJ4da46OeSXg+puO39XH9DeQpWCslifrRn9JWugy0D+DvvBvkm2WxmHe61O/H19JM66p1z7RHVkZ6A==", + "license": "Apache-2.0", + "dependencies": { + "@mongodb-js/saslprep": "^1.3.0", + "bson": "^6.10.4", + "mongodb-connection-string-url": "^3.0.2" + }, + "engines": { + "node": ">=16.20.1" + }, + "peerDependencies": { + "@aws-sdk/credential-providers": "^3.188.0", + "@mongodb-js/zstd": "^1.1.0 || ^2.0.0", + "gcp-metadata": "^5.2.0", + "kerberos": "^2.0.1", + "mongodb-client-encryption": ">=6.0.0 <7", + "snappy": "^7.3.2", + "socks": "^2.7.1" + }, + "peerDependenciesMeta": { + "@aws-sdk/credential-providers": { + "optional": true + }, + "@mongodb-js/zstd": { + "optional": true + }, + "gcp-metadata": { + "optional": true + }, + "kerberos": { + "optional": true + }, + "mongodb-client-encryption": { + "optional": true + }, + "snappy": { + "optional": true + }, + "socks": { + "optional": true + } + } + }, + "packages/endpoint-microsub/node_modules/mongodb-connection-string-url": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mongodb-connection-string-url/-/mongodb-connection-string-url-3.0.2.tgz", + "integrity": "sha512-rMO7CGo/9BFwyZABcKAWL8UJwH/Kc2x0g72uhDWzG48URRax5TCIcJ7Rc3RZqffZzO/Gwff/jyKwCU9TN8gehA==", + "license": "Apache-2.0", + "dependencies": { + "@types/whatwg-url": "^11.0.2", + "whatwg-url": "^14.1.0 || ^13.0.0" + } + }, + "packages/endpoint-microsub/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "packages/endpoint-microsub/node_modules/negotiator": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz", + "integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "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/tr46": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-5.1.1.tgz", + "integrity": "sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==", + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=18" + } + }, + "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.0.0", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.0.0.tgz", + "integrity": "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "packages/endpoint-microsub/node_modules/webidl-conversions": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz", + "integrity": "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + } + }, + "packages/endpoint-microsub/node_modules/whatwg-url": { + "version": "14.2.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-14.2.0.tgz", + "integrity": "sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw==", + "license": "MIT", + "dependencies": { + "tr46": "^5.1.0", + "webidl-conversions": "^7.0.0" + }, + "engines": { + "node": ">=18" + } + }, "packages/endpoint-posts": { "name": "@indiekit/endpoint-posts", "version": "1.0.0-beta.28", From 5e0b7835757fccd0480214222e0273b23ebcd01f Mon Sep 17 00:00:00 2001 From: Paul Robert Lloyd Date: Sat, 4 Jul 2026 15:53:05 +0100 Subject: [PATCH 3/3] 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"