diff --git a/docs/.vitepress/config.js b/docs/.vitepress/config.js
index 2b8b1efea..e8bd94089 100644
--- a/docs/.vitepress/config.js
+++ b/docs/.vitepress/config.js
@@ -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",
diff --git a/docs/plugins/endpoints/index.md b/docs/plugins/endpoints/index.md
index cf0e3bc6e..64152be70 100644
--- a/docs/plugins/endpoints/index.md
+++ b/docs/plugins/endpoints/index.md
@@ -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`
diff --git a/docs/plugins/endpoints/microsub.md b/docs/plugins/endpoints/microsub.md
new file mode 100644
index 000000000..d0327d7d4
--- /dev/null
+++ b/docs/plugins/endpoints/microsub.md
@@ -0,0 +1 @@
+
diff --git a/indiekit.config.js b/indiekit.config.js
index 67c2a8af3..1b90fe864 100644
--- a/indiekit.config.js
+++ b/indiekit.config.js
@@ -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",
diff --git a/package-lock.json b/package-lock.json
index 8e2bcd7dc..8aead908a 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
@@ -5398,7 +5402,7 @@
"version": "8.0.2",
"resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz",
"integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==",
- "devOptional": true,
+ "dev": true,
"license": "ISC",
"dependencies": {
"string-width": "^5.1.2",
@@ -5416,7 +5420,7 @@
"version": "6.4.0",
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.4.0.tgz",
"integrity": "sha512-KzTVk2tCWAHtYrvvvaP8bJKJq2pVinhLcGEQdtLIYPbmNGNyYe8QwNaTUYQp2J7/vIsUKt5QCqAfUkYyG9DkOw==",
- "devOptional": true,
+ "dev": true,
"license": "MIT",
"engines": {
"node": ">=12"
@@ -5429,7 +5433,7 @@
"version": "6.2.3",
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz",
"integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==",
- "devOptional": true,
+ "dev": true,
"license": "MIT",
"engines": {
"node": ">=12"
@@ -5442,14 +5446,14 @@
"version": "9.2.2",
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz",
"integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==",
- "devOptional": true,
+ "dev": true,
"license": "MIT"
},
"node_modules/@isaacs/cliui/node_modules/string-width": {
"version": "5.1.2",
"resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz",
"integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==",
- "devOptional": true,
+ "dev": true,
"license": "MIT",
"dependencies": {
"eastasianwidth": "^0.2.0",
@@ -5467,7 +5471,7 @@
"version": "7.2.0",
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz",
"integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==",
- "devOptional": true,
+ "dev": true,
"license": "MIT",
"dependencies": {
"ansi-regex": "^6.2.2"
@@ -5483,7 +5487,7 @@
"version": "8.1.0",
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz",
"integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==",
- "devOptional": true,
+ "dev": true,
"license": "MIT",
"dependencies": {
"ansi-styles": "^6.1.0",
@@ -8410,6 +8414,7 @@
"version": "0.11.0",
"resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz",
"integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==",
+ "dev": true,
"license": "MIT",
"optional": true,
"engines": {
@@ -11252,7 +11257,7 @@
"version": "9.3.1",
"resolved": "https://registry.npmjs.org/bignumber.js/-/bignumber.js-9.3.1.tgz",
"integrity": "sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ==",
- "devOptional": true,
+ "dev": true,
"license": "MIT",
"engines": {
"node": "*"
@@ -12502,7 +12507,7 @@
"version": "7.0.6",
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
"integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==",
- "devOptional": true,
+ "dev": true,
"license": "MIT",
"dependencies": {
"path-key": "^3.1.0",
@@ -12567,7 +12572,7 @@
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-4.0.1.tgz",
"integrity": "sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==",
- "devOptional": true,
+ "dev": true,
"license": "MIT",
"engines": {
"node": ">= 12"
@@ -13092,7 +13097,7 @@
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz",
"integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==",
- "devOptional": true,
+ "dev": true,
"license": "MIT"
},
"node_modules/easymde": {
@@ -14137,7 +14142,7 @@
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz",
"integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==",
- "devOptional": true,
+ "dev": true,
"license": "MIT"
},
"node_modules/fast-deep-equal": {
@@ -14315,7 +14320,7 @@
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/fetch-blob/-/fetch-blob-3.2.0.tgz",
"integrity": "sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==",
- "devOptional": true,
+ "dev": true,
"funding": [
{
"type": "github",
@@ -14556,7 +14561,7 @@
"version": "3.3.1",
"resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz",
"integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==",
- "devOptional": true,
+ "dev": true,
"license": "ISC",
"dependencies": {
"cross-spawn": "^7.0.6",
@@ -14619,7 +14624,7 @@
"version": "4.0.10",
"resolved": "https://registry.npmjs.org/formdata-polyfill/-/formdata-polyfill-4.0.10.tgz",
"integrity": "sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g==",
- "devOptional": true,
+ "dev": true,
"license": "MIT",
"dependencies": {
"fetch-blob": "^3.1.2"
@@ -14773,7 +14778,7 @@
"version": "7.1.3",
"resolved": "https://registry.npmjs.org/gaxios/-/gaxios-7.1.3.tgz",
"integrity": "sha512-YGGyuEdVIjqxkxVH1pUTMY/XtmmsApXrCVv5EU25iX6inEPbV+VakJfLealkBtJN69AQmh1eGOdCl9Sm1UP6XQ==",
- "devOptional": true,
+ "dev": true,
"license": "Apache-2.0",
"dependencies": {
"extend": "^3.0.2",
@@ -14789,7 +14794,7 @@
"version": "7.1.4",
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz",
"integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==",
- "devOptional": true,
+ "dev": true,
"license": "MIT",
"engines": {
"node": ">= 14"
@@ -14799,7 +14804,7 @@
"version": "7.0.6",
"resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz",
"integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==",
- "devOptional": true,
+ "dev": true,
"license": "MIT",
"dependencies": {
"agent-base": "^7.1.2",
@@ -15143,7 +15148,7 @@
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/google-logging-utils/-/google-logging-utils-1.1.3.tgz",
"integrity": "sha512-eAmLkjDjAFCVXg7A1unxHsLf961m6y17QFqXqAXGj/gVkKFrEICfStRfwUlGNfeCEjNRa32JEWOUTlYXPyyKvA==",
- "devOptional": true,
+ "dev": true,
"license": "Apache-2.0",
"engines": {
"node": ">=14"
@@ -16542,7 +16547,7 @@
"version": "3.4.3",
"resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz",
"integrity": "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==",
- "devOptional": true,
+ "dev": true,
"license": "BlueOak-1.0.0",
"dependencies": {
"@isaacs/cliui": "^8.0.2"
@@ -16690,7 +16695,7 @@
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/json-bigint/-/json-bigint-1.0.0.tgz",
"integrity": "sha512-SiPv/8VpZuWbvLSMtTDU8hEfrZWg/mH/nV/b4o0CYbSxu1UIQPLdwKOCIyLQX+VIPO5vrLX3i8qtqFyhdPSUSQ==",
- "devOptional": true,
+ "dev": true,
"license": "MIT",
"dependencies": {
"bignumber.js": "^9.0.0"
@@ -17552,7 +17557,7 @@
"version": "10.4.3",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz",
"integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==",
- "devOptional": true,
+ "dev": true,
"license": "ISC"
},
"node_modules/magic-string": {
@@ -18756,7 +18761,7 @@
"resolved": "https://registry.npmjs.org/node-domexception/-/node-domexception-1.0.0.tgz",
"integrity": "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==",
"deprecated": "Use your platform's native DOMException instead",
- "devOptional": true,
+ "dev": true,
"funding": [
{
"type": "github",
@@ -18805,7 +18810,7 @@
"version": "3.3.2",
"resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-3.3.2.tgz",
"integrity": "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==",
- "devOptional": true,
+ "dev": true,
"license": "MIT",
"dependencies": {
"data-uri-to-buffer": "^4.0.0",
@@ -19844,7 +19849,7 @@
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz",
"integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==",
- "devOptional": true,
+ "dev": true,
"license": "BlueOak-1.0.0"
},
"node_modules/pacote": {
@@ -20627,9 +20632,9 @@
}
},
"node_modules/process-warning": {
- "version": "5.1.0",
- "resolved": "https://registry.npmjs.org/process-warning/-/process-warning-5.1.0.tgz",
- "integrity": "sha512-jQSaVHsPgtyw60e1rQ/A+/ArPEj/S8pS/vFnyGa/gYFXrKk/6RuDkoqVDQ5NI5MmS01698ltlAk0NoDBNLujRw==",
+ "version": "5.1.1",
+ "resolved": "https://registry.npmjs.org/process-warning/-/process-warning-5.1.1.tgz",
+ "integrity": "sha512-oCkNVj4OKnKEcocVBcDfiIugN1/cE6rgfT3nCtzeqZPA8+rIxMaQ0xXzIkGP8Gj2qUaP1UR7uhJ1VD8lUCpzCg==",
"dev": true,
"funding": [
{
@@ -21285,7 +21290,7 @@
"version": "5.0.10",
"resolved": "https://registry.npmjs.org/rimraf/-/rimraf-5.0.10.tgz",
"integrity": "sha512-l0OE8wL34P4nJH/H2ffoaniAokM2qSmrtXHmlpvYr5AVVX8msAyW0l8NVJFDxlSK4u3Uh/f41cQheDVdnYijwQ==",
- "devOptional": true,
+ "dev": true,
"license": "ISC",
"dependencies": {
"glob": "^10.3.7"
@@ -21301,14 +21306,14 @@
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
"integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
- "devOptional": true,
+ "dev": true,
"license": "MIT"
},
"node_modules/rimraf/node_modules/brace-expansion": {
"version": "2.1.7",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.7.tgz",
"integrity": "sha512-uZbew1NqdmPDTMJ8ah1y+b+9QEJrfkXFk3RcTQw3X0jW/xRUvFKsg1CfQdSYGdTbXZWExtU3J3ccxtnfw1Fi0g==",
- "devOptional": true,
+ "dev": true,
"license": "MIT",
"dependencies": {
"balanced-match": "^1.0.0"
@@ -21319,7 +21324,7 @@
"resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz",
"integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==",
"deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me",
- "devOptional": true,
+ "dev": true,
"license": "ISC",
"dependencies": {
"foreground-child": "^3.1.0",
@@ -21340,7 +21345,7 @@
"version": "9.0.9",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz",
"integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==",
- "devOptional": true,
+ "dev": true,
"license": "ISC",
"dependencies": {
"brace-expansion": "^2.0.2"
@@ -21356,7 +21361,7 @@
"version": "1.11.1",
"resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz",
"integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==",
- "devOptional": true,
+ "dev": true,
"license": "BlueOak-1.0.0",
"dependencies": {
"lru-cache": "^10.2.0",
@@ -21942,7 +21947,7 @@
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
"integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==",
- "devOptional": true,
+ "dev": true,
"license": "MIT",
"dependencies": {
"shebang-regex": "^3.0.0"
@@ -21955,7 +21960,7 @@
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz",
"integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==",
- "devOptional": true,
+ "dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
@@ -22559,7 +22564,7 @@
"version": "4.2.3",
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
"integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
- "devOptional": true,
+ "dev": true,
"license": "MIT",
"dependencies": {
"emoji-regex": "^8.0.0",
@@ -22689,7 +22694,7 @@
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
"integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
- "devOptional": true,
+ "dev": true,
"license": "MIT",
"dependencies": {
"ansi-regex": "^5.0.1"
@@ -24265,7 +24270,7 @@
"version": "3.3.3",
"resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-3.3.3.tgz",
"integrity": "sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==",
- "devOptional": true,
+ "dev": true,
"license": "MIT",
"engines": {
"node": ">= 8"
@@ -24310,7 +24315,7 @@
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
"integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==",
- "devOptional": true,
+ "dev": true,
"license": "ISC",
"dependencies": {
"isexe": "^2.0.0"
@@ -24464,7 +24469,7 @@
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz",
"integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==",
- "devOptional": true,
+ "dev": true,
"license": "MIT",
"dependencies": {
"ansi-styles": "^4.0.0",
@@ -24482,7 +24487,7 @@
"version": "4.2.3",
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
"integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
- "devOptional": true,
+ "dev": true,
"license": "MIT",
"dependencies": {
"emoji-regex": "^8.0.0",
@@ -26458,6 +26463,300 @@
"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",
+ "@indiekit/util": "^1.0.0-beta.28",
+ "debug": "^4.4.3",
+ "express": "^5.3.0"
+ },
+ "engines": {
+ "node": ">=24.17"
+ }
+ },
+ "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/content-disposition": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-2.0.1.tgz",
+ "integrity": "sha512-e+H0ZXHSWYrENhQzw1LPuP4oF5MzVKmDU6d3hxlvaPEYLLg62MxtQNPRx4SYSuYJSBUgnQIG4HIN2tEtNv7Dog==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
+ "packages/endpoint-microsub/node_modules/content-type": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.1.0.tgz",
+ "integrity": "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag==",
+ "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/express": {
+ "version": "5.3.0",
+ "resolved": "https://registry.npmjs.org/express/-/express-5.3.0.tgz",
+ "integrity": "sha512-qCqy1xSoaugxOD4IVHb1ZWdkvCF2W3aE2okKouWj8VwJ+ztskSiXoBnCSJqfKT+cVPJhuJqe9YLbY425+Zh00g==",
+ "license": "MIT",
+ "dependencies": {
+ "accepts": "^2.0.0",
+ "body-parser": "^2.3.0",
+ "content-disposition": "^2.0.1",
+ "content-type": "^2.0.0",
+ "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.8",
+ "qs": "^6.16.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.1.0",
+ "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.1",
+ "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.1.tgz",
+ "integrity": "sha512-yz3xRaG20c6/BOzvYoDaGtPmGscs7YivItZEEqe6GbwNfHuxu9YNmvnEkMzKldAGY4/80pRcQRZSEnhquk9XuQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
+ "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/negotiator": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.1.0.tgz",
+ "integrity": "sha512-NMPBRMJgiQHjbd8phG3Vebdx4kZ1H121rbl5IkMqeOsahptB9BKo/d7oJ3zTXqTgagn2bWlNSXkh0QUGM31RYg==",
+ "license": "MIT",
+ "dependencies": {
+ "content-type": "^2.1.0"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
+ "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/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-posts": {
"name": "@indiekit/endpoint-posts",
"version": "1.0.0-beta.29",
diff --git a/packages/endpoint-microsub/README.md b/packages/endpoint-microsub/README.md
new file mode 100644
index 000000000..7bb8c8ca7
--- /dev/null
+++ b/packages/endpoint-microsub/README.md
@@ -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.
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..c15d0a5d3
--- /dev/null
+++ b/packages/endpoint-microsub/index.js
@@ -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);
+ }
+ }
+}
diff --git a/packages/endpoint-microsub/lib/controllers/channels.js b/packages/endpoint-microsub/lib/controllers/channels.js
new file mode 100644
index 000000000..8928cf27c
--- /dev/null
+++ b/packages/endpoint-microsub/lib/controllers/channels.js
@@ -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}
+ */
+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 };
diff --git a/packages/endpoint-microsub/lib/controllers/microsub.js b/packages/endpoint-microsub/lib/controllers/microsub.js
new file mode 100644
index 000000000..c4dada3a3
--- /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 {import("express").NextFunction} 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(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}
+ */
+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 };
diff --git a/packages/endpoint-microsub/lib/controllers/timeline.js b/packages/endpoint-microsub/lib/controllers/timeline.js
new file mode 100644
index 000000000..cb1ffabcb
--- /dev/null
+++ b/packages/endpoint-microsub/lib/controllers/timeline.js
@@ -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=
+ * @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}
+ */
+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 };
diff --git a/packages/endpoint-microsub/lib/storage/channels.js b/packages/endpoint-microsub/lib/storage/channels.js
new file mode 100644
index 000000000..5d1256fa3
--- /dev/null
+++ b/packages/endpoint-microsub/lib/storage/channels.js
@@ -0,0 +1,221 @@
+/**
+ * Channel storage operations
+ * @module storage/channels
+ */
+
+import { randomString } from "@indiekit/util";
+import makeDebug from "debug";
+
+import { getChannelsCollection, getItemsCollection } from "./collections.js";
+
+const debug = makeDebug("indiekit:endpoint-microsub");
+
+/**
+ * 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