Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
4 changes: 4 additions & 0 deletions docs/.vitepress/config.js
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,10 @@ const sidebarPlugins = [
text: "Micropub media",
link: "/plugins/endpoints/media",
},
{
text: "Microsub",
link: "/plugins/endpoints/microsub",
},
{
text: "Posts",
link: "/plugins/endpoints/posts",
Expand Down
1 change: 1 addition & 0 deletions docs/plugins/endpoints/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ An [endpoint](../../concepts#endpoint) is a path on your Indiekit server that ap
- [Files](files.md) `@indiekit/endpoint-files`
- [Image resizing](image.md) `@indiekit/endpoint-image`
- [Micropub](micropub.md) `@indiekit/endpoint-micropub`
- [Microsub](microsub.md) `@indiekit/endpoint-microsub`
- [Media](media.md) `@indiekit/endpoint-media`
- [Posts](posts.md) `@indiekit/endpoint-posts`
- [Share](share.md) `@indiekit/endpoint-share`
Expand Down
1 change: 1 addition & 0 deletions docs/plugins/endpoints/microsub.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
<!--@include: ../../../packages/endpoint-microsub/README.md-->
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
383 changes: 341 additions & 42 deletions package-lock.json

Large diffs are not rendered by default.

35 changes: 35 additions & 0 deletions packages/endpoint-microsub/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
# @indiekit/endpoint-microsub

Microsub endpoint for Indiekit. Lets a Microsub client, such as a social reader, manage channels and read their timelines.

## Installation

`npm install @indiekit/endpoint-microsub`

## Usage

Add `@indiekit/endpoint-microsub` to your list of plug-ins, specifying options as required:

```json
{
"plugins": ["@indiekit/endpoint-microsub"],
"@indiekit/endpoint-microsub": {
"mountPath": "/reader"
}
}
```

## Options

| Option | Type | Description |
| :---------- | :------- | :------------------------------------------------------------------------ |
| `mountPath` | `string` | Path to listen to Microsub requests. _Optional_, defaults to `/microsub`. |

## Supported actions

- Channels: `/microsub?action=channels` lists them; `POST` with `method` set to `create`, `update`, `delete` or `order` changes them.
- Timeline: `/microsub?action=timeline&channel=UID` lists a channel's items, newest first, paged with `after` and `before`; `POST` with `method` set to `mark_read`, `mark_unread` or `remove` changes them.

Following, muting, blocking, search and preview are not supported yet.

This endpoint requires a database.
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.
55 changes: 55 additions & 0 deletions packages/endpoint-microsub/index.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
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) {
indiekit.addCollection("microsub_channels");
indiekit.addCollection("microsub_items");

// Register endpoint
indiekit.addEndpoint(this);

// Set microsub endpoint URL in config
if (!indiekit.config.application.microsubEndpoint) {
indiekit.config.application.microsubEndpoint = this.mountPath;
}

if (indiekit.database) {
await createIndexes(indiekit);
}
}
}
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 };
Loading
Loading