Skip to content
Merged
Show file tree
Hide file tree
Changes from 16 commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
e6b8f79
feat(endpoint-microsub): add core Microsub server with channels and t…
rmdes Feb 1, 2026
cdd714a
ci: add microsub endpoint to development config
paulrobertlloyd Jul 4, 2026
7d2c6a6
feat(endpoint-microsub): add plug-in icon
paulrobertlloyd Jul 4, 2026
0e41045
fix(endpoint-microsub): use same mongodb version as indiekit
rmdes Aug 15, 2026
818707a
style(endpoint-microsub): fix eslint errors
rmdes Aug 15, 2026
eb32e2f
test(endpoint-microsub): add unit and integration tests
rmdes Aug 15, 2026
96036f8
refactor(endpoint-microsub): use getObjectId from @indiekit/util
rmdes Aug 16, 2026
182ec81
chore: update lockfile for endpoint-microsub dependency change
rmdes Aug 22, 2026
76c927d
refactor(endpoint-microsub): use utility methods for uid and logging
rmdes Aug 22, 2026
3a2af4f
test(endpoint-microsub): drop the constant parameter eslint rejects
rmdes Oct 9, 2026
4bd122e
chore(endpoint-microsub): satisfy strictNullChecks and the new lint r…
rmdes Oct 9, 2026
7992fbe
refactor(endpoint-microsub): use mongodb's ObjectId now that util no …
rmdes Oct 10, 2026
a022176
feat(endpoint-microsub): page the timeline with the shared cursor
rmdes Oct 10, 2026
615154a
refactor(endpoint-microsub): reference channels by uid, not by Mongo id
rmdes Oct 10, 2026
8332296
refactor(endpoint-microsub): address review
rmdes Oct 10, 2026
1270f88
refactor(endpoint-microsub): type the localiser, drop the uid retry, …
rmdes Oct 10, 2026
66802a0
chore(endpoint-microsub): no init logging, README and docs page, engi…
rmdes Oct 10, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions indiekit.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ const config = {
plugins: [
"@indiekit-test/frontend",
"@indiekit/endpoint-json-feed",
"@indiekit/endpoint-microsub",
"@indiekit/endpoint-webmention-io",
"@indiekit/post-type-audio",
"@indiekit/post-type-event",
Expand Down
377 changes: 338 additions & 39 deletions package-lock.json

Large diffs are not rendered by default.

4 changes: 4 additions & 0 deletions packages/endpoint-microsub/assets/icon.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
68 changes: 68 additions & 0 deletions packages/endpoint-microsub/index.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
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
*/
async init(indiekit) {
console.info("[Microsub] Initializing endpoint-microsub plugin");
Comment thread
paulrobertlloyd marked this conversation as resolved.
Outdated

// Register MongoDB collections
indiekit.addCollection("microsub_channels");
indiekit.addCollection("microsub_items");

console.info("[Microsub] Registered MongoDB collections");
Comment thread
paulrobertlloyd marked this conversation as resolved.
Outdated

// 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
if (indiekit.database) {
try {
await createIndexes(indiekit);
} catch (error) {
console.warn(
"[Microsub] Index creation failed:",
error instanceof Error ? error.message : String(error),
);
}
}
}
}
105 changes: 105 additions & 0 deletions packages/endpoint-microsub/lib/controllers/channels.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
/**
* Channel management controller
* @module controllers/channels
*/

import { IndiekitError } from "@indiekit/error";

import {
getChannels,
createChannel,
updateChannel,
deleteChannel,
reorderChannels,
} from "../storage/channels.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, publication } = request.app.locals;

const channels = await getChannels(application, publication.me);

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, publication } = request.app.locals;
const { __ } = response.locals;
const userId = publication.me;
const { method, name, uid } = request.body;

// Delete channel
if (method === "delete") {
validateChannel(__, uid);

const deleted = await deleteChannel(application, uid, userId);
if (!deleted) {
throw IndiekitError.notFound(__("microsub.error.channelNotFound"));
}

return response.json({ deleted: uid });
}

// Reorder channels
if (method === "order") {
const channelUids = parseArrayParameter(request.body, "channels");
if (channelUids.length === 0) {
throw IndiekitError.badRequest(
__("BadRequestError.missingParameter", "channels"),
);
}

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 IndiekitError.notFound(__("microsub.error.channelNotFound"));
}

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 };
86 changes: 86 additions & 0 deletions packages/endpoint-microsub/lib/controllers/microsub.js
Original file line number Diff line number Diff line change
@@ -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 {import("express").NextFunction} 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(response.locals.__, action);

switch (action) {
case "channels": {
return listChannels(request, response);
}

case "timeline": {
return getTimeline(request, response);
}

default: {
throw IndiekitError.badRequest(
response.locals.__("BadRequestError.invalidValue", "action"),
);
}
}
} catch (error) {
next(error);
}
}

/**
* Route POST requests to appropriate action handler
* @param {object} request - Express request
* @param {object} response - Express response
* @param {import("express").NextFunction} next - Express next function
* @returns {Promise<void>}
*/
export async function post(request, response, next) {
try {
const action = request.body.action || request.query.action;
validateAction(response.locals.__, action);

switch (action) {
case "channels": {
return channelAction(request, response);
}

case "timeline": {
return timelineAction(request, response);
}

default: {
throw IndiekitError.badRequest(
response.locals.__("BadRequestError.invalidValue", "action"),
);
}
}
} catch (error) {
next(error);
}
}

export const microsubController = { get, post };
116 changes: 116 additions & 0 deletions packages/endpoint-microsub/lib/controllers/timeline.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
/**
* 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 {
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, publication } = request.app.locals;
const { __ } = response.locals;
const userId = publication.me;
const { channel, before, after, limit } = request.query;

validateChannel(__, channel);

// Verify channel exists
const channelDocument = await getChannel(application, channel, userId);
if (!channelDocument) {
throw IndiekitError.notFound(__("microsub.error.channelNotFound"));
}

const timeline = await getTimelineItems(application, channelDocument.uid, {
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, publication } = request.app.locals;
const { __ } = response.locals;
const userId = publication.me;
const { method, channel } = request.body;

validateChannel(__, channel);

// Verify channel exists
const channelDocument = await getChannel(application, channel, userId);
if (!channelDocument) {
throw IndiekitError.notFound(__("microsub.error.channelNotFound"));
}

// Get entry IDs from request
const entries = parseArrayParameter(request.body, "entry");

switch (method) {
case "mark_read": {
validateEntries(__, entries);
const count = await markItemsRead(
application,
channelDocument.uid,
entries,
userId,
);
return response.json({ result: "ok", updated: count });
}

case "mark_unread": {
validateEntries(__, entries);
const count = await markItemsUnread(
application,
channelDocument.uid,
entries,
userId,
);
return response.json({ result: "ok", updated: count });
}

case "remove": {
validateEntries(__, entries);
const count = await removeItems(
application,
channelDocument.uid,
entries,
);
return response.json({ result: "ok", removed: count });
}

default: {
throw IndiekitError.badRequest(
__("BadRequestError.invalidValue", "method"),
);
}
}
}

export const timelineController = { get, action };
Loading
Loading