feat: initial commit - Microsub endpoint for Indiekit

Fork of @indiekit/endpoint-microsub with customizations.
Enables subscribing to feeds and reading content using the Microsub protocol.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Ricardo
2026-02-06 16:32:55 +01:00
commit 30f9939b3a
13 changed files with 1286 additions and 0 deletions

1
.gitignore vendored Normal file
View File

@@ -0,0 +1 @@
node_modules/

63
index.js Normal file
View File

@@ -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);
});
}
}
}

110
lib/controllers/channels.js Normal file
View File

@@ -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<void>}
*/
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 };

View File

@@ -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<void>}
*/
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<void>}
*/
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 };

119
lib/controllers/timeline.js Normal file
View File

@@ -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=<uid>
* @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<void>}
*/
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 };

253
lib/storage/channels.js Normal file
View File

@@ -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<object>} 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>} 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<object|null>} 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<object|null>} 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<boolean>} 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<void>}
*/
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<object>} 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;
}

260
lib/storage/items.js Normal file
View File

@@ -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<object>} 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>} 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>} 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>} 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<void>}
*/
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 });
}

35
lib/utils/auth.js Normal file
View File

@@ -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";
}

148
lib/utils/pagination.js Normal file
View File

@@ -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);
}

17
lib/utils/uid.js Normal file
View File

@@ -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;
}

129
lib/utils/validation.js Normal file
View File

@@ -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;
}

15
locales/en.json Normal file
View File

@@ -0,0 +1,15 @@
{
"microsub": {
"title": "Microsub",
"channels": {
"title": "Channels"
},
"timeline": {
"title": "Timeline"
},
"error": {
"channelNotFound": "Channel not found",
"invalidAction": "Invalid action"
}
}
}

50
package.json Normal file
View File

@@ -0,0 +1,50 @@
{
"name": "@rmdes/indiekit-endpoint-microsub",
"version": "1.0.11",
"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://github.com/rmdes/indiekit-endpoint-microsub",
"author": {
"name": "Ricardo Mendes",
"url": "https://rmendes.net"
},
"contributors": [
{
"name": "Paul Robert Lloyd",
"url": "https://paulrobertlloyd.com"
}
],
"license": "MIT",
"engines": {
"node": ">=20"
},
"type": "module",
"main": "index.js",
"files": [
"lib",
"locales",
"index.js"
],
"bugs": {
"url": "https://github.com/rmdes/indiekit-endpoint-microsub/issues"
},
"repository": {
"type": "git",
"url": "git+https://github.com/rmdes/indiekit-endpoint-microsub.git"
},
"dependencies": {
"@indiekit/error": "^1.0.0-beta.25",
"express": "^5.0.0",
"mongodb": "^6.0.0"
},
"publishConfig": {
"access": "public"
}
}