diff --git a/CHANGES.md b/CHANGES.md index 36e7c17dd..2b17dc7ba 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -138,7 +138,7 @@ To be released. ### @fedify/relay - Created ActivityPub relay integration as the *@fedify/relay* package. - [[#359], [#459], [#471] by Jiwon Kwon] + [[#359], [#459], [#471], [#490] by Jiwon Kwon] - Added `Relay` interface defining the common contract for relay implementations. @@ -149,10 +149,13 @@ To be released. - Added `SubscriptionRequestHandler` type for custom subscription approval logic. - Added `RelayOptions` interface for relay configuration. + - Added `RelayType` type alias to document the type-safe parameter + - Added `createRelay()` factory function as a key public API [#359]: https://github.com/fedify-dev/fedify/issues/359 [#459]: https://github.com/fedify-dev/fedify/pull/459 [#471]: https://github.com/fedify-dev/fedify/pull/471 +[#490]: https://github.com/fedify-dev/fedify/pull/490 ### @fedify/vocab-tools diff --git a/deno.lock b/deno.lock index ffa195ab2..69818ea07 100644 --- a/deno.lock +++ b/deno.lock @@ -1,31 +1,45 @@ { "version": "5", "specifiers": { + "jsr:@alinea/suite@~0.6.3": "0.6.3", "jsr:@david/console-static-text@0.3": "0.3.0", "jsr:@david/dax@~0.43.2": "0.43.2", "jsr:@david/path@0.2": "0.2.0", "jsr:@david/which@~0.4.1": "0.4.1", "jsr:@es-toolkit/es-toolkit@^1.39.5": "1.43.0", "jsr:@hongminhee/localtunnel@0.3": "0.3.0", + "jsr:@hono/hono@^4.7.1": "4.11.1", "jsr:@hono/hono@^4.8.3": "4.11.1", "jsr:@logtape/file@^1.2.2": "1.3.5", "jsr:@logtape/logtape@^1.0.4": "1.3.5", "jsr:@logtape/logtape@^1.2.2": "1.3.5", "jsr:@logtape/logtape@^1.3.5": "1.3.5", - "jsr:@optique/core@~0.6.1": "0.6.5", - "jsr:@optique/core@~0.6.5": "0.6.5", - "jsr:@optique/run@~0.6.1": "0.6.5", + "jsr:@optique/core@~0.6.1": "0.6.6", + "jsr:@optique/core@~0.6.6": "0.6.6", + "jsr:@optique/run@~0.6.1": "0.6.6", + "jsr:@std/assert@0.224": "0.224.0", + "jsr:@std/assert@0.226": "0.226.0", + "jsr:@std/assert@^1.0.13": "1.0.16", + "jsr:@std/async@^1.0.13": "1.0.15", "jsr:@std/bytes@^1.0.5": "1.0.6", + "jsr:@std/fmt@0.224": "0.224.0", "jsr:@std/fmt@1": "1.0.8", + "jsr:@std/fs@0.224": "0.224.0", "jsr:@std/fs@1": "1.0.20", + "jsr:@std/fs@^1.0.3": "1.0.20", + "jsr:@std/internal@0.224": "0.224.0", + "jsr:@std/internal@1": "1.0.12", "jsr:@std/internal@^1.0.12": "1.0.12", "jsr:@std/io@0.225": "0.225.2", + "jsr:@std/path@0.224": "0.224.0", "jsr:@std/path@1": "1.1.3", "jsr:@std/path@^1.0.6": "1.1.3", "jsr:@std/path@^1.1.3": "1.1.3", + "jsr:@std/testing@0.224": "0.224.0", + "jsr:@std/yaml@^1.0.8": "1.0.10", "npm:@alinea/suite@~0.6.3": "0.6.3", "npm:@cfworker/json-schema@^4.1.1": "4.1.1", - "npm:@cloudflare/workers-types@^4.20250529.0": "4.20251219.0", + "npm:@cloudflare/workers-types@^4.20250529.0": "4.20251223.0", "npm:@fxts/core@^1.21.1": "1.21.1", "npm:@hongminhee/localtunnel@0.3": "0.3.0", "npm:@inquirer/prompts@^7.8.4": "7.10.1_@types+node@22.19.3", @@ -33,22 +47,23 @@ "npm:@jimp/wasm-webp@^1.6.0": "1.6.0", "npm:@js-temporal/polyfill@~0.5.1": "0.5.1", "npm:@multiformats/base-x@^4.0.1": "4.0.1", - "npm:@nestjs/common@^11.0.1": "11.1.9_reflect-metadata@0.2.2_rxjs@7.8.2", + "npm:@nestjs/common@^11.0.1": "11.1.10_reflect-metadata@0.2.2_rxjs@7.8.2", "npm:@opentelemetry/api@^1.9.0": "1.9.0", "npm:@opentelemetry/core@^1.30.1": "1.30.1_@opentelemetry+api@1.9.0", "npm:@opentelemetry/sdk-trace-base@^1.30.1": "1.30.1_@opentelemetry+api@1.9.0", "npm:@opentelemetry/semantic-conventions@^1.27.0": "1.28.0", - "npm:@optique/core@~0.6.1": "0.6.5", - "npm:@optique/run@~0.6.1": "0.6.5", + "npm:@optique/core@~0.6.1": "0.6.6", + "npm:@optique/run@~0.6.1": "0.6.6", "npm:@poppanator/http-constants@^1.1.1": "1.1.1", "npm:@sveltejs/kit@2": "2.49.2_@opentelemetry+api@1.9.0_@sveltejs+vite-plugin-svelte@6.2.1__svelte@5.46.0___acorn@8.15.0__vite@7.3.0___@types+node@22.19.3___tsx@4.21.0___yaml@2.8.2___picomatch@4.0.3__@types+node@22.19.3__tsx@4.21.0__yaml@2.8.2_svelte@5.46.0__acorn@8.15.0_vite@7.3.0__@types+node@22.19.3__tsx@4.21.0__yaml@2.8.2__picomatch@4.0.3_acorn@8.15.0_@types+node@22.19.3_tsx@4.21.0_yaml@2.8.2", + "npm:@types/amqplib@*": "0.10.8", "npm:@types/amqplib@~0.10.7": "0.10.8", "npm:@types/eslint@9": "9.6.1", "npm:@types/estree@^1.0.8": "1.0.8", "npm:@types/node@^22.16.0": "22.19.3", "npm:@types/node@^24.2.1": "24.10.4", - "npm:@typescript-eslint/parser@^8.49.0": "8.50.0_eslint@9.39.2_typescript@5.9.3", - "npm:@typescript-eslint/utils@8": "8.50.0_eslint@9.39.2_typescript@5.9.3", + "npm:@typescript-eslint/parser@^8.49.0": "8.50.1_eslint@9.39.2_typescript@5.9.3", + "npm:@typescript-eslint/utils@8": "8.50.1_eslint@9.39.2_typescript@5.9.3", "npm:amqplib@~0.10.8": "0.10.9", "npm:asn1js@^3.0.5": "3.0.7", "npm:asn1js@^3.0.6": "3.0.7", @@ -91,15 +106,18 @@ "npm:shiki@^1.6.4": "1.29.2", "npm:srvx@~0.8.7": "0.8.16", "npm:structured-field-values@^2.0.4": "2.0.4", - "npm:tsdown@~0.12.9": "0.12.9_rolldown@1.0.0-beta.55", + "npm:tsdown@~0.12.9": "0.12.9_rolldown@1.0.0-beta.56", "npm:tsx@^4.19.4": "4.21.0", "npm:uri-template-router@1": "1.0.0", "npm:url-template@^3.1.1": "3.1.1", "npm:urlpattern-polyfill@^10.1.0": "10.1.0", - "npm:wrangler@^4.17.0": "4.56.0_@cloudflare+workers-types@4.20251219.0_unenv@2.0.0-rc.24_workerd@1.20251217.0", + "npm:wrangler@^4.17.0": "4.56.0_@cloudflare+workers-types@4.20251223.0_unenv@2.0.0-rc.24_workerd@1.20251217.0", "npm:yaml@^2.8.1": "2.8.2" }, "jsr": { + "@alinea/suite@0.6.3": { + "integrity": "7d24a38729663b84d8a263d64ff7e3f8c72ac7cbb1db8ec5f414d0416b6b72e2" + }, "@david/console-static-text@0.3.0": { "integrity": "2dfb46ecee525755f7989f94ece30bba85bd8ffe3e8666abc1bf926e1ee0698d" }, @@ -109,8 +127,8 @@ "jsr:@david/console-static-text", "jsr:@david/path", "jsr:@david/which", - "jsr:@std/fmt", - "jsr:@std/fs", + "jsr:@std/fmt@1", + "jsr:@std/fs@1", "jsr:@std/io", "jsr:@std/path@1" ] @@ -118,7 +136,7 @@ "@david/path@0.2.0": { "integrity": "f2d7aa7f02ce5a55e27c09f9f1381794acb09d328f8d3c8a2e3ab3ffc294dccd", "dependencies": [ - "jsr:@std/fs", + "jsr:@std/fs@1", "jsr:@std/path@1" ] }, @@ -146,28 +164,65 @@ "@logtape/logtape@1.3.5": { "integrity": "a5cdb130daf1a9d384006b0f850cc4443bfc2e163dadc6fa667875e79770beb3" }, - "@optique/core@0.6.5": { - "integrity": "6568b8aef8b576e1b9ad8d57d5abdfe4dfeb960953205a1b9dced1426f7e0109" + "@optique/core@0.6.6": { + "integrity": "8043acec7e1a1732ac74c7159255a2935bc1de541f648e0021703863d4624501" }, - "@optique/run@0.6.5": { - "integrity": "1c6ab2606ea4c5d2f6b851a857e95a37634797223e82aa8bc14af42988fc8fa9", + "@optique/run@0.6.6": { + "integrity": "11fc826cecd8aab73c96ce9054eaf9139009e0b106b728686269b7fbe6aa0ffc", "dependencies": [ - "jsr:@optique/core@~0.6.5" + "jsr:@optique/core@~0.6.6" ] }, + "@std/assert@0.224.0": { + "integrity": "8643233ec7aec38a940a8264a6e3eed9bfa44e7a71cc6b3c8874213ff401967f", + "dependencies": [ + "jsr:@std/fmt@0.224", + "jsr:@std/internal@0.224" + ] + }, + "@std/assert@0.226.0": { + "integrity": "0dfb5f7c7723c18cec118e080fec76ce15b4c31154b15ad2bd74822603ef75b3", + "dependencies": [ + "jsr:@std/internal@1" + ] + }, + "@std/assert@1.0.16": { + "integrity": "6a7272ed1eaa77defe76e5ff63ca705d9c495077e2d5fd0126d2b53fc5bd6532", + "dependencies": [ + "jsr:@std/internal@^1.0.12" + ] + }, + "@std/async@1.0.15": { + "integrity": "55d1d9d04f99403fe5730ab16bdcc3c47f658a6bf054cafb38a50f046238116e" + }, "@std/bytes@1.0.6": { "integrity": "f6ac6adbd8ccd99314045f5703e23af0a68d7f7e58364b47d2c7f408aeb5820a" }, + "@std/fmt@0.224.0": { + "integrity": "e20e9a2312a8b5393272c26191c0a68eda8d2c4b08b046bad1673148f1d69851" + }, "@std/fmt@1.0.8": { "integrity": "71e1fc498787e4434d213647a6e43e794af4fd393ef8f52062246e06f7e372b7" }, + "@std/fs@0.224.0": { + "integrity": "52a5ec89731ac0ca8f971079339286f88c571a4d61686acf75833f03a89d8e69", + "dependencies": [ + "jsr:@std/path@0.224" + ] + }, "@std/fs@1.0.20": { "integrity": "e953206aae48d46ee65e8783ded459f23bec7dd1f3879512911c35e5484ea187", "dependencies": [ - "jsr:@std/internal", + "jsr:@std/internal@^1.0.12", "jsr:@std/path@^1.1.3" ] }, + "@std/internal@0.224.0": { + "integrity": "afc50644f9cdf4495eeb80523a8f6d27226b4b36c45c7c195dfccad4b8509291", + "dependencies": [ + "jsr:@std/fmt@0.224" + ] + }, "@std/internal@1.0.12": { "integrity": "972a634fd5bc34b242024402972cd5143eac68d8dffaca5eaa4dba30ce17b027" }, @@ -177,11 +232,26 @@ "jsr:@std/bytes" ] }, + "@std/path@0.224.0": { + "integrity": "55bca6361e5a6d158b9380e82d4981d82d338ec587de02951e2b7c3a24910ee6" + }, "@std/path@1.1.3": { "integrity": "b015962d82a5e6daea980c32b82d2c40142149639968549c649031a230b1afb3", "dependencies": [ - "jsr:@std/internal" + "jsr:@std/internal@^1.0.12" + ] + }, + "@std/testing@0.224.0": { + "integrity": "371b8a929aa7132240d5dd766a439be8f780ef5c176ab194e0bcab72370c761e", + "dependencies": [ + "jsr:@std/assert@0.224", + "jsr:@std/fmt@0.224", + "jsr:@std/fs@0.224", + "jsr:@std/path@0.224" ] + }, + "@std/yaml@1.0.10": { + "integrity": "245706ea3511cc50c8c6d00339c23ea2ffa27bd2c7ea5445338f8feff31fa58e" } }, "npm": { @@ -268,8 +338,8 @@ "os": ["win32"], "cpu": ["x64"] }, - "@cloudflare/workers-types@4.20251219.0": { - "integrity": "sha512-qwuvc3ZDdCfcK9dJrBSFHOsX8kL72sypfBilzEWbbb+slB2NiggjsHeGMV2ZQiQc1zyBMQPjIvsVeE7Apxp7hw==" + "@cloudflare/workers-types@4.20251223.0": { + "integrity": "sha512-r7oxkFjbMcmzhIrzjXaiJlGFDmmeu3+GlwkLlZbUxVWrXHTCkvqu+DrWnNmF6xZEf9j+2/PpuKIS21J522xhJA==" }, "@colors/colors@1.5.0": { "integrity": "sha512-ooWCrlZP11i8GImSjTHYHLkvFDP48nS4+204nGb1RiX/WXYHmJA2III9/e2DWVabCESdW7hBAEzHRqUn9OUVvQ==" @@ -1233,10 +1303,10 @@ "@tybys/wasm-util" ] }, - "@nestjs/common@11.1.9_reflect-metadata@0.2.2_rxjs@7.8.2": { - "integrity": "sha512-zDntUTReRbAThIfSp3dQZ9kKqI+LjgLp5YZN5c1bgNRDuoeLySAoZg46Bg1a+uV8TMgIRziHocglKGNzr6l+bQ==", + "@nestjs/common@11.1.10_reflect-metadata@0.2.2_rxjs@7.8.2": { + "integrity": "sha512-NoBzJFtq1bzHGia5Q5NO1pJNpx530nupbEu/auCWOFCGL5y8Zo8kiG28EXTCDfIhQgregEtn1Cs6H8WSLUC8kg==", "dependencies": [ - "file-type@21.1.0", + "file-type@21.1.1", "iterare", "load-esm", "reflect-metadata", @@ -1278,11 +1348,11 @@ "@opentelemetry/semantic-conventions@1.28.0": { "integrity": "sha512-lp4qAiMTD4sNWW4DbKLBkfiMZ4jbAboJIGOQr5DvciMRI494OapieI9qiODpOt0XBr1LjIDy1xAGAnVs5supTA==" }, - "@optique/core@0.6.5": { - "integrity": "sha512-H3O//t/qxq7GT+25oLi4mXyxB/PccTcj+0P4HcboDcTnAN7gcTgoxvAugaHExw9s7WrVOlRQRZuYXseKtU/cyw==" + "@optique/core@0.6.6": { + "integrity": "sha512-rllp+mWPhkQcbpY1L8AQJCHPDUEJ/TVBEsAvx3HkPAnJXp4LNlLS25KF0S7qqsuy47cwmpW8Tkme27CzA5iazA==" }, - "@optique/run@0.6.5": { - "integrity": "sha512-dJTfcNXRWM+dmGxbeTFNDt/cf3v92wNYcJVZUu+FqwNXD5lX/koWeMIGwT0eoJegGPWvzpCVRb0CVjc+b/AUbQ==", + "@optique/run@0.6.6": { + "integrity": "sha512-VaEIAbTyer76ywpAKcoBzhrvhVUp/inRxEuesrYMlxRc/5uTJNUTGCngUBy/+ClYHtHpCW9BvhM4i53lmJ7gIw==", "dependencies": [ "@optique/core" ] @@ -1322,183 +1392,183 @@ "quansync" ] }, - "@rolldown/binding-android-arm64@1.0.0-beta.55": { - "integrity": "sha512-5cPpHdO+zp+klznZnIHRO1bMHDq5hS9cqXodEKAaa/dQTPDjnE91OwAsy3o1gT2x4QaY8NzdBXAvutYdaw0WeA==", + "@rolldown/binding-android-arm64@1.0.0-beta.56": { + "integrity": "sha512-GFsly+vPnl1Sa61sC2LwK4Hrz48W+YBqBmLSxBEj9IJW6nHNsWof1wwh1gwnxMIm/yN5F9M0B/cRAwn6rTINyg==", "os": ["android"], "cpu": ["arm64"] }, - "@rolldown/binding-darwin-arm64@1.0.0-beta.55": { - "integrity": "sha512-l0887CGU2SXZr0UJmeEcXSvtDCOhDTTYXuoWbhrEJ58YQhQk24EVhDhHMTyjJb1PBRniUgNc1G0T51eF8z+TWw==", + "@rolldown/binding-darwin-arm64@1.0.0-beta.56": { + "integrity": "sha512-8fSkk5g5MVZpddrH8hOyc9O5t5Dqv2Vi3Qe628xe+2zJedJxucUc5DX/KY1OVBRp8XY09LJO+J1V56LsxeBVPA==", "os": ["darwin"], "cpu": ["arm64"] }, - "@rolldown/binding-darwin-x64@1.0.0-beta.55": { - "integrity": "sha512-d7qP2AVYzN0tYIP4vJ7nmr26xvmlwdkLD/jWIc9Z9dqh5y0UGPigO3m5eHoHq9BNazmwdD9WzDHbQZyXFZjgtA==", + "@rolldown/binding-darwin-x64@1.0.0-beta.56": { + "integrity": "sha512-R+Q5zd763MKvgYSkBfr2gr/3nZQENaK88qEqfRUUYrpq/W0okOpbOJaxn5FDIIS+yq3cjyktYm115I5RiI6G5A==", "os": ["darwin"], "cpu": ["x64"] }, - "@rolldown/binding-freebsd-x64@1.0.0-beta.55": { - "integrity": "sha512-j311E4NOB0VMmXHoDDZhrWidUf7L/Sa6bu/+i2cskvHKU40zcUNPSYeD2YiO2MX+hhDFa5bJwhliYfs+bTrSZw==", + "@rolldown/binding-freebsd-x64@1.0.0-beta.56": { + "integrity": "sha512-YEsv0rfJoHHRNaVx6AfW/o4bmwTY7BJnSQ45rRCyU6DWEgvFZMojh6qzMQmW5ZVdcikE3cU1ZnrQQ2yem9H9Yg==", "os": ["freebsd"], "cpu": ["x64"] }, - "@rolldown/binding-linux-arm-gnueabihf@1.0.0-beta.55": { - "integrity": "sha512-lAsaYWhfNTW2A/9O7zCpb5eIJBrFeNEatOS/DDOZ5V/95NHy50g4b/5ViCqchfyFqRb7MKUR18/+xWkIcDkeIw==", + "@rolldown/binding-linux-arm-gnueabihf@1.0.0-beta.56": { + "integrity": "sha512-mpaV+NCKcHUOkcAThvz1KiXcNshLQRSBLNNKqum2dG7oLZKk+z+02Fxa8BSuFFqq/rmmO6Fq2TPAdZUgOrwiqw==", "os": ["linux"], "cpu": ["arm"] }, - "@rolldown/binding-linux-arm64-gnu@1.0.0-beta.55": { - "integrity": "sha512-2x6ffiVLZrQv7Xii9+JdtyT1U3bQhKj59K3eRnYlrXsKyjkjfmiDUVx2n+zSyijisUqD62fcegmx2oLLfeTkCA==", + "@rolldown/binding-linux-arm64-gnu@1.0.0-beta.56": { + "integrity": "sha512-wj1uQRN4GEhYw5cs0dobGzZg3oKMLuQ3hY3fW7cLzvlwi9XRdzW7NmU58e6YUp6boOQLarSxdmAaqCMgaMZfcQ==", "os": ["linux"], "cpu": ["arm64"] }, - "@rolldown/binding-linux-arm64-musl@1.0.0-beta.55": { - "integrity": "sha512-QbNncvqAXziya5wleI+OJvmceEE15vE4yn4qfbI/hwT/+8ZcqxyfRZOOh62KjisXxp4D0h3JZspycXYejxAU3w==", + "@rolldown/binding-linux-arm64-musl@1.0.0-beta.56": { + "integrity": "sha512-Z2PWbAHjW2EUflb1/tPvouMqppwWF5Va1Y9b4GQpO6QlpGK0Wqmn90GO2VKiheDh/gSZlsxZ7uOZoXh2y8R7Kg==", "os": ["linux"], "cpu": ["arm64"] }, - "@rolldown/binding-linux-x64-gnu@1.0.0-beta.55": { - "integrity": "sha512-YZCTZZM+rujxwVc6A+QZaNMJXVtmabmFYLG2VGQTKaBfYGvBKUgtbMEttnp/oZ88BMi2DzadBVhOmfQV8SuHhw==", + "@rolldown/binding-linux-x64-gnu@1.0.0-beta.56": { + "integrity": "sha512-Z/uv04/Tsf7oqhwjPUiDiSildhWmCpsklA0e5PEB+0eGGmm07B+M2SmqRe9Fd0ypfU2TPGhq+Hn7RVUGIfSMxg==", "os": ["linux"], "cpu": ["x64"] }, - "@rolldown/binding-linux-x64-musl@1.0.0-beta.55": { - "integrity": "sha512-28q9OQ/DDpFh2keS4BVAlc3N65/wiqKbk5K1pgLdu/uWbKa8hgUJofhXxqO+a+Ya2HVTUuYHneWsI2u+eu3N5Q==", + "@rolldown/binding-linux-x64-musl@1.0.0-beta.56": { + "integrity": "sha512-u+yP0Pt9ar3PkLGGiyGmQKVj9j20X0E831DY0OVmbKYHAAbTyLKYx+UIIorCm+SQnhGKfkD+0pmwfTc2t2Vt/g==", "os": ["linux"], "cpu": ["x64"] }, - "@rolldown/binding-openharmony-arm64@1.0.0-beta.55": { - "integrity": "sha512-LiCA4BjCnm49B+j1lFzUtlC+4ZphBv0d0g5VqrEJua/uyv9Ey1v9tiaMql1C8c0TVSNDUmrkfHQ71vuQC7YfpQ==", + "@rolldown/binding-openharmony-arm64@1.0.0-beta.56": { + "integrity": "sha512-Kuc6r5Uya+KxdJ7MUSok3K8zta/1bcsaSNxTvYujm2mWYuffadqgkkR3d0UCRbbCH5klZ+7VG6DR3VtPRlCntw==", "os": ["openharmony"], "cpu": ["arm64"] }, - "@rolldown/binding-wasm32-wasi@1.0.0-beta.55": { - "integrity": "sha512-nZ76tY7T0Oe8vamz5Cv5CBJvrqeQxwj1WaJ2GxX8Msqs0zsQMMcvoyxOf0glnJlxxgKjtoBxAOxaAU8ERbW6Tg==", + "@rolldown/binding-wasm32-wasi@1.0.0-beta.56": { + "integrity": "sha512-pejT5oLj8xlfn8tjC3bJKeuAsk/un6GKwjbsBQG0AchefdaHf2+S4QRn8XfEMB1l1ZTbe5yEiiV92mr7Jdjaeg==", "dependencies": [ "@napi-rs/wasm-runtime" ], "cpu": ["wasm32"] }, - "@rolldown/binding-win32-arm64-msvc@1.0.0-beta.55": { - "integrity": "sha512-TFVVfLfhL1G+pWspYAgPK/FSqjiBtRKYX9hixfs508QVEZPQlubYAepHPA7kEa6lZXYj5ntzF87KC6RNhxo+ew==", + "@rolldown/binding-win32-arm64-msvc@1.0.0-beta.56": { + "integrity": "sha512-1NKkRLQR2ghmHMd+14nm1noOhoLei62pkdGlf1g4F+9lfFws66+9LBnP6Z+E+KK8Do9hzQ6FFRwtkC3EADAeyA==", "os": ["win32"], "cpu": ["arm64"] }, - "@rolldown/binding-win32-x64-msvc@1.0.0-beta.55": { - "integrity": "sha512-j1WBlk0p+ISgLzMIgl0xHp1aBGXenoK2+qWYc/wil2Vse7kVOdFq9aeQ8ahK6/oxX2teQ5+eDvgjdywqTL+daA==", + "@rolldown/binding-win32-x64-msvc@1.0.0-beta.56": { + "integrity": "sha512-BC3mObCr7/O+1jMJ/Hm3INikBk5D25RTxCha10Rq8b1gHlBfb9eA460+7xQfc8FxUsMCUgHtvrK3Vs5izgwBOQ==", "os": ["win32"], "cpu": ["x64"] }, - "@rolldown/pluginutils@1.0.0-beta.55": { - "integrity": "sha512-vajw/B3qoi7aYnnD4BQ4VoCcXQWnF0roSwE2iynbNxgW4l9mFwtLmLmUhpDdcTBfKyZm1p/T0D13qG94XBLohA==" + "@rolldown/pluginutils@1.0.0-beta.56": { + "integrity": "sha512-cw9jwAgCs024Nic4OB8PeFDLBHLD1Athcv3bRvyYATIVD9B/gL5X5cJkezT94Y7m7Dk9HXaUMcvb7ypvSX46sA==" }, - "@rollup/rollup-android-arm-eabi@4.53.5": { - "integrity": "sha512-iDGS/h7D8t7tvZ1t6+WPK04KD0MwzLZrG0se1hzBjSi5fyxlsiggoJHwh18PCFNn7tG43OWb6pdZ6Y+rMlmyNQ==", + "@rollup/rollup-android-arm-eabi@4.54.0": { + "integrity": "sha512-OywsdRHrFvCdvsewAInDKCNyR3laPA2mc9bRYJ6LBp5IyvF3fvXbbNR0bSzHlZVFtn6E0xw2oZlyjg4rKCVcng==", "os": ["android"], "cpu": ["arm"] }, - "@rollup/rollup-android-arm64@4.53.5": { - "integrity": "sha512-wrSAViWvZHBMMlWk6EJhvg8/rjxzyEhEdgfMMjREHEq11EtJ6IP6yfcCH57YAEca2Oe3FNCE9DSTgU70EIGmVw==", + "@rollup/rollup-android-arm64@4.54.0": { + "integrity": "sha512-Skx39Uv+u7H224Af+bDgNinitlmHyQX1K/atIA32JP3JQw6hVODX5tkbi2zof/E69M1qH2UoN3Xdxgs90mmNYw==", "os": ["android"], "cpu": ["arm64"] }, - "@rollup/rollup-darwin-arm64@4.53.5": { - "integrity": "sha512-S87zZPBmRO6u1YXQLwpveZm4JfPpAa6oHBX7/ghSiGH3rz/KDgAu1rKdGutV+WUI6tKDMbaBJomhnT30Y2t4VQ==", + "@rollup/rollup-darwin-arm64@4.54.0": { + "integrity": "sha512-k43D4qta/+6Fq+nCDhhv9yP2HdeKeP56QrUUTW7E6PhZP1US6NDqpJj4MY0jBHlJivVJD5P8NxrjuobZBJTCRw==", "os": ["darwin"], "cpu": ["arm64"] }, - "@rollup/rollup-darwin-x64@4.53.5": { - "integrity": "sha512-YTbnsAaHo6VrAczISxgpTva8EkfQus0VPEVJCEaboHtZRIb6h6j0BNxRBOwnDciFTZLDPW5r+ZBmhL/+YpTZgA==", + "@rollup/rollup-darwin-x64@4.54.0": { + "integrity": "sha512-cOo7biqwkpawslEfox5Vs8/qj83M/aZCSSNIWpVzfU2CYHa2G3P1UN5WF01RdTHSgCkri7XOlTdtk17BezlV3A==", "os": ["darwin"], "cpu": ["x64"] }, - "@rollup/rollup-freebsd-arm64@4.53.5": { - "integrity": "sha512-1T8eY2J8rKJWzaznV7zedfdhD1BqVs1iqILhmHDq/bqCUZsrMt+j8VCTHhP0vdfbHK3e1IQ7VYx3jlKqwlf+vw==", + "@rollup/rollup-freebsd-arm64@4.54.0": { + "integrity": "sha512-miSvuFkmvFbgJ1BevMa4CPCFt5MPGw094knM64W9I0giUIMMmRYcGW/JWZDriaw/k1kOBtsWh1z6nIFV1vPNtA==", "os": ["freebsd"], "cpu": ["arm64"] }, - "@rollup/rollup-freebsd-x64@4.53.5": { - "integrity": "sha512-sHTiuXyBJApxRn+VFMaw1U+Qsz4kcNlxQ742snICYPrY+DDL8/ZbaC4DVIB7vgZmp3jiDaKA0WpBdP0aqPJoBQ==", + "@rollup/rollup-freebsd-x64@4.54.0": { + "integrity": "sha512-KGXIs55+b/ZfZsq9aR026tmr/+7tq6VG6MsnrvF4H8VhwflTIuYh+LFUlIsRdQSgrgmtM3fVATzEAj4hBQlaqQ==", "os": ["freebsd"], "cpu": ["x64"] }, - "@rollup/rollup-linux-arm-gnueabihf@4.53.5": { - "integrity": "sha512-dV3T9MyAf0w8zPVLVBptVlzaXxka6xg1f16VAQmjg+4KMSTWDvhimI/Y6mp8oHwNrmnmVl9XxJ/w/mO4uIQONA==", + "@rollup/rollup-linux-arm-gnueabihf@4.54.0": { + "integrity": "sha512-EHMUcDwhtdRGlXZsGSIuXSYwD5kOT9NVnx9sqzYiwAc91wfYOE1g1djOEDseZJKKqtHAHGwnGPQu3kytmfaXLQ==", "os": ["linux"], "cpu": ["arm"] }, - "@rollup/rollup-linux-arm-musleabihf@4.53.5": { - "integrity": "sha512-wIGYC1x/hyjP+KAu9+ewDI+fi5XSNiUi9Bvg6KGAh2TsNMA3tSEs+Sh6jJ/r4BV/bx/CyWu2ue9kDnIdRyafcQ==", + "@rollup/rollup-linux-arm-musleabihf@4.54.0": { + "integrity": "sha512-+pBrqEjaakN2ySv5RVrj/qLytYhPKEUwk+e3SFU5jTLHIcAtqh2rLrd/OkbNuHJpsBgxsD8ccJt5ga/SeG0JmA==", "os": ["linux"], "cpu": ["arm"] }, - "@rollup/rollup-linux-arm64-gnu@4.53.5": { - "integrity": "sha512-Y+qVA0D9d0y2FRNiG9oM3Hut/DgODZbU9I8pLLPwAsU0tUKZ49cyV1tzmB/qRbSzGvY8lpgGkJuMyuhH7Ma+Vg==", + "@rollup/rollup-linux-arm64-gnu@4.54.0": { + "integrity": "sha512-NSqc7rE9wuUaRBsBp5ckQ5CVz5aIRKCwsoa6WMF7G01sX3/qHUw/z4pv+D+ahL1EIKy6Enpcnz1RY8pf7bjwng==", "os": ["linux"], "cpu": ["arm64"] }, - "@rollup/rollup-linux-arm64-musl@4.53.5": { - "integrity": "sha512-juaC4bEgJsyFVfqhtGLz8mbopaWD+WeSOYr5E16y+1of6KQjc0BpwZLuxkClqY1i8sco+MdyoXPNiCkQou09+g==", + "@rollup/rollup-linux-arm64-musl@4.54.0": { + "integrity": "sha512-gr5vDbg3Bakga5kbdpqx81m2n9IX8M6gIMlQQIXiLTNeQW6CucvuInJ91EuCJ/JYvc+rcLLsDFcfAD1K7fMofg==", "os": ["linux"], "cpu": ["arm64"] }, - "@rollup/rollup-linux-loong64-gnu@4.53.5": { - "integrity": "sha512-rIEC0hZ17A42iXtHX+EPJVL/CakHo+tT7W0pbzdAGuWOt2jxDFh7A/lRhsNHBcqL4T36+UiAgwO8pbmn3dE8wA==", + "@rollup/rollup-linux-loong64-gnu@4.54.0": { + "integrity": "sha512-gsrtB1NA3ZYj2vq0Rzkylo9ylCtW/PhpLEivlgWe0bpgtX5+9j9EZa0wtZiCjgu6zmSeZWyI/e2YRX1URozpIw==", "os": ["linux"], "cpu": ["loong64"] }, - "@rollup/rollup-linux-ppc64-gnu@4.53.5": { - "integrity": "sha512-T7l409NhUE552RcAOcmJHj3xyZ2h7vMWzcwQI0hvn5tqHh3oSoclf9WgTl+0QqffWFG8MEVZZP1/OBglKZx52Q==", + "@rollup/rollup-linux-ppc64-gnu@4.54.0": { + "integrity": "sha512-y3qNOfTBStmFNq+t4s7Tmc9hW2ENtPg8FeUD/VShI7rKxNW7O4fFeaYbMsd3tpFlIg1Q8IapFgy7Q9i2BqeBvA==", "os": ["linux"], "cpu": ["ppc64"] }, - "@rollup/rollup-linux-riscv64-gnu@4.53.5": { - "integrity": "sha512-7OK5/GhxbnrMcxIFoYfhV/TkknarkYC1hqUw1wU2xUN3TVRLNT5FmBv4KkheSG2xZ6IEbRAhTooTV2+R5Tk0lQ==", + "@rollup/rollup-linux-riscv64-gnu@4.54.0": { + "integrity": "sha512-89sepv7h2lIVPsFma8iwmccN7Yjjtgz0Rj/Ou6fEqg3HDhpCa+Et+YSufy27i6b0Wav69Qv4WBNl3Rs6pwhebQ==", "os": ["linux"], "cpu": ["riscv64"] }, - "@rollup/rollup-linux-riscv64-musl@4.53.5": { - "integrity": "sha512-GwuDBE/PsXaTa76lO5eLJTyr2k8QkPipAyOrs4V/KJufHCZBJ495VCGJol35grx9xryk4V+2zd3Ri+3v7NPh+w==", + "@rollup/rollup-linux-riscv64-musl@4.54.0": { + "integrity": "sha512-ZcU77ieh0M2Q8Ur7D5X7KvK+UxbXeDHwiOt/CPSBTI1fBmeDMivW0dPkdqkT4rOgDjrDDBUed9x4EgraIKoR2A==", "os": ["linux"], "cpu": ["riscv64"] }, - "@rollup/rollup-linux-s390x-gnu@4.53.5": { - "integrity": "sha512-IAE1Ziyr1qNfnmiQLHBURAD+eh/zH1pIeJjeShleII7Vj8kyEm2PF77o+lf3WTHDpNJcu4IXJxNO0Zluro8bOw==", + "@rollup/rollup-linux-s390x-gnu@4.54.0": { + "integrity": "sha512-2AdWy5RdDF5+4YfG/YesGDDtbyJlC9LHmL6rZw6FurBJ5n4vFGupsOBGfwMRjBYH7qRQowT8D/U4LoSvVwOhSQ==", "os": ["linux"], "cpu": ["s390x"] }, - "@rollup/rollup-linux-x64-gnu@4.53.5": { - "integrity": "sha512-Pg6E+oP7GvZ4XwgRJBuSXZjcqpIW3yCBhK4BcsANvb47qMvAbCjR6E+1a/U2WXz1JJxp9/4Dno3/iSJLcm5auw==", + "@rollup/rollup-linux-x64-gnu@4.54.0": { + "integrity": "sha512-WGt5J8Ij/rvyqpFexxk3ffKqqbLf9AqrTBbWDk7ApGUzaIs6V+s2s84kAxklFwmMF/vBNGrVdYgbblCOFFezMQ==", "os": ["linux"], "cpu": ["x64"] }, - "@rollup/rollup-linux-x64-musl@4.53.5": { - "integrity": "sha512-txGtluxDKTxaMDzUduGP0wdfng24y1rygUMnmlUJ88fzCCULCLn7oE5kb2+tRB+MWq1QDZT6ObT5RrR8HFRKqg==", + "@rollup/rollup-linux-x64-musl@4.54.0": { + "integrity": "sha512-JzQmb38ATzHjxlPHuTH6tE7ojnMKM2kYNzt44LO/jJi8BpceEC8QuXYA908n8r3CNuG/B3BV8VR3Hi1rYtmPiw==", "os": ["linux"], "cpu": ["x64"] }, - "@rollup/rollup-openharmony-arm64@4.53.5": { - "integrity": "sha512-3DFiLPnTxiOQV993fMc+KO8zXHTcIjgaInrqlG8zDp1TlhYl6WgrOHuJkJQ6M8zHEcntSJsUp1XFZSY8C1DYbg==", + "@rollup/rollup-openharmony-arm64@4.54.0": { + "integrity": "sha512-huT3fd0iC7jigGh7n3q/+lfPcXxBi+om/Rs3yiFxjvSxbSB6aohDFXbWvlspaqjeOh+hx7DDHS+5Es5qRkWkZg==", "os": ["openharmony"], "cpu": ["arm64"] }, - "@rollup/rollup-win32-arm64-msvc@4.53.5": { - "integrity": "sha512-nggc/wPpNTgjGg75hu+Q/3i32R00Lq1B6N1DO7MCU340MRKL3WZJMjA9U4K4gzy3dkZPXm9E1Nc81FItBVGRlA==", + "@rollup/rollup-win32-arm64-msvc@4.54.0": { + "integrity": "sha512-c2V0W1bsKIKfbLMBu/WGBz6Yci8nJ/ZJdheE0EwB73N3MvHYKiKGs3mVilX4Gs70eGeDaMqEob25Tw2Gb9Nqyw==", "os": ["win32"], "cpu": ["arm64"] }, - "@rollup/rollup-win32-ia32-msvc@4.53.5": { - "integrity": "sha512-U/54pTbdQpPLBdEzCT6NBCFAfSZMvmjr0twhnD9f4EIvlm9wy3jjQ38yQj1AGznrNO65EWQMgm/QUjuIVrYF9w==", + "@rollup/rollup-win32-ia32-msvc@4.54.0": { + "integrity": "sha512-woEHgqQqDCkAzrDhvDipnSirm5vxUXtSKDYTVpZG3nUdW/VVB5VdCYA2iReSj/u3yCZzXID4kuKG7OynPnB3WQ==", "os": ["win32"], "cpu": ["ia32"] }, - "@rollup/rollup-win32-x64-gnu@4.53.5": { - "integrity": "sha512-2NqKgZSuLH9SXBBV2dWNRCZmocgSOx8OJSdpRaEcRlIfX8YrKxUT6z0F1NpvDVhOsl190UFTRh2F2WDWWCYp3A==", + "@rollup/rollup-win32-x64-gnu@4.54.0": { + "integrity": "sha512-dzAc53LOuFvHwbCEOS0rPbXp6SIhAf2txMP5p6mGyOXXw5mWY8NGGbPMPrs4P1WItkfApDathBj/NzMLUZ9rtQ==", "os": ["win32"], "cpu": ["x64"] }, - "@rollup/rollup-win32-x64-msvc@4.53.5": { - "integrity": "sha512-JRpZUhCfhZ4keB5v0fe02gQJy05GqboPOaxvjugW04RLSYYoB/9t2lx2u/tMs/Na/1NXfY8QYjgRljRpN+MjTQ==", + "@rollup/rollup-win32-x64-msvc@4.54.0": { + "integrity": "sha512-hYT5d3YNdSh3mbCU1gwQyPgQd3T2ne0A3KG8KSBdav5TiBg6eInVmV+TeR5uHufiIgSFg0XsOWGW5/RhNcSvPg==", "os": ["win32"], "cpu": ["x64"] }, @@ -1615,11 +1685,10 @@ "vitefu" ] }, - "@tokenizer/inflate@0.3.1": { - "integrity": "sha512-4oeoZEBQdLdt5WmP/hx1KZ6D3/Oid/0cUb2nk4F0pTDAWy+KCH3/EnAkZF/bvckWo8I33EqBm01lIPgmgc8rCA==", + "@tokenizer/inflate@0.4.1": { + "integrity": "sha512-2mAv+8pkG6GIZiF1kNg1jAjh27IDxEPKwdGul3snfztFerfPGI1LjDezZp3i7BElXompqEtPmoPx6c2wgtWsOA==", "dependencies": [ "debug@4.4.3", - "fflate", "token-types@6.1.1" ] }, @@ -1708,8 +1777,8 @@ "@types/wrap-ansi@3.0.0": { "integrity": "sha512-ltIpx+kM7g/MLRZfkbL7EsCEjfzCcScLpkg37eXEtx5kmrAKBkTJwd1GIAjDSL8wTpM6Hzn5YO4pSb91BEwu1g==" }, - "@typescript-eslint/parser@8.50.0_eslint@9.39.2_typescript@5.9.3": { - "integrity": "sha512-6/cmF2piao+f6wSxUsJLZjck7OQsYyRtcOZS02k7XINSNlz93v6emM8WutDQSXnroG2xwYlEVHJI+cPA7CPM3Q==", + "@typescript-eslint/parser@8.50.1_eslint@9.39.2_typescript@5.9.3": { + "integrity": "sha512-hM5faZwg7aVNa819m/5r7D0h0c9yC4DUlWAOvHAtISdFTc8xB86VmX5Xqabrama3wIPJ/q9RbGS1worb6JfnMg==", "dependencies": [ "@typescript-eslint/scope-manager", "@typescript-eslint/types", @@ -1720,8 +1789,8 @@ "typescript" ] }, - "@typescript-eslint/project-service@8.50.0_typescript@5.9.3": { - "integrity": "sha512-Cg/nQcL1BcoTijEWyx4mkVC56r8dj44bFDvBdygifuS20f3OZCHmFbjF34DPSi07kwlFvqfv/xOLnJ5DquxSGQ==", + "@typescript-eslint/project-service@8.50.1_typescript@5.9.3": { + "integrity": "sha512-E1ur1MCVf+YiP89+o4Les/oBAVzmSbeRB0MQLfSlYtbWU17HPxZ6Bhs5iYmKZRALvEuBoXIZMOIRRc/P++Ortg==", "dependencies": [ "@typescript-eslint/tsconfig-utils", "@typescript-eslint/types", @@ -1729,24 +1798,24 @@ "typescript" ] }, - "@typescript-eslint/scope-manager@8.50.0": { - "integrity": "sha512-xCwfuCZjhIqy7+HKxBLrDVT5q/iq7XBVBXLn57RTIIpelLtEIZHXAF/Upa3+gaCpeV1NNS5Z9A+ID6jn50VD4A==", + "@typescript-eslint/scope-manager@8.50.1": { + "integrity": "sha512-mfRx06Myt3T4vuoHaKi8ZWNTPdzKPNBhiblze5N50//TSHOAQQevl/aolqA/BcqqbJ88GUnLqjjcBc8EWdBcVw==", "dependencies": [ "@typescript-eslint/types", "@typescript-eslint/visitor-keys" ] }, - "@typescript-eslint/tsconfig-utils@8.50.0_typescript@5.9.3": { - "integrity": "sha512-vxd3G/ybKTSlm31MOA96gqvrRGv9RJ7LGtZCn2Vrc5htA0zCDvcMqUkifcjrWNNKXHUU3WCkYOzzVSFBd0wa2w==", + "@typescript-eslint/tsconfig-utils@8.50.1_typescript@5.9.3": { + "integrity": "sha512-ooHmotT/lCWLXi55G4mvaUF60aJa012QzvLK0Y+Mp4WdSt17QhMhWOaBWeGTFVkb2gDgBe19Cxy1elPXylslDw==", "dependencies": [ "typescript" ] }, - "@typescript-eslint/types@8.50.0": { - "integrity": "sha512-iX1mgmGrXdANhhITbpp2QQM2fGehBse9LbTf0sidWK6yg/NE+uhV5dfU1g6EYPlcReYmkE9QLPq/2irKAmtS9w==" + "@typescript-eslint/types@8.50.1": { + "integrity": "sha512-v5lFIS2feTkNyMhd7AucE/9j/4V9v5iIbpVRncjk/K0sQ6Sb+Np9fgYS/63n6nwqahHQvbmujeBL7mp07Q9mlA==" }, - "@typescript-eslint/typescript-estree@8.50.0_typescript@5.9.3": { - "integrity": "sha512-W7SVAGBR/IX7zm1t70Yujpbk+zdPq/u4soeFSknWFdXIFuWsBGBOUu/Tn/I6KHSKvSh91OiMuaSnYp3mtPt5IQ==", + "@typescript-eslint/typescript-estree@8.50.1_typescript@5.9.3": { + "integrity": "sha512-woHPdW+0gj53aM+cxchymJCrh0cyS7BTIdcDxWUNsclr9VDkOSbqC13juHzxOmQ22dDkMZEpZB+3X1WpUvzgVQ==", "dependencies": [ "@typescript-eslint/project-service", "@typescript-eslint/tsconfig-utils", @@ -1760,8 +1829,8 @@ "typescript" ] }, - "@typescript-eslint/utils@8.50.0_eslint@9.39.2_typescript@5.9.3": { - "integrity": "sha512-87KgUXET09CRjGCi2Ejxy3PULXna63/bMYv72tCAlDJC3Yqwln0HiFJ3VJMst2+mEtNtZu5oFvX4qJGjKsnAgg==", + "@typescript-eslint/utils@8.50.1_eslint@9.39.2_typescript@5.9.3": { + "integrity": "sha512-lCLp8H1T9T7gPbEuJSnHwnSuO9mDf8mfK/Nion5mZmiEaQD9sWf9W4dfeFqRyqRjF06/kBuTmAqcs9sewM2NbQ==", "dependencies": [ "@eslint-community/eslint-utils", "@typescript-eslint/scope-manager", @@ -1771,8 +1840,8 @@ "typescript" ] }, - "@typescript-eslint/visitor-keys@8.50.0": { - "integrity": "sha512-Xzmnb58+Db78gT/CCj/PVCvK+zxbnsw6F+O1oheYszJbBSdEjVhQi3C/Xttzxgi/GLmpvOggRs1RFpiJ8+c34Q==", + "@typescript-eslint/visitor-keys@8.50.1": { + "integrity": "sha512-IrDKrw7pCRUR94zeuCSUWQ+w8JEf5ZX5jl/e6AHGSLi1/zIr0lgutfn/7JpfCey+urpgQEdrZVYzCaVVKiTwhQ==", "dependencies": [ "@typescript-eslint/types", "eslint-visitor-keys@4.2.1" @@ -2576,9 +2645,6 @@ "regexparam" ] }, - "fflate@0.8.2": { - "integrity": "sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A==" - }, "file-entry-cache@8.0.0": { "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==", "dependencies": [ @@ -2602,8 +2668,8 @@ "uint8array-extras" ] }, - "file-type@21.1.0": { - "integrity": "sha512-boU4EHmP3JXkwDo4uhyBhTt5pPstxB6eEXKJBu2yu2l7aAMMm7QQYQEzssJmKReZYrFdFOJS8koVo6bXIBGDqA==", + "file-type@21.1.1": { + "integrity": "sha512-ifJXo8zUqbQ/bLbl9sFoqHNTNWbnPY1COImFfM6CCy7z+E+jC1eY9YfOKkx0fckIg+VljAy2/87T61fp0+eEkg==", "dependencies": [ "@tokenizer/inflate", "strtok3@10.3.4", @@ -3709,7 +3775,7 @@ "rfdc@1.4.1": { "integrity": "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==" }, - "rolldown-plugin-dts@0.13.14_rolldown@1.0.0-beta.55": { + "rolldown-plugin-dts@0.13.14_rolldown@1.0.0-beta.56": { "integrity": "sha512-wjNhHZz9dlN6PTIXyizB6u/mAg1wEFMW9yw7imEVe3CxHSRnNHVyycIX0yDEOVJfDNISLPbkCIPEpFpizy5+PQ==", "dependencies": [ "@babel/generator", @@ -3723,8 +3789,8 @@ "rolldown" ] }, - "rolldown@1.0.0-beta.55": { - "integrity": "sha512-r8Ws43aYCnfO07ao0SvQRz4TBAtZJjGWNvScRBOHuiNHvjfECOJBIqJv0nUkL1GYcltjvvHswRilDF1ocsC0+g==", + "rolldown@1.0.0-beta.56": { + "integrity": "sha512-9MHiUvRH2R8rb6ad6EaLxahS3RbQKdMMlrh9XKmbz2HiCGfK4IWKSNv4N6GhYr+7kHExg6oIc5EF1xA3iR4x1A==", "dependencies": [ "@oxc-project/types", "@rolldown/pluginutils" @@ -3746,8 +3812,8 @@ ], "bin": true }, - "rollup@4.53.5": { - "integrity": "sha512-iTNAbFSlRpcHeeWu73ywU/8KuU/LZmNCSxp6fjQkJBD3ivUb8tpDrXhIxEzA05HlYMEwmtaUnb3RP+YNv162OQ==", + "rollup@4.54.0": { + "integrity": "sha512-3nk8Y3a9Ea8szgKhinMlGMhGMw89mqule3KWczxhIzqudyHdCIOHw8WJlj/r329fACjKLEh13ZSk7oE22kyeIw==", "dependencies": [ "@types/estree" ], @@ -4173,7 +4239,7 @@ "typescript" ] }, - "tsdown@0.12.9_rolldown@1.0.0-beta.55": { + "tsdown@0.12.9_rolldown@1.0.0-beta.56": { "integrity": "sha512-MfrXm9PIlT3saovtWKf/gCJJ/NQCdE0SiREkdNC+9Qy6UHhdeDPxnkFaBD7xttVUmgp0yUHtGirpoLB+OVLuLA==", "dependencies": [ "ansis", @@ -4445,7 +4511,7 @@ "scripts": true, "bin": true }, - "wrangler@4.56.0_@cloudflare+workers-types@4.20251219.0_unenv@2.0.0-rc.24_workerd@1.20251217.0": { + "wrangler@4.56.0_@cloudflare+workers-types@4.20251223.0_unenv@2.0.0-rc.24_workerd@1.20251217.0": { "integrity": "sha512-Nqi8duQeRbA+31QrD6QlWHW3IZVnuuRxMy7DEg46deUzywivmaRV/euBN5KKXDPtA24VyhYsK7I0tkb7P5DM2w==", "dependencies": [ "@cloudflare/kv-asset-handler", diff --git a/packages/relay/README.md b/packages/relay/README.md index c06aada65..e0402fdc8 100644 --- a/packages/relay/README.md +++ b/packages/relay/README.md @@ -46,11 +46,16 @@ Key features: ### LitePub-style relay -*LitePub relay support is planned for a future release.* - The LitePub-style relay protocol uses bidirectional following relationships and wraps activities in `Announce` activities for distribution. +Key features: + + - Reciprocal following between relay and subscribers + - Activities wrapped in `Announce` for distribution + - Two-phase subscription (pending → accepted) + - Enhanced federation capabilities + Installation ------------ @@ -83,43 +88,56 @@ bun add @fedify/relay Usage ----- -### Creating a Mastodon-style relay +### Creating a relay -Here's a simple example of creating a Mastodon-compatible relay server: +Here's a simple example of creating a relay server using the factory function: ~~~~ typescript -import { MastodonRelay } from "@fedify/relay"; +import { createRelay } from "@fedify/relay"; import { MemoryKvStore } from "@fedify/fedify"; -const relay = new MastodonRelay({ +// Create a Mastodon-style relay +const relay = createRelay("mastodon", { kv: new MemoryKvStore(), domain: "relay.example.com", -}); - -// Optional: Set a custom subscription handler to approve/reject subscriptions -relay.setSubscriptionHandler(async (ctx, actor) => { - // Implement your approval logic here - // Return true to approve, false to reject - const domain = new URL(actor.id!).hostname; - const blockedDomains = ["spam.example", "blocked.example"]; - return !blockedDomains.includes(domain); + // Optional: Set a custom subscription handler to approve/reject subscriptions + subscriptionHandler: async (ctx, actor) => { + // Implement your approval logic here + // Return true to approve, false to reject + const domain = new URL(actor.id!).hostname; + const blockedDomains = ["spam.example", "blocked.example"]; + return !blockedDomains.includes(domain); + }, }); // Serve the relay Deno.serve((request) => relay.fetch(request)); ~~~~ +You can also create a LitePub-style relay by changing the type: + +~~~~ typescript +const relay = createRelay("litepub", { + kv: new MemoryKvStore(), + domain: "relay.example.com", +}); +~~~~ + ### Subscription handling By default, the relay automatically rejects all subscription requests. -You can customize this behavior by setting a subscription handler: +You can customize this behavior by providing a subscription handler in the options: ~~~~ typescript -relay.setSubscriptionHandler(async (ctx, actor) => { - // Example: Only allow subscriptions from specific domains - const domain = new URL(actor.id!).hostname; - const allowedDomains = ["mastodon.social", "fosstodon.org"]; - return allowedDomains.includes(domain); +const relay = createRelay("mastodon", { + kv: new MemoryKvStore(), + domain: "relay.example.com", + subscriptionHandler: async (ctx, actor) => { + // Example: Only allow subscriptions from specific domains + const domain = new URL(actor.id!).hostname; + const allowedDomains = ["mastodon.social", "fosstodon.org"]; + return allowedDomains.includes(domain); + }, }); ~~~~ @@ -131,11 +149,11 @@ example with Hono: ~~~~ typescript import { Hono } from "hono"; -import { MastodonRelay } from "@fedify/relay"; +import { createRelay } from "@fedify/relay"; import { MemoryKvStore } from "@fedify/fedify"; const app = new Hono(); -const relay = new MastodonRelay({ +const relay = createRelay("mastodon", { kv: new MemoryKvStore(), domain: "relay.example.com", }); @@ -191,25 +209,47 @@ details. API reference ------------- -### `MastodonRelay` - -A Mastodon-compatible ActivityPub relay implementation. +### `createRelay()` -#### Constructor +Factory function to create a relay instance. ~~~~ typescript -new MastodonRelay(options: RelayOptions) +function createRelay( + type: "mastodon" | "litepub", + options: RelayOptions +): BaseRelay ~~~~ -#### Properties +**Parameters:** + + - `type`: The type of relay to create (`"mastodon"` or `"litepub"`) + - `options`: Configuration options for the relay - - `domain`: The relay's domain name (read-only) +**Returns:** A relay instance (`MastodonRelay` or `LitePubRelay`) + +### `BaseRelay` + +Abstract base class for relay implementations. #### Methods - `fetch(request: Request): Promise`: Handle incoming HTTP requests - - `setSubscriptionHandler(handler: SubscriptionRequestHandler): this`: - Set a custom handler for subscription approval/rejection + +### `MastodonRelay` + +A Mastodon-compatible ActivityPub relay implementation that extends `BaseRelay`. + + - Uses direct activity forwarding + - Immediate subscription approval + - Compatible with standard ActivityPub implementations + +### `LitePubRelay` + +A LitePub-compatible ActivityPub relay implementation that extends `BaseRelay`. + + - Uses bidirectional following + - Activities wrapped in `Announce` + - Two-phase subscription (pending → accepted) ### `RelayOptions` @@ -217,12 +257,13 @@ Configuration options for the relay: - `kv: KvStore` (required): Key–value store for persisting relay data - `domain?: string`: Relay's domain name (defaults to `"localhost"`) + - `name?: string`: Relay's display name (defaults to `"ActivityPub Relay"`) + - `subscriptionHandler?: SubscriptionRequestHandler`: Custom handler for + subscription approval/rejection - `documentLoaderFactory?: DocumentLoaderFactory`: Custom document loader factory - `authenticatedDocumentLoaderFactory?: AuthenticatedDocumentLoaderFactory`: Custom authenticated document loader factory - - `federation?: Federation`: Custom Federation instance (for advanced - use cases) - `queue?: MessageQueue`: Message queue for background activity processing ### `SubscriptionRequestHandler` @@ -231,17 +272,17 @@ A function that determines whether to approve a subscription request: ~~~~ typescript type SubscriptionRequestHandler = ( - ctx: Context, + ctx: Context, clientActor: Actor, ) => Promise ~~~~ -Parameters: +**Parameters:** - - `ctx`: The Fedify context object + - `ctx`: The Fedify context object with relay options - `clientActor`: The actor requesting to subscribe -Returns: +**Returns:** - `true` to approve the subscription - `false` to reject the subscription diff --git a/packages/relay/package.json b/packages/relay/package.json index 801b6adc8..610690a13 100644 --- a/packages/relay/package.json +++ b/packages/relay/package.json @@ -10,7 +10,7 @@ ], "author": { "name": "Jiwon Kwon", - "email": "jiwonkwon@duck.com" + "email": "work@kwonjiwon.org" }, "homepage": "https://fedify.dev/", "repository": { @@ -47,6 +47,10 @@ "dist/", "package.json" ], + "dependencies": { + "@js-temporal/polyfill": "catalog:", + "@logtape/logtape": "catalog:" + }, "peerDependencies": { "@fedify/fedify": "workspace:^" }, @@ -61,7 +65,6 @@ "prepack": "deno task codegen && tsdown", "prepublish": "deno task codegen && tsdown", "test": "deno task codegen && tsdown && node --test", - "test:bun": "deno task codegen && tsdown && bun test --timeout 60000", - "test:cfworkers": "deno task codegen && wrangler deploy --dry-run --outdir src/cfworkers && node --import=tsx src/cfworkers/client.ts" + "test:bun": "deno task codegen && tsdown && bun test --timeout 60000" } } diff --git a/packages/relay/src/base.ts b/packages/relay/src/base.ts new file mode 100644 index 000000000..81cb08411 --- /dev/null +++ b/packages/relay/src/base.ts @@ -0,0 +1,39 @@ +import type { Federation, FederationBuilder } from "@fedify/fedify"; +import type { RelayOptions } from "./types.ts"; + +/** + * Abstract base class for relay implementations. + * Provides common infrastructure for both Mastodon and LitePub relays. + * + * @since 2.0.0 + */ +export abstract class BaseRelay { + protected federationBuilder: FederationBuilder; + protected options: RelayOptions; + protected federation?: Federation; + + constructor( + options: RelayOptions, + relayBuilder: FederationBuilder, + ) { + this.options = options; + this.federationBuilder = relayBuilder; + } + + async fetch(request: Request): Promise { + if (this.federation == null) { + this.federation = await this.federationBuilder.build(this.options); + this.setupInboxListeners(); + } + + return await this.federation.fetch(request, { + contextData: this.options, + }); + } + + /** + * Set up inbox listeners for handling ActivityPub activities. + * Each relay type implements this method with protocol-specific logic. + */ + protected abstract setupInboxListeners(): void; +} diff --git a/packages/relay/src/builder.ts b/packages/relay/src/builder.ts new file mode 100644 index 000000000..9ab634ac0 --- /dev/null +++ b/packages/relay/src/builder.ts @@ -0,0 +1,112 @@ +import { + type Context, + createFederationBuilder, + exportJwk, + type FederationBuilder, + generateCryptoKeyPair, + importJwk, +} from "@fedify/fedify"; +import { Application, isActor, Object } from "@fedify/fedify/vocab"; +import type { Actor } from "@fedify/fedify/vocab"; +import { + RELAY_SERVER_ACTOR, + type RelayFollower, + type RelayOptions, +} from "./types.ts"; + +export const relayBuilder: FederationBuilder = + createFederationBuilder(); + +relayBuilder.setActorDispatcher( + "/users/{identifier}", + async (ctx, identifier) => { + if (identifier !== RELAY_SERVER_ACTOR) return null; + const keys = await ctx.getActorKeyPairs(identifier); + return new Application({ + id: ctx.getActorUri(identifier), + preferredUsername: identifier, + name: ctx.data.name ?? "ActivityPub Relay", + inbox: ctx.getInboxUri(), // This should be shared inbox uri + followers: ctx.getFollowersUri(identifier), + following: ctx.getFollowingUri(identifier), + url: ctx.getActorUri(identifier), + publicKey: keys[0].cryptographicKey, + + assertionMethods: keys.map((k) => k.multikey), + }); + }, +) + .setKeyPairsDispatcher( + async (ctx, identifier) => { + if (identifier !== RELAY_SERVER_ACTOR) return []; + + const rsaPairJson = await ctx.data.kv.get< + { privateKey: JsonWebKey; publicKey: JsonWebKey } + >(["keypair", "rsa", identifier]); + const ed25519PairJson = await ctx.data.kv.get< + { privateKey: JsonWebKey; publicKey: JsonWebKey } + >(["keypair", "ed25519", identifier]); + if (rsaPairJson == null || ed25519PairJson == null) { + const rsaPair = await generateCryptoKeyPair("RSASSA-PKCS1-v1_5"); + const ed25519Pair = await generateCryptoKeyPair("Ed25519"); + await ctx.data.kv.set(["keypair", "rsa", identifier], { + privateKey: await exportJwk(rsaPair.privateKey), + publicKey: await exportJwk(rsaPair.publicKey), + }); + await ctx.data.kv.set(["keypair", "ed25519", identifier], { + privateKey: await exportJwk(ed25519Pair.privateKey), + publicKey: await exportJwk(ed25519Pair.publicKey), + }); + + return [rsaPair, ed25519Pair]; + } + + const rsaPair: CryptoKeyPair = { + privateKey: await importJwk(rsaPairJson.privateKey, "private"), + publicKey: await importJwk(rsaPairJson.publicKey, "public"), + }; + const ed25519Pair: CryptoKeyPair = { + privateKey: await importJwk(ed25519PairJson.privateKey, "private"), + publicKey: await importJwk(ed25519PairJson.publicKey, "public"), + }; + return [rsaPair, ed25519Pair]; + }, + ); + +async function getFollowerActors( + ctx: Context, +): Promise { + const followers = await ctx.data.kv.get(["followers"]) ?? []; + + const actors: Actor[] = []; + for (const followerId of followers) { + const follower = await ctx.data.kv.get([ + "follower", + followerId, + ]); + if (!follower) continue; + const actor = await Object.fromJsonLd(follower.actor); + if (!isActor(actor)) continue; + actors.push(actor); + } + return actors; +} + +async function dispatchRelayActors( + ctx: Context, + identifier: string, +) { + if (identifier !== RELAY_SERVER_ACTOR) return null; + const actors = await getFollowerActors(ctx); + return { items: actors }; +} + +relayBuilder.setFollowersDispatcher( + "/users/{identifier}/followers", + dispatchRelayActors, +); + +relayBuilder.setFollowingDispatcher( + "/users/{identifier}/following", + dispatchRelayActors, +); diff --git a/packages/relay/src/factory.ts b/packages/relay/src/factory.ts new file mode 100644 index 000000000..a513f57ab --- /dev/null +++ b/packages/relay/src/factory.ts @@ -0,0 +1,37 @@ +import type { BaseRelay } from "./base.ts"; +import { relayBuilder } from "./builder.ts"; +import { LitePubRelay } from "./litepub.ts"; +import { MastodonRelay } from "./mastodon.ts"; +import type { RelayOptions, RelayType } from "./types.ts"; + +/** + * Factory function to create a relay instance. + * + * @param type The type of relay to create ("mastodon" or "litepub") + * @param options Configuration options for the relay + * @returns A relay instance + * + * @example + * ```ts + * import { createRelay } from "@fedify/relay"; + * import { MemoryKvStore } from "@fedify/fedify"; + * + * const relay = createRelay("mastodon", { + * kv: new MemoryKvStore(), + * domain: "relay.example.com", + * }); + * ``` + * + * @since 2.0.0 + */ +export function createRelay( + type: RelayType, + options: RelayOptions, +): BaseRelay { + switch (type) { + case "mastodon": + return new MastodonRelay(options, relayBuilder); + case "litepub": + return new LitePubRelay(options, relayBuilder); + } +} diff --git a/packages/relay/src/follow.ts b/packages/relay/src/follow.ts new file mode 100644 index 000000000..1f0475278 --- /dev/null +++ b/packages/relay/src/follow.ts @@ -0,0 +1,105 @@ +import { + Accept, + type Context, + Follow, + Reject, + type Undo, +} from "@fedify/fedify"; +import type { Actor } from "@fedify/fedify/vocab"; +import type { getLogger } from "@logtape/logtape"; +import { RELAY_SERVER_ACTOR, type RelayOptions } from "./types.ts"; + +/** + * Validate Follow activity and return follower actor if valid. + * This validation is common to both Mastodon and LitePub relay protocols. + * + * @param ctx The federation context + * @param follow The Follow activity to validate + * @returns The follower Actor if valid, null otherwise + */ +export async function validateFollowActivity( + ctx: Context, + follow: Follow, +): Promise { + if (follow.id == null || follow.objectId == null) return null; + + const parsed = ctx.parseUri(follow.objectId); + const isPublicFollow = follow.objectId.href === + "https://www.w3.org/ns/activitystreams#Public"; + if (!isPublicFollow && parsed?.type !== "actor") return null; + + const follower = await follow.getActor(ctx); + if ( + follower == null || follower.id == null || + follower.preferredUsername == null || + follower.inboxId == null + ) return null; + + return follower; +} + +/** + * Send Accept or Reject response for a Follow activity. + * This is common to both Mastodon and LitePub relay protocols. + * + * @param ctx The federation context + * @param follow The Follow activity being responded to + * @param follower The actor who sent the Follow + * @param approved Whether the follow was approved + */ +export async function sendFollowResponse( + ctx: Context, + follow: Follow, + follower: Actor, + approved: boolean, +): Promise { + const relayActorUri = ctx.getActorUri(RELAY_SERVER_ACTOR); + const Activity = approved ? Accept : Reject; + const action = approved ? "accepts" : "rejects"; + + await ctx.sendActivity( + { identifier: RELAY_SERVER_ACTOR }, + follower, + new Activity({ + id: new URL(`#${action}`, relayActorUri), + actor: relayActorUri, + object: follow, + }), + ); +} + +/** + * Handle Undo activity for Follow. + * This logic is identical for both Mastodon and LitePub relay protocols. + * + * @param ctx The federation context + * @param undo The Undo activity to handle + * @param logger The logger instance to use for warnings + */ +export async function handleUndoFollow( + ctx: Context, + undo: Undo, + logger: ReturnType, +): Promise { + const activity = await undo.getObject({ + crossOrigin: "trust", + ...ctx, + }); + + if (activity instanceof Follow) { + if (activity.id == null || activity.actorId == null) return; + + const followers = await ctx.data.kv.get(["followers"]) ?? []; + const updatedFollowers = followers.filter((id) => + id !== activity.actorId?.href + ); + + await ctx.data.kv.set(["followers"], updatedFollowers); + await ctx.data.kv.delete(["follower", activity.actorId?.href]); + } else { + logger.warn( + "Unsupported object type ({type}) for Undo activity: {object}", + { type: activity?.constructor.name, object: activity }, + ); + } +} diff --git a/packages/relay/src/litepub.test.ts b/packages/relay/src/litepub.test.ts new file mode 100644 index 000000000..34389d8c1 --- /dev/null +++ b/packages/relay/src/litepub.test.ts @@ -0,0 +1,896 @@ +// deno-lint-ignore-file no-explicit-any +import { MemoryKvStore, signRequest } from "@fedify/fedify"; +import { + Accept, + Announce, + Create, + Delete, + Follow, + Move, + Note, + Person, + Undo, + Update, +} from "@fedify/fedify/vocab"; +import { + exportSpki, + getDocumentLoader, + type RemoteDocument, +} from "@fedify/vocab-runtime"; +import { ok, strictEqual } from "node:assert"; +import test, { describe } from "node:test"; +import { createRelay, type RelayOptions } from "@fedify/relay"; + +// Simple mock document loader that returns a minimal context +const mockDocumentLoader = async (url: string): Promise => { + if ( + url === "https://remote.example.com/users/alice" || + url === "https://remote.example.com/users/alice#main-key" + ) { + return { + contextUrl: null, + documentUrl: url.replace(/#main-key$/, ""), + document: { + "@context": [ + "https://www.w3.org/ns/activitystreams", + "https://w3id.org/security/v1", + ], + id: url, + type: "Person", + preferredUsername: "alice", + inbox: "https://remote.example.com/users/alice/inbox", + publicKey: { + id: "https://remote.example.com/users/alice#main-key", + owner: url.replace(/#main-key$/, ""), + publicKeyPem: await exportSpki(rsaKeyPair.publicKey), + }, + }, + }; + } else if (url === "https://remote.example.com/notes/1") { + return { + contextUrl: null, + documentUrl: url, + document: { + "@context": "https://www.w3.org/ns/activitystreams", + id: url, + type: "Note", + content: "Hello world", + }, + }; + } else if (url.startsWith("https://remote.example.com/")) { + throw new Error(`Document not found: ${url}`); + } + return await getDocumentLoader()(url); +}; + +// Mock RSA key pair for testing +const rsaKeyPair = await crypto.subtle.generateKey( + { + name: "RSASSA-PKCS1-v1_5", + modulusLength: 2048, + publicExponent: new Uint8Array([0x01, 0x00, 0x01]), + hash: "SHA-256", + }, + true, + ["sign", "verify"], +); + +const rsaPublicKey = { + id: new URL("https://remote.example.com/users/alice#main-key"), + ...rsaKeyPair.publicKey, +}; + +describe("LitePubRelay", () => { + test("constructor with required options", () => { + const options: RelayOptions = { + kv: new MemoryKvStore(), + domain: "relay.example.com", + documentLoaderFactory: () => mockDocumentLoader, + subscriptionHandler: async (_ctx, _actor) => { + return await Promise.resolve(true); + }, + }; + + const relay = createRelay("litepub", options); + ok(relay); + }); + + test("fetch method returns Response", async () => { + const kv = new MemoryKvStore(); + const relay = createRelay("litepub", { + kv, + domain: "relay.example.com", + documentLoaderFactory: () => mockDocumentLoader, + }); + + const request = new Request("https://relay.example.com/users/relay", { + headers: { "Accept": "application/activity+json" }, + }); + const response = await relay.fetch(request); + + ok(response instanceof Response); + }); + + test("fetching relay actor returns Application", async () => { + const kv = new MemoryKvStore(); + const relay = createRelay("litepub", { + kv, + domain: "relay.example.com", + documentLoaderFactory: () => mockDocumentLoader, + }); + + const request = new Request("https://relay.example.com/users/relay", { + headers: { "Accept": "application/activity+json" }, + }); + const response = await relay.fetch(request); + + strictEqual(response.status, 200); + const json = await response.json() as any; + strictEqual(json.type, "Application"); + strictEqual(json.preferredUsername, "relay"); + strictEqual(json.name, "ActivityPub Relay"); + }); + + test("fetching non-relay actor returns 404", async () => { + const kv = new MemoryKvStore(); + const relay = createRelay("litepub", { + kv, + domain: "relay.example.com", + documentLoaderFactory: () => mockDocumentLoader, + }); + + const request = new Request( + "https://relay.example.com/users/non-existent", + { + headers: { "Accept": "application/activity+json" }, + }, + ); + const response = await relay.fetch(request); + + strictEqual(response.status, 404); + }); + + test("followers collection returns empty list initially", async () => { + const kv = new MemoryKvStore(); + const relay = createRelay("litepub", { + kv, + domain: "relay.example.com", + documentLoaderFactory: () => mockDocumentLoader, + }); + + const request = new Request( + "https://relay.example.com/users/relay/followers", + { + headers: { "Accept": "application/activity+json" }, + }, + ); + const response = await relay.fetch(request); + + strictEqual(response.status, 200); + const json = await response.json() as any; + ok(json); + ok(json.type === "Collection" || json.type === "OrderedCollection"); + }); + + test("followers collection returns populated list", async () => { + const kv = new MemoryKvStore(); + + // Pre-populate followers + const follower1 = new Person({ + id: new URL("https://remote1.example.com/users/alice"), + preferredUsername: "alice", + inbox: new URL("https://remote1.example.com/users/alice/inbox"), + }); + + const follower2 = new Person({ + id: new URL("https://remote2.example.com/users/bob"), + preferredUsername: "bob", + inbox: new URL("https://remote2.example.com/users/bob/inbox"), + }); + + const follower1Id = "https://remote1.example.com/users/alice"; + const follower2Id = "https://remote2.example.com/users/bob"; + + await kv.set(["followers"], [follower1Id, follower2Id]); + await kv.set( + ["follower", follower1Id], + { actor: await follower1.toJsonLd(), state: "accepted" }, + ); + await kv.set( + ["follower", follower2Id], + { actor: await follower2.toJsonLd(), state: "accepted" }, + ); + + const relay = createRelay("litepub", { + kv, + domain: "relay.example.com", + documentLoaderFactory: () => mockDocumentLoader, + }); + + const request = new Request( + "https://relay.example.com/users/relay/followers", + { + headers: { "Accept": "application/activity+json" }, + }, + ); + const response = await relay.fetch(request); + + strictEqual(response.status, 200); + const json = await response.json() as any; + ok(json); + ok(json.type === "Collection" || json.type === "OrderedCollection"); + if (json.totalItems !== undefined) { + strictEqual(json.totalItems, 2); + } + }); + + test("relay actor has correct properties", async () => { + const kv = new MemoryKvStore(); + const relay = createRelay("litepub", { + kv, + domain: "relay.example.com", + documentLoaderFactory: () => mockDocumentLoader, + }); + + const request = new Request("https://relay.example.com/users/relay", { + headers: { "Accept": "application/activity+json" }, + }); + const response = await relay.fetch(request); + + strictEqual(response.status, 200); + const json = await response.json() as any; + + strictEqual(json.type, "Application"); + strictEqual(json.preferredUsername, "relay"); + strictEqual(json.name, "ActivityPub Relay"); + strictEqual(json.id, "https://relay.example.com/users/relay"); + strictEqual(json.inbox, "https://relay.example.com/inbox"); + strictEqual( + json.followers, + "https://relay.example.com/users/relay/followers", + ); + strictEqual( + json.following, + "https://relay.example.com/users/relay/following", + ); + }); + + test("handles Follow activity with subscription approval", async () => { + const kv = new MemoryKvStore(); + let handlerCalled = false; + let handlerActor: any = null; + + const relay = createRelay("litepub", { + kv, + domain: "relay.example.com", + documentLoaderFactory: () => mockDocumentLoader, + authenticatedDocumentLoaderFactory: () => mockDocumentLoader, + subscriptionHandler: async (_ctx, actor) => { + handlerCalled = true; + handlerActor = actor; + return await Promise.resolve(true); + }, + }); + + const follower = new Person({ + id: new URL("https://remote.example.com/users/alice"), + preferredUsername: "alice", + inbox: new URL("https://remote.example.com/users/alice/inbox"), + }); + + const followActivity = new Follow({ + id: new URL("https://remote.example.com/activities/follow/1"), + actor: follower.id, + object: new URL("https://relay.example.com/users/relay"), + }); + + let request = new Request("https://relay.example.com/inbox", { + method: "POST", + headers: { + "Content-Type": "application/activity+json", + }, + body: JSON.stringify( + await followActivity.toJsonLd({ contextLoader: mockDocumentLoader }), + ), + }); + + request = await signRequest( + request, + rsaKeyPair.privateKey, + rsaPublicKey.id, + ); + + await relay.fetch(request); + + // Verify handler was called + strictEqual(handlerCalled, true); + ok(handlerActor); + + // Verify follower was stored with "pending" state (awaiting reciprocal Accept) + const followerData = await kv.get([ + "follower", + "https://remote.example.com/users/alice", + ]); + ok(followerData); + strictEqual((followerData as any).state, "pending"); + }); + + test("handles Follow activity with subscription rejection", async () => { + const kv = new MemoryKvStore(); + + const relay = createRelay("litepub", { + kv, + domain: "relay.example.com", + documentLoaderFactory: () => mockDocumentLoader, + authenticatedDocumentLoaderFactory: () => mockDocumentLoader, + subscriptionHandler: async (_ctx, _actor) => { + return await Promise.resolve(false); // Reject the subscription + }, + }); + + const follower = new Person({ + id: new URL("https://remote.example.com/users/alice"), + preferredUsername: "alice", + inbox: new URL("https://remote.example.com/users/alice/inbox"), + }); + + const followActivity = new Follow({ + id: new URL("https://remote.example.com/activities/follow/1"), + actor: follower.id, + object: new URL("https://relay.example.com/users/relay"), + }); + + let request = new Request("https://relay.example.com/inbox", { + method: "POST", + headers: { + "Content-Type": "application/activity+json", + }, + body: JSON.stringify( + await followActivity.toJsonLd({ contextLoader: mockDocumentLoader }), + ), + }); + + request = await signRequest( + request, + rsaKeyPair.privateKey, + rsaPublicKey.id, + ); + + await relay.fetch(request); + + // Verify follower was NOT stored + const followerData = await kv.get([ + "follower", + "https://remote.example.com/users/alice", + ]); + strictEqual(followerData, undefined); + + // Verify followers list is empty + const followers = await kv.get(["followers"]); + ok(!followers || followers.length === 0); + }); + + test("handles public Follow activity", async () => { + const kv = new MemoryKvStore(); + + const relay = createRelay("litepub", { + kv, + domain: "relay.example.com", + documentLoaderFactory: () => mockDocumentLoader, + authenticatedDocumentLoaderFactory: () => mockDocumentLoader, + subscriptionHandler: async (_ctx, _actor) => await Promise.resolve(true), + }); + + const follower = new Person({ + id: new URL("https://remote.example.com/users/alice"), + preferredUsername: "alice", + inbox: new URL("https://remote.example.com/users/alice/inbox"), + }); + + // Public follow activity + const followActivity = new Follow({ + id: new URL("https://remote.example.com/activities/follow/1"), + actor: follower.id, + object: new URL("https://www.w3.org/ns/activitystreams#Public"), + }); + + let request = new Request("https://relay.example.com/inbox", { + method: "POST", + headers: { + "Content-Type": "application/activity+json", + }, + body: JSON.stringify( + await followActivity.toJsonLd({ contextLoader: mockDocumentLoader }), + ), + }); + + request = await signRequest( + request, + rsaKeyPair.privateKey, + rsaPublicKey.id, + ); + + await relay.fetch(request); + + // Verify follower was stored with "pending" state + const followerData = await kv.get([ + "follower", + "https://remote.example.com/users/alice", + ]); + ok(followerData); + strictEqual((followerData as any).state, "pending"); + }); + + test("ignores Follow activity without required fields", async () => { + const kv = new MemoryKvStore(); + + const relay = createRelay("litepub", { + kv, + domain: "relay.example.com", + documentLoaderFactory: () => mockDocumentLoader, + authenticatedDocumentLoaderFactory: () => mockDocumentLoader, + subscriptionHandler: async (_ctx, _actor) => await Promise.resolve(true), + }); + + // Follow activity without id + const followActivity = new Follow({ + actor: new URL("https://remote.example.com/users/alice"), + object: new URL("https://relay.example.com/users/relay"), + }); + + let request = new Request("https://relay.example.com/inbox", { + method: "POST", + headers: { + "Content-Type": "application/activity+json", + }, + body: JSON.stringify( + await followActivity.toJsonLd({ contextLoader: mockDocumentLoader }), + ), + }); + + request = await signRequest( + request, + rsaKeyPair.privateKey, + rsaPublicKey.id, + ); + + await relay.fetch(request); + + // Verify follower was NOT stored + const followerData = await kv.get([ + "follower", + "https://remote.example.com/users/alice", + ]); + strictEqual(followerData, undefined); + }); + + test("ignores duplicate Follow activity from pending follower", async () => { + const kv = new MemoryKvStore(); + let handlerCallCount = 0; + + const relay = createRelay("litepub", { + kv, + domain: "relay.example.com", + documentLoaderFactory: () => mockDocumentLoader, + authenticatedDocumentLoaderFactory: () => mockDocumentLoader, + subscriptionHandler: async (_ctx, _actor) => { + handlerCallCount++; + return await Promise.resolve(true); + }, + }); + + const follower = new Person({ + id: new URL("https://remote.example.com/users/alice"), + preferredUsername: "alice", + inbox: new URL("https://remote.example.com/users/alice/inbox"), + }); + + // Pre-populate with pending follower + await kv.set( + ["follower", "https://remote.example.com/users/alice"], + { actor: await follower.toJsonLd(), state: "pending" }, + ); + + const followActivity = new Follow({ + id: new URL("https://remote.example.com/activities/follow/1"), + actor: follower.id, + object: new URL("https://relay.example.com/users/relay"), + }); + + let request = new Request("https://relay.example.com/inbox", { + method: "POST", + headers: { + "Content-Type": "application/activity+json", + }, + body: JSON.stringify( + await followActivity.toJsonLd({ contextLoader: mockDocumentLoader }), + ), + }); + + request = await signRequest( + request, + rsaKeyPair.privateKey, + rsaPublicKey.id, + ); + + await relay.fetch(request); + + // Verify handler was NOT called (duplicate follow ignored) + strictEqual(handlerCallCount, 0); + }); + + test("handles Accept activity completing reciprocal follow", async () => { + const kv = new MemoryKvStore(); + + const follower = new Person({ + id: new URL("https://remote.example.com/users/alice"), + preferredUsername: "alice", + inbox: new URL("https://remote.example.com/users/alice/inbox"), + }); + + // Pre-populate with pending follower + await kv.set( + ["follower", "https://remote.example.com/users/alice"], + { actor: await follower.toJsonLd(), state: "pending" }, + ); + + const relay = createRelay("litepub", { + kv, + domain: "relay.example.com", + documentLoaderFactory: () => mockDocumentLoader, + authenticatedDocumentLoaderFactory: () => mockDocumentLoader, + }); + + const relayFollow = new Follow({ + id: new URL("https://relay.example.com/activities/follow/1"), + actor: new URL("https://relay.example.com/users/relay"), + object: new URL("https://remote.example.com/users/alice"), + }); + + const acceptActivity = new Accept({ + id: new URL("https://remote.example.com/activities/accept/1"), + actor: new URL("https://remote.example.com/users/alice"), + object: relayFollow, + }); + + let request = new Request("https://relay.example.com/inbox", { + method: "POST", + headers: { + "Content-Type": "application/activity+json", + }, + body: JSON.stringify( + await acceptActivity.toJsonLd({ contextLoader: mockDocumentLoader }), + ), + }); + + request = await signRequest( + request, + rsaKeyPair.privateKey, + rsaPublicKey.id, + ); + + await relay.fetch(request); + + // Verify follower state changed to "accepted" + const followerData = await kv.get([ + "follower", + "https://remote.example.com/users/alice", + ]); + ok(followerData); + strictEqual((followerData as any).state, "accepted"); + + // Verify follower was added to followers list + const followers = await kv.get(["followers"]); + ok(followers); + strictEqual(followers.length, 1); + strictEqual(followers[0], "https://remote.example.com/users/alice"); + }); + + test("handles Undo Follow activity", async () => { + const kv = new MemoryKvStore(); + + // Pre-populate with an accepted follower + const followerId = "https://remote.example.com/users/alice"; + const follower = new Person({ + id: new URL(followerId), + preferredUsername: "alice", + inbox: new URL("https://remote.example.com/users/alice/inbox"), + }); + + await kv.set(["followers"], [followerId]); + await kv.set( + ["follower", followerId], + { actor: await follower.toJsonLd(), state: "accepted" }, + ); + + const relay = createRelay("litepub", { + kv, + domain: "relay.example.com", + documentLoaderFactory: () => mockDocumentLoader, + authenticatedDocumentLoaderFactory: () => mockDocumentLoader, + }); + + const originalFollow = new Follow({ + id: new URL("https://remote.example.com/activities/follow/1"), + actor: new URL(followerId), + object: new URL("https://relay.example.com/users/relay"), + }); + + const undoActivity = new Undo({ + id: new URL("https://remote.example.com/activities/undo/1"), + actor: new URL(followerId), + object: originalFollow, + }); + + let request = new Request("https://relay.example.com/inbox", { + method: "POST", + headers: { + "Content-Type": "application/activity+json", + }, + body: JSON.stringify( + await undoActivity.toJsonLd({ contextLoader: mockDocumentLoader }), + ), + }); + + request = await signRequest( + request, + rsaKeyPair.privateKey, + rsaPublicKey.id, + ); + + await relay.fetch(request); + + // Verify follower was removed + const followers = await kv.get(["followers"]); + ok(followers); + strictEqual(followers.length, 0); + + const followerData = await kv.get(["follower", followerId]); + strictEqual(followerData, undefined); + }); + + test("handles Create activity with Announce forwarding", async () => { + const kv = new MemoryKvStore(); + + // Pre-populate with a follower + const followerId = "https://remote.example.com/users/alice"; + const follower = new Person({ + id: new URL(followerId), + preferredUsername: "alice", + inbox: new URL("https://remote.example.com/users/alice/inbox"), + }); + + await kv.set(["followers"], [followerId]); + await kv.set( + ["follower", followerId], + { actor: await follower.toJsonLd(), state: "accepted" }, + ); + + const relay = createRelay("litepub", { + kv, + domain: "relay.example.com", + documentLoaderFactory: () => mockDocumentLoader, + authenticatedDocumentLoaderFactory: () => mockDocumentLoader, + }); + + const note = new Note({ + id: new URL("https://remote.example.com/notes/1"), + content: "Hello world", + }); + + const createActivity = new Create({ + id: new URL("https://remote.example.com/activities/create/1"), + actor: new URL(followerId), + object: note, + }); + + let request = new Request("https://relay.example.com/inbox", { + method: "POST", + headers: { + "Content-Type": "application/activity+json", + }, + body: JSON.stringify( + await createActivity.toJsonLd({ contextLoader: mockDocumentLoader }), + ), + }); + + request = await signRequest( + request, + rsaKeyPair.privateKey, + rsaPublicKey.id, + ); + + const response = await relay.fetch(request); + + // Verify the request was accepted (forwarding happens in background) + ok(response.status === 200 || response.status === 202); + }); + + test("handles Update activity with Announce forwarding", async () => { + const kv = new MemoryKvStore(); + + const relay = createRelay("litepub", { + kv, + domain: "relay.example.com", + documentLoaderFactory: () => mockDocumentLoader, + authenticatedDocumentLoaderFactory: () => mockDocumentLoader, + }); + + const note = new Note({ + id: new URL("https://remote.example.com/notes/1"), + content: "Updated content", + }); + + const updateActivity = new Update({ + id: new URL("https://remote.example.com/activities/update/1"), + actor: new URL("https://remote.example.com/users/alice"), + object: note, + }); + + let request = new Request("https://relay.example.com/inbox", { + method: "POST", + headers: { + "Content-Type": "application/activity+json", + }, + body: JSON.stringify( + await updateActivity.toJsonLd({ contextLoader: mockDocumentLoader }), + ), + }); + + request = await signRequest( + request, + rsaKeyPair.privateKey, + rsaPublicKey.id, + ); + + const response = await relay.fetch(request); + + // Verify the request was accepted + ok(response.status === 200 || response.status === 202); + }); + + test("handles Move activity with Announce forwarding", async () => { + const kv = new MemoryKvStore(); + + const relay = createRelay("litepub", { + kv, + domain: "relay.example.com", + documentLoaderFactory: () => mockDocumentLoader, + authenticatedDocumentLoaderFactory: () => mockDocumentLoader, + }); + + const moveActivity = new Move({ + id: new URL("https://remote.example.com/activities/move/1"), + actor: new URL("https://remote.example.com/users/alice"), + object: new URL("https://remote.example.com/users/alice"), + target: new URL("https://other.example.com/users/alice"), + }); + + let request = new Request("https://relay.example.com/inbox", { + method: "POST", + headers: { + "Content-Type": "application/activity+json", + }, + body: JSON.stringify( + await moveActivity.toJsonLd({ contextLoader: mockDocumentLoader }), + ), + }); + + request = await signRequest( + request, + rsaKeyPair.privateKey, + rsaPublicKey.id, + ); + + const response = await relay.fetch(request); + + // Verify the request was accepted + ok(response.status === 200 || response.status === 202); + }); + + test("handles Delete activity with Announce forwarding", async () => { + const kv = new MemoryKvStore(); + + const relay = createRelay("litepub", { + kv, + domain: "relay.example.com", + documentLoaderFactory: () => mockDocumentLoader, + authenticatedDocumentLoaderFactory: () => mockDocumentLoader, + }); + + const deleteActivity = new Delete({ + id: new URL("https://remote.example.com/activities/delete/1"), + actor: new URL("https://remote.example.com/users/alice"), + object: new URL("https://remote.example.com/notes/1"), + }); + + let request = new Request("https://relay.example.com/inbox", { + method: "POST", + headers: { + "Content-Type": "application/activity+json", + }, + body: JSON.stringify( + await deleteActivity.toJsonLd({ contextLoader: mockDocumentLoader }), + ), + }); + + request = await signRequest( + request, + rsaKeyPair.privateKey, + rsaPublicKey.id, + ); + + const response = await relay.fetch(request); + + // Verify the request was accepted + ok(response.status === 200 || response.status === 202); + }); + + test("handles Announce activity forwarding", async () => { + const kv = new MemoryKvStore(); + + const relay = createRelay("litepub", { + kv, + domain: "relay.example.com", + documentLoaderFactory: () => mockDocumentLoader, + authenticatedDocumentLoaderFactory: () => mockDocumentLoader, + }); + + const announceActivity = new Announce({ + id: new URL("https://remote.example.com/activities/announce/1"), + actor: new URL("https://remote.example.com/users/alice"), + object: new URL("https://remote.example.com/notes/1"), + }); + + let request = new Request("https://relay.example.com/inbox", { + method: "POST", + headers: { + "Content-Type": "application/activity+json", + }, + body: JSON.stringify( + await announceActivity.toJsonLd({ contextLoader: mockDocumentLoader }), + ), + }); + + request = await signRequest( + request, + rsaKeyPair.privateKey, + rsaPublicKey.id, + ); + + const response = await relay.fetch(request); + + // Verify the request was accepted + ok(response.status === 200 || response.status === 202); + }); + + test("multiple followers can be stored", async () => { + const kv = new MemoryKvStore(); + + // Simulate multiple accepted followers + const followIds = [ + "https://remote1.example.com/users/user1", + "https://remote2.example.com/users/user2", + "https://remote3.example.com/users/user3", + ]; + + const followers: string[] = []; + for (const followId of followIds) { + followers.push(followId); + const actor = new Person({ + id: new URL(followId), + preferredUsername: `user${followers.length}`, + inbox: new URL(`${followId}/inbox`), + }); + await kv.set( + ["follower", followId], + { actor: await actor.toJsonLd(), state: "accepted" }, + ); + } + await kv.set(["followers"], followers); + + const storedFollowers = await kv.get(["followers"]); + ok(storedFollowers); + strictEqual(storedFollowers.length, 3); + }); +}); diff --git a/packages/relay/src/litepub.ts b/packages/relay/src/litepub.ts new file mode 100644 index 000000000..a51a77d4f --- /dev/null +++ b/packages/relay/src/litepub.ts @@ -0,0 +1,170 @@ +import { + Accept, + Announce, + Create, + Delete, + Follow, + type InboxContext, + isActor, + Move, + PUBLIC_COLLECTION, + Undo, + Update, +} from "@fedify/fedify"; +import { getLogger } from "@logtape/logtape"; +import { BaseRelay } from "./base.ts"; +import { + handleUndoFollow, + sendFollowResponse, + validateFollowActivity, +} from "./follow.ts"; +import { + RELAY_SERVER_ACTOR, + type RelayFollower, + type RelayOptions, +} from "./types.ts"; + +const logger = getLogger(["fedify", "relay", "litepub"]); + +/** + * A LitePub-compatible ActivityPub relay implementation. + * This relay follows LitePub's relay protocol and extensions for + * enhanced federation capabilities. + * + * @since 2.0.0 + */ +export class LitePubRelay extends BaseRelay { + async #announceToFollowers( + ctx: InboxContext, + activity: Create | Delete | Move | Update | Announce, + ): Promise { + const sender = await activity.getActor(ctx); + const excludeBaseUris = sender?.id ? [new URL(sender.id)] : []; + + const announce = new Announce({ + id: new URL(`/announce#${crypto.randomUUID()}`, ctx.origin), + actor: ctx.getActorUri(RELAY_SERVER_ACTOR), + object: activity.objectId, + to: PUBLIC_COLLECTION, + published: Temporal.Now.instant(), + }); + + await ctx.sendActivity( + { identifier: RELAY_SERVER_ACTOR }, + "followers", + announce, + { + excludeBaseUris, + preferSharedInbox: true, + }, + ); + } + + protected setupInboxListeners(): void { + if (this.federation != null) { + this.federation.setInboxListeners("/users/{identifier}/inbox", "/inbox") + .on(Follow, async (ctx, follow) => { + const follower = await validateFollowActivity(ctx, follow); + if (!follower || !follower.id) return; + + // Litepub-specific: check if already in pending state + const existingFollow = await ctx.data.kv.get([ + "follower", + follower.id.href, + ]); + if (existingFollow?.state === "pending") return; + + let approved = false; + if (this.options.subscriptionHandler) { + approved = await this.options.subscriptionHandler(ctx, follower); + } + + if (approved) { + // Litepub-specific: save with "pending" state + await ctx.data.kv.set( + ["follower", follower.id.href], + { actor: await follower.toJsonLd(), state: "pending" }, + ); + + await sendFollowResponse(ctx, follow, follower, approved); + + // Litepub-specific: send reciprocal follow + const relayActorUri = ctx.getActorUri(RELAY_SERVER_ACTOR); + await ctx.sendActivity( + { identifier: RELAY_SERVER_ACTOR }, + follower, + new Follow({ + actor: relayActorUri, + object: follower.id, + to: follower.id, + }), + ); + } else { + await sendFollowResponse(ctx, follow, follower, approved); + } + }) + .on(Accept, async (ctx, accept) => { + // Validate follow activity from accept activity + const follow = await accept.getObject({ + crossOrigin: "trust", + ...ctx, + }); + if (!(follow instanceof Follow)) return; + const relayActorId = follow.actorId; + if (relayActorId == null) return; + + // Validate follower actor - accept activity sender + const followerActor = await accept.getActor(); + if (!isActor(followerActor) || !followerActor.id) return; + const parsed = ctx.parseUri(relayActorId); + if (parsed == null || parsed.type !== "actor") return; + + // Get follower from kv store + const followerData = await ctx.data.kv.get([ + "follower", + followerActor.id.href, + ]); + if (followerData == null) return; + + // Update follower state + const updatedFollowerData = { ...followerData, state: "accepted" }; + await ctx.data.kv.set( + ["follower", followerActor.id.href], + updatedFollowerData, + ); + + // Update followers list + const followers = await ctx.data.kv.get(["followers"]) ?? + []; + followers.push(followerActor.id.href); + await ctx.data.kv.set(["followers"], followers); + }) + .on( + Undo, + async (ctx, undo) => await handleUndoFollow(ctx, undo, logger), + ) + .on( + Create, + async (ctx, create) => await this.#announceToFollowers(ctx, create), + ) + .on( + Update, + async (ctx, update) => await this.#announceToFollowers(ctx, update), + ) + .on( + Move, + async (ctx, move) => await this.#announceToFollowers(ctx, move), + ) + .on( + Delete, + async (ctx, deleteActivity) => + await this.#announceToFollowers(ctx, deleteActivity), + ) + .on( + Announce, + async (ctx, announce) => + await this.#announceToFollowers(ctx, announce), + ); + } + } +} diff --git a/packages/relay/src/mastodon.test.ts b/packages/relay/src/mastodon.test.ts new file mode 100644 index 000000000..13544a204 --- /dev/null +++ b/packages/relay/src/mastodon.test.ts @@ -0,0 +1,784 @@ +// deno-lint-ignore-file no-explicit-any +import { MemoryKvStore, signRequest } from "@fedify/fedify"; +import { + Create, + Delete, + Follow, + Move, + Note, + Person, + Undo, + Update, +} from "@fedify/fedify/vocab"; +import { + exportSpki, + getDocumentLoader, + type RemoteDocument, +} from "@fedify/vocab-runtime"; +import { ok, strictEqual } from "node:assert"; +import test, { describe } from "node:test"; +import { createRelay, type RelayOptions } from "@fedify/relay"; + +// Simple mock document loader that returns a minimal context +const mockDocumentLoader = async (url: string): Promise => { + if ( + url === "https://remote.example.com/users/alice" || + url === "https://remote.example.com/users/alice#main-key" + ) { + return { + contextUrl: null, + documentUrl: url.replace(/#main-key$/, ""), + document: { + "@context": [ + "https://www.w3.org/ns/activitystreams", + "https://w3id.org/security/v1", + ], + id: url, + type: "Person", + preferredUsername: "alice", + inbox: "https://remote.example.com/users/alice/inbox", + publicKey: { + id: "https://remote.example.com/users/alice#main-key", + owner: url.replace(/#main-key$/, ""), + publicKeyPem: await exportSpki(rsaKeyPair.publicKey), + }, + }, + }; + } else if (url === "https://remote.example.com/notes/1") { + return { + contextUrl: null, + documentUrl: url, + document: { + "@context": "https://www.w3.org/ns/activitystreams", + id: url, + type: "Note", + content: "Hello world", + }, + }; + } else if (url.startsWith("https://remote.example.com/")) { + throw new Error(`Document not found: ${url}`); + } + return await getDocumentLoader()(url); +}; + +// Mock RSA key pair for testing +const rsaKeyPair = await crypto.subtle.generateKey( + { + name: "RSASSA-PKCS1-v1_5", + modulusLength: 2048, + publicExponent: new Uint8Array([0x01, 0x00, 0x01]), + hash: "SHA-256", + }, + true, + ["sign", "verify"], +); + +const rsaPublicKey = { + id: new URL("https://remote.example.com/users/alice#main-key"), + ...rsaKeyPair.publicKey, +}; + +describe("MastodonRelay", () => { + test("constructor with required options", () => { + const options: RelayOptions = { + kv: new MemoryKvStore(), + domain: "relay.example.com", + documentLoaderFactory: () => mockDocumentLoader, + subscriptionHandler: async (_ctx, _actor) => { + return await Promise.resolve(true); + }, + }; + + const relay = createRelay("mastodon", options); + ok(relay); + }); + + test("fetch method returns Response", async () => { + const kv = new MemoryKvStore(); + const relay = createRelay("mastodon", { + kv, + domain: "relay.example.com", + documentLoaderFactory: () => mockDocumentLoader, + }); + + const request = new Request("https://relay.example.com/users/relay", { + headers: { "Accept": "application/activity+json" }, + }); + const response = await relay.fetch(request); + + ok(response instanceof Response); + }); + + test("fetching relay actor returns Application", async () => { + const kv = new MemoryKvStore(); + const relay = createRelay("mastodon", { + kv, + domain: "relay.example.com", + documentLoaderFactory: () => mockDocumentLoader, + }); + + const request = new Request("https://relay.example.com/users/relay", { + headers: { "Accept": "application/activity+json" }, + }); + const response = await relay.fetch(request); + + strictEqual(response.status, 200); + const json = await response.json() as any; + strictEqual(json.type, "Application"); + strictEqual(json.preferredUsername, "relay"); + strictEqual(json.name, "ActivityPub Relay"); + }); + + test("fetching non-relay actor returns 404", async () => { + const kv = new MemoryKvStore(); + const relay = createRelay("mastodon", { + kv, + domain: "relay.example.com", + documentLoaderFactory: () => mockDocumentLoader, + }); + + const request = new Request( + "https://relay.example.com/users/non-existent", + { + headers: { "Accept": "application/activity+json" }, + }, + ); + const response = await relay.fetch(request); + + strictEqual(response.status, 404); + }); + + test("followers collection returns empty list initially", async () => { + const kv = new MemoryKvStore(); + const relay = createRelay("mastodon", { + kv, + domain: "relay.example.com", + documentLoaderFactory: () => mockDocumentLoader, + }); + + const request = new Request( + "https://relay.example.com/users/relay/followers", + { + headers: { "Accept": "application/activity+json" }, + }, + ); + const response = await relay.fetch(request); + + strictEqual(response.status, 200); + const json = await response.json() as any; + // The followers dispatcher is configured, verify response structure + ok(json); + ok(json.type === "Collection" || json.type === "OrderedCollection"); + }); + + test("followers collection returns populated list", async () => { + const kv = new MemoryKvStore(); + + // Pre-populate followers + const follower1 = new Person({ + id: new URL("https://remote1.example.com/users/alice"), + preferredUsername: "alice", + inbox: new URL("https://remote1.example.com/users/alice/inbox"), + }); + + const follower2 = new Person({ + id: new URL("https://remote2.example.com/users/bob"), + preferredUsername: "bob", + inbox: new URL("https://remote2.example.com/users/bob/inbox"), + }); + + const follower1Id = "https://remote1.example.com/users/alice"; + const follower2Id = "https://remote2.example.com/users/bob"; + + await kv.set(["followers"], [follower1Id, follower2Id]); + await kv.set( + ["follower", follower1Id], + { actor: await follower1.toJsonLd(), state: "accepted" }, + ); + await kv.set( + ["follower", follower2Id], + { actor: await follower2.toJsonLd(), state: "accepted" }, + ); + + const relay = createRelay("mastodon", { + kv, + domain: "relay.example.com", + documentLoaderFactory: () => mockDocumentLoader, + }); + + const request = new Request( + "https://relay.example.com/users/relay/followers", + { + headers: { "Accept": "application/activity+json" }, + }, + ); + const response = await relay.fetch(request); + + strictEqual(response.status, 200); + const json = await response.json() as any; + ok(json); + ok(json.type === "Collection" || json.type === "OrderedCollection"); + // Fedify wraps the items in a collection, check totalItems if available + if (json.totalItems !== undefined) { + strictEqual(json.totalItems, 2); + } + }); + + test("stores follower in KV when Follow is approved", async () => { + const kv = new MemoryKvStore(); + + // Manually simulate what happens when a Follow is approved + const followActivityId = "https://remote.example.com/activities/follow/1"; + const follower = new Person({ + id: new URL("https://remote.example.com/users/alice"), + preferredUsername: "alice", + inbox: new URL("https://remote.example.com/users/alice/inbox"), + }); + + // Simulate the relay's internal logic + const followers = (await kv.get(["followers"])) ?? []; + followers.push(followActivityId); + await kv.set(["followers"], followers); + await kv.set( + ["follower", followActivityId], + { actor: await follower.toJsonLd(), state: "accepted" }, + ); + + // Verify storage + const storedFollowers = await kv.get(["followers"]); + ok(storedFollowers); + strictEqual(storedFollowers?.length, 1); + strictEqual(storedFollowers[0], followActivityId); + + const storedActor = await kv.get(["follower", followActivityId]); + ok(storedActor); + }); + + test("removes follower from KV when Undo Follow is received", async () => { + const kv = new MemoryKvStore(); + + // Pre-populate with a follower + const followerId = "https://remote.example.com/users/alice"; + const follower = new Person({ + id: new URL(followerId), + preferredUsername: "alice", + inbox: new URL("https://remote.example.com/users/alice/inbox"), + }); + + await kv.set(["followers"], [followerId]); + await kv.set( + ["follower", followerId], + { actor: await follower.toJsonLd(), state: "accepted" }, + ); + + // Simulate the Undo Follow logic + const followers = (await kv.get(["followers"])) ?? []; + const updatedFollowers = followers.filter((id) => id !== followerId); + await kv.set(["followers"], updatedFollowers); + await kv.delete(["follower", followerId]); + + // Verify removal + const storedFollowers = await kv.get(["followers"]); + ok(storedFollowers); + strictEqual(storedFollowers.length, 0); + + const storedActor = await kv.get(["follower", followerId]); + strictEqual(storedActor, undefined); + }); + + test("relay actor has correct properties", async () => { + const kv = new MemoryKvStore(); + const relay = createRelay("mastodon", { + kv, + domain: "relay.example.com", + documentLoaderFactory: () => mockDocumentLoader, + }); + + const request = new Request("https://relay.example.com/users/relay", { + headers: { "Accept": "application/activity+json" }, + }); + const response = await relay.fetch(request); + + strictEqual(response.status, 200); + const json = await response.json() as any; + + strictEqual(json.type, "Application"); + strictEqual(json.preferredUsername, "relay"); + strictEqual(json.name, "ActivityPub Relay"); + strictEqual(json.id, "https://relay.example.com/users/relay"); + strictEqual(json.inbox, "https://relay.example.com/inbox"); + strictEqual( + json.followers, + "https://relay.example.com/users/relay/followers", + ); + strictEqual( + json.following, + "https://relay.example.com/users/relay/following", + ); + }); + + test("multiple followers can be stored", async () => { + const kv = new MemoryKvStore(); + + // Simulate multiple Follow activities + const followIds = [ + "https://remote1.example.com/users/user1", + "https://remote2.example.com/users/user2", + "https://remote3.example.com/users/user3", + ]; + + const followers: string[] = []; + for (const followId of followIds) { + followers.push(followId); + const actor = new Person({ + id: new URL(followId), + preferredUsername: `user${followers.length}`, + inbox: new URL(`${followId}/inbox`), + }); + await kv.set( + ["follower", followId], + { actor: await actor.toJsonLd(), state: "accepted" }, + ); + } + await kv.set(["followers"], followers); + + const storedFollowers = await kv.get(["followers"]); + ok(storedFollowers); + strictEqual(storedFollowers.length, 3); + }); + + test("handles Follow activity with subscription approval", async () => { + const kv = new MemoryKvStore(); + let handlerCalled = false; + let handlerActor: any = null; + + const relay = createRelay("mastodon", { + kv, + domain: "relay.example.com", + documentLoaderFactory: () => mockDocumentLoader, + authenticatedDocumentLoaderFactory: () => mockDocumentLoader, + subscriptionHandler: async (_ctx, actor) => { + handlerCalled = true; + handlerActor = actor; + return await Promise.resolve(true); + }, + }); + + const follower = new Person({ + id: new URL("https://remote.example.com/users/alice"), + preferredUsername: "alice", + inbox: new URL("https://remote.example.com/users/alice/inbox"), + }); + + const followActivity = new Follow({ + id: new URL("https://remote.example.com/activities/follow/1"), + actor: follower.id, + object: new URL("https://relay.example.com/users/relay"), + }); + + let request = new Request("https://relay.example.com/inbox", { + method: "POST", + headers: { + "Content-Type": "application/activity+json", + }, + body: JSON.stringify( + await followActivity.toJsonLd({ contextLoader: mockDocumentLoader }), + ), + }); + + request = await signRequest( + request, + rsaKeyPair.privateKey, + rsaPublicKey.id, + ); + + await relay.fetch(request); + + // Verify handler was called + strictEqual(handlerCalled, true); + ok(handlerActor); + + // Verify follower was stored + const followers = await kv.get(["followers"]); + ok(followers); + strictEqual(followers.length, 1); + strictEqual( + followers[0], + "https://remote.example.com/users/alice", + ); + }); + + test("handles Follow activity with subscription rejection", async () => { + const kv = new MemoryKvStore(); + + const relay = createRelay("mastodon", { + kv, + domain: "relay.example.com", + documentLoaderFactory: () => mockDocumentLoader, + authenticatedDocumentLoaderFactory: () => mockDocumentLoader, + subscriptionHandler: async (_ctx, _actor) => { + return await Promise.resolve(false); // Reject the subscription + }, + }); + + const follower = new Person({ + id: new URL("https://remote.example.com/users/alice"), + preferredUsername: "alice", + inbox: new URL("https://remote.example.com/users/alice/inbox"), + }); + + const followActivity = new Follow({ + id: new URL("https://remote.example.com/activities/follow/1"), + actor: follower.id, + object: new URL("https://relay.example.com/users/relay"), + }); + + let request = new Request("https://relay.example.com/inbox", { + method: "POST", + headers: { + "Content-Type": "application/activity+json", + }, + body: JSON.stringify( + await followActivity.toJsonLd({ contextLoader: mockDocumentLoader }), + ), + }); + + request = await signRequest( + request, + rsaKeyPair.privateKey, + rsaPublicKey.id, + ); + + await relay.fetch(request); + + // Verify follower was NOT stored + const followers = await kv.get(["followers"]); + ok(!followers || followers.length === 0); + }); + + test("handles Undo Follow activity", async () => { + const kv = new MemoryKvStore(); + + // Pre-populate with a follower + const followerId = "https://remote.example.com/users/alice"; + const follower = new Person({ + id: new URL(followerId), + preferredUsername: "alice", + inbox: new URL("https://remote.example.com/users/alice/inbox"), + }); + + const followActivityId = "https://remote.example.com/activities/follow/1"; + await kv.set(["followers"], [followerId]); + await kv.set( + ["follower", followerId], + { actor: await follower.toJsonLd(), state: "accepted" }, + ); + + const relay = createRelay("mastodon", { + kv, + domain: "relay.example.com", + documentLoaderFactory: () => mockDocumentLoader, + authenticatedDocumentLoaderFactory: () => mockDocumentLoader, + }); + + const originalFollow = new Follow({ + id: new URL(followActivityId), + actor: new URL(followerId), + object: new URL("https://relay.example.com/users/relay"), + }); + + const undoActivity = new Undo({ + id: new URL("https://remote.example.com/activities/undo/1"), + actor: new URL(followerId), + object: originalFollow, + }); + + let request = new Request("https://relay.example.com/inbox", { + method: "POST", + headers: { + "Content-Type": "application/activity+json", + }, + body: JSON.stringify( + await undoActivity.toJsonLd({ contextLoader: mockDocumentLoader }), + ), + }); + + request = await signRequest( + request, + rsaKeyPair.privateKey, + rsaPublicKey.id, + ); + + await relay.fetch(request); + + // Verify follower was removed + const followers = await kv.get(["followers"]); + ok(followers); + strictEqual(followers.length, 0); + }); + + test("handles Create activity forwarding", async () => { + const kv = new MemoryKvStore(); + + // Pre-populate with a follower + const followerId = "https://remote.example.com/users/alice"; + const follower = new Person({ + id: new URL(followerId), + preferredUsername: "alice", + inbox: new URL("https://remote.example.com/users/alice/inbox"), + }); + + await kv.set(["followers"], [followerId]); + await kv.set( + ["follower", followerId], + { actor: await follower.toJsonLd(), state: "accepted" }, + ); + + const relay = createRelay("mastodon", { + kv, + domain: "relay.example.com", + documentLoaderFactory: () => mockDocumentLoader, + authenticatedDocumentLoaderFactory: () => mockDocumentLoader, + }); + + const note = new Note({ + id: new URL("https://remote.example.com/notes/1"), + content: "Hello world", + }); + + const createActivity = new Create({ + id: new URL("https://remote.example.com/activities/create/1"), + actor: new URL(followerId), + object: note, + }); + + let request = new Request("https://relay.example.com/inbox", { + method: "POST", + headers: { + "Content-Type": "application/activity+json", + }, + body: JSON.stringify( + await createActivity.toJsonLd({ contextLoader: mockDocumentLoader }), + ), + }); + + request = await signRequest( + request, + rsaKeyPair.privateKey, + rsaPublicKey.id, + ); + + const response = await relay.fetch(request); + + // Verify the request was accepted (forwarding happens in background) + ok(response.status === 200 || response.status === 202); + }); + + test("handles Delete activity forwarding", async () => { + const kv = new MemoryKvStore(); + + const relay = createRelay("mastodon", { + kv, + domain: "relay.example.com", + documentLoaderFactory: () => mockDocumentLoader, + authenticatedDocumentLoaderFactory: () => mockDocumentLoader, + }); + + const deleteActivity = new Delete({ + id: new URL("https://remote.example.com/activities/delete/1"), + actor: new URL("https://remote.example.com/users/alice"), + object: new URL("https://remote.example.com/notes/1"), + }); + + let request = new Request("https://relay.example.com/inbox", { + method: "POST", + headers: { + "Content-Type": "application/activity+json", + }, + body: JSON.stringify( + await deleteActivity.toJsonLd({ contextLoader: mockDocumentLoader }), + ), + }); + + request = await signRequest( + request, + rsaKeyPair.privateKey, + rsaPublicKey.id, + ); + + const response = await relay.fetch(request); + + // Verify the request was accepted + ok(response.status === 200 || response.status === 202); + }); + + test("handles Update activity forwarding", async () => { + const kv = new MemoryKvStore(); + + const relay = createRelay("mastodon", { + kv, + domain: "relay.example.com", + documentLoaderFactory: () => mockDocumentLoader, + authenticatedDocumentLoaderFactory: () => mockDocumentLoader, + }); + + const note = new Note({ + id: new URL("https://remote.example.com/notes/1"), + content: "Updated content", + }); + + const updateActivity = new Update({ + id: new URL("https://remote.example.com/activities/update/1"), + actor: new URL("https://remote.example.com/users/alice"), + object: note, + }); + + let request = new Request("https://relay.example.com/inbox", { + method: "POST", + headers: { + "Content-Type": "application/activity+json", + }, + body: JSON.stringify( + await updateActivity.toJsonLd({ contextLoader: mockDocumentLoader }), + ), + }); + + request = await signRequest( + request, + rsaKeyPair.privateKey, + rsaPublicKey.id, + ); + + const response = await relay.fetch(request); + + // Verify the request was accepted + ok(response.status === 200 || response.status === 202); + }); + + test("handles Move activity forwarding", async () => { + const kv = new MemoryKvStore(); + + const relay = createRelay("mastodon", { + kv, + domain: "relay.example.com", + documentLoaderFactory: () => mockDocumentLoader, + authenticatedDocumentLoaderFactory: () => mockDocumentLoader, + }); + + const moveActivity = new Move({ + id: new URL("https://remote.example.com/activities/move/1"), + actor: new URL("https://remote.example.com/users/alice"), + object: new URL("https://remote.example.com/users/alice"), + target: new URL("https://other.example.com/users/alice"), + }); + + let request = new Request("https://relay.example.com/inbox", { + method: "POST", + headers: { + "Content-Type": "application/activity+json", + }, + body: JSON.stringify( + await moveActivity.toJsonLd({ contextLoader: mockDocumentLoader }), + ), + }); + + request = await signRequest( + request, + rsaKeyPair.privateKey, + rsaPublicKey.id, + ); + + const response = await relay.fetch(request); + + // Verify the request was accepted + ok(response.status === 200 || response.status === 202); + }); + + test("ignores Follow activity without required fields", async () => { + const kv = new MemoryKvStore(); + + const relay = createRelay("mastodon", { + kv, + domain: "relay.example.com", + documentLoaderFactory: () => mockDocumentLoader, + authenticatedDocumentLoaderFactory: () => mockDocumentLoader, + subscriptionHandler: async (_ctx, _actor) => await Promise.resolve(true), + }); + + // Follow activity without id + const followActivity = new Follow({ + actor: new URL("https://remote.example.com/users/alice"), + object: new URL("https://relay.example.com/users/relay"), + }); + + let request = new Request("https://relay.example.com/inbox", { + method: "POST", + headers: { + "Content-Type": "application/activity+json", + }, + body: JSON.stringify( + await followActivity.toJsonLd({ contextLoader: mockDocumentLoader }), + ), + }); + + request = await signRequest( + request, + rsaKeyPair.privateKey, + rsaPublicKey.id, + ); + + await relay.fetch(request); + + // Verify follower was NOT stored + const followers = await kv.get(["followers"]); + ok(!followers || followers.length === 0); + }); + + test("handles public Follow activity", async () => { + const kv = new MemoryKvStore(); + + const relay = createRelay("mastodon", { + kv, + domain: "relay.example.com", + documentLoaderFactory: () => mockDocumentLoader, + authenticatedDocumentLoaderFactory: () => mockDocumentLoader, + subscriptionHandler: async (_ctx, _actor) => await Promise.resolve(true), + }); + + const follower = new Person({ + id: new URL("https://remote.example.com/users/alice"), + preferredUsername: "alice", + inbox: new URL("https://remote.example.com/users/alice/inbox"), + }); + + // Public follow activity + const followActivity = new Follow({ + id: new URL("https://remote.example.com/activities/follow/1"), + actor: follower.id, + object: new URL("https://www.w3.org/ns/activitystreams#Public"), + }); + + let request = new Request("https://relay.example.com/inbox", { + method: "POST", + headers: { + "Content-Type": "application/activity+json", + }, + body: JSON.stringify( + await followActivity.toJsonLd({ contextLoader: mockDocumentLoader }), + ), + }); + + request = await signRequest( + request, + rsaKeyPair.privateKey, + rsaPublicKey.id, + ); + + await relay.fetch(request); + + // Verify follower was stored + const followers = await kv.get(["followers"]); + ok(followers); + strictEqual(followers.length, 1); + }); +}); diff --git a/packages/relay/src/mastodon.ts b/packages/relay/src/mastodon.ts new file mode 100644 index 000000000..a27a8060f --- /dev/null +++ b/packages/relay/src/mastodon.ts @@ -0,0 +1,103 @@ +import { + Announce, + Create, + Delete, + Follow, + type InboxContext, + Move, + Undo, + Update, +} from "@fedify/fedify"; +import { getLogger } from "@logtape/logtape"; +import { BaseRelay } from "./base.ts"; +import { + handleUndoFollow, + sendFollowResponse, + validateFollowActivity, +} from "./follow.ts"; +import { RELAY_SERVER_ACTOR, type RelayOptions } from "./types.ts"; + +const logger = getLogger(["fedify", "relay", "mastodon"]); + +/** + * A Mastodon-compatible ActivityPub relay implementation. + * This relay follows Mastodon's relay protocol for compatibility + * with Mastodon instances. + * + * @since 2.0.0 + */ +export class MastodonRelay extends BaseRelay { + async #forwardToFollowers( + ctx: InboxContext, + activity: Create | Delete | Move | Update | Announce, + ): Promise { + const sender = await activity.getActor(ctx); + const excludeBaseUris = sender?.id ? [new URL(sender.id)] : []; + + await ctx.forwardActivity( + { identifier: RELAY_SERVER_ACTOR }, + "followers", + { + skipIfUnsigned: true, + excludeBaseUris, + preferSharedInbox: true, + }, + ); + } + + protected setupInboxListeners(): void { + if (this.federation != null) { + this.federation.setInboxListeners("/users/{identifier}/inbox", "/inbox") + .on(Follow, async (ctx, follow) => { + const follower = await validateFollowActivity(ctx, follow); + if (!follower || !follower.id) return; + + let approved = false; + if (this.options.subscriptionHandler) { + approved = await this.options.subscriptionHandler(ctx, follower); + } + + if (approved) { + // Mastodon-specific: immediately add to followers list + const followers = await ctx.data.kv.get(["followers"]) ?? + []; + followers.push(follower.id.href); + await ctx.data.kv.set(["followers"], followers); + + await ctx.data.kv.set( + ["follower", follower.id.href], + { actor: await follower.toJsonLd(), state: "accepted" }, + ); + } + + await sendFollowResponse(ctx, follow, follower, approved); + }) + .on( + Undo, + async (ctx, undo) => await handleUndoFollow(ctx, undo, logger), + ) + .on( + Create, + async (ctx, create) => await this.#forwardToFollowers(ctx, create), + ) + .on( + Delete, + async (ctx, deleteActivity) => + await this.#forwardToFollowers(ctx, deleteActivity), + ) + .on( + Move, + async (ctx, move) => await this.#forwardToFollowers(ctx, move), + ) + .on( + Update, + async (ctx, update) => await this.#forwardToFollowers(ctx, update), + ) + .on( + Announce, + async (ctx, announce) => + await this.#forwardToFollowers(ctx, announce), + ); + } + } +} diff --git a/packages/relay/src/mod.ts b/packages/relay/src/mod.ts index 6cb98fea1..dcdf5b9da 100644 --- a/packages/relay/src/mod.ts +++ b/packages/relay/src/mod.ts @@ -7,7 +7,14 @@ * * @module */ - -// Export relay functionality here -export type { Relay, RelayOptions } from "./relay.ts"; -export { LitePubRelay, MastodonRelay } from "./relay.ts"; +export { relayBuilder } from "./builder.ts"; +export { createRelay } from "./factory.ts"; +export { LitePubRelay } from "./litepub.ts"; +export { MastodonRelay } from "./mastodon.ts"; +export { + RELAY_SERVER_ACTOR, + type RelayFollower, + type RelayOptions, + type RelayType, + type SubscriptionRequestHandler, +} from "./types.ts"; diff --git a/packages/relay/src/relay.test.ts b/packages/relay/src/relay.test.ts deleted file mode 100644 index c9f76c9ac..000000000 --- a/packages/relay/src/relay.test.ts +++ /dev/null @@ -1,1128 +0,0 @@ -// deno-lint-ignore-file no-explicit-any -import { ok, strictEqual } from "node:assert/strict"; -import { describe, test } from "node:test"; -import { MemoryKvStore } from "@fedify/fedify"; -import { Accept, Follow, Person } from "@fedify/fedify/vocab"; -import { signRequest } from "@fedify/fedify/sig"; -import { LitePubRelay, MastodonRelay, type RelayOptions } from "@fedify/relay"; -import { createFederation } from "@fedify/testing"; -import { - exportSpki, - getDocumentLoader, - type RemoteDocument, -} from "@fedify/vocab-runtime"; - -// Simple mock document loader that returns a minimal context -const mockDocumentLoader = async (url: string): Promise => { - if ( - url === "https://remote.example.com/users/alice" || - url === "https://remote.example.com/users/alice#main-key" - ) { - return { - contextUrl: null, - documentUrl: url.replace(/#main-key$/, ""), - document: { - "@context": [ - "https://www.w3.org/ns/activitystreams", - "https://w3id.org/security/v1", - ], - id: url, - type: "Person", - preferredUsername: "alice", - inbox: "https://remote.example.com/users/alice/inbox", - publicKey: { - id: "https://remote.example.com/users/alice#main-key", - owner: url.replace(/#main-key$/, ""), - publicKeyPem: await exportSpki(rsaKeyPair.publicKey), - }, - }, - }; - } else if (url === "https://remote.example.com/notes/1") { - return { - contextUrl: null, - documentUrl: url, - document: { - "@context": "https://www.w3.org/ns/activitystreams", - id: url, - type: "Note", - content: "Hello world", - }, - }; - } else if (url.startsWith("https://remote.example.com/")) { - throw new Error(`Document not found: ${url}`); - } - return await getDocumentLoader()(url); -}; - -// Mock RSA key pair for testing -const rsaKeyPair = await crypto.subtle.generateKey( - { - name: "RSASSA-PKCS1-v1_5", - modulusLength: 2048, - publicExponent: new Uint8Array([0x01, 0x00, 0x01]), - hash: "SHA-256", - }, - true, - ["sign", "verify"], -); - -const rsaPublicKey = { - id: new URL("https://remote.example.com/users/alice#main-key"), - ...rsaKeyPair.publicKey, -}; - -describe("MastodonRelay", () => { - test("constructor with required options", () => { - const options: RelayOptions = { - kv: new MemoryKvStore(), - domain: "relay.example.com", - documentLoaderFactory: () => mockDocumentLoader, - federation: createFederation({ contextData: undefined }), - }; - - const relay = new MastodonRelay(options); - strictEqual(relay.domain, "relay.example.com"); - }); - - test("creates relay with default domain", () => { - const options: RelayOptions = { - kv: new MemoryKvStore(), - documentLoaderFactory: () => mockDocumentLoader, - }; - - const relay = new MastodonRelay(options); - strictEqual(relay.domain, "localhost"); - }); - - test("setSubscriptionHandler returns relay instance for chaining", () => { - const kv = new MemoryKvStore(); - const relay = new MastodonRelay({ - kv, - domain: "relay.example.com", - documentLoaderFactory: () => mockDocumentLoader, - }); - - const result = relay.setSubscriptionHandler(async (_ctx, _actor) => { - return await Promise.resolve(true); - }); - - strictEqual(result, relay); - }); - - test("fetch method returns Response", async () => { - const kv = new MemoryKvStore(); - const relay = new MastodonRelay({ - kv, - domain: "relay.example.com", - documentLoaderFactory: () => mockDocumentLoader, - federation: createFederation({ contextData: undefined }), - }); - - const request = new Request("https://relay.example.com/users/relay", { - headers: { "Accept": "application/activity+json" }, - }); - const response = await relay.fetch(request); - - ok(response instanceof Response); - }); - - test("fetching relay actor returns Service", async () => { - const kv = new MemoryKvStore(); - const relay = new MastodonRelay({ - kv, - domain: "relay.example.com", - documentLoaderFactory: () => mockDocumentLoader, - }); - - const request = new Request("https://relay.example.com/users/relay", { - headers: { "Accept": "application/activity+json" }, - }); - const response = await relay.fetch(request); - - strictEqual(response.status, 200); - const json = await response.json() as any; - strictEqual(json.type, "Service"); - strictEqual(json.preferredUsername, "relay"); - strictEqual(json.name, "ActivityPub Relay"); - }); - - test("fetching non-relay actor returns 404", async () => { - const kv = new MemoryKvStore(); - const relay = new MastodonRelay({ - kv, - domain: "relay.example.com", - documentLoaderFactory: () => mockDocumentLoader, - }); - - const request = new Request( - "https://relay.example.com/users/non-existent", - { - headers: { "Accept": "application/activity+json" }, - }, - ); - const response = await relay.fetch(request); - - strictEqual(response.status, 404); - }); - - test("followers collection returns empty list initially", async () => { - const kv = new MemoryKvStore(); - const relay = new MastodonRelay({ - kv, - domain: "relay.example.com", - documentLoaderFactory: () => mockDocumentLoader, - }); - - const request = new Request( - "https://relay.example.com/users/relay/followers", - { - headers: { "Accept": "application/activity+json" }, - }, - ); - const response = await relay.fetch(request); - - strictEqual(response.status, 200); - const json = await response.json() as any; - // The followers dispatcher is configured, verify response structure - ok(json); - ok(json.type === "Collection" || json.type === "OrderedCollection"); - }); - - test("followers collection returns populated list", async () => { - const kv = new MemoryKvStore(); - - // Pre-populate followers - const follower1 = new Person({ - id: new URL("https://remote1.example.com/users/alice"), - preferredUsername: "alice", - inbox: new URL("https://remote1.example.com/users/alice/inbox"), - }); - - const follower2 = new Person({ - id: new URL("https://remote2.example.com/users/bob"), - preferredUsername: "bob", - inbox: new URL("https://remote2.example.com/users/bob/inbox"), - }); - - const followActivity1Id = "https://remote1.example.com/activities/follow/1"; - const followActivity2Id = "https://remote2.example.com/activities/follow/2"; - - await kv.set(["followers"], [followActivity1Id, followActivity2Id]); - await kv.set(["follower", followActivity1Id], follower1.toJsonLd()); - await kv.set(["follower", followActivity2Id], follower2.toJsonLd()); - - const relay = new MastodonRelay({ - kv, - domain: "relay.example.com", - documentLoaderFactory: () => mockDocumentLoader, - }); - - const request = new Request( - "https://relay.example.com/users/relay/followers", - { - headers: { "Accept": "application/activity+json" }, - }, - ); - const response = await relay.fetch(request); - - strictEqual(response.status, 200); - const json = await response.json() as any; - ok(json); - ok(json.type === "Collection" || json.type === "OrderedCollection"); - // Fedify wraps the items in a collection, check totalItems if available - if (json.totalItems !== undefined) { - strictEqual(json.totalItems, 2); - } - }); - - test("subscription handler is called on Follow activity", async () => { - const kv = new MemoryKvStore(); - const relay = new MastodonRelay({ - kv, - domain: "relay.example.com", - documentLoaderFactory: () => mockDocumentLoader, - authenticatedDocumentLoaderFactory: () => mockDocumentLoader, - }); - - let handlerCalled = false; - let handlerActor: unknown = null; - - relay.setSubscriptionHandler(async (_ctx, actor) => { - handlerCalled = true; - handlerActor = actor; - return await Promise.resolve(true); - }); - - // Create a Follow activity - const follower = new Person({ - id: new URL("https://remote.example.com/users/alice"), - preferredUsername: "alice", - inbox: new URL("https://remote.example.com/users/alice/inbox"), - }); - - const followActivity = new Follow({ - id: new URL("https://remote.example.com/activities/follow/1"), - actor: follower.id, - object: new URL("https://relay.example.com/users/relay"), - }); - - // Sign and send the Follow activity to the relay's inbox - let request = new Request("https://relay.example.com/users/relay/inbox", { - method: "POST", - headers: { - "Content-Type": "application/activity+json", - "Accept": "application/activity+json", - }, - body: JSON.stringify( - await followActivity.toJsonLd({ contextLoader: mockDocumentLoader }), - ), - }); - - request = await signRequest( - request, - rsaKeyPair.privateKey, - rsaPublicKey.id!, - ); - - const _response = await relay.fetch(request); - - strictEqual(handlerCalled, true); - ok(handlerActor); - }); - - test("stores follower in KV when Follow is approved", async () => { - const kv = new MemoryKvStore(); - const relay = new MastodonRelay({ - kv, - domain: "relay.example.com", - documentLoaderFactory: () => mockDocumentLoader, - federation: createFederation({ contextData: undefined }), - }); - - relay.setSubscriptionHandler(async (_ctx, _actor) => { - return await Promise.resolve(true); - }); - - // Manually simulate what happens when a Follow is approved - const followActivityId = "https://remote.example.com/activities/follow/1"; - const follower = new Person({ - id: new URL("https://remote.example.com/users/alice"), - preferredUsername: "alice", - inbox: new URL("https://remote.example.com/users/alice/inbox"), - }); - - // Simulate the relay's internal logic - const followers = (await kv.get(["followers"])) ?? []; - followers.push(followActivityId); - await kv.set(["followers"], followers); - await kv.set(["follower", followActivityId], follower.toJsonLd()); - - // Verify storage - const storedFollowers = await kv.get(["followers"]); - ok(storedFollowers); - strictEqual(storedFollowers?.length, 1); - strictEqual(storedFollowers[0], followActivityId); - - const storedActor = await kv.get(["follower", followActivityId]); - ok(storedActor); - }); - - test("removes follower from KV when Undo Follow is received", async () => { - const kv = new MemoryKvStore(); - - // Pre-populate with a follower - const followActivityId = "https://remote.example.com/activities/follow/1"; - const follower = new Person({ - id: new URL("https://remote.example.com/users/alice"), - preferredUsername: "alice", - inbox: new URL("https://remote.example.com/users/alice/inbox"), - }); - - await kv.set(["followers"], [followActivityId]); - await kv.set(["follower", followActivityId], follower.toJsonLd()); - - const _relay = new MastodonRelay({ - kv, - domain: "relay.example.com", - documentLoaderFactory: () => mockDocumentLoader, - federation: createFederation({ contextData: undefined }), - }); - - // Simulate the Undo Follow logic - const followers = (await kv.get(["followers"])) ?? []; - const updatedFollowers = followers.filter((id) => id !== followActivityId); - await kv.set(["followers"], updatedFollowers); - await kv.delete(["follower", followActivityId]); - - // Verify removal - const storedFollowers = await kv.get(["followers"]); - ok(storedFollowers); - strictEqual(storedFollowers.length, 0); - - const storedActor = await kv.get(["follower", followActivityId]); - strictEqual(storedActor, undefined); - }); - - test("does not store follower when Follow is rejected", async () => { - const kv = new MemoryKvStore(); - const relay = new MastodonRelay({ - kv, - domain: "relay.example.com", - documentLoaderFactory: () => mockDocumentLoader, - federation: createFederation({ contextData: undefined }), - }); - - relay.setSubscriptionHandler(async (_ctx, _actor) => { - return await Promise.resolve(false); - }); - - // Verify no followers are stored initially - const followers = await kv.get(["followers"]); - ok(!followers || followers.length === 0); - }); - - test("relay actor has correct properties", async () => { - const kv = new MemoryKvStore(); - const relay = new MastodonRelay({ - kv, - domain: "relay.example.com", - documentLoaderFactory: () => mockDocumentLoader, - }); - - const request = new Request("https://relay.example.com/users/relay", { - headers: { "Accept": "application/activity+json" }, - }); - const response = await relay.fetch(request); - - strictEqual(response.status, 200); - const json = await response.json() as any; - - strictEqual(json.type, "Service"); - strictEqual(json.preferredUsername, "relay"); - strictEqual(json.name, "ActivityPub Relay"); - strictEqual( - json.summary, - "Mastodon-compatible ActivityPub relay server", - ); - strictEqual(json.id, "https://relay.example.com/users/relay"); - strictEqual(json.inbox, "https://relay.example.com/inbox"); - strictEqual( - json.followers, - "https://relay.example.com/users/relay/followers", - ); - }); - - test("multiple followers can be stored", async () => { - const kv = new MemoryKvStore(); - const relay = new MastodonRelay({ - kv, - domain: "relay.example.com", - documentLoaderFactory: () => mockDocumentLoader, - federation: createFederation({ contextData: undefined }), - }); - - relay.setSubscriptionHandler(async (_ctx, _actor) => - await Promise.resolve(true) - ); - - // Simulate multiple Follow activities - const followIds = [ - "https://remote1.example.com/activities/follow/1", - "https://remote2.example.com/activities/follow/2", - "https://remote3.example.com/activities/follow/3", - ]; - - const followers: string[] = []; - for (const followId of followIds) { - followers.push(followId); - const actor = new Person({ - id: new URL(followId.replace("/activities/follow/", "/users/user")), - preferredUsername: `user${followers.length}`, - inbox: new URL( - followId.replace("/activities/follow/", "/users/user") + "/inbox", - ), - }); - await kv.set(["follower", followId], actor.toJsonLd()); - } - await kv.set(["followers"], followers); - - const storedFollowers = await kv.get(["followers"]); - ok(storedFollowers); - strictEqual(storedFollowers.length, 3); - }); -}); - -describe("LitePubRelay", () => { - test("creates relay with required options", () => { - const options: RelayOptions = { - kv: new MemoryKvStore(), - domain: "relay.example.com", - documentLoaderFactory: () => mockDocumentLoader, - federation: createFederation({ contextData: undefined }), - }; - - const relay = new LitePubRelay(options); - strictEqual(relay.domain, "relay.example.com"); - }); - - test("creates relay with default domain", () => { - const options: RelayOptions = { - kv: new MemoryKvStore(), - documentLoaderFactory: () => mockDocumentLoader, - }; - - const relay = new LitePubRelay(options); - strictEqual(relay.domain, "localhost"); - }); - - test("setSubscriptionHandler returns relay instance for chaining", () => { - const kv = new MemoryKvStore(); - const relay = new LitePubRelay({ - kv, - domain: "relay.example.com", - documentLoaderFactory: () => mockDocumentLoader, - }); - - const result = relay.setSubscriptionHandler(async (_ctx, _actor) => - await Promise.resolve(true) - ); - - strictEqual(result, relay); - }); - - test("fetch method returns Response", async () => { - const kv = new MemoryKvStore(); - const relay = new LitePubRelay({ - kv, - domain: "relay.example.com", - documentLoaderFactory: () => mockDocumentLoader, - federation: createFederation({ contextData: undefined }), - }); - - const request = new Request("https://relay.example.com/users/relay", { - headers: { "Accept": "application/activity+json" }, - }); - const response = await relay.fetch(request); - - ok(response instanceof Response); - }); - - test("fetching relay actor returns Application", async () => { - const kv = new MemoryKvStore(); - const relay = new LitePubRelay({ - kv, - domain: "relay.example.com", - documentLoaderFactory: () => mockDocumentLoader, - }); - - const request = new Request("https://relay.example.com/users/relay", { - headers: { "Accept": "application/activity+json" }, - }); - const response = await relay.fetch(request); - - strictEqual(response.status, 200); - const json = await response.json() as any; - strictEqual(json.type, "Application"); - strictEqual(json.preferredUsername, "relay"); - strictEqual(json.name, "ActivityPub Relay"); - }); - - test("fetching non-relay actor returns 404", async () => { - const kv = new MemoryKvStore(); - const relay = new LitePubRelay({ - kv, - domain: "relay.example.com", - documentLoaderFactory: () => mockDocumentLoader, - }); - - const request = new Request( - "https://relay.example.com/users/non-existent", - { - headers: { "Accept": "application/activity+json" }, - }, - ); - const response = await relay.fetch(request); - - strictEqual(response.status, 404); - }); - - test("followers collection returns empty list initially", async () => { - const kv = new MemoryKvStore(); - const relay = new LitePubRelay({ - kv, - domain: "relay.example.com", - documentLoaderFactory: () => mockDocumentLoader, - }); - - const request = new Request( - "https://relay.example.com/users/relay/followers", - { - headers: { "Accept": "application/activity+json" }, - }, - ); - const response = await relay.fetch(request); - - strictEqual(response.status, 200); - const json = await response.json() as any; - ok(json); - ok(json.type === "Collection" || json.type === "OrderedCollection"); - }); - - test("followers collection returns populated list", async () => { - const kv = new MemoryKvStore(); - - // Pre-populate followers with LitePub structure (actor + state) - const follower1 = new Person({ - id: new URL("https://remote1.example.com/users/alice"), - preferredUsername: "alice", - inbox: new URL("https://remote1.example.com/users/alice/inbox"), - }); - - const follower2 = new Person({ - id: new URL("https://remote2.example.com/users/bob"), - preferredUsername: "bob", - inbox: new URL("https://remote2.example.com/users/bob/inbox"), - }); - - const follower1Id = "https://remote1.example.com/users/alice"; - const follower2Id = "https://remote2.example.com/users/bob"; - - // LitePub stores actor IDs in followers list and uses LitePubRelayFollower structure - await kv.set(["followers"], [follower1Id, follower2Id]); - await kv.set(["follower", follower1Id], { - actor: await follower1.toJsonLd(), - state: "accepted", - }); - await kv.set(["follower", follower2Id], { - actor: await follower2.toJsonLd(), - state: "accepted", - }); - - const relay = new LitePubRelay({ - kv, - domain: "relay.example.com", - documentLoaderFactory: () => mockDocumentLoader, - }); - - const request = new Request( - "https://relay.example.com/users/relay/followers", - { - headers: { "Accept": "application/activity+json" }, - }, - ); - const response = await relay.fetch(request); - - strictEqual(response.status, 200); - const json = await response.json() as any; - ok(json); - ok(json.type === "Collection" || json.type === "OrderedCollection"); - if (json.totalItems !== undefined) { - strictEqual(json.totalItems, 2); - } - }); - - test("subscription handler is called on Follow activity", async () => { - const kv = new MemoryKvStore(); - const relay = new LitePubRelay({ - kv, - domain: "relay.example.com", - documentLoaderFactory: () => mockDocumentLoader, - authenticatedDocumentLoaderFactory: () => mockDocumentLoader, - }); - - let handlerCalled = false; - let handlerActor: unknown = null; - - relay.setSubscriptionHandler(async (_ctx, actor) => { - handlerCalled = true; - handlerActor = actor; - return await Promise.resolve(true); - }); - - // Create a Follow activity - const follower = new Person({ - id: new URL("https://remote.example.com/users/alice"), - preferredUsername: "alice", - inbox: new URL("https://remote.example.com/users/alice/inbox"), - }); - - const followActivity = new Follow({ - id: new URL("https://remote.example.com/activities/follow/1"), - actor: follower.id, - object: new URL("https://relay.example.com/users/relay"), - }); - - // Sign and send the Follow activity to the relay's inbox - let request = new Request("https://relay.example.com/users/relay/inbox", { - method: "POST", - headers: { - "Content-Type": "application/activity+json", - "Accept": "application/activity+json", - }, - body: JSON.stringify( - await followActivity.toJsonLd({ contextLoader: mockDocumentLoader }), - ), - }); - - request = await signRequest( - request, - rsaKeyPair.privateKey, - rsaPublicKey.id!, - ); - - const _response = await relay.fetch(request); - - strictEqual(handlerCalled, true); - ok(handlerActor); - }); - - test("stores follower with pending state then accepted after two-step handshake", async () => { - const kv = new MemoryKvStore(); - const relay = new LitePubRelay({ - kv, - domain: "relay.example.com", - documentLoaderFactory: () => mockDocumentLoader, - federation: createFederation({ contextData: undefined }), - }); - - relay.setSubscriptionHandler(async (_ctx, _actor) => { - return await Promise.resolve(true); - }); - - const follower = new Person({ - id: new URL("https://remote.example.com/users/alice"), - preferredUsername: "alice", - inbox: new URL("https://remote.example.com/users/alice/inbox"), - }); - - const followerId = follower.id!.href; - - // Step 1: Simulate Follow received and stored with "pending" state - await kv.set(["follower", followerId], { - actor: await follower.toJsonLd(), - state: "pending", - }); - - // Verify follower is in pending state - const followerData = await kv.get<{ actor: unknown; state: string }>([ - "follower", - followerId, - ]); - ok(followerData); - strictEqual(followerData.state, "pending"); - - // Verify follower is NOT in followers list yet - let followers = await kv.get(["followers"]); - ok(!followers || followers.length === 0); - - // Step 2: Simulate Accept received - state changes to "accepted" - if (followerData) { - const updatedFollowerData = { ...followerData, state: "accepted" }; - await kv.set(["follower", followerId], updatedFollowerData); - } - - // Now add to followers list - followers = (await kv.get(["followers"])) ?? []; - followers.push(followerId); - await kv.set(["followers"], followers); - - // Verify final state - const storedFollowers = await kv.get(["followers"]); - ok(storedFollowers); - strictEqual(storedFollowers.length, 1); - strictEqual(storedFollowers[0], followerId); - - const storedFollowerData = await kv.get<{ actor: unknown; state: string }>( - ["follower", followerId], - ); - ok(storedFollowerData); - strictEqual(storedFollowerData.state, "accepted"); - }); - - test("removes follower from KV when Undo Follow is received", async () => { - const kv = new MemoryKvStore(); - - // Pre-populate with a follower (LitePub uses actor ID as key) - const follower = new Person({ - id: new URL("https://remote.example.com/users/alice"), - preferredUsername: "alice", - inbox: new URL("https://remote.example.com/users/alice/inbox"), - }); - - const followerId = follower.id!.href; - - await kv.set(["followers"], [followerId]); - await kv.set(["follower", followerId], { - actor: await follower.toJsonLd(), - state: "accepted", - }); - - const _relay = new LitePubRelay({ - kv, - domain: "relay.example.com", - documentLoaderFactory: () => mockDocumentLoader, - federation: createFederation({ contextData: undefined }), - }); - - // Simulate the Undo Follow logic (uses actor ID) - const followers = (await kv.get(["followers"])) ?? []; - const updatedFollowers = followers.filter((id) => id !== followerId); - await kv.set(["followers"], updatedFollowers); - await kv.delete(["follower", followerId]); - - // Verify removal - const storedFollowers = await kv.get(["followers"]); - ok(storedFollowers); - strictEqual(storedFollowers.length, 0); - - const storedActor = await kv.get(["follower", followerId]); - strictEqual(storedActor, undefined); - }); - - test("does not store follower when Follow is rejected", async () => { - const kv = new MemoryKvStore(); - const relay = new LitePubRelay({ - kv, - domain: "relay.example.com", - documentLoaderFactory: () => mockDocumentLoader, - federation: createFederation({ contextData: undefined }), - }); - - relay.setSubscriptionHandler(async (_ctx, _actor) => { - return await Promise.resolve(false); - }); - - // Verify no followers are stored initially - const followers = await kv.get(["followers"]); - ok(!followers || followers.length === 0); - }); - - test("relay actor has correct properties", async () => { - const kv = new MemoryKvStore(); - const relay = new LitePubRelay({ - kv, - domain: "relay.example.com", - documentLoaderFactory: () => mockDocumentLoader, - }); - - const request = new Request("https://relay.example.com/users/relay", { - headers: { "Accept": "application/activity+json" }, - }); - const response = await relay.fetch(request); - - strictEqual(response.status, 200); - const json = await response.json() as any; - - strictEqual(json.type, "Application"); - strictEqual(json.preferredUsername, "relay"); - strictEqual(json.name, "ActivityPub Relay"); - strictEqual( - json.summary, - "LitePub-compatible ActivityPub relay server", - ); - strictEqual(json.id, "https://relay.example.com/users/relay"); - strictEqual(json.inbox, "https://relay.example.com/inbox"); - strictEqual( - json.followers, - "https://relay.example.com/users/relay/followers", - ); - }); - - test("multiple followers can be stored", async () => { - const kv = new MemoryKvStore(); - const relay = new LitePubRelay({ - kv, - domain: "relay.example.com", - documentLoaderFactory: () => mockDocumentLoader, - federation: createFederation({ contextData: undefined }), - }); - - relay.setSubscriptionHandler(async (_ctx, _actor) => - await Promise.resolve(true) - ); - - // Simulate multiple Follow activities (LitePub uses actor IDs as keys) - const actorIds = [ - "https://remote1.example.com/users/user1", - "https://remote2.example.com/users/user2", - "https://remote3.example.com/users/user3", - ]; - - const followers: string[] = []; - for (let i = 0; i < actorIds.length; i++) { - const actorId = actorIds[i]; - followers.push(actorId); - const actor = new Person({ - id: new URL(actorId), - preferredUsername: `user${i + 1}`, - inbox: new URL(`${actorId}/inbox`), - }); - await kv.set(["follower", actorId], { - actor: await actor.toJsonLd(), - state: "accepted", - }); - } - await kv.set(["followers"], followers); - - const storedFollowers = await kv.get(["followers"]); - ok(storedFollowers); - strictEqual(storedFollowers.length, 3); - }); - - test("Accept handler updates follower state and adds to followers list", async () => { - const kv = new MemoryKvStore(); - const relay = new LitePubRelay({ - kv, - domain: "relay.example.com", - documentLoaderFactory: () => mockDocumentLoader, - authenticatedDocumentLoaderFactory: () => mockDocumentLoader, - }); - - relay.setSubscriptionHandler(async (_ctx, _actor) => - await Promise.resolve(true) - ); - - const follower = new Person({ - id: new URL("https://remote.example.com/users/alice"), - preferredUsername: "alice", - inbox: new URL("https://remote.example.com/users/alice/inbox"), - }); - - const followerId = follower.id!.href; - - // Pre-populate with pending follower (simulating initial Follow was processed) - await kv.set(["follower", followerId], { - actor: await follower.toJsonLd(), - state: "pending", - }); - - // Verify not in followers list yet - let followers = await kv.get(["followers"]); - ok(!followers || followers.length === 0); - - // Create Accept activity from the client - const relayFollowActivity = new Follow({ - id: new URL("https://relay.example.com/activities/follow/1"), - actor: new URL("https://relay.example.com/users/relay"), - object: follower.id, - }); - - const acceptActivity = new Accept({ - id: new URL("https://remote.example.com/activities/accept/1"), - actor: follower.id, - object: relayFollowActivity, - }); - - // Sign and send the Accept activity to the relay's inbox - let request = new Request("https://relay.example.com/inbox", { - method: "POST", - headers: { - "Content-Type": "application/activity+json", - "Accept": "application/activity+json", - }, - body: JSON.stringify( - await acceptActivity.toJsonLd({ contextLoader: mockDocumentLoader }), - ), - }); - - request = await signRequest( - request, - rsaKeyPair.privateKey, - rsaPublicKey.id!, - ); - - await relay.fetch(request); - - // Verify state changed to accepted - const updatedFollowerData = await kv.get<{ actor: unknown; state: string }>( - ["follower", followerId], - ); - ok(updatedFollowerData); - strictEqual(updatedFollowerData.state, "accepted"); - - // Verify added to followers list - followers = await kv.get(["followers"]); - ok(followers); - strictEqual(followers.length, 1); - strictEqual(followers[0], followerId); - }); - - test("following dispatcher returns same actors as followers", async () => { - const kv = new MemoryKvStore(); - - // Pre-populate followers with accepted state - const follower1 = new Person({ - id: new URL("https://remote1.example.com/users/alice"), - preferredUsername: "alice", - inbox: new URL("https://remote1.example.com/users/alice/inbox"), - }); - - const follower2 = new Person({ - id: new URL("https://remote2.example.com/users/bob"), - preferredUsername: "bob", - inbox: new URL("https://remote2.example.com/users/bob/inbox"), - }); - - const follower1Id = follower1.id!.href; - const follower2Id = follower2.id!.href; - - await kv.set(["followers"], [follower1Id, follower2Id]); - await kv.set(["follower", follower1Id], { - actor: await follower1.toJsonLd(), - state: "accepted", - }); - await kv.set(["follower", follower2Id], { - actor: await follower2.toJsonLd(), - state: "accepted", - }); - - const relay = new LitePubRelay({ - kv, - domain: "relay.example.com", - documentLoaderFactory: () => mockDocumentLoader, - }); - - // Fetch following collection - const followingRequest = new Request( - "https://relay.example.com/users/relay/following", - { - headers: { "Accept": "application/activity+json" }, - }, - ); - const followingResponse = await relay.fetch(followingRequest); - - strictEqual(followingResponse.status, 200); - const followingJson = await followingResponse.json() as any; - ok(followingJson); - ok( - followingJson.type === "Collection" || - followingJson.type === "OrderedCollection", - ); - - // Fetch followers collection - const followersRequest = new Request( - "https://relay.example.com/users/relay/followers", - { - headers: { "Accept": "application/activity+json" }, - }, - ); - const followersResponse = await relay.fetch(followersRequest); - - strictEqual(followersResponse.status, 200); - const followersJson = await followersResponse.json() as any; - - // Verify both collections have same count - if (followingJson.totalItems !== undefined) { - strictEqual(followingJson.totalItems, 2); - strictEqual(followersJson.totalItems, 2); - } - }); - - test("pending followers are not in followers list", async () => { - const kv = new MemoryKvStore(); - const relay = new LitePubRelay({ - kv, - domain: "relay.example.com", - documentLoaderFactory: () => mockDocumentLoader, - }); - - const follower = new Person({ - id: new URL("https://remote.example.com/users/alice"), - preferredUsername: "alice", - inbox: new URL("https://remote.example.com/users/alice/inbox"), - }); - - const followerId = follower.id!.href; - - // Store follower with pending state - await kv.set(["follower", followerId], { - actor: await follower.toJsonLd(), - state: "pending", - }); - - // Fetch followers collection - const request = new Request( - "https://relay.example.com/users/relay/followers", - { - headers: { "Accept": "application/activity+json" }, - }, - ); - const response = await relay.fetch(request); - - strictEqual(response.status, 200); - const json = await response.json() as any; - ok(json); - - // Verify pending follower is NOT in collection - if (json.totalItems !== undefined) { - strictEqual(json.totalItems, 0); - } - }); - - test("duplicate Follow is ignored when follower is pending", async () => { - const kv = new MemoryKvStore(); - const relay = new LitePubRelay({ - kv, - domain: "relay.example.com", - documentLoaderFactory: () => mockDocumentLoader, - authenticatedDocumentLoaderFactory: () => mockDocumentLoader, - }); - - let handlerCallCount = 0; - - relay.setSubscriptionHandler(async (_ctx, _actor) => { - handlerCallCount++; - return await Promise.resolve(true); - }); - - const follower = new Person({ - id: new URL("https://remote.example.com/users/alice"), - preferredUsername: "alice", - inbox: new URL("https://remote.example.com/users/alice/inbox"), - }); - - const followerId = follower.id!.href; - - // Pre-populate with pending follower - await kv.set(["follower", followerId], { - actor: await follower.toJsonLd(), - state: "pending", - }); - - // Send another Follow activity from the same user - const followActivity = new Follow({ - id: new URL("https://remote.example.com/activities/follow/2"), - actor: follower.id, - object: new URL("https://relay.example.com/users/relay"), - }); - - let request = new Request("https://relay.example.com/inbox", { - method: "POST", - headers: { - "Content-Type": "application/activity+json", - "Accept": "application/activity+json", - }, - body: JSON.stringify( - await followActivity.toJsonLd({ contextLoader: mockDocumentLoader }), - ), - }); - - request = await signRequest( - request, - rsaKeyPair.privateKey, - rsaPublicKey.id!, - ); - - await relay.fetch(request); - - // Verify subscription handler was NOT called (duplicate was ignored) - strictEqual(handlerCallCount, 0); - - // Verify state is still pending - const followerData = await kv.get<{ actor: unknown; state: string }>( - ["follower", followerId], - ); - ok(followerData); - strictEqual(followerData.state, "pending"); - }); -}); diff --git a/packages/relay/src/relay.ts b/packages/relay/src/relay.ts deleted file mode 100644 index 6d9d34fe1..000000000 --- a/packages/relay/src/relay.ts +++ /dev/null @@ -1,695 +0,0 @@ -import { - type Context, - createFederation, - exportJwk, - type Federation, - generateCryptoKeyPair, - importJwk, - type KvStore, - type MessageQueue, -} from "@fedify/fedify"; -import { - Accept, - type Actor, - Announce, - Application, - Create, - Delete, - Follow, - isActor, - Move, - Object, - PUBLIC_COLLECTION, - Reject, - Service, - Undo, - Update, -} from "@fedify/fedify/vocab"; -import type { - AuthenticatedDocumentLoaderFactory, - DocumentLoaderFactory, -} from "@fedify/vocab-runtime"; - -const RELAY_SERVER_ACTOR = "relay"; - -/** - * Handler for subscription requests (Follow/Undo activities). - */ -export type SubscriptionRequestHandler = ( - ctx: Context, - clientActor: Actor, -) => Promise; - -/** - * Configuration options for the ActivityPub relay. - */ -export interface RelayOptions { - kv: KvStore; - domain?: string; - documentLoaderFactory?: DocumentLoaderFactory; - authenticatedDocumentLoaderFactory?: AuthenticatedDocumentLoaderFactory; - federation?: Federation; - queue?: MessageQueue; -} - -/** - * Base interface for ActivityPub relay implementations. - */ -export interface Relay { - readonly domain: string; - - fetch(request: Request): Promise; - setSubscriptionHandler(handler: SubscriptionRequestHandler): this; -} - -interface LitePubRelayFollower { - readonly actor: unknown; - readonly state: string; -} - -/** - * A Mastodon-compatible ActivityPub relay implementation. - * This relay follows Mastodon's relay protocol for maximum compatibility - * with Mastodon instances. - * - * @since 2.0.0 - */ -export class MastodonRelay implements Relay { - #federation: Federation; - #options: RelayOptions; - #subscriptionHandler?: SubscriptionRequestHandler; - - constructor(options: RelayOptions) { - this.#options = options; - this.#federation = options.federation ?? createFederation({ - kv: options.kv, - queue: options.queue, - documentLoaderFactory: options.documentLoaderFactory, - authenticatedDocumentLoaderFactory: - options.authenticatedDocumentLoaderFactory, - }); - - this.#federation.setActorDispatcher( - "/users/{identifier}", - async (ctx, identifier) => { - if (identifier !== RELAY_SERVER_ACTOR) return null; - const keys = await ctx.getActorKeyPairs(identifier); - return new Service({ - id: ctx.getActorUri(identifier), - preferredUsername: identifier, - name: "ActivityPub Relay", - summary: "Mastodon-compatible ActivityPub relay server", - inbox: ctx.getInboxUri(), // This should be sharedInboxUri - followers: ctx.getFollowersUri(identifier), - url: ctx.getActorUri(identifier), - publicKey: keys[0].cryptographicKey, - assertionMethods: keys.map((k) => k.multikey), - }); - }, - ) - .setKeyPairsDispatcher( - async (_ctx, identifier) => { - if (identifier !== RELAY_SERVER_ACTOR) return []; - - const rsaPairJson = await options.kv.get< - { privateKey: JsonWebKey; publicKey: JsonWebKey } - >(["keypair", "rsa", identifier]); - const ed25519PairJson = await options.kv.get< - { privateKey: JsonWebKey; publicKey: JsonWebKey } - >(["keypair", "ed25519", identifier]); - if (rsaPairJson == null || ed25519PairJson == null) { - const rsaPair = await generateCryptoKeyPair("RSASSA-PKCS1-v1_5"); - const ed25519Pair = await generateCryptoKeyPair("Ed25519"); - await options.kv.set(["keypair", "rsa", identifier], { - privateKey: await exportJwk(rsaPair.privateKey), - publicKey: await exportJwk(rsaPair.publicKey), - }); - await options.kv.set(["keypair", "ed25519", identifier], { - privateKey: await exportJwk(ed25519Pair.privateKey), - publicKey: await exportJwk(ed25519Pair.publicKey), - }); - - return [rsaPair, ed25519Pair]; - } - - const rsaPair: CryptoKeyPair = { - privateKey: await importJwk(rsaPairJson.privateKey, "private"), - publicKey: await importJwk(rsaPairJson.publicKey, "public"), - }; - const ed25519Pair: CryptoKeyPair = { - privateKey: await importJwk(ed25519PairJson.privateKey, "private"), - publicKey: await importJwk(ed25519PairJson.publicKey, "public"), - }; - return [rsaPair, ed25519Pair]; - }, - ); - - this.#federation.setFollowersDispatcher( - "/users/{identifier}/followers", - async (_ctx, identifier) => { - if (identifier !== RELAY_SERVER_ACTOR) return null; - - const activityIds = await options.kv.get(["followers"]) ?? - []; - - const actors: Actor[] = []; - for (const activityId of activityIds) { - const actorJson = await options.kv.get(["follower", activityId]); - - const actor = await Object.fromJsonLd(actorJson); - if (!isActor(actor)) continue; - - actors.push(actor); - } - return { items: actors }; - }, - ); - - this.#federation.setInboxListeners("/users/{identifier}/inbox", "/inbox") - .on(Follow, async (ctx, follow) => { - if (follow.id == null || follow.objectId == null) return; - const parsed = ctx.parseUri(follow.objectId); - const isPublicFollow = follow.objectId.href === - "https://www.w3.org/ns/activitystreams#Public"; - if (!isPublicFollow && parsed?.type !== "actor") return; - - const relayActorUri = ctx.getActorUri(RELAY_SERVER_ACTOR); - const follower = await follow.getActor(ctx); - if ( - follower == null || follower.id == null || - follower.preferredUsername == null || - follower.inboxId == null - ) return; - let approved = false; - - if (this.#subscriptionHandler) { - approved = await this.#subscriptionHandler( - ctx, - follower, - ); - } - - if (approved) { - const followers = await options.kv.get(["followers"]) ?? []; - followers.push(follow.id.href); - await options.kv.set(["followers"], followers); - - await options.kv.set( - ["follower", follow.id.href], - await follower.toJsonLd(), - ); - - await ctx.sendActivity( - { identifier: RELAY_SERVER_ACTOR }, - follower, - new Accept({ - id: new URL(`#accepts`, relayActorUri), - actor: relayActorUri, - object: follow, - }), - ); - } else { - await ctx.sendActivity( - { identifier: RELAY_SERVER_ACTOR }, - follower, - new Reject({ - id: new URL(`#rejects`, relayActorUri), - actor: relayActorUri, - object: follow, - }), - ); - } - }) - .on(Undo, async (ctx, undo) => { - const activity = await undo.getObject(ctx); - if (activity instanceof Follow) { - if ( - activity.id == null || - activity.actorId == null - ) return; - const activityId = activity.id.href; - const followers = await options.kv.get(["followers"]) ?? - []; - const updatedFollowers = followers.filter((id) => id !== activityId); - await options.kv.set(["followers"], updatedFollowers); - options.kv.delete(["follower", activityId]); - } else { - console.warn( - "Unsupported object type ({type}) for Undo activity: {object}", - { type: activity?.constructor.name, object: activity }, - ); - } - }) - .on(Create, async (ctx, create) => { - const sender = await create.getActor(ctx); - // Exclude the sender's origin to prevent forwarding back to them - const excludeBaseUris = sender?.id ? [new URL(sender.id)] : []; - - await ctx.forwardActivity( - { identifier: RELAY_SERVER_ACTOR }, - "followers", - { - skipIfUnsigned: true, - excludeBaseUris, - preferSharedInbox: true, - }, - ); - }) - .on(Delete, async (ctx, deleteActivity) => { - const sender = await deleteActivity.getActor(ctx); - // Exclude the sender's origin to prevent forwarding back to them - const excludeBaseUris = sender?.id ? [new URL(sender.id)] : []; - - await ctx.forwardActivity( - { identifier: RELAY_SERVER_ACTOR }, - "followers", - { - skipIfUnsigned: true, - excludeBaseUris, - preferSharedInbox: true, - }, - ); - }) - .on(Move, async (ctx, deleteActivity) => { - const sender = await deleteActivity.getActor(ctx); - // Exclude the sender's origin to prevent forwarding back to them - const excludeBaseUris = sender?.id ? [new URL(sender.id)] : []; - - await ctx.forwardActivity( - { identifier: RELAY_SERVER_ACTOR }, - "followers", - { - skipIfUnsigned: true, - excludeBaseUris, - preferSharedInbox: true, - }, - ); - }) - .on(Update, async (ctx, deleteActivity) => { - const sender = await deleteActivity.getActor(ctx); - // Exclude the sender's origin to prevent forwarding back to them - const excludeBaseUris = sender?.id ? [new URL(sender.id)] : []; - - await ctx.forwardActivity( - { identifier: RELAY_SERVER_ACTOR }, - "followers", - { - skipIfUnsigned: true, - excludeBaseUris, - preferSharedInbox: true, - }, - ); - }); - } - - get domain(): string { - return this.#options.domain || "localhost"; - } - - fetch(request: Request): Promise { - return this.#federation.fetch(request, { contextData: undefined }); - } - - setSubscriptionHandler(handler: SubscriptionRequestHandler): this { - this.#subscriptionHandler = handler; - return this; - } -} - -/** - * A LitePub-compatible ActivityPub relay implementation. - * This relay follows LitePub's relay protocol and extensions for - * enhanced federation capabilities. - * - * @since 2.0.0 - */ -export class LitePubRelay implements Relay { - #federation: Federation; - #options: RelayOptions; - #subscriptionHandler?: SubscriptionRequestHandler; - - constructor(options: RelayOptions) { - this.#options = options; - this.#federation = options.federation ?? createFederation({ - kv: options.kv, - queue: options.queue, - documentLoaderFactory: options.documentLoaderFactory, - authenticatedDocumentLoaderFactory: - options.authenticatedDocumentLoaderFactory, - }); - - this.#federation.setActorDispatcher( - "/users/{identifier}", - async (ctx, identifier) => { - if (identifier !== RELAY_SERVER_ACTOR) return null; - const keys = await ctx.getActorKeyPairs(identifier); - return new Application({ - id: ctx.getActorUri(identifier), - preferredUsername: identifier, - name: "ActivityPub Relay", - summary: "LitePub-compatible ActivityPub relay server", - inbox: ctx.getInboxUri(), // This should be sharedInboxUri - followers: ctx.getFollowersUri(identifier), - following: ctx.getFollowingUri(identifier), // LitePub Relay should implement following dispatcher - url: ctx.getActorUri(identifier), - publicKey: keys[0].cryptographicKey, - - assertionMethods: keys.map((k) => k.multikey), - }); - }, - ) - .setKeyPairsDispatcher( - async (_ctx, identifier) => { - if (identifier !== RELAY_SERVER_ACTOR) return []; - - const rsaPairJson = await options.kv.get< - { privateKey: JsonWebKey; publicKey: JsonWebKey } - >(["keypair", "rsa", identifier]); - const ed25519PairJson = await options.kv.get< - { privateKey: JsonWebKey; publicKey: JsonWebKey } - >(["keypair", "ed25519", identifier]); - if (rsaPairJson == null || ed25519PairJson == null) { - const rsaPair = await generateCryptoKeyPair("RSASSA-PKCS1-v1_5"); - const ed25519Pair = await generateCryptoKeyPair("Ed25519"); - await options.kv.set(["keypair", "rsa", identifier], { - privateKey: await exportJwk(rsaPair.privateKey), - publicKey: await exportJwk(rsaPair.publicKey), - }); - await options.kv.set(["keypair", "ed25519", identifier], { - privateKey: await exportJwk(ed25519Pair.privateKey), - publicKey: await exportJwk(ed25519Pair.publicKey), - }); - - return [rsaPair, ed25519Pair]; - } - - const rsaPair: CryptoKeyPair = { - privateKey: await importJwk(rsaPairJson.privateKey, "private"), - publicKey: await importJwk(rsaPairJson.publicKey, "public"), - }; - const ed25519Pair: CryptoKeyPair = { - privateKey: await importJwk(ed25519PairJson.privateKey, "private"), - publicKey: await importJwk(ed25519PairJson.publicKey, "public"), - }; - return [rsaPair, ed25519Pair]; - }, - ); - - this.#federation.setFollowingDispatcher( - "/users/{identifier}/following", - async (_ctx, identifier) => { - if (identifier !== RELAY_SERVER_ACTOR) return null; - - const followers = await options.kv.get(["followers"]) ?? - []; - - const actors: Actor[] = []; - for (const followerId of followers) { - const follower = await options.kv.get([ - "follower", - followerId, - ]); - if (!follower) continue; - const actor = await Object.fromJsonLd(follower.actor); - if (!isActor(actor)) continue; - actors.push(actor); - } - return { items: actors }; - }, - ); - - this.#federation.setFollowersDispatcher( - "/users/{identifier}/followers", - async (_ctx, identifier) => { - if (identifier !== RELAY_SERVER_ACTOR) return null; - - const followers = await options.kv.get(["followers"]) ?? - []; - - const actors: Actor[] = []; - for (const followerId of followers) { - const follower = await options.kv.get([ - "follower", - followerId, - ]); - if (!follower) continue; - const actor = await Object.fromJsonLd(follower.actor); - if (!isActor(actor)) continue; - actors.push(actor); - } - return { items: actors }; - }, - ); - - this.#federation.setInboxListeners("/users/{identifier}/inbox", "/inbox") - .on(Follow, async (ctx, follow) => { - if (follow.id == null || follow.objectId == null) return; - const parsed = ctx.parseUri(follow.objectId); - const isPublicFollow = follow.objectId.href === - "https://www.w3.org/ns/activitystreams#Public"; - if (!isPublicFollow && parsed?.type !== "actor") return; - - const relayActorUri = ctx.getActorUri(RELAY_SERVER_ACTOR); - const follower = await follow.getActor(ctx); - if ( - follower == null || follower.id == null || - follower.preferredUsername == null || - follower.inboxId == null - ) return; - - // Check if this is a follow from a client or if we already have a pending state - const existingFollow = await options.kv.get([ - "follower", - follower.id.href, - ]); - - // "pending" follower means this follower client requested subscription already. - if (existingFollow?.state === "pending") return; - - let subscriptionApproved = false; - - // Receive follow request from the relay client. - if (this.#subscriptionHandler) { - subscriptionApproved = await this.#subscriptionHandler( - ctx, - follower, - ); - } - - if (subscriptionApproved) { - // Add state pending - await options.kv.set( - ["follower", follower.id.href], - { "actor": await follower.toJsonLd(), "state": "pending" }, - ); - - await ctx.sendActivity( - { identifier: RELAY_SERVER_ACTOR }, - follower, - new Accept({ - id: new URL(`#accepts`, relayActorUri), - actor: relayActorUri, - object: follow, - }), - ); - - // Send reciprocal follow - await ctx.sendActivity( - { identifier: RELAY_SERVER_ACTOR }, - follower, - new Follow({ - actor: relayActorUri, - object: follower.id, - to: follower.id, - }), - ); - } else { - await ctx.sendActivity( - { identifier: RELAY_SERVER_ACTOR }, - follower, - new Reject({ - id: new URL(`#rejects`, relayActorUri), - actor: relayActorUri, - object: follow, - }), - ); - } - }) - .on(Accept, async (ctx, accept) => { - // Validate follow activity from accept activity - const follow = await accept.getObject({ - crossOrigin: "trust", - ...ctx, - }); - if (!(follow instanceof Follow)) return; - const follower = follow.actorId; - if (follower == null) return; - - // Validate following - accept activity sender - const following = await accept.getActor(); - if (!isActor(following) || !following.id) return; - const parsed = ctx.parseUri(follower); - if (parsed == null || parsed.type !== "actor") return; - - // Get follower from kv store - const followerData = await options.kv.get([ - "follower", - following.id.href, - ]); - if (followerData == null) return; - - // Update follower state - const updatedFollowerData = { ...followerData, state: "accepted" }; - await options.kv.set( - ["follower", following.id.href], - updatedFollowerData, - ); - - // Update followers list - const followers = await options.kv.get(["followers"]) ?? []; - followers.push(following.id.href); - await options.kv.set(["followers"], followers); - }) - .on(Undo, async (ctx, undo) => { - const activity = await undo.getObject({ crossOrigin: "trust", ...ctx }); - if (activity instanceof Follow) { - if ( - activity.id == null || - activity.actorId == null - ) return; - const followers = await options.kv.get(["followers"]) ?? - []; // actor ids - - const updatedFollowers = followers.filter((id) => - id !== activity.actorId?.href - ); - await options.kv.set(["followers"], updatedFollowers); - options.kv.delete(["follower", activity.actorId?.href]); - } else { - console.warn( - "Unsupported object type ({type}) for Undo activity: {object}", - { type: activity?.constructor.name, object: activity }, - ); - } - }) - .on(Create, async (ctx, create) => { - const sender = await create.getActor(ctx); - const excludeBaseUris = sender?.id ? [new URL(sender.id)] : []; - - const announce = new Announce({ - id: new URL(`/announce#${crypto.randomUUID()}`, ctx.origin), - actor: ctx.getActorUri(RELAY_SERVER_ACTOR), - object: create.objectId, - to: PUBLIC_COLLECTION, - published: Temporal.Now.instant(), - }); - - await ctx.sendActivity( - { identifier: RELAY_SERVER_ACTOR }, - "followers", - announce, - { - excludeBaseUris, - preferSharedInbox: true, - }, - ); - }) - .on(Update, async (ctx, update) => { - const sender = await update.getActor(ctx); - const excludeBaseUris = sender?.id ? [new URL(sender.id)] : []; - - const announce = new Announce({ - id: new URL(`/announce#${crypto.randomUUID()}`, ctx.origin), - actor: ctx.getActorUri(RELAY_SERVER_ACTOR), - object: update.objectId, - to: PUBLIC_COLLECTION, - }); - - await ctx.sendActivity( - { identifier: RELAY_SERVER_ACTOR }, - "followers", - announce, - { - excludeBaseUris, - preferSharedInbox: true, - }, - ); - }) - .on(Move, async (ctx, move) => { - const sender = await move.getActor(ctx); - const excludeBaseUris = sender?.id ? [new URL(sender.id)] : []; - - const announce = new Announce({ - id: new URL(`/announce#${crypto.randomUUID()}`, ctx.origin), - actor: ctx.getActorUri(RELAY_SERVER_ACTOR), - object: move.objectId, - to: PUBLIC_COLLECTION, - }); - - await ctx.sendActivity( - { identifier: RELAY_SERVER_ACTOR }, - "followers", - announce, - { - excludeBaseUris, - preferSharedInbox: true, - }, - ); - }) - .on(Delete, async (ctx, deleteActivity) => { - const sender = await deleteActivity.getActor(ctx); - const excludeBaseUris = sender?.id ? [new URL(sender.id)] : []; - - const announce = new Announce({ - id: new URL(`/announce#${crypto.randomUUID()}`, ctx.origin), - actor: ctx.getActorUri(RELAY_SERVER_ACTOR), - object: deleteActivity.objectId, - to: PUBLIC_COLLECTION, - }); - - await ctx.sendActivity( - { identifier: RELAY_SERVER_ACTOR }, - "followers", - announce, - { - excludeBaseUris, - preferSharedInbox: true, - }, - ); - }) - .on(Announce, async (ctx, announceActivity) => { - const sender = await announceActivity.getActor(ctx); - const excludeBaseUris = sender?.id ? [new URL(sender.id)] : []; - - const announce = new Announce({ - id: new URL(`/announce#${crypto.randomUUID()}`, ctx.origin), - actor: ctx.getActorUri(RELAY_SERVER_ACTOR), - object: announceActivity.objectId, - to: PUBLIC_COLLECTION, - }); - - await ctx.sendActivity( - { identifier: RELAY_SERVER_ACTOR }, - "followers", - announce, - { - excludeBaseUris, - preferSharedInbox: true, - }, - ); - }); - } - - get domain(): string { - return this.#options.domain || "localhost"; - } - - fetch(request: Request): Promise { - return this.#federation.fetch(request, { contextData: undefined }); - } - - setSubscriptionHandler(handler: SubscriptionRequestHandler): this { - this.#subscriptionHandler = handler; - return this; - } -} diff --git a/packages/relay/src/types.ts b/packages/relay/src/types.ts new file mode 100644 index 000000000..aba752155 --- /dev/null +++ b/packages/relay/src/types.ts @@ -0,0 +1,39 @@ +import type { Context, KvStore, MessageQueue } from "@fedify/fedify"; +import type { Actor } from "@fedify/fedify/vocab"; +import type { + AuthenticatedDocumentLoaderFactory, + DocumentLoaderFactory, +} from "@fedify/vocab-runtime"; + +export const RELAY_SERVER_ACTOR = "relay"; + +/** + * Supported relay types. + */ +export type RelayType = "mastodon" | "litepub"; + +/** + * Handler for subscription requests (Follow/Undo activities). + */ +export type SubscriptionRequestHandler = ( + ctx: Context, + clientActor: Actor, +) => Promise; + +/** + * Configuration options for the ActivityPub relay. + */ +export interface RelayOptions { + kv: KvStore; + domain?: string; + name?: string; + documentLoaderFactory?: DocumentLoaderFactory; + authenticatedDocumentLoaderFactory?: AuthenticatedDocumentLoaderFactory; + queue?: MessageQueue; + subscriptionHandler?: SubscriptionRequestHandler; +} + +export interface RelayFollower { + readonly actor: unknown; + readonly state: "pending" | "accepted"; +} diff --git a/packages/relay/tsdown.config.ts b/packages/relay/tsdown.config.ts index 27a91233e..a89df939d 100644 --- a/packages/relay/tsdown.config.ts +++ b/packages/relay/tsdown.config.ts @@ -5,4 +5,16 @@ export default defineConfig({ dts: true, format: ["esm", "cjs"], platform: "node", + outputOptions(outputOptions, format) { + if (format === "cjs") { + outputOptions.intro = ` + const { Temporal } = require("@js-temporal/polyfill"); + `; + } else { + outputOptions.intro = ` + import { Temporal } from "@js-temporal/polyfill"; + `; + } + return outputOptions; + }, }); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 246666400..2e6572db5 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -278,7 +278,7 @@ importers: version: 1.6.3(patch_hash=56bd737eca4c1ba581d00bedd4fe307f6e48f782af59933eac54bb7d43206b99)(@algolia/client-search@5.29.0)(@types/node@22.19.1)(@types/react@18.3.23)(lightningcss@1.30.1)(postcss@8.5.6)(search-insights@2.17.3)(typescript@5.9.3) vitepress-plugin-group-icons: specifier: ^1.3.5 - version: 1.6.1(markdown-it@14.1.0)(vite@5.4.19(@types/node@22.19.1)(lightningcss@1.30.1)) + version: 1.6.1(markdown-it@14.1.0)(vite@7.1.3(@types/node@22.19.1)(jiti@2.5.1)(lightningcss@1.30.1)(tsx@4.20.3)(yaml@2.8.1)) vitepress-plugin-llms: specifier: ^1.1.0 version: 1.6.0 @@ -1101,6 +1101,12 @@ importers: '@fedify/fedify': specifier: workspace:^ version: link:../fedify + '@js-temporal/polyfill': + specifier: 'catalog:' + version: 0.5.1 + '@logtape/logtape': + specifier: 'catalog:' + version: 1.3.5 devDependencies: '@fedify/testing': specifier: workspace:^ @@ -6777,6 +6783,7 @@ packages: next@14.2.30: resolution: {integrity: sha512-+COdu6HQrHHFQ1S/8BBsCag61jZacmvbuL2avHvQFbWa2Ox7bE+d8FyNgxRLjXQ5wtPyQwEmk85js/AuaG2Sbg==} engines: {node: '>=18.17.0'} + deprecated: This version has a security vulnerability. Please upgrade to a patched version. See https://nextjs.org/blog/security-update-2025-12-11 for more details. hasBin: true peerDependencies: '@opentelemetry/api': ^1.1.0 @@ -11006,7 +11013,7 @@ snapshots: '@types/accepts@1.3.7': dependencies: - '@types/node': 24.3.0 + '@types/node': 22.19.1 '@types/amqplib@0.10.7': dependencies: @@ -11019,7 +11026,7 @@ snapshots: '@types/body-parser@1.19.6': dependencies: '@types/connect': 3.4.36 - '@types/node': 24.3.0 + '@types/node': 22.19.1 '@types/bun@1.2.17': dependencies: @@ -11031,7 +11038,7 @@ snapshots: '@types/connect@3.4.36': dependencies: - '@types/node': 24.3.0 + '@types/node': 22.19.1 '@types/content-disposition@0.5.9': {} @@ -11042,7 +11049,7 @@ snapshots: '@types/connect': 3.4.36 '@types/express': 4.17.23 '@types/keygrip': 1.0.6 - '@types/node': 24.3.0 + '@types/node': 22.19.1 '@types/d3-array@3.2.1': {} @@ -11174,7 +11181,7 @@ snapshots: '@types/express-serve-static-core@4.19.6': dependencies: - '@types/node': 24.3.0 + '@types/node': 22.19.1 '@types/qs': 6.14.0 '@types/range-parser': 1.2.7 '@types/send': 0.17.5 @@ -11238,11 +11245,11 @@ snapshots: '@types/mute-stream@0.0.4': dependencies: - '@types/node': 24.3.0 + '@types/node': 22.19.1 '@types/mysql@2.15.26': dependencies: - '@types/node': 24.3.0 + '@types/node': 22.19.1 '@types/node@16.9.1': {} @@ -11268,7 +11275,7 @@ snapshots: '@types/pg@8.6.1': dependencies: - '@types/node': 24.3.0 + '@types/node': 22.19.1 pg-protocol: 1.10.3 pg-types: 2.2.0 @@ -11298,19 +11305,19 @@ snapshots: '@types/send@0.17.5': dependencies: '@types/mime': 1.3.5 - '@types/node': 24.3.0 + '@types/node': 22.19.1 '@types/serve-static@1.15.8': dependencies: '@types/http-errors': 2.0.5 - '@types/node': 24.3.0 + '@types/node': 22.19.1 '@types/send': 0.17.5 '@types/shimmer@1.2.0': {} '@types/tedious@4.0.14': dependencies: - '@types/node': 24.3.0 + '@types/node': 22.19.1 '@types/trusted-types@2.0.7': optional: true @@ -12227,7 +12234,7 @@ snapshots: bun-types@1.2.17: dependencies: - '@types/node': 24.3.0 + '@types/node': 22.19.1 bun-types@1.2.19(@types/react@19.1.8): dependencies: @@ -12236,7 +12243,7 @@ snapshots: bun-types@1.3.3: dependencies: - '@types/node': 24.3.0 + '@types/node': 22.19.1 busboy@1.6.0: dependencies: @@ -15409,7 +15416,7 @@ snapshots: '@protobufjs/path': 1.1.2 '@protobufjs/pool': 1.1.0 '@protobufjs/utf8': 1.1.0 - '@types/node': 24.3.0 + '@types/node': 22.19.1 long: 5.3.2 proxy-addr@2.0.7: @@ -16590,6 +16597,22 @@ snapshots: fsevents: 2.3.3 lightningcss: 1.30.1 + vite@7.1.3(@types/node@22.19.1)(jiti@2.5.1)(lightningcss@1.30.1)(tsx@4.20.3)(yaml@2.8.1): + dependencies: + esbuild: 0.25.5 + fdir: 6.5.0(picomatch@4.0.3) + picomatch: 4.0.3 + postcss: 8.5.6 + rollup: 4.44.1 + tinyglobby: 0.2.14 + optionalDependencies: + '@types/node': 22.19.1 + fsevents: 2.3.3 + jiti: 2.5.1 + lightningcss: 1.30.1 + tsx: 4.20.3 + yaml: 2.8.1 + vite@7.1.3(@types/node@24.3.0)(jiti@2.5.1)(lightningcss@1.30.1)(tsx@4.20.3)(yaml@2.8.1): dependencies: esbuild: 0.25.5 @@ -16610,13 +16633,13 @@ snapshots: optionalDependencies: vite: 7.1.3(@types/node@24.3.0)(jiti@2.5.1)(lightningcss@1.30.1)(tsx@4.20.3)(yaml@2.8.1) - vitepress-plugin-group-icons@1.6.1(markdown-it@14.1.0)(vite@5.4.19(@types/node@22.19.1)(lightningcss@1.30.1)): + vitepress-plugin-group-icons@1.6.1(markdown-it@14.1.0)(vite@7.1.3(@types/node@22.19.1)(jiti@2.5.1)(lightningcss@1.30.1)(tsx@4.20.3)(yaml@2.8.1)): dependencies: '@iconify-json/logos': 1.2.4 '@iconify-json/vscode-icons': 1.2.23 '@iconify/utils': 2.3.0 markdown-it: 14.1.0 - vite: 5.4.19(@types/node@22.19.1)(lightningcss@1.30.1) + vite: 7.1.3(@types/node@22.19.1)(jiti@2.5.1)(lightningcss@1.30.1)(tsx@4.20.3)(yaml@2.8.1) transitivePeerDependencies: - supports-color