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",
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/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