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} Created channel + */ +export async function createChannel(application, { name, userId }) { + const collection = getChannelsCollection(application); + + // 24 base64url characters are 144 random bits: a collision is not a case + // to handle, and the uid is what clients and URLs name the channel by + const uid = randomString(24); + + // Get max order for user + const maxOrderResult = await collection + .find({ userId }) + .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 of channels with unread counts + */ +export async function getChannels(application, userId) { + const collection = getChannelsCollection(application); + const itemsCollection = getItemsCollection(application); + + const filter = userId ? { userId } : {}; + // eslint-disable-next-line unicorn/no-array-callback-reference -- 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({ + channel: channel.uid, + readBy: { $ne: userId }, + }); + + return { + uid: channel.uid, + name: channel.name, + unread: unreadCount > 0 && unreadCount, + }; + }), + ); + + // Always include notifications channel first + const notificationsChannel = channelsWithCounts.find( + (channel) => channel.uid === "notifications", + ); + const otherChannels = channelsWithCounts.filter( + (channel) => channel.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} Channel or null + */ +export async function getChannel(application, uid, userId) { + const collection = getChannelsCollection(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} Updated channel + */ +export async function updateChannel(application, uid, updates, userId) { + const collection = getChannelsCollection(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} True if deleted + */ +export async function deleteChannel(application, uid, userId) { + const collection = getChannelsCollection(application); + const itemsCollection = getItemsCollection(application); + const query = { uid }; + if (userId) query.userId = userId; + + // Don't allow deleting notifications channel + if (uid === "notifications") { + return false; + } + + const result = await collection.deleteOne(query); + if (result.deletedCount === 0) { + return false; + } + + // Delete all items in channel + const itemsDeleted = await itemsCollection.deleteMany({ channel: uid }); + debug(`Deleted channel ${uid}: ${itemsDeleted.deletedCount} items`); + + return true; +} + +/** + * Reorder channels + * @param {object} application - Indiekit application + * @param {Array} channelUids - Ordered array of channel UIDs + * @param {string} [userId] - User ID + * @returns {Promise} + */ +export async function reorderChannels(application, channelUids, userId) { + const collection = getChannelsCollection(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} Notifications channel + */ +export async function ensureNotificationsChannel(application, userId) { + const collection = getChannelsCollection(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; +} diff --git a/packages/endpoint-microsub/lib/storage/collections.js b/packages/endpoint-microsub/lib/storage/collections.js new file mode 100644 index 000000000..7b1c05281 --- /dev/null +++ b/packages/endpoint-microsub/lib/storage/collections.js @@ -0,0 +1,20 @@ +/** + * Database collections the endpoint keeps + * @module storage/collections + */ + +/** + * Get the channels collection + * @param {object} application - Indiekit application + * @returns {object} MongoDB collection + */ +export const getChannelsCollection = (application) => + application.collections.get("microsub_channels"); + +/** + * Get the timeline items collection + * @param {object} application - Indiekit application + * @returns {object} MongoDB collection + */ +export const getItemsCollection = (application) => + application.collections.get("microsub_items"); diff --git a/packages/endpoint-microsub/lib/storage/items.js b/packages/endpoint-microsub/lib/storage/items.js new file mode 100644 index 000000000..ed0a8d151 --- /dev/null +++ b/packages/endpoint-microsub/lib/storage/items.js @@ -0,0 +1,194 @@ +/** + * Timeline item storage operations + * @module storage/items + */ + +import { getCursor } from "@indiekit/util"; + +import { parseLimit } from "../utils/pagination.js"; + +import { getItemsCollection } from "./collections.js"; + +/** + * Get timeline items for a channel + * @param {object} application - Indiekit application + * @param {string} channel - Channel uid + * @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} Timeline with items and paging + */ +export async function getTimelineItems(application, channel, options = {}) { + const collection = getItemsCollection(application); + const limit = parseLimit(options.limit); + + // Items are listed and paged by their `id`, a UUIDv7 stamped with the + // publication date, through the same cursor posts and media use. + const cursor = await getCursor( + collection, + options.after, + options.before, + limit, + { filter: { channel }, key: "id" }, + ); + + const items = cursor.items.map((item) => + transformToJf2(item, options.userId), + ); + + // Microsub paging: `after` continues down to older items, `before` back + // up to newer ones + const paging = {}; + if (cursor.hasNext) { + paging.after = cursor.lastItem; + } + if (cursor.hasPrev) { + paging.before = cursor.firstItem; + } + + return { items, 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, + _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 {string} channel - Channel uid + * @param {Array} entryIds - Array of entry IDs to mark as read + * @param {string} userId - User ID + * @returns {Promise} Number of items updated + */ +export async function markItemsRead(application, channel, entryIds, userId) { + const collection = getItemsCollection(application); + + // Handle "last-read-entry" special value + if (entryIds.includes("last-read-entry")) { + const result = await collection.updateMany( + { channel }, + { $addToSet: { readBy: userId } }, + ); + return result.modifiedCount; + } + + // Match by the id clients see, the feed's own uid, or url + const result = await collection.updateMany( + { + channel, + $or: [ + { id: { $in: entryIds } }, + { uid: { $in: entryIds } }, + { url: { $in: entryIds } }, + ], + }, + { $addToSet: { readBy: userId } }, + ); + + return result.modifiedCount; +} + +/** + * Mark items as unread + * @param {object} application - Indiekit application + * @param {string} channel - Channel uid + * @param {Array} entryIds - Array of entry IDs to mark as unread + * @param {string} userId - User ID + * @returns {Promise} Number of items updated + */ +export async function markItemsUnread(application, channel, entryIds, userId) { + const collection = getItemsCollection(application); + + // Match by the id clients see, the feed's own uid, or url + const result = await collection.updateMany( + { + channel, + $or: [ + { id: { $in: entryIds } }, + { uid: { $in: entryIds } }, + { url: { $in: entryIds } }, + ], + }, + { $pull: { readBy: userId } }, + ); + + return result.modifiedCount; +} + +/** + * Remove items from channel + * @param {object} application - Indiekit application + * @param {string} channel - Channel uid + * @param {Array} entryIds - Array of entry IDs to remove + * @returns {Promise} Number of items removed + */ +export async function removeItems(application, channel, entryIds) { + const collection = getItemsCollection(application); + + // Match by the id clients see, the feed's own uid, or url + const result = await collection.deleteMany({ + channel, + $or: [ + { id: { $in: entryIds } }, + { uid: { $in: entryIds } }, + { url: { $in: entryIds } }, + ], + }); + + return result.deletedCount; +} + +/** + * Create indexes for efficient queries + * @param {object} application - Indiekit application + * @returns {Promise} + */ +export async function createIndexes(application) { + const collection = getItemsCollection(application); + + // Primary query indexes: `id` orders and pages the timeline, `uid` is the + // feed's own identifier and keeps an item from being stored twice + await collection.createIndex({ channel: 1, id: 1 }, { unique: true }); + await collection.createIndex({ channel: 1, uid: 1 }, { unique: true }); + + // URL matching index for mark_read operations + await collection.createIndex({ channel: 1, url: 1 }); +} diff --git a/packages/endpoint-microsub/lib/utils/pagination.js b/packages/endpoint-microsub/lib/utils/pagination.js new file mode 100644 index 000000000..ca73d876d --- /dev/null +++ b/packages/endpoint-microsub/lib/utils/pagination.js @@ -0,0 +1,27 @@ +/** + * Timeline limit handling + * @module utils/pagination + */ + +/** + * Default pagination limit + */ +export const DEFAULT_LIMIT = 20; + +/** + * Maximum pagination limit + */ +export const MAX_LIMIT = 100; + +/** + * Parse and validate limit parameter + * @param {string|number} [limit] - Requested limit + * @returns {number} Validated limit + */ +export function parseLimit(limit) { + const parsed = Math.trunc(Number(limit)); + if (Number.isNaN(parsed) || parsed < 1) { + return DEFAULT_LIMIT; + } + return Math.min(parsed, MAX_LIMIT); +} diff --git a/packages/endpoint-microsub/lib/utils/validation.js b/packages/endpoint-microsub/lib/utils/validation.js new file mode 100644 index 000000000..832b91be0 --- /dev/null +++ b/packages/endpoint-microsub/lib/utils/validation.js @@ -0,0 +1,140 @@ +/** + * Input validation utilities for Microsub + * @module utils/validation + */ + +import { IndiekitError } from "@indiekit/error"; + +/** + * Microsub actions this endpoint supports + */ +export const VALID_ACTIONS = ["channels", "timeline"]; + +/** + * Longest channel name accepted + */ +export const MAX_NAME_LENGTH = 100; + +/** + * Validate action parameter + * @param {(key: string, ...values: Array) => string} __ - Localisation function + * @param {string|null} [action] - Action to validate + * @throws {IndiekitError} If action is invalid + */ +export function validateAction(__, action) { + if (!action) { + throw IndiekitError.badRequest( + __("BadRequestError.missingParameter", "action"), + ); + } + + if (!VALID_ACTIONS.includes(action)) { + throw IndiekitError.badRequest( + __("BadRequestError.invalidValue", "action"), + ); + } +} + +/** + * Validate channel UID + * @param {(key: string, ...values: Array) => string} __ - Localisation function + * @param {string} [channel] - Channel UID to validate + * @param {boolean} [isRequired] - Whether channel is required + * @throws {IndiekitError} If channel is invalid + */ +export function validateChannel(__, channel, isRequired = true) { + if (isRequired && !channel) { + throw IndiekitError.badRequest( + __("BadRequestError.missingParameter", "channel"), + ); + } + + if (channel && typeof channel !== "string") { + throw IndiekitError.badRequest( + __("BadRequestError.invalidValue", "channel"), + ); + } +} + +/** + * Validate entry/entries parameter + * @param {(key: string, ...values: Array) => string} __ - Localisation function + * @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 IndiekitError.badRequest( + __("BadRequestError.missingParameter", "entry"), + ); + } + + // Normalize to array + const entries = Array.isArray(entry) ? entry : [entry]; + + if (entries.length === 0) { + throw IndiekitError.badRequest( + __("BadRequestError.missingProperty", "entry"), + ); + } + + return entries; +} + +/** + * Validate channel name + * @param {(key: string, ...values: Array) => string} __ - Localisation function + * @param {string} name - Channel name to validate + * @throws {IndiekitError} If name is invalid + */ +export function validateChannelName(__, name) { + if (!name || typeof name !== "string") { + throw IndiekitError.badRequest( + __("BadRequestError.missingParameter", "name"), + ); + } + + if (name.length > MAX_NAME_LENGTH) { + throw IndiekitError.badRequest( + __("microsub.error.nameTooLong", MAX_NAME_LENGTH), + ); + } +} + +/** + * 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) { + const value = body[parameterName]; + + // Direct array + if (Array.isArray(value)) { + return value; + } + + // Single value + if (value) { + return [value]; + } + + // 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[]) + const bracketValues = body[`${parameterName}[]`]; + if (bracketValues) { + return Array.isArray(bracketValues) ? bracketValues : [bracketValues]; + } + + return result; +} diff --git a/packages/endpoint-microsub/locales/en.json b/packages/endpoint-microsub/locales/en.json new file mode 100644 index 000000000..cbbaf458b --- /dev/null +++ b/packages/endpoint-microsub/locales/en.json @@ -0,0 +1,8 @@ +{ + "microsub": { + "error": { + "channelNotFound": "Channel not found", + "nameTooLong": "Channel name must be %s characters or less" + } + } +} diff --git a/packages/endpoint-microsub/package.json b/packages/endpoint-microsub/package.json new file mode 100644 index 000000000..8f7c15b66 --- /dev/null +++ b/packages/endpoint-microsub/package.json @@ -0,0 +1,53 @@ +{ + "name": "@indiekit/endpoint-microsub", + "version": "1.0.0-alpha.1", + "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://getindiekit.com", + "author": { + "name": "Paul Robert Lloyd", + "url": "https://paulrobertlloyd.com" + }, + "contributors": [ + { + "name": "Ricardo Mendes", + "url": "https://rmendes.net" + } + ], + "license": "MIT", + "engines": { + "node": ">=24.17" + }, + "type": "module", + "main": "index.js", + "files": [ + "assets", + "lib", + "locales", + "index.js" + ], + "bugs": { + "url": "https://github.com/getindiekit/indiekit/issues" + }, + "repository": { + "type": "git", + "url": "https://github.com/getindiekit/indiekit.git", + "directory": "packages/endpoint-microsub" + }, + "dependencies": { + "@indiekit/error": "^1.0.0-beta.25", + "@indiekit/util": "^1.0.0-beta.28", + "debug": "^4.4.3", + "express": "^5.3.0" + }, + "publishConfig": { + "access": "public" + } +} diff --git a/packages/endpoint-microsub/test/integration/200-get-channels.js b/packages/endpoint-microsub/test/integration/200-get-channels.js new file mode 100644 index 000000000..32c8ea564 --- /dev/null +++ b/packages/endpoint-microsub/test/integration/200-get-channels.js @@ -0,0 +1,63 @@ +import { strict as assert } from "node:assert"; +import { after, before, describe, it } from "node:test"; + +import { testDatabase } from "@indiekit-test/database"; +import { testServer } from "@indiekit-test/server"; +import { testCookie } from "@indiekit-test/session"; +import supertest from "supertest"; + +const { client, mongoServer, mongoUri } = await testDatabase(); +const server = await testServer({ + application: { mongodbUrl: mongoUri }, + plugins: ["@indiekit/endpoint-microsub"], +}); +const request = supertest.agent(server); +const cookie = testCookie(); + +describe("endpoint-microsub GET /microsub?action=channels", () => { + before(async () => { + for (const name of ["Tech News", "Photos"]) { + await request + .post("/microsub") + .type("form") + .set("cookie", cookie) + .send({ action: "channels", name }); + } + }); + + it("Returns the list of channels", async () => { + const response = await request + .get("/microsub?action=channels") + .set("cookie", cookie); + + assert.equal(response.status, 200); + assert.deepEqual( + response.body.channels.map((channel) => channel.name), + ["Tech News", "Photos"], + ); + }); + + it("Reports channels with no items as read", async () => { + const response = await request + .get("/microsub?action=channels") + .set("cookie", cookie); + + assert.equal(response.body.channels[0].unread, false); + }); + + it("Returns a UID for each channel", async () => { + const response = await request + .get("/microsub?action=channels") + .set("cookie", cookie); + + for (const channel of response.body.channels) { + assert.match(channel.uid, /^[\w-]{24}$/); + } + }); + + after(async () => { + await client.close(); + await mongoServer.stop(); + server.close((error) => process.exit(error ? 1 : 0)); + }); +}); diff --git a/packages/endpoint-microsub/test/integration/200-get-endpoint-info.js b/packages/endpoint-microsub/test/integration/200-get-endpoint-info.js new file mode 100644 index 000000000..ed12a6d1d --- /dev/null +++ b/packages/endpoint-microsub/test/integration/200-get-endpoint-info.js @@ -0,0 +1,30 @@ +import { strict as assert } from "node:assert"; +import { after, describe, it } from "node:test"; + +import { testDatabase } from "@indiekit-test/database"; +import { testServer } from "@indiekit-test/server"; +import { testCookie } from "@indiekit-test/session"; +import supertest from "supertest"; + +const { client, mongoServer, mongoUri } = await testDatabase(); +const server = await testServer({ + application: { mongodbUrl: mongoUri }, + plugins: ["@indiekit/endpoint-microsub"], +}); +const request = supertest.agent(server); + +describe("endpoint-microsub GET /microsub", () => { + it("Returns endpoint information when no action given", async () => { + const response = await request.get("/microsub").set("cookie", testCookie()); + + assert.equal(response.status, 200); + assert.equal(response.body.type, "microsub"); + assert.deepEqual(response.body.actions, ["channels", "timeline"]); + }); + + after(async () => { + await client.close(); + await mongoServer.stop(); + server.close((error) => process.exit(error ? 1 : 0)); + }); +}); diff --git a/packages/endpoint-microsub/test/integration/200-get-timeline.js b/packages/endpoint-microsub/test/integration/200-get-timeline.js new file mode 100644 index 000000000..ecab5ed46 --- /dev/null +++ b/packages/endpoint-microsub/test/integration/200-get-timeline.js @@ -0,0 +1,97 @@ +import { strict as assert } from "node:assert"; +import { after, before, describe, it } from "node:test"; + +import { uuidv7At } from "@indiekit/util"; +import { testDatabase } from "@indiekit-test/database"; +import { testServer } from "@indiekit-test/server"; +import { testCookie } from "@indiekit-test/session"; +import supertest from "supertest"; + +const { client, mongoServer, mongoUri } = await testDatabase(); +const server = await testServer({ + application: { mongodbUrl: mongoUri }, + plugins: ["@indiekit/endpoint-microsub"], +}); +const request = supertest.agent(server); +const cookie = testCookie(); + +// Indiekit uses ‘indiekit’ as its default database, not ‘test’ +const database = client.db("indiekit"); + +const fixture = {}; + +describe("endpoint-microsub GET /microsub?action=timeline", () => { + before(async () => { + const created = await request + .post("/microsub") + .type("form") + .set("cookie", cookie) + .send({ action: "channels", name: "Tech News" }); + + fixture.channelUid = created.body.uid; + + await database.collection("microsub_items").insertMany( + Array.from({ length: 3 }, (_, index) => { + const published = new Date(Date.UTC(2026, 0, index + 1)); + + return { + channel: fixture.channelUid, + id: uuidv7At(published.getTime()), + type: "entry", + uid: `item-${index}`, + url: `https://website.example/${index}`, + name: `Item ${index}`, + published, + readBy: [], + }; + }), + ); + }); + + it("Returns timeline items newest first", async () => { + const response = await request + .get(`/microsub?action=timeline&channel=${fixture.channelUid}`) + .set("cookie", cookie); + + assert.equal(response.status, 200); + assert.deepEqual( + response.body.items.map((item) => item.name), + ["Item 2", "Item 1", "Item 0"], + ); + }); + + it("Returns items in jf2 format", async () => { + const response = await request + .get(`/microsub?action=timeline&channel=${fixture.channelUid}`) + .set("cookie", cookie); + const [item] = response.body.items; + + assert.equal(item.type, "entry"); + assert.equal(item.url, "https://website.example/2"); + assert.equal(item._is_read, false); + }); + + it("Applies the limit parameter and returns paging cursors", async () => { + const response = await request + .get(`/microsub?action=timeline&channel=${fixture.channelUid}&limit=2`) + .set("cookie", cookie); + + assert.equal(response.body.items.length, 2); + assert.ok(response.body.paging.after); + }); + + it("Returns 400 when channel is missing", async () => { + const response = await request + .get("/microsub?action=timeline") + .set("cookie", cookie); + + assert.equal(response.status, 400); + assert.match(response.text, /Missing parameter: channel<\/code>/); + }); + + after(async () => { + await client.close(); + await mongoServer.stop(); + server.close((error) => process.exit(error ? 1 : 0)); + }); +}); diff --git a/packages/endpoint-microsub/test/integration/200-post-channel-delete.js b/packages/endpoint-microsub/test/integration/200-post-channel-delete.js new file mode 100644 index 000000000..5626f1e23 --- /dev/null +++ b/packages/endpoint-microsub/test/integration/200-post-channel-delete.js @@ -0,0 +1,89 @@ +import { strict as assert } from "node:assert"; +import { after, describe, it } from "node:test"; + +import { testDatabase } from "@indiekit-test/database"; +import { testServer } from "@indiekit-test/server"; +import { testCookie } from "@indiekit-test/session"; +import supertest from "supertest"; + +const { client, mongoServer, mongoUri } = await testDatabase(); +const server = await testServer({ + application: { mongodbUrl: mongoUri }, + plugins: ["@indiekit/endpoint-microsub"], +}); +const request = supertest.agent(server); +const cookie = testCookie(); + +/** + * Create a channel via the Microsub API + * @param {string} name - Channel name + * @returns {Promise} Created channel UID + */ +async function createChannel(name) { + const response = await request + .post("/microsub") + .type("form") + .set("cookie", cookie) + .send({ action: "channels", name }); + + return response.body.uid; +} + +describe("endpoint-microsub POST /microsub?action=channels (delete)", () => { + it("Deletes a channel", async () => { + const uid = await createChannel("Doomed"); + + const response = await request + .post("/microsub") + .type("form") + .set("cookie", cookie) + .send({ action: "channels", method: "delete", uid }); + + assert.equal(response.status, 200); + assert.equal(response.body.deleted, uid); + }); + + it("Removes the channel from the channel list", async () => { + const uid = await createChannel("Doomed too"); + + await request + .post("/microsub") + .type("form") + .set("cookie", cookie) + .send({ action: "channels", method: "delete", uid }); + + const response = await request + .get("/microsub?action=channels") + .set("cookie", cookie); + const uids = response.body.channels.map((channel) => channel.uid); + + assert.equal(uids.includes(uid), false); + }); + + it("Returns 404 for an unknown channel", async () => { + const response = await request + .post("/microsub") + .type("form") + .set("cookie", cookie) + .send({ action: "channels", method: "delete", uid: "nonexistent" }); + + assert.equal(response.status, 404); + assert.match(response.text, /Channel not found/); + }); + + it("Refuses to delete the notifications channel", async () => { + const response = await request + .post("/microsub") + .type("form") + .set("cookie", cookie) + .send({ action: "channels", method: "delete", uid: "notifications" }); + + assert.equal(response.status, 404); + }); + + after(async () => { + await client.close(); + await mongoServer.stop(); + server.close((error) => process.exit(error ? 1 : 0)); + }); +}); diff --git a/packages/endpoint-microsub/test/integration/200-post-channel-update.js b/packages/endpoint-microsub/test/integration/200-post-channel-update.js new file mode 100644 index 000000000..15377ad69 --- /dev/null +++ b/packages/endpoint-microsub/test/integration/200-post-channel-update.js @@ -0,0 +1,80 @@ +import { strict as assert } from "node:assert"; +import { after, describe, it } from "node:test"; + +import { testDatabase } from "@indiekit-test/database"; +import { testServer } from "@indiekit-test/server"; +import { testCookie } from "@indiekit-test/session"; +import supertest from "supertest"; + +const { client, mongoServer, mongoUri } = await testDatabase(); +const server = await testServer({ + application: { mongodbUrl: mongoUri }, + plugins: ["@indiekit/endpoint-microsub"], +}); +const request = supertest.agent(server); +const cookie = testCookie(); + +/** + * Create a channel via the Microsub API + * @param {string} name - Channel name + * @returns {Promise} Created channel UID + */ +async function createChannel(name) { + const response = await request + .post("/microsub") + .type("form") + .set("cookie", cookie) + .send({ action: "channels", name }); + + return response.body.uid; +} + +describe("endpoint-microsub POST /microsub?action=channels (update)", () => { + it("Renames a channel", async () => { + const uid = await createChannel("Old name"); + + const response = await request + .post("/microsub") + .type("form") + .set("cookie", cookie) + .send({ action: "channels", uid, name: "New name" }); + + assert.equal(response.status, 200); + assert.equal(response.body.uid, uid); + assert.equal(response.body.name, "New name"); + }); + + it("Persists the new name", async () => { + const uid = await createChannel("Before"); + + await request + .post("/microsub") + .type("form") + .set("cookie", cookie) + .send({ action: "channels", uid, name: "After" }); + + const response = await request + .get("/microsub?action=channels") + .set("cookie", cookie); + const channel = response.body.channels.find((c) => c.uid === uid); + + assert.equal(channel.name, "After"); + }); + + it("Returns 404 for an unknown channel", async () => { + const response = await request + .post("/microsub") + .type("form") + .set("cookie", cookie) + .send({ action: "channels", uid: "nonexistent", name: "New name" }); + + assert.equal(response.status, 404); + assert.match(response.text, /Channel not found/); + }); + + after(async () => { + await client.close(); + await mongoServer.stop(); + server.close((error) => process.exit(error ? 1 : 0)); + }); +}); diff --git a/packages/endpoint-microsub/test/integration/200-post-channels-order.js b/packages/endpoint-microsub/test/integration/200-post-channels-order.js new file mode 100644 index 000000000..6ccb16cfe --- /dev/null +++ b/packages/endpoint-microsub/test/integration/200-post-channels-order.js @@ -0,0 +1,79 @@ +import { strict as assert } from "node:assert"; +import { after, before, describe, it } from "node:test"; + +import { testDatabase } from "@indiekit-test/database"; +import { testServer } from "@indiekit-test/server"; +import { testCookie } from "@indiekit-test/session"; +import supertest from "supertest"; + +const { client, mongoServer, mongoUri } = await testDatabase(); +const server = await testServer({ + application: { mongodbUrl: mongoUri }, + plugins: ["@indiekit/endpoint-microsub"], +}); +const request = supertest.agent(server); +const cookie = testCookie(); + +const uids = {}; + +describe("endpoint-microsub POST /microsub?action=channels (order)", () => { + before(async () => { + for (const name of ["First", "Second", "Third"]) { + const response = await request + .post("/microsub") + .type("form") + .set("cookie", cookie) + .send({ action: "channels", name }); + + uids[name] = response.body.uid; + } + }); + + it("Reorders channels", async () => { + const response = await request + .post("/microsub") + .type("form") + .set("cookie", cookie) + .send({ + action: "channels", + method: "order", + "channels[0]": uids.Third, + "channels[1]": uids.First, + "channels[2]": uids.Second, + }); + + assert.equal(response.status, 200); + assert.deepEqual( + response.body.channels.map((channel) => channel.name), + ["Third", "First", "Second"], + ); + }); + + it("Persists the new order", async () => { + const response = await request + .get("/microsub?action=channels") + .set("cookie", cookie); + + assert.deepEqual( + response.body.channels.map((channel) => channel.name), + ["Third", "First", "Second"], + ); + }); + + it("Returns 400 when no channels are given", async () => { + const response = await request + .post("/microsub") + .type("form") + .set("cookie", cookie) + .send({ action: "channels", method: "order" }); + + assert.equal(response.status, 400); + assert.match(response.text, /Missing parameter: channels<\/code>/); + }); + + after(async () => { + await client.close(); + await mongoServer.stop(); + server.close((error) => process.exit(error ? 1 : 0)); + }); +}); diff --git a/packages/endpoint-microsub/test/integration/200-post-timeline.js b/packages/endpoint-microsub/test/integration/200-post-timeline.js new file mode 100644 index 000000000..efbe2db07 --- /dev/null +++ b/packages/endpoint-microsub/test/integration/200-post-timeline.js @@ -0,0 +1,167 @@ +import { strict as assert } from "node:assert"; +import { after, beforeEach, describe, it } from "node:test"; + +import { uuidv7At } from "@indiekit/util"; +import { testDatabase } from "@indiekit-test/database"; +import { testServer } from "@indiekit-test/server"; +import { testCookie } from "@indiekit-test/session"; +import supertest from "supertest"; + +const { client, mongoServer, mongoUri } = await testDatabase(); +const server = await testServer({ + application: { mongodbUrl: mongoUri }, + plugins: ["@indiekit/endpoint-microsub"], +}); +const request = supertest.agent(server); +const cookie = testCookie(); + +// Indiekit uses ‘indiekit’ as its default database, not ‘test’ +const database = client.db("indiekit"); +const items = database.collection("microsub_items"); + +const fixture = {}; + +describe("endpoint-microsub POST /microsub?action=timeline", () => { + beforeEach(async () => { + await items.deleteMany({}); + + const created = await request + .post("/microsub") + .type("form") + .set("cookie", cookie) + .send({ action: "channels", name: "Tech News" }); + + fixture.channelUid = created.body.uid; + + await items.insertMany( + Array.from({ length: 3 }, (_, index) => { + const published = new Date(Date.UTC(2026, 0, index + 1)); + + return { + channel: fixture.channelUid, + id: uuidv7At(published.getTime()), + type: "entry", + uid: `item-${index}`, + url: `https://website.example/${index}`, + published, + readBy: [], + }; + }), + ); + }); + + it("Marks entries as read", async () => { + const response = await request + .post("/microsub") + .type("form") + .set("cookie", cookie) + .send({ + action: "timeline", + method: "mark_read", + channel: fixture.channelUid, + "entry[0]": "item-0", + "entry[1]": "item-1", + }); + + assert.equal(response.status, 200); + assert.equal(response.body.result, "ok"); + assert.equal(response.body.updated, 2); + }); + + it("Reflects read state in the timeline", async () => { + await request.post("/microsub").type("form").set("cookie", cookie).send({ + action: "timeline", + method: "mark_read", + channel: fixture.channelUid, + entry: "item-2", + }); + + const response = await request + .get(`/microsub?action=timeline&channel=${fixture.channelUid}`) + .set("cookie", cookie); + const item = response.body.items.find((index) => index.uid === "item-2"); + + assert.equal(item._is_read, true); + }); + + it("Marks entries as unread", async () => { + await request.post("/microsub").type("form").set("cookie", cookie).send({ + action: "timeline", + method: "mark_read", + channel: fixture.channelUid, + entry: "item-0", + }); + + const response = await request + .post("/microsub") + .type("form") + .set("cookie", cookie) + .send({ + action: "timeline", + method: "mark_unread", + channel: fixture.channelUid, + entry: "item-0", + }); + + assert.equal(response.status, 200); + assert.equal(response.body.updated, 1); + }); + + it("Removes entries", async () => { + const response = await request + .post("/microsub") + .type("form") + .set("cookie", cookie) + .send({ + action: "timeline", + method: "remove", + channel: fixture.channelUid, + entry: "item-0", + }); + + assert.equal(response.status, 200); + assert.equal(response.body.removed, 1); + assert.equal(await items.countDocuments({ uid: "item-0" }), 0); + }); + + it("Returns 400 for an unknown method", async () => { + const response = await request + .post("/microsub") + .type("form") + .set("cookie", cookie) + .send({ + action: "timeline", + method: "bogus", + channel: fixture.channelUid, + entry: "item-0", + }); + + assert.equal(response.status, 400); + assert.match( + response.text, + /Invalid value provided for: method<\/code>/, + ); + }); + + it("Returns 404 for an unknown channel", async () => { + const response = await request + .post("/microsub") + .type("form") + .set("cookie", cookie) + .send({ + action: "timeline", + method: "mark_read", + channel: "nonexistent", + entry: "item-0", + }); + + assert.equal(response.status, 404); + assert.match(response.text, /Channel not found/); + }); + + after(async () => { + await client.close(); + await mongoServer.stop(); + server.close((error) => process.exit(error ? 1 : 0)); + }); +}); diff --git a/packages/endpoint-microsub/test/integration/201-post-channel-create.js b/packages/endpoint-microsub/test/integration/201-post-channel-create.js new file mode 100644 index 000000000..8023b8d93 --- /dev/null +++ b/packages/endpoint-microsub/test/integration/201-post-channel-create.js @@ -0,0 +1,72 @@ +import { strict as assert } from "node:assert"; +import { after, describe, it } from "node:test"; + +import { testDatabase } from "@indiekit-test/database"; +import { testServer } from "@indiekit-test/server"; +import { testCookie } from "@indiekit-test/session"; +import supertest from "supertest"; + +const { client, mongoServer, mongoUri } = await testDatabase(); +const server = await testServer({ + application: { mongodbUrl: mongoUri }, + plugins: ["@indiekit/endpoint-microsub"], +}); +const request = supertest.agent(server); +const cookie = testCookie(); + +describe("endpoint-microsub POST /microsub?action=channels", () => { + it("Creates a channel", async () => { + const response = await request + .post("/microsub") + .type("form") + .set("cookie", cookie) + .send({ action: "channels", name: "Tech News" }); + + assert.equal(response.status, 201); + assert.equal(response.body.name, "Tech News"); + assert.match(response.body.uid, /^[\w-]{24}$/); + }); + + it("Returns the created channel in the channel list", async () => { + const created = await request + .post("/microsub") + .type("form") + .set("cookie", cookie) + .send({ action: "channels", name: "Photos" }); + + const response = await request + .get("/microsub?action=channels") + .set("cookie", cookie); + const uids = response.body.channels.map((channel) => channel.uid); + + assert.ok(uids.includes(created.body.uid)); + }); + + it("Returns 400 when name is missing", async () => { + const response = await request + .post("/microsub") + .type("form") + .set("cookie", cookie) + .send({ action: "channels" }); + + assert.equal(response.status, 400); + assert.match(response.text, /Missing parameter: name<\/code>/); + }); + + it("Returns 400 when name exceeds 100 characters", async () => { + const response = await request + .post("/microsub") + .type("form") + .set("cookie", cookie) + .send({ action: "channels", name: "a".repeat(101) }); + + assert.equal(response.status, 400); + assert.match(response.text, /100 characters or less/); + }); + + after(async () => { + await client.close(); + await mongoServer.stop(); + server.close((error) => process.exit(error ? 1 : 0)); + }); +}); diff --git a/packages/endpoint-microsub/test/integration/302-get-unauthenticated.js b/packages/endpoint-microsub/test/integration/302-get-unauthenticated.js new file mode 100644 index 000000000..8e1ce2464 --- /dev/null +++ b/packages/endpoint-microsub/test/integration/302-get-unauthenticated.js @@ -0,0 +1,33 @@ +import { strict as assert } from "node:assert"; +import { after, describe, it } from "node:test"; + +import { testDatabase } from "@indiekit-test/database"; +import { testServer } from "@indiekit-test/server"; +import supertest from "supertest"; + +const { client, mongoServer, mongoUri } = await testDatabase(); +const server = await testServer({ + application: { mongodbUrl: mongoUri }, + plugins: ["@indiekit/endpoint-microsub"], +}); +const request = supertest.agent(server); + +describe("endpoint-microsub GET /microsub", () => { + it("Redirects to sign-in when unauthenticated", async () => { + const response = await request.get("/microsub?action=channels"); + + assert.equal(response.status, 302); + }); + + it("Redirects unauthenticated timeline requests", async () => { + const response = await request.get("/microsub?action=timeline&channel=abc"); + + assert.equal(response.status, 302); + }); + + after(async () => { + await client.close(); + await mongoServer.stop(); + server.close((error) => process.exit(error ? 1 : 0)); + }); +}); diff --git a/packages/endpoint-microsub/test/integration/400-invalid-action.js b/packages/endpoint-microsub/test/integration/400-invalid-action.js new file mode 100644 index 000000000..bc4a282cb --- /dev/null +++ b/packages/endpoint-microsub/test/integration/400-invalid-action.js @@ -0,0 +1,60 @@ +import { strict as assert } from "node:assert"; +import { after, describe, it } from "node:test"; + +import { testDatabase } from "@indiekit-test/database"; +import { testServer } from "@indiekit-test/server"; +import { testCookie } from "@indiekit-test/session"; +import supertest from "supertest"; + +const { client, mongoServer, mongoUri } = await testDatabase(); +const server = await testServer({ + application: { mongodbUrl: mongoUri }, + plugins: ["@indiekit/endpoint-microsub"], +}); +const request = supertest.agent(server); +const cookie = testCookie(); + +describe("endpoint-microsub invalid action", () => { + it("Returns 400 for an unsupported GET action", async () => { + const response = await request + .get("/microsub?action=bogus") + .set("cookie", cookie); + + assert.equal(response.status, 400); + assert.match( + response.text, + /Invalid value provided for: action<\/code>/, + ); + }); + + it("Returns 400 for an unsupported POST action", async () => { + const response = await request + .post("/microsub") + .type("form") + .set("cookie", cookie) + .send({ action: "bogus" }); + + assert.equal(response.status, 400); + assert.match( + response.text, + /Invalid value provided for: action<\/code>/, + ); + }); + + it("Returns 400 when POST has no action", async () => { + const response = await request + .post("/microsub") + .type("form") + .set("cookie", cookie) + .send({ name: "Tech News" }); + + assert.equal(response.status, 400); + assert.match(response.text, /Missing parameter: action<\/code>/); + }); + + after(async () => { + await client.close(); + await mongoServer.stop(); + server.close((error) => process.exit(error ? 1 : 0)); + }); +}); diff --git a/packages/endpoint-microsub/test/integration/400-post-unauthenticated.js b/packages/endpoint-microsub/test/integration/400-post-unauthenticated.js new file mode 100644 index 000000000..d56a92ae4 --- /dev/null +++ b/packages/endpoint-microsub/test/integration/400-post-unauthenticated.js @@ -0,0 +1,31 @@ +import { strict as assert } from "node:assert"; +import { after, describe, it } from "node:test"; + +import { testDatabase } from "@indiekit-test/database"; +import { testServer } from "@indiekit-test/server"; +import supertest from "supertest"; + +const { client, mongoServer, mongoUri } = await testDatabase(); +const server = await testServer({ + application: { mongodbUrl: mongoUri }, + plugins: ["@indiekit/endpoint-microsub"], +}); +const request = supertest.agent(server); + +describe("endpoint-microsub POST /microsub", () => { + it("Rejects unauthenticated requests without a CSRF token", async () => { + const response = await request + .post("/microsub") + .type("form") + .send({ action: "channels", name: "Tech News" }); + + assert.equal(response.status, 400); + assert.match(response.text, /InvalidRequestError/); + }); + + after(async () => { + await client.close(); + await mongoServer.stop(); + server.close((error) => process.exit(error ? 1 : 0)); + }); +}); diff --git a/packages/endpoint-microsub/test/unit/storage/channels.js b/packages/endpoint-microsub/test/unit/storage/channels.js new file mode 100644 index 000000000..15d4c7c53 --- /dev/null +++ b/packages/endpoint-microsub/test/unit/storage/channels.js @@ -0,0 +1,319 @@ +import { strict as assert } from "node:assert"; +import { after, beforeEach, describe, it, mock } from "node:test"; + +import { testDatabase } from "@indiekit-test/database"; + +import { + createChannel, + deleteChannel, + ensureNotificationsChannel, + getChannel, + getChannels, + reorderChannels, + updateChannel, +} from "../../../lib/storage/channels.js"; + +mock.method(console, "info", () => {}); // Disable console.info + +const { client, database, mongoServer } = await testDatabase(); +const channels = database.collection("microsub_channels"); +const items = database.collection("microsub_items"); +const application = { + collections: new Map([ + ["microsub_channels", channels], + ["microsub_items", items], + ]), +}; + +describe("endpoint-microsub/lib/storage/channels", () => { + beforeEach(async () => { + await channels.deleteMany({}); + await items.deleteMany({}); + }); + + after(async () => { + await client.close(); + await mongoServer.stop(); + }); + + describe("createChannel", () => { + it("Creates a channel with a generated UID", async () => { + const channel = await createChannel(application, { + name: "Tech News", + userId: "user-1", + }); + + assert.match(channel.uid, /^[\w-]{24}$/); + assert.equal(channel.name, "Tech News"); + assert.equal(channel.userId, "user-1"); + assert.ok(channel.createdAt instanceof Date); + }); + + it("Persists the channel", async () => { + const channel = await createChannel(application, { + name: "Tech News", + userId: "user-1", + }); + + const stored = await channels.findOne({ uid: channel.uid }); + + assert.equal(stored.name, "Tech News"); + }); + + it("Assigns order 0 to a user's first channel", async () => { + const channel = await createChannel(application, { + name: "First", + userId: "user-1", + }); + + assert.equal(channel.order, 0); + }); + + it("Increments order for subsequent channels", async () => { + await createChannel(application, { name: "First", userId: "user-1" }); + const second = await createChannel(application, { + name: "Second", + userId: "user-1", + }); + + assert.equal(second.order, 1); + }); + + it("Tracks order separately for each user", async () => { + await createChannel(application, { name: "First", userId: "user-1" }); + const other = await createChannel(application, { + name: "Other", + userId: "user-2", + }); + + assert.equal(other.order, 0); + }); + }); + + describe("getChannels", () => { + it("Returns an empty array when no channels exist", async () => { + const result = await getChannels(application, "user-1"); + + assert.deepEqual(result, []); + }); + + it("Returns channels in order", async () => { + await createChannel(application, { name: "First", userId: "user-1" }); + await createChannel(application, { name: "Second", userId: "user-1" }); + + const result = await getChannels(application, "user-1"); + + assert.deepEqual( + result.map((channel) => channel.name), + ["First", "Second"], + ); + }); + + it("Returns only the requested user's channels", async () => { + await createChannel(application, { name: "Mine", userId: "user-1" }); + await createChannel(application, { name: "Theirs", userId: "user-2" }); + + const result = await getChannels(application, "user-1"); + + assert.equal(result.length, 1); + assert.equal(result[0].name, "Mine"); + }); + + it("Returns false as unread count when all items are read", async () => { + const channel = await createChannel(application, { + name: "Tech News", + userId: "user-1", + }); + const stored = await channels.findOne({ uid: channel.uid }); + await items.insertOne({ channel: stored.uid, readBy: ["user-1"] }); + + const result = await getChannels(application, "user-1"); + + assert.equal(result[0].unread, false); + }); + + it("Counts items not yet read by the user", async () => { + const channel = await createChannel(application, { + name: "Tech News", + userId: "user-1", + }); + const stored = await channels.findOne({ uid: channel.uid }); + await items.insertMany([ + { channel: stored.uid, readBy: [] }, + { channel: stored.uid, readBy: [] }, + { channel: stored.uid, readBy: ["user-1"] }, + ]); + + const result = await getChannels(application, "user-1"); + + assert.equal(result[0].unread, 2); + }); + + it("Lists the notifications channel first", async () => { + await createChannel(application, { name: "Tech News", userId: "user-1" }); + await ensureNotificationsChannel(application, "user-1"); + + const result = await getChannels(application, "user-1"); + + assert.equal(result[0].uid, "notifications"); + }); + }); + + describe("getChannel", () => { + it("Returns a channel by UID", async () => { + const channel = await createChannel(application, { + name: "Tech News", + userId: "user-1", + }); + + const result = await getChannel(application, channel.uid, "user-1"); + + assert.equal(result.name, "Tech News"); + }); + + it("Returns null for an unknown UID", async () => { + const result = await getChannel(application, "nonexistent", "user-1"); + + // eslint-disable-next-line unicorn/no-null -- MongoDB returns null + assert.equal(result, null); + }); + + it("Does not return another user's channel", async () => { + const channel = await createChannel(application, { + name: "Theirs", + userId: "user-2", + }); + + const result = await getChannel(application, channel.uid, "user-1"); + + // eslint-disable-next-line unicorn/no-null -- MongoDB returns null + assert.equal(result, null); + }); + }); + + describe("updateChannel", () => { + it("Updates the channel name", async () => { + const channel = await createChannel(application, { + name: "Old name", + userId: "user-1", + }); + + const result = await updateChannel( + application, + channel.uid, + { name: "New name" }, + "user-1", + ); + + assert.equal(result.name, "New name"); + }); + + it("Returns null for an unknown UID", async () => { + const result = await updateChannel( + application, + "nonexistent", + { name: "New name" }, + "user-1", + ); + + // eslint-disable-next-line unicorn/no-null -- MongoDB returns null + assert.equal(result, null); + }); + }); + + describe("deleteChannel", () => { + it("Deletes the channel", async () => { + const channel = await createChannel(application, { + name: "Tech News", + userId: "user-1", + }); + + const result = await deleteChannel(application, channel.uid, "user-1"); + + assert.equal(result, true); + assert.equal(await channels.countDocuments({ uid: channel.uid }), 0); + }); + + it("Deletes the channel's items", async () => { + const channel = await createChannel(application, { + name: "Tech News", + userId: "user-1", + }); + const stored = await channels.findOne({ uid: channel.uid }); + await items.insertOne({ channel: stored.uid }); + + await deleteChannel(application, channel.uid, "user-1"); + + assert.equal(await items.countDocuments({ channel: stored.uid }), 0); + }); + + it("Refuses to delete the notifications channel", async () => { + await ensureNotificationsChannel(application, "user-1"); + + const result = await deleteChannel( + application, + "notifications", + "user-1", + ); + + assert.equal(result, false); + assert.equal(await channels.countDocuments({ uid: "notifications" }), 1); + }); + + it("Returns false for an unknown UID", async () => { + const result = await deleteChannel(application, "nonexistent", "user-1"); + + assert.equal(result, false); + }); + }); + + describe("reorderChannels", () => { + it("Applies the given order", async () => { + const first = await createChannel(application, { + name: "First", + userId: "user-1", + }); + const second = await createChannel(application, { + name: "Second", + userId: "user-1", + }); + + await reorderChannels(application, [second.uid, first.uid], "user-1"); + + const result = await getChannels(application, "user-1"); + + assert.deepEqual( + result.map((channel) => channel.name), + ["Second", "First"], + ); + }); + + it("Does nothing when given an empty list", async () => { + await createChannel(application, { name: "First", userId: "user-1" }); + + await reorderChannels(application, [], "user-1"); + + const result = await getChannels(application, "user-1"); + + assert.equal(result.length, 1); + }); + }); + + describe("ensureNotificationsChannel", () => { + it("Creates the notifications channel", async () => { + const channel = await ensureNotificationsChannel(application, "user-1"); + + assert.equal(channel.uid, "notifications"); + assert.equal(channel.name, "Notifications"); + assert.equal(channel.order, -1); + }); + + it("Returns the existing channel without duplicating it", async () => { + const first = await ensureNotificationsChannel(application, "user-1"); + const second = await ensureNotificationsChannel(application, "user-1"); + + assert.equal(second._id.toString(), first._id.toString()); + assert.equal(await channels.countDocuments({ uid: "notifications" }), 1); + }); + }); +}); diff --git a/packages/endpoint-microsub/test/unit/storage/items.js b/packages/endpoint-microsub/test/unit/storage/items.js new file mode 100644 index 000000000..c7e43b81c --- /dev/null +++ b/packages/endpoint-microsub/test/unit/storage/items.js @@ -0,0 +1,368 @@ +import { strict as assert } from "node:assert"; +import { after, beforeEach, describe, it } from "node:test"; + +import { uuidv7At } from "@indiekit/util"; +import { testDatabase } from "@indiekit-test/database"; + +import { + createIndexes, + getTimelineItems, + markItemsRead, + markItemsUnread, + removeItems, +} from "../../../lib/storage/items.js"; + +const { client, database, mongoServer } = await testDatabase(); +const items = database.collection("microsub_items"); +const application = { + collections: new Map([["microsub_items", items]]), +}; + +const channel = "channel-1"; +const otherChannel = "channel-2"; + +/** + * Insert timeline items, oldest first + * @param {number} count - Number of items to insert + * @param {object} [overrides] - Fields to merge into each item + * @returns {Promise} Inserted item documents + */ +async function insertItems(count, overrides = {}) { + const documents = Array.from({ length: count }, (_, index) => { + const published = new Date(Date.UTC(2026, 0, index + 1)); + + return { + channel, + id: uuidv7At(published.getTime()), + type: "entry", + uid: `item-${index}`, + url: `https://website.example/${index}`, + name: `Item ${index}`, + published, + readBy: [], + ...overrides, + }; + }); + + await items.insertMany(documents); + + return documents; +} + +describe("endpoint-microsub/lib/storage/items", () => { + beforeEach(async () => { + await items.deleteMany({}); + }); + + after(async () => { + await client.close(); + await mongoServer.stop(); + }); + + describe("getTimelineItems", () => { + it("Returns an empty timeline when the channel has no items", async () => { + const result = await getTimelineItems(application, channel); + + assert.deepEqual(result.items, []); + assert.deepEqual(result.paging, {}); + }); + + it("Returns items newest first", async () => { + await insertItems(3); + + const result = await getTimelineItems(application, channel); + + assert.deepEqual( + result.items.map((item) => item.name), + ["Item 2", "Item 1", "Item 0"], + ); + }); + + it("Excludes items from other channels", async () => { + await insertItems(2); + await items.insertOne({ + channel: otherChannel, + id: uuidv7At(Date.now()), + uid: "other", + published: new Date(), + }); + + const result = await getTimelineItems(application, channel); + + assert.equal(result.items.length, 2); + }); + + it("Applies the requested limit", async () => { + await insertItems(5); + + const result = await getTimelineItems(application, channel, { + limit: 2, + }); + + assert.equal(result.items.length, 2); + }); + + it("Returns an after cursor when more items remain", async () => { + await insertItems(5); + + const result = await getTimelineItems(application, channel, { + limit: 2, + }); + + assert.ok(result.paging.after); + }); + + it("Pages back to newer items using the before cursor", async () => { + await insertItems(4); + + const first = await getTimelineItems(application, channel, { + limit: 2, + }); + const second = await getTimelineItems(application, channel, { + limit: 2, + after: first.paging.after, + }); + const back = await getTimelineItems(application, channel, { + limit: 2, + before: second.paging.before, + }); + + assert.deepEqual( + back.items.map((item) => item.name), + ["Item 3", "Item 2"], + ); + assert.equal("before" in back.paging, false); + }); + + it("Pages through items using the after cursor", async () => { + await insertItems(4); + + const first = await getTimelineItems(application, channel, { + limit: 2, + }); + const second = await getTimelineItems(application, channel, { + limit: 2, + after: first.paging.after, + }); + + assert.deepEqual( + second.items.map((item) => item.name), + ["Item 1", "Item 0"], + ); + }); + + it("Transforms items to jf2", async () => { + await insertItems(1, { author: "Alice", category: ["indieweb"] }); + + const { items: result } = await getTimelineItems(application, channel); + + assert.equal(result[0].type, "entry"); + assert.equal(result[0].uid, "item-0"); + assert.equal(result[0].author, "Alice"); + assert.deepEqual(result[0].category, ["indieweb"]); + assert.equal(typeof result[0].published, "string"); + const stored = await items.findOne({ uid: "item-0" }); + assert.equal(result[0]._id, stored.id); + }); + + it("Omits optional fields that are absent", async () => { + await insertItems(1); + + const { items: result } = await getTimelineItems(application, channel); + + assert.equal("author" in result[0], false); + assert.equal("category" in result[0], false); + }); + + it("Maps interaction properties to their jf2 names", async () => { + await insertItems(1, { + likeOf: ["https://website.example/liked"], + inReplyTo: ["https://website.example/replied"], + }); + + const { items: result } = await getTimelineItems(application, channel); + + assert.deepEqual(result[0]["like-of"], ["https://website.example/liked"]); + assert.deepEqual(result[0]["in-reply-to"], [ + "https://website.example/replied", + ]); + }); + + it("Reports read state for the given user", async () => { + await insertItems(1, { readBy: ["user-1"] }); + + const { items: result } = await getTimelineItems(application, channel, { + userId: "user-1", + }); + + assert.equal(result[0]._is_read, true); + }); + + it("Reports items as unread for a different user", async () => { + await insertItems(1, { readBy: ["user-2"] }); + + const { items: result } = await getTimelineItems(application, channel, { + userId: "user-1", + }); + + assert.equal(result[0]._is_read, false); + }); + }); + + describe("markItemsRead", () => { + it("Marks the given items as read", async () => { + await insertItems(3); + + const count = await markItemsRead( + application, + channel, + ["item-0", "item-1"], + "user-1", + ); + + assert.equal(count, 2); + assert.equal( + await items.countDocuments({ channel, readBy: "user-1" }), + 2, + ); + }); + + it("Matches items by URL", async () => { + await insertItems(2); + + const count = await markItemsRead( + application, + channel, + ["https://website.example/0"], + "user-1", + ); + + assert.equal(count, 1); + }); + + it("Matches items by the id clients see", async () => { + await insertItems(1); + const item = await items.findOne({ uid: "item-0" }); + + const count = await markItemsRead( + application, + channel, + [item.id], + "user-1", + ); + + assert.equal(count, 1); + }); + + it("Marks the whole channel read for last-read-entry", async () => { + await insertItems(3); + + const count = await markItemsRead( + application, + channel, + ["last-read-entry"], + "user-1", + ); + + assert.equal(count, 3); + }); + + it("Does not mark items in other channels", async () => { + await insertItems(1); + await items.insertOne({ + channel: otherChannel, + uid: "item-0", + readBy: [], + }); + + await markItemsRead(application, channel, ["item-0"], "user-1"); + + const other = await items.findOne({ channel: otherChannel }); + + assert.deepEqual(other.readBy, []); + }); + + it("Does not add a duplicate user to readBy", async () => { + await insertItems(1, { readBy: ["user-1"] }); + + await markItemsRead(application, channel, ["item-0"], "user-1"); + + const item = await items.findOne({ uid: "item-0" }); + + assert.deepEqual(item.readBy, ["user-1"]); + }); + }); + + describe("markItemsUnread", () => { + it("Removes the user from readBy", async () => { + await insertItems(2, { readBy: ["user-1"] }); + + const count = await markItemsUnread( + application, + channel, + ["item-0"], + "user-1", + ); + + assert.equal(count, 1); + + const item = await items.findOne({ uid: "item-0" }); + + assert.deepEqual(item.readBy, []); + }); + + it("Leaves other users' read state intact", async () => { + await insertItems(1, { readBy: ["user-1", "user-2"] }); + + await markItemsUnread(application, channel, ["item-0"], "user-1"); + + const item = await items.findOne({ uid: "item-0" }); + + assert.deepEqual(item.readBy, ["user-2"]); + }); + }); + + describe("removeItems", () => { + it("Deletes the given items", async () => { + await insertItems(3); + + const count = await removeItems(application, channel, [ + "item-0", + "item-1", + ]); + + assert.equal(count, 2); + assert.equal(await items.countDocuments({ channel }), 1); + }); + + it("Does not delete items in other channels", async () => { + await insertItems(1); + await items.insertOne({ channel: otherChannel, uid: "item-0" }); + + await removeItems(application, channel, ["item-0"]); + + assert.equal(await items.countDocuments({ channel: otherChannel }), 1); + }); + + it("Returns 0 when nothing matches", async () => { + await insertItems(1); + + const count = await removeItems(application, channel, ["nonexistent"]); + + assert.equal(count, 0); + }); + }); + + describe("createIndexes", () => { + it("Creates the expected indexes", async () => { + await createIndexes(application); + + const indexes = await items.indexes(); + const keys = new Set(indexes.map((index) => JSON.stringify(index.key))); + + assert.ok(keys.has(JSON.stringify({ channel: 1, id: 1 }))); + assert.ok(keys.has(JSON.stringify({ channel: 1, uid: 1 }))); + assert.ok(keys.has(JSON.stringify({ channel: 1, url: 1 }))); + }); + }); +}); diff --git a/packages/endpoint-microsub/test/unit/utils/pagination.js b/packages/endpoint-microsub/test/unit/utils/pagination.js new file mode 100644 index 000000000..0e9995e48 --- /dev/null +++ b/packages/endpoint-microsub/test/unit/utils/pagination.js @@ -0,0 +1,50 @@ +import { strict as assert } from "node:assert"; +import { describe, it } from "node:test"; + +import { + DEFAULT_LIMIT, + MAX_LIMIT, + parseLimit, +} from "../../../lib/utils/pagination.js"; + +describe("endpoint-microsub/lib/utils/pagination", () => { + describe("parseLimit", () => { + it("Returns parsed number for valid string", () => { + assert.equal(parseLimit("25"), 25); + }); + + it("Returns DEFAULT_LIMIT for invalid string", () => { + assert.equal(parseLimit("abc"), DEFAULT_LIMIT); + }); + + it("Returns DEFAULT_LIMIT for negative number", () => { + assert.equal(parseLimit("-5"), DEFAULT_LIMIT); + }); + + it("Returns DEFAULT_LIMIT for zero", () => { + assert.equal(parseLimit("0"), DEFAULT_LIMIT); + }); + + it("Clamps to MAX_LIMIT for large values", () => { + assert.equal(parseLimit("500"), MAX_LIMIT); + }); + + it("Returns DEFAULT_LIMIT for undefined", () => { + assert.equal(parseLimit(), DEFAULT_LIMIT); + }); + + it("Handles number input", () => { + assert.equal(parseLimit(30), 30); + }); + }); + + describe("Constants", () => { + it("DEFAULT_LIMIT is 20", () => { + assert.equal(DEFAULT_LIMIT, 20); + }); + + it("MAX_LIMIT is 100", () => { + assert.equal(MAX_LIMIT, 100); + }); + }); +}); diff --git a/packages/endpoint-microsub/test/unit/utils/validation.js b/packages/endpoint-microsub/test/unit/utils/validation.js new file mode 100644 index 000000000..ee9ac3afb --- /dev/null +++ b/packages/endpoint-microsub/test/unit/utils/validation.js @@ -0,0 +1,119 @@ +import { strict as assert } from "node:assert"; +import { describe, it } from "node:test"; + +import { + validateAction, + validateChannel, + validateEntries, + validateChannelName, + parseArrayParameter, +} from "../../../lib/utils/validation.js"; + +/** + * Stand-in for the localiser: the key and its values, space-separated + * @param {string} key - Locale key + * @param {...string} values - Values + * @returns {string} Message + */ +const __ = (key, ...values) => [key, ...values].join(" "); + +describe("endpoint-microsub/lib/utils/validation", () => { + describe("validateAction", () => { + it("Accepts valid actions", () => { + assert.doesNotThrow(() => validateAction(__, "channels")); + assert.doesNotThrow(() => validateAction(__, "timeline")); + }); + + it("Rejects missing action", () => { + assert.throws(() => validateAction(__), { + message: /missingParameter action/, + }); + // eslint-disable-next-line unicorn/no-null -- Testing null input handling + assert.throws(() => validateAction(__, null), { + message: /missingParameter action/, + }); + }); + + it("Rejects invalid action", () => { + assert.throws(() => validateAction(__, "invalid"), { + message: /invalidValue action/, + }); + }); + }); + + describe("validateChannel", () => { + it("Accepts valid channel", () => { + assert.doesNotThrow(() => validateChannel(__, "test-channel")); + }); + + it("Rejects missing channel when required", () => { + assert.throws(() => validateChannel(__), { + message: /missingParameter channel/, + }); + }); + + it("Allows missing channel when not required", () => { + assert.doesNotThrow(() => validateChannel(__, undefined, false)); + }); + }); + + describe("validateEntries", () => { + it("Returns array for single entry", () => { + const result = validateEntries(__, "entry-1"); + assert.deepEqual(result, ["entry-1"]); + }); + + it("Returns array for array of entries", () => { + const result = validateEntries(__, ["entry-1", "entry-2"]); + assert.deepEqual(result, ["entry-1", "entry-2"]); + }); + + it("Rejects missing entries", () => { + assert.throws(() => validateEntries(__), { + message: /missingParameter entry/, + }); + }); + }); + + describe("validateChannelName", () => { + it("Accepts valid name", () => { + assert.doesNotThrow(() => validateChannelName(__, "My Channel")); + }); + + it("Rejects empty name", () => { + assert.throws(() => validateChannelName(__, ""), { + message: /missingParameter name/, + }); + }); + + it("Rejects name over 100 characters", () => { + const longName = "a".repeat(101); + assert.throws(() => validateChannelName(__, longName), { + message: /nameTooLong 100/, + }); + }); + }); + + describe("parseArrayParameter", () => { + it("Handles direct array", () => { + const result = parseArrayParameter({ items: ["a", "b"] }, "items"); + assert.deepEqual(result, ["a", "b"]); + }); + + it("Handles single value", () => { + const result = parseArrayParameter({ item: "single" }, "item"); + assert.deepEqual(result, ["single"]); + }); + + it("Handles indexed values", () => { + const body = { "item[0]": "first", "item[1]": "second" }; + const result = parseArrayParameter(body, "item"); + assert.deepEqual(result, ["first", "second"]); + }); + + it("Returns empty array for missing parameter", () => { + const result = parseArrayParameter({}, "missing"); + assert.deepEqual(result, []); + }); + }); +}); diff --git a/packages/indiekit/lib/migrate-uid.js b/packages/indiekit/lib/migrate-uid.js index 29f03ec13..cff9914f1 100644 --- a/packages/indiekit/lib/migrate-uid.js +++ b/packages/indiekit/lib/migrate-uid.js @@ -1,28 +1,4 @@ -import { randomBytes } from "node:crypto"; - -/** - * A UUIDv7 for a known point in time - * - * `crypto.randomUUIDv7()` always stamps the current time, so it cannot give an - * existing post an identifier that sorts by when the post was created. RFC 9562 - * lays the value out as a 48-bit big-endian millisecond timestamp, four version - * bits, twelve free bits, two variant bits, then random. `seq` goes in the free - * bits so that documents sharing a timestamp keep the order they arrive in. - * @param {number} msecs - Milliseconds since the epoch - * @param {number} seq - Tiebreaker within one millisecond, 0-4095 - * @returns {string} UUIDv7 - */ -export const uuidv7At = (msecs, seq) => { - const bytes = randomBytes(16); - - bytes.writeUIntBE(msecs, 0, 6); - bytes.writeUInt16BE(0x70_00 | (seq & 0x0f_ff), 6); - bytes[8] = (bytes[8] & 0x3f) | 0x80; - - return bytes - .toString("hex") - .replace(/(.{8})(.{4})(.{4})(.{4})(.{12})/, "$1-$2-$3-$4-$5"); -}; +import { uuidv7At } from "@indiekit/util"; /** * Give every document in a collection a `properties.uid` diff --git a/packages/indiekit/test/unit/migrate-uid.js b/packages/indiekit/test/unit/migrate-uid.js index 20b5f8726..26271c89d 100644 --- a/packages/indiekit/test/unit/migrate-uid.js +++ b/packages/indiekit/test/unit/migrate-uid.js @@ -1,9 +1,10 @@ import { strict as assert } from "node:assert"; import { after, before, describe, it, mock } from "node:test"; +import { uuidv7At } from "@indiekit/util"; import { testDatabase } from "@indiekit-test/database"; -import { backfillUids, uuidv7At } from "../../lib/migrate-uid.js"; +import { backfillUids } from "../../lib/migrate-uid.js"; // UUIDs sort as strings; a plain `.sort()` would coerce and compare lexically // by default anyway, but the compare function keeps `unicorn/require-array-sort-compare` happy. diff --git a/packages/util/index.js b/packages/util/index.js index 701e74f6d..26853bb78 100644 --- a/packages/util/index.js +++ b/packages/util/index.js @@ -18,5 +18,6 @@ export { slugify, supplant, } from "./lib/string.js"; +export { uuidv7At } from "./lib/uid.js"; export { getCanonicalUrl, isSameOrigin } from "./lib/url.js"; export { isRequired } from "./lib/validation-schema.js"; diff --git a/packages/util/lib/mongodb.js b/packages/util/lib/mongodb.js index 4e1d33773..a298bda45 100644 --- a/packages/util/lib/mongodb.js +++ b/packages/util/lib/mongodb.js @@ -27,7 +27,8 @@ const getBoundary = (value) => { /** * Get pagination cursor * - * Items are ordered by `properties.uid`. A UUIDv7 leads with a 48-bit + * Items are ordered by `properties.uid`, unless another key is given, and a + * query can confine every page to a subset. A UUIDv7 leads with a 48-bit * millisecond timestamp, so comparing two of them as strings compares when * they were created — the ordering `_id` gave, now carried by a property of * the item itself rather than by the database. Cursor values are the same @@ -40,15 +41,38 @@ const getBoundary = (value) => { * @param {string|string[]} [after] - Items created after item with this uid * @param {string|string[]} [before] - Items created before item with this uid * @param {number} [limit] - Number of items to return within cursor + * @param {object} [options] - Options + * @param {object} [options.filter] - Query every page is confined to + * @param {string} [options.key] - Path of the time-ordered identifier the + * items are listed and paged by * @returns {Promise} Pagination cursor */ -export const getCursor = async (collection, after, before, limit) => { +export const getCursor = async ( + collection, + after, + before, + limit, + { filter = {}, key = "properties.uid" } = {}, +) => { const cursor = { items: [], hasNext: false, hasPrev: false, }; + /** + * @param {object} item - Database document + * @returns {string} The item's identifier at `key` + */ + const identifier = (item) => { + let value = item; + for (const segment of key.split(".")) { + value = value?.[segment]; + } + + return value; + }; + // `before` wins when both are given const boundaryValue = before || after; const boundary = boundaryValue ? getBoundary(boundaryValue) : undefined; @@ -60,11 +84,9 @@ export const getCursor = async (collection, after, before, limit) => { /** * @type {Record} */ - const query = { "properties.uid": { $type: "string" } }; + const query = { ...filter, [key]: { $type: "string" } }; if (boundary) { - query["properties.uid"] = isPagingBackwards - ? { $gt: boundary } - : { $lt: boundary }; + query[key] = isPagingBackwards ? { $gt: boundary } : { $lt: boundary }; } const options = { @@ -73,7 +95,7 @@ export const getCursor = async (collection, after, before, limit) => { // the smallest uids above it. Taking them in the listing’s own descending // order would instead take the largest — the newest items in the // collection — so that going back from page three landed on page one. - sort: { "properties.uid": isPagingBackwards ? 1 : -1 }, + sort: { [key]: isPagingBackwards ? 1 : -1 }, }; const items = await collection.find(query, options).toArray(); @@ -85,16 +107,18 @@ export const getCursor = async (collection, after, before, limit) => { if (items.length > 0) { cursor.items = items; - cursor.lastItem = items.at(-1).properties.uid; - cursor.firstItem = items[0].properties.uid; + cursor.lastItem = identifier(items.at(-1)); + cursor.firstItem = identifier(items[0]); cursor.hasNext = Boolean( await collection.findOne({ - "properties.uid": { $lt: cursor.lastItem }, + ...filter, + [key]: { $lt: cursor.lastItem }, }), ); cursor.hasPrev = Boolean( await collection.findOne({ - "properties.uid": { $gt: cursor.firstItem }, + ...filter, + [key]: { $gt: cursor.firstItem }, }), ); } diff --git a/packages/util/lib/uid.js b/packages/util/lib/uid.js new file mode 100644 index 000000000..af3266282 --- /dev/null +++ b/packages/util/lib/uid.js @@ -0,0 +1,27 @@ +import { randomBytes } from "node:crypto"; + +/** + * A UUIDv7 for a known point in time + * + * `crypto.randomUUIDv7()` always stamps the current time, so it cannot give a + * document an identifier that sorts by when it was created or published. RFC + * 9562 lays the value out as a 48-bit big-endian millisecond timestamp, four + * version bits, twelve free bits, two variant bits, then random. `seq` goes in + * the free bits so that documents sharing a timestamp keep the order they + * arrive in; left out, those bits stay random. + * @param {number} msecs - Milliseconds since the epoch + * @param {number} [seq] - Tiebreaker within one millisecond, 0-4095 + * @returns {string} UUIDv7 + */ +export const uuidv7At = (msecs, seq) => { + const bytes = randomBytes(16); + const sequence = seq ?? bytes.readUInt16BE(6) & 0x0f_ff; + + bytes.writeUIntBE(msecs, 0, 6); + bytes.writeUInt16BE(0x70_00 | (sequence & 0x0f_ff), 6); + bytes[8] = (bytes[8] & 0x3f) | 0x80; + + return bytes + .toString("hex") + .replace(/(.{8})(.{4})(.{4})(.{4})(.{12})/, "$1-$2-$3-$4-$5"); +}; diff --git a/packages/util/test/unit/mongodb.js b/packages/util/test/unit/mongodb.js index 3bcc50694..f1c69aafe 100644 --- a/packages/util/test/unit/mongodb.js +++ b/packages/util/test/unit/mongodb.js @@ -187,6 +187,44 @@ describe("util/lib/mongodb", async () => { assert.deepEqual(names(result), ["item-1", "item-0"]); }); + it("Pages within a filter", async () => { + await seed(5); + // item-3 and item-1 match, newest first; item-4 never counts + const filter = { "properties.name": { $in: ["item-1", "item-3"] } }; + const page = await getCursor(items, undefined, undefined, 1, { filter }); + assert.deepEqual(names(page), ["item-3"]); + assert.equal(page.hasNext, true); + assert.equal(page.hasPrev, false); + + const next = await getCursor(items, page.lastItem, undefined, 1, { + filter, + }); + assert.deepEqual(names(next), ["item-1"]); + assert.equal(next.hasNext, false); + assert.equal(next.hasPrev, true); + }); + + it("Orders and pages on another key", async () => { + await items.insertMany( + Array.from({ length: 3 }, (_, index) => ({ + id: uidAt(index), + properties: { name: `item-${index}` }, + })), + ); + + const page = await getCursor(items, undefined, undefined, 2, { + key: "id", + }); + assert.deepEqual(names(page), ["item-2", "item-1"]); + assert.equal(page.lastItem, uidAt(1)); + + const next = await getCursor(items, page.lastItem, undefined, 2, { + key: "id", + }); + assert.deepEqual(names(next), ["item-0"]); + assert.equal(next.hasPrev, true); + }); + it("Omits items that have no uid", async () => { await seed(5); await items.insertOne({ properties: { name: "item-x" } }); diff --git a/packages/util/test/unit/uid.js b/packages/util/test/unit/uid.js new file mode 100644 index 000000000..4786b6783 --- /dev/null +++ b/packages/util/test/unit/uid.js @@ -0,0 +1,36 @@ +import { strict as assert } from "node:assert"; +import { describe, it } from "node:test"; + +import { uuidv7At } from "../../lib/uid.js"; + +describe("util/lib/uid", () => { + it("Encodes the given time in the first 48 bits", () => { + const uid = uuidv7At(Date.UTC(2026, 0, 2)); + + assert.match( + uid, + /^[\da-f]{8}-[\da-f]{4}-7[\da-f]{3}-[89ab][\da-f]{3}-[\da-f]{12}$/, + ); + const milliseconds = Number.parseInt(uid.slice(0, 13).replace("-", ""), 16); + assert.equal(milliseconds, Date.UTC(2026, 0, 2)); + }); + + it("Sorts by the given time", () => { + assert.ok(uuidv7At(Date.UTC(2026, 0, 1)) < uuidv7At(Date.UTC(2026, 0, 2))); + }); + + it("Keeps the given sequence within a millisecond", () => { + const first = uuidv7At(Date.UTC(2026, 0, 1), 1); + const second = uuidv7At(Date.UTC(2026, 0, 1), 2); + + assert.ok(first < second); + assert.equal(first.slice(14, 18), "7001"); + }); + + it("Differs between two calls for the same millisecond", () => { + assert.notEqual( + uuidv7At(Date.UTC(2026, 0, 1)), + uuidv7At(Date.UTC(2026, 0, 1)), + ); + }); +});