From a37366772abc6480783b5379c59f1c9e391b1a09 Mon Sep 17 00:00:00 2001 From: skulidropek <66840575+skulidropek@users.noreply.github.com> Date: Thu, 21 May 2026 08:16:48 +0000 Subject: [PATCH 1/5] fix(api): use Fedify for ActivityPub protocol documents --- bun.lock | 88 ++- packages/api/package.json | 2 + packages/api/src/api/activitypub-schema.ts | 223 +----- packages/api/src/api/contracts.ts | 32 +- packages/api/src/api/schema.ts | 15 +- packages/api/src/http.ts | 119 +-- packages/api/src/services/federation.ts | 106 +-- .../api/src/services/fedify-federation.ts | 247 ++++++ .../tests/activitypub-schema-parity.test.ts | 710 +++++------------- packages/api/tests/federation.test.ts | 40 +- packages/api/tests/http-config.test.ts | 85 ++- 11 files changed, 652 insertions(+), 1015 deletions(-) create mode 100644 packages/api/src/services/fedify-federation.ts diff --git a/bun.lock b/bun.lock index 670f464d..83310edd 100644 --- a/bun.lock +++ b/bun.lock @@ -18,6 +18,8 @@ "@effect/platform": "^0.96.1", "@effect/platform-node": "^0.106.0", "@effect/schema": "^0.75.5", + "@fedify/fedify": "^2.2.3", + "@fedify/vocab": "^2.2.3", "effect": "^3.21.2", "node-pty": "^1.1.0", "ws": "^8.20.1", @@ -38,7 +40,7 @@ }, "packages/app": { "name": "@prover-coder-ai/docker-git", - "version": "1.1.27", + "version": "1.1.31", "bin": { "docker-git": "dist/src/docker-git/main.js", }, @@ -108,7 +110,7 @@ }, "packages/docker-git-session-sync": { "name": "@prover-coder-ai/docker-git-session-sync", - "version": "1.0.30", + "version": "1.0.34", "bin": { "docker-git-session-sync": "dist/docker-git-session-sync.js", }, @@ -236,6 +238,8 @@ "@biomejs/cli-win32-x64": ["@biomejs/cli-win32-x64@2.4.15", "", { "os": "win32", "cpu": "x64" }, "sha512-zBrGq5mx5wwpnow4+2BxUvleDM+GNd4sLbPaMapsSLQLD0NGRCquqPBTgN+7XkUteHvj7M+BstuI8tmnV7+HgQ=="], + "@cfworker/json-schema": ["@cfworker/json-schema@4.1.1", "", {}, "sha512-gAmrUZSGtKc3AiBL71iNWxDsyUC5uMaKKGdvzYsBoTW/xi42JQHl7eKV2OYzCUqvc+D2RCcf7EXY2iCyFIk6og=="], + "@changesets/apply-release-plan": ["@changesets/apply-release-plan@7.1.1", "", { "dependencies": { "@changesets/config": "^3.1.4", "@changesets/get-version-range-type": "^0.4.0", "@changesets/git": "^3.0.4", "@changesets/should-skip-package": "^0.1.2", "@changesets/types": "^6.1.0", "@manypkg/get-packages": "^1.1.3", "detect-indent": "^6.0.0", "fs-extra": "^7.0.1", "lodash.startcase": "^4.4.0", "outdent": "^0.5.0", "prettier": "^2.7.1", "resolve-from": "^5.0.0", "semver": "^7.5.3" } }, "sha512-9qPCm/rLx/xoOFXIHGB229+4GOL76S4MC+7tyOuTsR6+1jYlfFDQORdvwR5hDA6y4FL2BPt3qpbcQIS+dW85LA=="], "@changesets/assemble-release-plan": ["@changesets/assemble-release-plan@6.0.10", "", { "dependencies": { "@changesets/errors": "^0.2.0", "@changesets/get-dependents-graph": "^2.1.4", "@changesets/should-skip-package": "^0.1.2", "@changesets/types": "^6.1.0", "@manypkg/get-packages": "^1.1.3", "semver": "^7.5.3" } }, "sha512-rSDcqdJ9KbVyjpBIuCidhvZNIiVt1XaIYp73ycVQRIA5n/j6wQaEk0ChRLMUQ1vkxZe51PTQ9OIhbg6HQMW45A=="], @@ -276,6 +280,8 @@ "@colors/colors": ["@colors/colors@1.5.0", "", {}, "sha512-ooWCrlZP11i8GImSjTHYHLkvFDP48nS4+204nGb1RiX/WXYHmJA2III9/e2DWVabCESdW7hBAEzHRqUn9OUVvQ=="], + "@digitalbazaar/http-client": ["@digitalbazaar/http-client@4.3.0", "", { "dependencies": { "ky": "^1.14.2", "undici": "^6.23.0" } }, "sha512-6lMpxpt9BOmqHKGs9Xm6DP4LlZTBFer/ZjHvP3FcW3IaUWYIWC7dw5RFZnvw4fP57kAVcm1dp3IF+Y50qhBvAw=="], + "@dprint/formatter": ["@dprint/formatter@0.4.1", "", {}, "sha512-IB/GXdlMOvi0UhQQ9mcY15Fxcrc2JPadmo6tqefCNV0bptFq7YBpggzpqYXldBXDa04CbKJ+rDwO2eNRPE2+/g=="], "@dprint/typescript": ["@dprint/typescript@0.91.8", "", {}, "sha512-tuKn4leCPItox1O4uunHcQF0QllDCvPWklnNQIh2PiWWVtRAGltJJnM4Cwj5AciplosD1Hiz7vAY3ew3crLb3A=="], @@ -292,7 +298,7 @@ "@effect/experimental": ["@effect/experimental@0.60.0", "", { "dependencies": { "uuid": "11.1.0" }, "peerDependencies": { "@effect/platform": "0.96.0", "effect": "3.21.0" } }, "sha512-i5zIg7Xup2KgHyqHlYtkgqSE1bNzCL0GbbTQxrpIzKF0q/ebknOk/ox8B/gIq2vImjoEE81h/oxU+6i1NH210g=="], - "@effect/language-service": ["@effect/language-service@0.86.1", "", { "bin": { "effect-language-service": "cli.js" } }, "sha512-jfUuFdtNPKRZ4ZSrgTnfMHPIVq8L9cM+YGEylQ1lhIgNY23ZvfdPg0cUlWESd4q9aGvOWSA8E79AS0qe3j4ROw=="], + "@effect/language-service": ["@effect/language-service@0.86.2", "", { "bin": { "effect-language-service": "cli.js" } }, "sha512-SaPln+8srOqDJDUwNTDmP5e+IYpEDr9+1epGznnsLqu8xvo6VnxyWARdeLpqvZJlb0Pgy9ca7ppqvvdWbHPXAg=="], "@effect/platform": ["@effect/platform@0.96.1", "", { "dependencies": { "find-my-way-ts": "^0.1.6", "msgpackr": "^1.11.10", "multipasta": "^0.2.7" }, "peerDependencies": { "effect": "^3.21.2" } }, "sha512-cjB1QZZYEP8JXCFNGvBLVi0T6YUBQTmOVEUA3SDbiQ6RUO+p6CE3eyD2vMWmrz5nE8yY5QSAuOV9v0boEcUv+A=="], @@ -396,6 +402,16 @@ "@eslint/plugin-kit": ["@eslint/plugin-kit@0.7.1", "", { "dependencies": { "@eslint/core": "^1.2.1", "levn": "^0.4.1" } }, "sha512-rZAP3aVgB9ds9KOeUSL+zZ21hPmo8dh6fnIFwRQj5EAZl9gzR7wxYbYXYysAM8CTqGmUGyp2S4kUdV17MnGuWQ=="], + "@fedify/fedify": ["@fedify/fedify@2.2.3", "", { "dependencies": { "@fedify/vocab": "2.2.3", "@fedify/vocab-runtime": "2.2.3", "@fedify/webfinger": "2.2.3", "@js-temporal/polyfill": "^0.5.1", "@logtape/logtape": "^2.0.5", "@opentelemetry/api": "^1.9.0", "@opentelemetry/core": "^2.5.0", "@opentelemetry/sdk-trace-base": "^2.5.0", "@opentelemetry/semantic-conventions": "^1.39.0", "byte-encodings": "^1.0.11", "es-toolkit": "1.43.0", "json-canon": "^1.0.1", "jsonld": "^9.0.0", "structured-field-values": "^2.0.4", "uri-template-router": "^1.0.0", "url-template": "^3.1.1", "urlpattern-polyfill": "^10.1.0" } }, "sha512-mEczGCZVZsgQQj8gCKZizOJsOuV3kOBpMVPJrhmOkE+RKh8XjnZwtzPwN2UWp4Yt2E9YsKg3fSxNJmJW/o4uDQ=="], + + "@fedify/vocab": ["@fedify/vocab@2.2.3", "", { "dependencies": { "@fedify/vocab-runtime": "2.2.3", "@fedify/vocab-tools": "2.2.3", "@fedify/webfinger": "2.2.3", "@js-temporal/polyfill": "^0.5.1", "@logtape/logtape": "^2.0.5", "@multiformats/base-x": "^4.0.1", "@opentelemetry/api": "^1.9.0", "asn1js": "^3.0.6", "es-toolkit": "1.43.0", "jsonld": "^9.0.0", "pkijs": "^3.3.3" } }, "sha512-a5llEaCj3f9UGIY+t1PadGdfhMt0tY1TnpMTtx2Ulw+U8ONKAbRTz9Wnq2QWAF7Yp7JOqyy/EluZ2w/AIUNSGw=="], + + "@fedify/vocab-runtime": ["@fedify/vocab-runtime@2.2.3", "", { "dependencies": { "@js-temporal/polyfill": "^0.5.1", "@logtape/logtape": "^2.0.5", "@multiformats/base-x": "^4.0.1", "@opentelemetry/api": "^1.9.0", "asn1js": "^3.0.6", "byte-encodings": "^1.0.11", "jsonld": "^9.0.0", "pkijs": "^3.3.3" } }, "sha512-MDAW28KooWhj9z3HQY3nDhVJ15HkgD4TOCK8vHCoARy1Q4HmTUGXZZzUdGiSuefQkm5mW7VckImCDNj05dIAPA=="], + + "@fedify/vocab-tools": ["@fedify/vocab-tools@2.2.3", "", { "dependencies": { "@cfworker/json-schema": "^4.1.1", "byte-encodings": "^1.0.11", "es-toolkit": "^1.39.10", "yaml": "^2.8.1" } }, "sha512-zmaRKeJk+7wC7kgiiQNX6PpGgNaQTwjEBCREqXdZuY0Zw2lYeoyzL3F8PEIttOUIxdBdBxGOC9/HE6AVMndN6Q=="], + + "@fedify/webfinger": ["@fedify/webfinger@2.2.3", "", { "dependencies": { "@fedify/vocab-runtime": "2.2.3", "@logtape/logtape": "^2.0.5", "@opentelemetry/api": "^1.9.0", "es-toolkit": "1.43.0" } }, "sha512-WJQw0cKkSfK4IbdPc+z9ODspYDO+t0q5dIRo7eR/oO31yqVGjwLYJsDL0dlyXQ0WF/Q+Qrfw2fQOvTs9Xarc9g=="], + "@gridland/bun": ["@gridland/bun@0.4.3", "", { "dependencies": { "@gridland/utils": "0.4.3", "react": "^19.0.0", "react-reconciler": "0.33.0", "yoga-layout": "^3.2.1" }, "optionalDependencies": { "@opentui/core-darwin-arm64": "0.1.86", "@opentui/core-darwin-x64": "0.1.86", "@opentui/core-linux-arm64": "0.1.86", "@opentui/core-linux-x64": "0.1.86" } }, "sha512-pNOK9ncUJKLDiR84E61qHFRW1SG6IrmZNuSRjSmWgzS4rzXhgCPUesQOKz0dBk7dk8c1qcilyV2kEvUD9f1QnA=="], "@gridland/utils": ["@gridland/utils@0.4.3", "", { "dependencies": { "react": "^19.0.0" } }, "sha512-FPBw1dPPWyFXpSG/ygsZExc6c0u35HnfXnRpZz+ZUDyezcVDaMIDpdbDlOccmLSt33qGVOTh+pEx9HuS0061vA=="], @@ -430,6 +446,8 @@ "@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "3.1.2", "@jridgewell/sourcemap-codec": "1.5.5" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="], + "@js-temporal/polyfill": ["@js-temporal/polyfill@0.5.1", "", { "dependencies": { "jsbi": "^4.3.0" } }, "sha512-hloP58zRVCRSpgDxmqCWJNlizAlUgJFqG2ypq79DCvyv9tHjRYMDOcPFjzfl/A1/YxDvRCZz8wvZvmapQnKwFQ=="], + "@jscpd/badge-reporter": ["@jscpd/badge-reporter@4.2.3", "", { "dependencies": { "badgen": "^3.2.3", "colors": "^1.4.0", "fs-extra": "^11.2.0" } }, "sha512-yNvbwWl/NwogHT5XrHyqXgF9yVZeLWA2QOhGqYTopvgi7LsSbDumpOqOcJMHP9Z4RalhMfahh+dVXFSI7tMcaA=="], "@jscpd/core": ["@jscpd/core@4.2.3", "", { "dependencies": { "eventemitter3": "^5.0.1" } }, "sha512-VQ2gH+tiI51ty3PBRD4HClNNgyX/VH9cs0dcFKuywxDzLQ64jYp7vhJPcqnyiVX9tVEIAa12sucRHQP/VHwugA=="], @@ -440,6 +458,8 @@ "@jscpd/tokenizer": ["@jscpd/tokenizer@4.2.3", "", { "dependencies": { "@jscpd/core": "4.2.3", "spark-md5": "^3.0.2" } }, "sha512-RvjD7/hwqtcQC9MWOl31odTti6kGCFxZ77DKEhwyMn+r6oVEUFbXgcGvzn0GC/wuTl7f3j5MF9JNMeTneOFwYA=="], + "@logtape/logtape": ["@logtape/logtape@2.1.1", "", {}, "sha512-aULbCqUQGerfOsZ3CMvcKtueKzmdchluXYUd3bIHKmOIS93fx1ko0+hyRQ4flloGZ8EiyRPydZXiy8n1J/eAQA=="], + "@manypkg/find-root": ["@manypkg/find-root@1.1.0", "", { "dependencies": { "@babel/runtime": "7.28.4", "@types/node": "12.20.55", "find-up": "4.1.0", "fs-extra": "8.1.0" } }, "sha512-mki5uBvhHzO8kYYix/WRy2WX8S3B5wdVSc9D6KcU5lQNglP2yt58/VfLuAK49glRXChosY8ap2oJ1qgma3GUVA=="], "@manypkg/get-packages": ["@manypkg/get-packages@1.1.3", "", { "dependencies": { "@babel/runtime": "7.28.4", "@changesets/types": "4.1.0", "@manypkg/find-root": "1.1.0", "fs-extra": "8.1.0", "globby": "11.1.0", "read-yaml-file": "1.1.0" } }, "sha512-fo+QhuU3qE/2TQMQmbVMqaQ6EWbMhi4ABWP+O4AM1NqPBuy0OrApV5LO6BrrgnhtAHS2NH6RrVk9OL181tTi8A=="], @@ -456,14 +476,28 @@ "@msgpackr-extract/msgpackr-extract-win32-x64": ["@msgpackr-extract/msgpackr-extract-win32-x64@3.0.3", "", { "os": "win32", "cpu": "x64" }, "sha512-x0fWaQtYp4E6sktbsdAqnehxDgEc/VwM7uLsRCYWaiGu0ykYdZPiS8zCWdnjHwyiumousxfBm4SO31eXqwEZhQ=="], + "@multiformats/base-x": ["@multiformats/base-x@4.0.1", "", {}, "sha512-eMk0b9ReBbV23xXU693TAIrLyeO5iTgBZGSJfpqriG8UkYvr/hC9u9pyMlAakDNHWmbhMZCDs6KQO0jzKD8OTw=="], + "@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@0.2.12", "", { "dependencies": { "@emnapi/core": "1.7.1", "@emnapi/runtime": "1.7.1", "@tybys/wasm-util": "0.10.1" } }, "sha512-ZVWUcfwY4E/yPitQJl481FjFo3K22D6qF0DuFH6Y/nbnE11GY5uguDxZMGXPQ8WQ0128MXQD7TnfHyK4oWoIJQ=="], + "@noble/hashes": ["@noble/hashes@1.4.0", "", {}, "sha512-V1JJ1WTRUqHHrOSh597hURcMqVKVGL/ea3kv0gSnEdsEZ0/+VyPghM1lMNGc00z7CIQorSvbKpuJkxvuHbvdbg=="], + "@nodelib/fs.scandir": ["@nodelib/fs.scandir@2.1.5", "", { "dependencies": { "@nodelib/fs.stat": "2.0.5", "run-parallel": "1.2.0" } }, "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g=="], "@nodelib/fs.stat": ["@nodelib/fs.stat@2.0.5", "", {}, "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A=="], "@nodelib/fs.walk": ["@nodelib/fs.walk@1.2.8", "", { "dependencies": { "@nodelib/fs.scandir": "2.1.5", "fastq": "1.19.1" } }, "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg=="], + "@opentelemetry/api": ["@opentelemetry/api@1.9.1", "", {}, "sha512-gLyJlPHPZYdAk1JENA9LeHejZe1Ti77/pTeFm/nMXmQH/HFZlcS/O2XJB+L8fkbrNSqhdtlvjBVjxwUYanNH5Q=="], + + "@opentelemetry/core": ["@opentelemetry/core@2.7.1", "", { "dependencies": { "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.0.0 <1.10.0" } }, "sha512-QAqIj32AtK6+pEVNG7EOVxHdE06RP+FM5qpiEJ4RtDcFIqKUZHYhl7/7UY5efhwmwNAg7j8QbJVBLxMerc0+gw=="], + + "@opentelemetry/resources": ["@opentelemetry/resources@2.7.1", "", { "dependencies": { "@opentelemetry/core": "2.7.1", "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.3.0 <1.10.0" } }, "sha512-DeT6KKolmC4e/dRQvMQ/RwlnzhaqeiFOXY5ngoOPJ07GgVVKxZOg9EcrNZb5aTzUn+iCrJldAgOfQm1O/QfPAQ=="], + + "@opentelemetry/sdk-trace-base": ["@opentelemetry/sdk-trace-base@2.7.1", "", { "dependencies": { "@opentelemetry/core": "2.7.1", "@opentelemetry/resources": "2.7.1", "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.3.0 <1.10.0" } }, "sha512-NAYIlsF8MPUsKqJMiDQJTMPOmlbawC1Iz/omMLygZ1C9am8fTKYjTaI+OZM+WTY3t3Glo0wnOg/6/pac6RGPPw=="], + + "@opentelemetry/semantic-conventions": ["@opentelemetry/semantic-conventions@1.41.1", "", {}, "sha512-/UhIkaZgPutTFmQ7RnIJGgDXZmtEJ7Dvi86xNTFWcnRxVRNk/aotsqDJYeEvDP+FSMB2SdW+pQzNMcWP0rwuNA=="], + "@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.1.86", "", { "os": "darwin", "cpu": "arm64" }, "sha512-Zp7q64+d+Dcx6YrH3mRcnHq8EOBnrfc1RvjgSWLhpXr49hY6LzuhqpfZM57aGErPYlR+ff8QM6e5FUkFnDfyjw=="], "@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.1.86", "", { "os": "darwin", "cpu": "x64" }, "sha512-NcxfjCJm1kLnTMVOpAPdRYNi8W8XdAXNa6N7i9khiVFrl2v5KRQfUjbrSOUYVxFJNc3jKFG6rsn3jEApvn92qA=="], @@ -726,6 +760,8 @@ "asap": ["asap@2.0.6", "", {}, "sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA=="], + "asn1js": ["asn1js@3.0.10", "", { "dependencies": { "pvtsutils": "^1.3.6", "pvutils": "^1.1.5", "tslib": "^2.8.1" } }, "sha512-S2s3aOytiKdFRdulw2qPE51MzjzVOisppcVv7jVFR+Kw0kxwvFrDcYA0h7Ndqbmj0HkMIXYWaoj7fli8kgx1eg=="], + "assert-never": ["assert-never@1.4.0", "", {}, "sha512-5oJg84os6NMQNl27T9LnZkvvqzvAnHu03ShCnoj6bsJwS7L8AO4lf+C/XjK/nvzEqQB744moC6V128RucQd1jA=="], "assertion-error": ["assertion-error@2.0.1", "", {}, "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA=="], @@ -762,8 +798,12 @@ "builtin-modules": ["builtin-modules@3.3.0", "", {}, "sha512-zhaCDicdLuWN5UbN5IMnFqNMhNfo919sH85y2/ea+5Yg9TsTkeZxpL+JLbp6cgYFS4sRLp3YV4S6yDuqVWHYOw=="], + "byte-encodings": ["byte-encodings@1.0.11", "", {}, "sha512-+/xR2+ySc2yKGtud3DGkGSH1DNwHfRVK0KTnMhoeH36/KwG+tHQ4d9B3jxJFq7dW27YcfudkywaYJRPA2dmxzg=="], + "bytes": ["bytes@3.1.2", "", {}, "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg=="], + "bytestreamjs": ["bytestreamjs@2.0.1", "", {}, "sha512-U1Z/ob71V/bXfVABvNr/Kumf5VyeQRBEm6Txb0PQ6S7V5GpBM3w4Cbqz/xPDicR5tN0uvDifng8C+5qECeGwyQ=="], + "call-bind": ["call-bind@1.0.8", "", { "dependencies": { "call-bind-apply-helpers": "1.0.2", "es-define-property": "1.0.1", "get-intrinsic": "1.3.0", "set-function-length": "1.2.2" } }, "sha512-oKlSFMcMwpUg2ednkhQ454wfWiU/ul3CkJe/PEHcTKuiX6RpbehUiFMXu13HalGZxfUwCQzZG747YXBn1im9ww=="], "call-bind-apply-helpers": ["call-bind-apply-helpers@1.0.2", "", { "dependencies": { "es-errors": "1.3.0", "function-bind": "1.1.2" } }, "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ=="], @@ -774,6 +814,8 @@ "caniuse-lite": ["caniuse-lite@1.0.30001792", "", {}, "sha512-hVLMUZFgR4JJ6ACt1uEESvQN1/dBVqPAKY0hgrV70eN3391K6juAfTjKZLKvOMsx8PxA7gsY1/tLMMTcfFLLpw=="], + "canonicalize": ["canonicalize@2.1.0", "", { "bin": { "canonicalize": "bin/canonicalize.js" } }, "sha512-F705O3xrsUtgt98j7leetNhTWPe+5S72rlL5O4jA1pKqBVQ/dT1O1D6PFxmSXvc0SUOinWS57DKx0I3CHrXJHQ=="], + "chai": ["chai@6.2.2", "", {}, "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg=="], "chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "4.3.0", "supports-color": "7.2.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="], @@ -904,6 +946,8 @@ "es-to-primitive": ["es-to-primitive@1.3.0", "", { "dependencies": { "is-callable": "1.2.7", "is-date-object": "1.1.0", "is-symbol": "1.1.1" } }, "sha512-w+5mJ3GuFL+NjVtJlvydShqE1eN3h3PbI7/5LAsYJP/2qtuMXjfL2LpHSRqo4b4eSF5K/DH1JXKUAHSB2UW50g=="], + "es-toolkit": ["es-toolkit@1.43.0", "", {}, "sha512-SKCT8AsWvYzBBuUqMk4NPwFlSdqLpJwmy6AP322ERn8W2YLIB6JBXnwMI2Qsh2gfphT3q7EKAxKb23cvFHFwKA=="], + "esbuild": ["esbuild@0.27.2", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.27.2", "@esbuild/android-arm": "0.27.2", "@esbuild/android-arm64": "0.27.2", "@esbuild/android-x64": "0.27.2", "@esbuild/darwin-arm64": "0.27.2", "@esbuild/darwin-x64": "0.27.2", "@esbuild/freebsd-arm64": "0.27.2", "@esbuild/freebsd-x64": "0.27.2", "@esbuild/linux-arm": "0.27.2", "@esbuild/linux-arm64": "0.27.2", "@esbuild/linux-ia32": "0.27.2", "@esbuild/linux-loong64": "0.27.2", "@esbuild/linux-mips64el": "0.27.2", "@esbuild/linux-ppc64": "0.27.2", "@esbuild/linux-riscv64": "0.27.2", "@esbuild/linux-s390x": "0.27.2", "@esbuild/linux-x64": "0.27.2", "@esbuild/netbsd-arm64": "0.27.2", "@esbuild/netbsd-x64": "0.27.2", "@esbuild/openbsd-arm64": "0.27.2", "@esbuild/openbsd-x64": "0.27.2", "@esbuild/openharmony-arm64": "0.27.2", "@esbuild/sunos-x64": "0.27.2", "@esbuild/win32-arm64": "0.27.2", "@esbuild/win32-ia32": "0.27.2", "@esbuild/win32-x64": "0.27.2" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-HyNQImnsOC7X9PMNaCIeAm4ISCQXs5a5YasTXVliKv4uuBo1dKrG0A+uQS8M5eXjVMnLg3WgXaKvprHlFJQffw=="], "escalade": ["escalade@3.2.0", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="], @@ -1192,6 +1236,8 @@ "js-yaml": ["js-yaml@4.1.1", "", { "dependencies": { "argparse": "2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA=="], + "jsbi": ["jsbi@4.3.2", "", {}, "sha512-9fqMSQbhJykSeii05nxKl4m6Eqn2P6rOlYiS+C5Dr/HPIU/7yZxu5qzbs40tgaFORiw2Amd0mirjxatXYMkIew=="], + "jscpd": ["jscpd@4.2.3", "", { "dependencies": { "@jscpd/badge-reporter": "4.2.3", "@jscpd/core": "4.2.3", "@jscpd/finder": "4.2.3", "@jscpd/html-reporter": "4.2.3", "@jscpd/tokenizer": "4.2.3", "colors": "^1.4.0", "commander": "^5.0.0", "fs-extra": "^11.2.0", "jscpd-sarif-reporter": "4.2.3" }, "bin": { "jscpd": "bin/jscpd" } }, "sha512-/1BEga1E1cY56/sdQOzU/PFtnea+n1beqG8/Xx4HopG9c5rkUO8ptnu9En8Xf1ILGW6KSWidV4vLQTm2FGYvpw=="], "jscpd-sarif-reporter": ["jscpd-sarif-reporter@4.0.5", "", { "dependencies": { "colors": "1.4.0", "fs-extra": "11.3.2", "node-sarif-builder": "3.4.0" } }, "sha512-cD1MtUdpomUPM5C0YD0vKZmdj+Gyr0KD5Bk47yGMrPCtwtgsK+7v59OzBIUjYOL8AuxNAt6hvPFo0PH+PYJh0Q=="], @@ -1200,6 +1246,8 @@ "json-buffer": ["json-buffer@3.0.1", "", {}, "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ=="], + "json-canon": ["json-canon@1.0.1", "", {}, "sha512-PQcj4PFOTAQxE8PgoQ4KrM0DcKWZd7S3ELOON8rmysl9I8JuFMgxu1H9v+oZsTPjjkpeS3IHPwLjr7d+gKygnw=="], + "json-parse-even-better-errors": ["json-parse-even-better-errors@2.3.1", "", {}, "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w=="], "json-schema-traverse": ["json-schema-traverse@0.4.1", "", {}, "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg=="], @@ -1210,6 +1258,8 @@ "jsonfile": ["jsonfile@4.0.0", "", { "optionalDependencies": { "graceful-fs": "4.2.11" } }, "sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg=="], + "jsonld": ["jsonld@9.0.0", "", { "dependencies": { "@digitalbazaar/http-client": "^4.2.0", "canonicalize": "^2.1.0", "lru-cache": "^6.0.0", "rdf-canonize": "^5.0.0" } }, "sha512-pjMIdkXfC1T2wrX9B9i2uXhGdyCmgec3qgMht+TDj+S0qX3bjWMQUfL7NeqEhuRTi8G5ESzmL9uGlST7nzSEWg=="], + "jstransformer": ["jstransformer@1.0.0", "", { "dependencies": { "is-promise": "2.2.2", "promise": "7.3.1" } }, "sha512-C9YK3Rf8q6VAPDCCU9fnqo3mAfOH6vUGnMcP4AQAYIEpWtfGLpwOTmZ+igtdK5y+VvI2n3CyYSzy4Qh34eq24A=="], "jsx-ast-utils-x": ["jsx-ast-utils-x@0.1.0", "", {}, "sha512-eQQBjBnsVtGacsG9uJNB8qOr3yA8rga4wAaGG1qRcBzSIvfhERLrWxMAM1hp5fcS6Abo8M4+bUBTekYR0qTPQw=="], @@ -1218,6 +1268,8 @@ "kubernetes-types": ["kubernetes-types@1.30.0", "", {}, "sha512-Dew1okvhM/SQcIa2rcgujNndZwU8VnSapDgdxlYoB84ZlpAD43U6KLAFqYo17ykSFGHNPrg0qry0bP+GJd9v7Q=="], + "ky": ["ky@1.14.3", "", {}, "sha512-9zy9lkjac+TR1c2tG+mkNSVlyOpInnWdSMiue4F+kq8TwJSgv6o8jhLRg8Ho6SnZ9wOYUq/yozts9qQCfk7bIw=="], + "levn": ["levn@0.4.1", "", { "dependencies": { "prelude-ls": "1.2.1", "type-check": "0.4.0" } }, "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ=="], "lightningcss": ["lightningcss@1.32.0", "", { "dependencies": { "detect-libc": "2.1.2" }, "optionalDependencies": { "lightningcss-android-arm64": "1.32.0", "lightningcss-darwin-arm64": "1.32.0", "lightningcss-darwin-x64": "1.32.0", "lightningcss-freebsd-x64": "1.32.0", "lightningcss-linux-arm-gnueabihf": "1.32.0", "lightningcss-linux-arm64-gnu": "1.32.0", "lightningcss-linux-arm64-musl": "1.32.0", "lightningcss-linux-x64-gnu": "1.32.0", "lightningcss-linux-x64-musl": "1.32.0", "lightningcss-win32-arm64-msvc": "1.32.0", "lightningcss-win32-x64-msvc": "1.32.0" } }, "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ=="], @@ -1256,7 +1308,7 @@ "loop-controls": ["loop-controls@1.1.0", "", {}, "sha512-otnxF3ngIuLecg99p7On7nJF6ws1mT2kNOiGOPFykEHQfhJtdsjcQMxM4LEHsUi3LeMrm2Ic0hFdykJcG0N1YQ=="], - "lru-cache": ["lru-cache@5.1.1", "", { "dependencies": { "yallist": "3.1.1" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="], + "lru-cache": ["lru-cache@6.0.0", "", { "dependencies": { "yallist": "^4.0.0" } }, "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA=="], "magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="], @@ -1400,6 +1452,8 @@ "pify": ["pify@4.0.1", "", {}, "sha512-uB80kBFb/tfd68bVleG9T5GGsGPjJrLAUpR5PZIrhBnIaRTQRjqdJSsIKkOP6OAIFbj7GOrcudc5pNjZ+geV2g=="], + "pkijs": ["pkijs@3.4.0", "", { "dependencies": { "@noble/hashes": "1.4.0", "asn1js": "^3.0.6", "bytestreamjs": "^2.0.1", "pvtsutils": "^1.3.6", "pvutils": "^1.1.3", "tslib": "^2.8.1" } }, "sha512-emEcLuomt2j03vxD54giVB4SxTjnsqkU692xZOZXHDVoYyypEm+b3jpiTcc+Cf+myooc+/Ly0z01jqeNHVgJGw=="], + "pluralize": ["pluralize@8.0.0", "", {}, "sha512-Nc3IT5yHzflTfbjgqWcCPpo7DaKy4FnpB0l/zCAW0Tc7jxAiuqSxHasntB3D7887LSrA93kDJ9IXovxJYxyLCA=="], "possible-typed-array-names": ["possible-typed-array-names@1.1.0", "", {}, "sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg=="], @@ -1446,10 +1500,16 @@ "pure-rand": ["pure-rand@8.4.0", "", {}, "sha512-IoM8YF/jY0hiugFo/wOWqfmarlE6J0wc6fDK1PhftMk7MGhVZl88sZimmqBBFomLOCSmcCCpsfj7wXASCpvK9A=="], + "pvtsutils": ["pvtsutils@1.3.6", "", { "dependencies": { "tslib": "^2.8.1" } }, "sha512-PLgQXQ6H2FWCaeRak8vvk1GW462lMxB5s3Jm673N82zI4vqtVUPuZdffdZbPDFRoU8kAhItWFtPCWiPpp4/EDg=="], + + "pvutils": ["pvutils@1.1.5", "", {}, "sha512-KTqnxsgGiQ6ZAzZCVlJH5eOjSnvlyEgx1m8bkRJfOhmGRqfo5KLvmAlACQkrjEtOQ4B7wF9TdSLIs9O90MX9xA=="], + "quansync": ["quansync@0.2.11", "", {}, "sha512-AifT7QEbW9Nri4tAwR5M/uzpBuqfZf+zwaEM/QkzEjj7NBuFD2rBuy0K3dE+8wltbezDV7JMA0WfnCPYRSYbXA=="], "queue-microtask": ["queue-microtask@1.2.3", "", {}, "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A=="], + "rdf-canonize": ["rdf-canonize@5.0.0", "", { "dependencies": { "setimmediate": "^1.0.5" } }, "sha512-g8OUrgMXAR9ys/ZuJVfBr05sPPoMA7nHIVs8VEvg9QwM5W4GR2qSFEEHjsyHF1eWlBaf8Ev40WNjQFQ+nJTO3w=="], + "react": ["react@19.2.6", "", {}, "sha512-sfWGGfavi0xr8Pg0sVsyHMAOziVYKgPLNrS7ig+ivMNb3wbCBw3KxtflsGBAwD3gYQlE/AEZsTLgToRrSCjb0Q=="], "react-dom": ["react-dom@19.2.6", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.6" } }, "sha512-0prMI+hvBbPjsWnxDLxlCGyM8PN6UuWjEUCYmZhO67xIV9Xasa/r/vDnq+Xyq4Lo27g8QSbO5YzARu0D1Sps3g=="], @@ -1518,6 +1578,8 @@ "set-proto": ["set-proto@1.0.0", "", { "dependencies": { "dunder-proto": "1.0.1", "es-errors": "1.3.0", "es-object-atoms": "1.1.1" } }, "sha512-RJRdvCo6IAnPdsvP/7m6bsQqNnn1FCBX5ZNtFL98MmFF/4xAIJTIg1YbHW5DC2W5SKZanrC6i4HsJqlajw/dZw=="], + "setimmediate": ["setimmediate@1.0.5", "", {}, "sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA=="], + "shebang-command": ["shebang-command@2.0.0", "", { "dependencies": { "shebang-regex": "3.0.0" } }, "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA=="], "shebang-regex": ["shebang-regex@3.0.0", "", {}, "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A=="], @@ -1586,6 +1648,8 @@ "strip-json-comments": ["strip-json-comments@3.1.1", "", {}, "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig=="], + "structured-field-values": ["structured-field-values@2.0.4", "", {}, "sha512-5zpJXYLPwW3WYUD/D58tQjIBs10l3Yx64jZfcKGs/RH79E2t9Xm/b9+ydwdMNVSksnsIY+HR/2IlQmgo0AcTAg=="], + "supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="], "supports-preserve-symlinks-flag": ["supports-preserve-symlinks-flag@1.0.0", "", {}, "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w=="], @@ -1654,6 +1718,12 @@ "uri-js": ["uri-js@4.4.1", "", { "dependencies": { "punycode": "2.3.1" } }, "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg=="], + "uri-template-router": ["uri-template-router@1.0.0", "", {}, "sha512-WKcL9ZSIEhHE3f5P4Z47Tf0nWbcgV1ISb/OBuF8YKEYi0SQOyTLCzM6B/gAKFWZhRhqA+C/Ks8UXe2qU5W0FVg=="], + + "url-template": ["url-template@3.1.1", "", {}, "sha512-4oszoaEKE/mQOtAmdMWqIRHmkxWkUZMnXFnjQ5i01CuRSK3uluxcH1MRVVVWmhlnzT1SCDfKxxficm2G37qzCA=="], + + "urlpattern-polyfill": ["urlpattern-polyfill@10.1.0", "", {}, "sha512-IGjKp/o0NL3Bso1PymYURCJxMPNAf/ILOpendP9f5B6e1rTJgdgiOvgfoT8VxCAdY+Wisb9uhGaJJf3yZ2V9nw=="], + "uuid": ["uuid@11.1.0", "", { "bin": { "uuid": "dist/esm/bin/uuid" } }, "sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A=="], "validate-npm-package-license": ["validate-npm-package-license@3.0.4", "", { "dependencies": { "spdx-correct": "3.2.0", "spdx-expression-parse": "3.0.1" } }, "sha512-DpKm2Ui/xN7/HQKCtpZxoRWBhZ9Z0kqtygG8XCgNQ8ZlDnxuQmWhj566j8fN4Cu3/JmbhsDo7fcAJq4s9h27Ew=="], @@ -1700,7 +1770,7 @@ "xterm-addon-fit": ["xterm-addon-fit@0.8.0", "", { "peerDependencies": { "xterm": "5.3.0" } }, "sha512-yj3Np7XlvxxhYF/EJ7p3KHaMt6OdwQ+HDu573Vx1lRXsVxOcnVJs51RgjZOouIZOczTsskaS+CpXspK81/DLqw=="], - "yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="], + "yallist": ["yallist@4.0.0", "", {}, "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A=="], "yaml": ["yaml@2.8.2", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A=="], @@ -1718,8 +1788,12 @@ "@babel/helper-compilation-targets/browserslist": ["browserslist@4.28.0", "", { "dependencies": { "baseline-browser-mapping": "2.8.32", "caniuse-lite": "1.0.30001759", "electron-to-chromium": "1.5.263", "node-releases": "2.0.27", "update-browserslist-db": "1.1.4" }, "bin": { "browserslist": "cli.js" } }, "sha512-tbydkR/CxfMwelN0vwdP/pLkDwyAASZ+VfWm4EOwlB6SWhx1sYnWLqo8N5j0rAzPfzfRaxt0mM/4wPU/Su84RQ=="], + "@babel/helper-compilation-targets/lru-cache": ["lru-cache@5.1.1", "", { "dependencies": { "yallist": "3.1.1" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="], + "@babel/helper-compilation-targets/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], + "@digitalbazaar/http-client/undici": ["undici@6.25.0", "", {}, "sha512-ZgpWDC5gmNiuY9CnLVXEH8rl50xhRCuLNA97fAUnKi8RRuV4E6KG31pDTsLVUKnohJE0I3XDrTeEydAXRw47xg=="], + "@effect/experimental/@effect/platform": ["@effect/platform@0.96.0", "", { "dependencies": { "find-my-way-ts": "0.1.6", "msgpackr": "1.11.5", "multipasta": "0.2.7" }, "peerDependencies": { "effect": "3.21.0" } }, "sha512-U7PLhkVzg7zzrgFvyWATOzD6reL87KG/fcdOxgLWBQ/J5CCU6qdPAVG+0o6o+IxcsLoqGwxs+rFxaFzrdtDV1A=="], "@effect/experimental/effect": ["effect@3.21.0", "", { "dependencies": { "@standard-schema/spec": "1.1.0", "fast-check": "3.23.2" } }, "sha512-PPN80qRokCd1f015IANNhrwOnLO7GrrMQfk4/lnZRE/8j7UPWrNNjPV0uBrZutI/nHzernbW+J0hdqQysHiSnQ=="], @@ -1818,6 +1892,8 @@ "@prover-coder-ai/dist-deps-prune/typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], + "@prover-coder-ai/docker-git/@effect/language-service": ["@effect/language-service@0.86.1", "", { "bin": { "effect-language-service": "cli.js" } }, "sha512-jfUuFdtNPKRZ4ZSrgTnfMHPIVq8L9cM+YGEylQ1lhIgNY23ZvfdPg0cUlWESd4q9aGvOWSA8E79AS0qe3j4ROw=="], + "@prover-coder-ai/eslint-plugin-suggest-members/@typescript-eslint/utils": ["@typescript-eslint/utils@8.57.2", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.9.1", "@typescript-eslint/scope-manager": "8.57.2", "@typescript-eslint/types": "8.57.2", "@typescript-eslint/typescript-estree": "8.57.2" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-krRIbvPK1ju1WBKIefiX+bngPs+odIQUtR7kymzPfo1POVw3jlF+nLkmexdSSd4UCbDcQn+wMBATOOmpBbqgKg=="], "@rolldown/binding-wasm32-wasi/@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.1.4", "", { "dependencies": { "@tybys/wasm-util": "^0.10.1" }, "peerDependencies": { "@emnapi/core": "^1.7.1", "@emnapi/runtime": "^1.7.1" } }, "sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow=="], @@ -1952,6 +2028,8 @@ "@babel/helper-compilation-targets/browserslist/update-browserslist-db": ["update-browserslist-db@1.1.4", "", { "dependencies": { "escalade": "3.2.0", "picocolors": "1.1.1" }, "peerDependencies": { "browserslist": "4.28.0" }, "bin": { "update-browserslist-db": "cli.js" } }, "sha512-q0SPT4xyU84saUX+tomz1WLkxUbuaJnR1xWt17M7fJtEJigJeWUNGUqrauFXsHnqev9y9JTRGwk13tFBuKby4A=="], + "@babel/helper-compilation-targets/lru-cache/yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="], + "@effect/experimental/@effect/platform/msgpackr": ["msgpackr@1.11.5", "", { "optionalDependencies": { "msgpackr-extract": "3.0.3" } }, "sha512-UjkUHN0yqp9RWKy0Lplhh+wlpdt9oQBYgULZOiFhV3VclSF1JnSQWZ5r9gORQlNYaUKQoR8itv7g7z1xDDuACA=="], "@effect/experimental/effect/fast-check": ["fast-check@3.23.2", "", { "dependencies": { "pure-rand": "6.1.0" } }, "sha512-h5+1OzzfCC3Ef7VbtKdcv7zsstUQwUDlYpUTvjeUsJAssPgLn7QzbboPtL5ro04Mq0rPOsMzl7q5hIbRs2wD1A=="], diff --git a/packages/api/package.json b/packages/api/package.json index 1a1d9437..e067bc1a 100644 --- a/packages/api/package.json +++ b/packages/api/package.json @@ -23,6 +23,8 @@ "@effect/platform": "^0.96.1", "@effect/platform-node": "^0.106.0", "@effect/schema": "^0.75.5", + "@fedify/fedify": "^2.2.3", + "@fedify/vocab": "^2.2.3", "effect": "^3.21.2", "node-pty": "^1.1.0", "ws": "^8.20.1" diff --git a/packages/api/src/api/activitypub-schema.ts b/packages/api/src/api/activitypub-schema.ts index 065168db..f8092289 100644 --- a/packages/api/src/api/activitypub-schema.ts +++ b/packages/api/src/api/activitypub-schema.ts @@ -1,21 +1,14 @@ import * as Schema from "effect/Schema" -import type * as SchemaAST from "effect/SchemaAST" import { activityStreamsJsonLdContext, - forgeFedJsonLdContext, - securityJsonLdContext, - socialWebWebfingerJsonLdContext + forgeFedJsonLdContext } from "./contracts.js" export type JsonPrimitive = boolean | number | string | null export type JsonValue = JsonPrimitive | JsonObject | ReadonlyArray export type JsonObject = Readonly<{ [key: string]: JsonValue }> -export const exactActivityPubParseOptions: SchemaAST.ParseOptions = { - onExcessProperty: "error" -} - export const JsonValueSchema: Schema.Schema = Schema.suspend(() => Schema.Union( Schema.Null, @@ -28,67 +21,12 @@ export const JsonValueSchema: Schema.Schema = Schema.suspend(() => ) const OptionalString = Schema.optional(Schema.String) -const JsonObjectSchema = Schema.Record({ key: Schema.String, value: JsonValueSchema }) -const JsonLdContextEntrySchema = Schema.Union(Schema.String, JsonObjectSchema) -const JsonLdIdMappingSchema = Schema.Struct({ - "@id": Schema.String, - "@type": Schema.Literal("@id") -}) export const ActivityForgeFedJsonLdContextSchema = Schema.Tuple( Schema.Literal(activityStreamsJsonLdContext), Schema.Literal(forgeFedJsonLdContext) ) -export const LocalActorJsonLdContextSchema = Schema.Tuple( - Schema.Literal(activityStreamsJsonLdContext), - Schema.Literal(securityJsonLdContext), - Schema.Literal(forgeFedJsonLdContext) -) - -export const MastodonActorContextExtensionsSchema = Schema.Struct({ - manuallyApprovesFollowers: Schema.String, - toot: Schema.String, - featured: JsonLdIdMappingSchema, - featuredTags: JsonLdIdMappingSchema, - alsoKnownAs: JsonLdIdMappingSchema, - movedTo: JsonLdIdMappingSchema, - schema: Schema.String, - PropertyValue: Schema.String, - value: Schema.String, - discoverable: Schema.String, - suspended: Schema.String, - memorial: Schema.String, - indexable: Schema.String, - attributionDomains: JsonLdIdMappingSchema, - showFeatured: Schema.String, - showMedia: Schema.String, - showRepliesInMedia: Schema.String, - gts: Schema.String, - interactionPolicy: JsonLdIdMappingSchema, - canQuote: JsonLdIdMappingSchema, - automaticApproval: JsonLdIdMappingSchema, - manualApproval: JsonLdIdMappingSchema -}) - -export const MastodonActorJsonLdContextSchema = Schema.Tuple( - Schema.Literal(activityStreamsJsonLdContext), - Schema.Literal(securityJsonLdContext), - Schema.Literal(socialWebWebfingerJsonLdContext), - MastodonActorContextExtensionsSchema -) - -export const ActorJsonLdContextSchema = Schema.Union( - LocalActorJsonLdContextSchema, - MastodonActorJsonLdContextSchema -) - -export const JsonLdContextSchema = Schema.Union( - Schema.String, - JsonObjectSchema, - Schema.Array(JsonLdContextEntrySchema) -) - export const ForgeFedTicketSourceSchema = Schema.Struct({ content: OptionalString, mediaType: OptionalString @@ -110,118 +48,6 @@ export const ForgeFedTicketSchema = Schema.Struct({ raw: Schema.optional(JsonValueSchema) }) -export const ActivityPubPublicKeySchema = Schema.Struct({ - id: Schema.String, - owner: Schema.String, - publicKeyPem: Schema.String -}) - -const ActivityPubEndpointsSchema = Schema.Struct({ - sharedInbox: Schema.String -}) - -const ActivityPubImageSchema = Schema.Struct({ - type: Schema.Literal("Image"), - mediaType: OptionalString, - url: Schema.String, - name: OptionalString -}) - -const ActivityPubActorAttachmentSchema = Schema.Struct({ - type: Schema.Literal("PropertyValue"), - name: Schema.String, - value: Schema.String -}) - -const ActivityPubHashtagTagSchema = Schema.Struct({ - type: Schema.Literal("Hashtag"), - name: Schema.String, - href: Schema.String -}) - -const ActivityPubEmojiTagSchema = Schema.Struct({ - type: Schema.Literal("Emoji"), - id: Schema.String, - name: Schema.String, - icon: ActivityPubImageSchema, - updated: OptionalString -}) - -const ActivityPubActorTagSchema = Schema.Union( - ActivityPubHashtagTagSchema, - ActivityPubEmojiTagSchema -) - -const ActivityPubInteractionApprovalSchema = Schema.Struct({ - automaticApproval: Schema.optional(Schema.Array(Schema.String)), - manualApproval: Schema.optional(Schema.Array(Schema.String)) -}).pipe( - Schema.filter((approval) => - approval.automaticApproval !== undefined || - approval.manualApproval !== undefined) -) - -const MastodonInteractionPolicySchema = Schema.Struct({ - canFeature: Schema.optional(ActivityPubInteractionApprovalSchema), - canQuote: Schema.optional(ActivityPubInteractionApprovalSchema) -}).pipe( - Schema.filter((policy) => - policy.canFeature !== undefined || - policy.canQuote !== undefined) -) - -export const LocalActivityPubPersonSchema = Schema.Struct({ - "@context": LocalActorJsonLdContextSchema, - type: Schema.Literal("Person"), - id: Schema.String, - name: Schema.String, - preferredUsername: Schema.String, - summary: Schema.String, - inbox: Schema.String, - outbox: Schema.String, - followers: Schema.String, - following: Schema.String, - liked: Schema.String, - publicKey: ActivityPubPublicKeySchema, - endpoints: ActivityPubEndpointsSchema -}) - -export const MastodonIssueActivityPubPersonSchema = Schema.Struct({ - "@context": MastodonActorJsonLdContextSchema, - id: Schema.String, - webfinger: Schema.String, - type: Schema.Literal("Person"), - following: Schema.String, - followers: Schema.String, - inbox: Schema.String, - outbox: Schema.String, - featured: Schema.String, - featuredTags: Schema.String, - preferredUsername: Schema.String, - name: Schema.String, - summary: Schema.String, - url: Schema.String, - manuallyApprovesFollowers: Schema.Boolean, - discoverable: Schema.Boolean, - indexable: Schema.Boolean, - published: Schema.String, - memorial: Schema.Boolean, - showFeatured: Schema.Boolean, - showMedia: Schema.Boolean, - showRepliesInMedia: Schema.Boolean, - interactionPolicy: MastodonInteractionPolicySchema, - featuredCollections: Schema.String, - publicKey: ActivityPubPublicKeySchema, - tag: Schema.Array(ActivityPubActorTagSchema), - attachment: Schema.Array(ActivityPubActorAttachmentSchema), - endpoints: ActivityPubEndpointsSchema -}) - -export const ActivityPubPersonSchema = Schema.Union( - LocalActivityPubPersonSchema, - MastodonIssueActivityPubPersonSchema -) - export const ActivityPubFollowActivitySchema = Schema.Struct({ "@context": ActivityForgeFedJsonLdContextSchema, id: Schema.String, @@ -239,50 +65,3 @@ export const LocalActivityPubOrderedCollectionSchema = Schema.Struct({ totalItems: Schema.Number, orderedItems: Schema.Array(JsonValueSchema) }) - -export const LocalActivityPubFollowersCollectionSchema = Schema.Struct({ - "@context": ActivityForgeFedJsonLdContextSchema, - type: Schema.Literal("OrderedCollection"), - id: Schema.String, - totalItems: Schema.Number, - first: Schema.String, - orderedItems: Schema.Array(JsonValueSchema) -}) - -export const MastodonFollowersOrderedCollectionSchema = Schema.Struct({ - "@context": Schema.Literal(activityStreamsJsonLdContext), - id: Schema.String, - type: Schema.Literal("OrderedCollection"), - totalItems: Schema.Number, - first: Schema.String -}) - -export const ActivityPubOrderedCollectionSchema = Schema.Union( - LocalActivityPubOrderedCollectionSchema, - LocalActivityPubFollowersCollectionSchema, - MastodonFollowersOrderedCollectionSchema -) - -export const LocalActivityPubOrderedCollectionPageSchema = Schema.Struct({ - "@context": ActivityForgeFedJsonLdContextSchema, - type: Schema.Literal("OrderedCollectionPage"), - id: Schema.String, - totalItems: Schema.Number, - partOf: Schema.String, - orderedItems: Schema.Array(JsonValueSchema) -}) - -export const MastodonFollowersOrderedCollectionPageSchema = Schema.Struct({ - "@context": Schema.Literal(activityStreamsJsonLdContext), - id: Schema.String, - type: Schema.Literal("OrderedCollectionPage"), - totalItems: Schema.Number, - partOf: Schema.String, - next: Schema.String, - orderedItems: Schema.Array(Schema.String) -}) - -export const ActivityPubOrderedCollectionPageSchema = Schema.Union( - LocalActivityPubOrderedCollectionPageSchema, - MastodonFollowersOrderedCollectionPageSchema -) diff --git a/packages/api/src/api/contracts.ts b/packages/api/src/api/contracts.ts index c30c0540..4fbcf4ee 100644 --- a/packages/api/src/api/contracts.ts +++ b/packages/api/src/api/contracts.ts @@ -3,17 +3,9 @@ import type * as Schema from "effect/Schema" import type { ActivityForgeFedJsonLdContextSchema, ActivityPubFollowActivitySchema, - ActivityPubOrderedCollectionPageSchema, - ActivityPubOrderedCollectionSchema, - ActivityPubPersonSchema, - ActivityPubPublicKeySchema, - ActorJsonLdContextSchema, ForgeFedTicketSchema, ForgeFedTicketSourceSchema, - LocalActivityPubFollowersCollectionSchema, - LocalActivityPubOrderedCollectionPageSchema, - LocalActivityPubOrderedCollectionSchema, - LocalActivityPubPersonSchema + LocalActivityPubOrderedCollectionSchema } from "./activitypub-schema.js" export type ProjectStatus = "running" | "stopped" | "unknown" @@ -560,15 +552,13 @@ export type ContainerTaskSnapshot = { export const activityStreamsJsonLdContext = "https://www.w3.org/ns/activitystreams" as const export const forgeFedJsonLdContext = "https://forgefed.org/ns" as const export const securityJsonLdContext = "https://w3id.org/security/v1" as const -export const socialWebWebfingerJsonLdContext = "https://purl.archive.org/socialweb/webfinger" as const export const activityForgeFedJsonLdContext = [ activityStreamsJsonLdContext, forgeFedJsonLdContext ] as const export const actorJsonLdContext = [ activityStreamsJsonLdContext, - securityJsonLdContext, - forgeFedJsonLdContext + securityJsonLdContext ] as const export const federationJsonLdContentType = `application/ld+json; profile="${activityStreamsJsonLdContext}"` as const @@ -576,7 +566,6 @@ export const federationJsonLdResponseContentType = `${federationJsonLdContentType}; charset=utf-8` as const export type ActivityForgeFedJsonLdContext = Schema.Schema.Type -export type ActorJsonLdContext = Schema.Schema.Type export type ForgeFedTicket = Schema.Schema.Type @@ -622,22 +611,7 @@ export type FollowStatus = "pending" | "accepted" | "rejected" export type ActivityPubFollowActivity = Schema.Schema.Type -export type ActivityPubPublicKey = Schema.Schema.Type - -export type ActivityPubPerson = Schema.Schema.Type - -export type LocalActivityPubPerson = Schema.Schema.Type - -export type ActivityPubOrderedCollection = Schema.Schema.Type - -export type LocalActivityPubOrderedCollection = - | Schema.Schema.Type - | Schema.Schema.Type - -export type ActivityPubOrderedCollectionPage = Schema.Schema.Type - -export type LocalActivityPubOrderedCollectionPage = - Schema.Schema.Type +export type LocalActivityPubOrderedCollection = Schema.Schema.Type export type FollowSubscription = { readonly id: string diff --git a/packages/api/src/api/schema.ts b/packages/api/src/api/schema.ts index 56904bf0..1d5a2c94 100644 --- a/packages/api/src/api/schema.ts +++ b/packages/api/src/api/schema.ts @@ -3,23 +3,10 @@ import * as Schema from "effect/Schema" export { ActivityForgeFedJsonLdContextSchema, ActivityPubFollowActivitySchema, - ActivityPubOrderedCollectionPageSchema, - ActivityPubOrderedCollectionSchema, - ActivityPubPersonSchema, - ActivityPubPublicKeySchema, - ActorJsonLdContextSchema, ForgeFedTicketSchema, ForgeFedTicketSourceSchema, - JsonLdContextSchema, JsonValueSchema, - LocalActivityPubFollowersCollectionSchema, - LocalActivityPubOrderedCollectionPageSchema, - LocalActivityPubOrderedCollectionSchema, - LocalActivityPubPersonSchema, - MastodonFollowersOrderedCollectionPageSchema, - MastodonFollowersOrderedCollectionSchema, - MastodonIssueActivityPubPersonSchema, - exactActivityPubParseOptions + LocalActivityPubOrderedCollectionSchema } from "./activitypub-schema.js" const OptionalString = Schema.optional(Schema.String) diff --git a/packages/api/src/http.ts b/packages/api/src/http.ts index 10eea7ec..460b90df 100644 --- a/packages/api/src/http.ts +++ b/packages/api/src/http.ts @@ -13,9 +13,6 @@ import { renderError, type AppError } from "@effect-template/lib/usecases/errors import { ApiAuthRequiredError, ApiBadRequestError, ApiConflictError, ApiInternalError, ApiNotFoundError, describeUnknown } from "./api/errors.js" import { federationJsonLdResponseContentType, type ApplyProjectRequest } from "./api/contracts.js" import { - ActivityPubOrderedCollectionPageSchema, - ActivityPubOrderedCollectionSchema, - ActivityPubPersonSchema, AuthMenuRequestSchema, AuthTerminalSessionRequestSchema, ActiveProjectTerminalSessionRequestSchema, @@ -44,8 +41,7 @@ import { StateCommitRequestSchema, StateInitRequestSchema, StateSyncRequestSchema, - UpProjectRequestSchema, - exactActivityPubParseOptions + UpProjectRequestSchema } from "./api/schema.js" import type { UpProjectRequestInput } from "./api/schema.js" import { defaultProjectsRoot } from "@effect-template/lib/usecases/menu-helpers" @@ -78,16 +74,19 @@ import { listExchangeSubscriptions, listFederationIssues, listFollowSubscriptions, - makeFederationActorDocument, makeFederationContext, makeFederationExchangeStatus, - makeFederationFollowersCollection, - makeFederationFollowersPageCollection, - makeFederationFollowingCollection, - makeFederationLikedCollection, - makeFederationOutboxCollection, pollExchangeOutboxes } from "./services/federation.js" +import { + fetchFedifyWebFinger, + makeFedifyActorJsonLd, + makeFedifyFollowersJsonLd, + makeFedifyFollowersPageJsonLd, + makeFedifyFollowingJsonLd, + makeFedifyLikedJsonLd, + makeFedifyOutboxJsonLd +} from "./services/fedify-federation.js" import { applyAllProjects, applyProjectById, @@ -311,24 +310,6 @@ const binaryResponse = (data: Uint8Array, contentType: string, status = 200) => const jsonLdResponse = (data: unknown, status: number) => textResponse(JSON.stringify(data), federationJsonLdResponseContentType, status) -const validatedJsonLdResponse = ( - schema: Schema.Schema, - data: unknown, - label: string, - status: number -) => - Schema.decodeUnknown(schema, exactActivityPubParseOptions)(data).pipe( - Effect.mapError((error) => - new ApiInternalError({ - message: `${label} does not satisfy its ActivityPub JSON-LD schema: ${ - ParseResult.TreeFormatter.formatIssueSync(error.issue) - }`, - cause: error - }) - ), - Effect.flatMap((payload) => jsonLdResponse(payload, status)) - ) - const parseQueryInt = (url: string, key: string, fallback: number): number => { const parsed = Number(new URL(url, "http://localhost").searchParams.get(key) ?? "") if (!Number.isFinite(parsed) || parsed <= 0) { @@ -655,7 +636,7 @@ export const federationExchangeStatusResponse = () => * @returns Effect that yields the local ActivityPub actor document response. * * @pure false - * @effect Reads HttpServerRequest, resolves federation context, renders makeFederationActorDocument, serializes with jsonLdResponse, and maps failures through errorResponse. + * @effect Reads HttpServerRequest, resolves federation context, renders a Fedify Person, serializes with jsonLdResponse, and maps failures through errorResponse. * @invariant successful responses contain the actor id derived from the resolved federation context. * @precondition request headers or configured env provide a non-empty public origin. * @postcondition successful responses contain a JSON-LD Person document with HTTP 200. @@ -666,14 +647,8 @@ export const federationActorDocumentResponse = () => Effect.gen(function*(_) { const request = yield* _(HttpServerRequest.HttpServerRequest) const context = yield* _(resolveFederationContext(request)) - return yield* _( - validatedJsonLdResponse( - ActivityPubPersonSchema, - makeFederationActorDocument(context), - "Federation actor document", - 200 - ) - ) + const document = yield* _(makeFedifyActorJsonLd(context)) + return yield* _(jsonLdResponse(document, 200)) }).pipe(Effect.catchAll(errorResponse)) /** @@ -682,7 +657,7 @@ export const federationActorDocumentResponse = () => * @returns Effect that yields the local ActivityPub outbox collection response. * * @pure false - * @effect Reads HttpServerRequest, resolves federation context, renders makeFederationOutboxCollection, serializes with jsonLdResponse, and maps failures through errorResponse. + * @effect Reads HttpServerRequest, resolves federation context, renders a Fedify OrderedCollection, serializes with jsonLdResponse, and maps failures through errorResponse. * @invariant successful responses contain the outbox id derived from the resolved federation context. * @precondition request headers or configured env provide a non-empty public origin. * @postcondition successful responses contain a JSON-LD OrderedCollection document with HTTP 200. @@ -693,14 +668,8 @@ export const federationOutboxDocumentResponse = () => Effect.gen(function*(_) { const request = yield* _(HttpServerRequest.HttpServerRequest) const context = yield* _(resolveFederationContext(request)) - return yield* _( - validatedJsonLdResponse( - ActivityPubOrderedCollectionSchema, - makeFederationOutboxCollection(context), - "Federation outbox collection", - 200 - ) - ) + const document = yield* _(makeFedifyOutboxJsonLd(context)) + return yield* _(jsonLdResponse(document, 200)) }).pipe(Effect.catchAll(errorResponse)) /** @@ -709,7 +678,7 @@ export const federationOutboxDocumentResponse = () => * @returns Effect that yields the local ActivityPub followers collection response. * * @pure false - * @effect Reads HttpServerRequest, resolves federation context, renders makeFederationFollowersCollection, serializes with jsonLdResponse, and maps failures through errorResponse. + * @effect Reads HttpServerRequest, resolves federation context, renders a Fedify OrderedCollection/Page, serializes with jsonLdResponse, and maps failures through errorResponse. * @invariant successful responses contain the followers id derived from the resolved federation context. * @precondition request headers or configured env provide a non-empty public origin. * @postcondition successful responses contain a JSON-LD OrderedCollection document with HTTP 200. @@ -721,21 +690,12 @@ export const federationFollowersDocumentResponse = () => const request = yield* _(HttpServerRequest.HttpServerRequest) const context = yield* _(resolveFederationContext(request)) const mode = yield* _(readFollowersPageMode(request.url)) - return yield* _( + const document = yield* _( mode === "page" - ? validatedJsonLdResponse( - ActivityPubOrderedCollectionPageSchema, - makeFederationFollowersPageCollection(context), - "Federation followers page", - 200 - ) - : validatedJsonLdResponse( - ActivityPubOrderedCollectionSchema, - makeFederationFollowersCollection(context), - "Federation followers collection", - 200 - ) + ? makeFedifyFollowersPageJsonLd(context) + : makeFedifyFollowersJsonLd(context) ) + return yield* _(jsonLdResponse(document, 200)) }).pipe(Effect.catchAll(errorResponse)) /** @@ -744,7 +704,7 @@ export const federationFollowersDocumentResponse = () => * @returns Effect that yields the local ActivityPub following collection response. * * @pure false - * @effect Reads HttpServerRequest, resolves federation context, renders makeFederationFollowingCollection, serializes with jsonLdResponse, and maps failures through errorResponse. + * @effect Reads HttpServerRequest, resolves federation context, renders a Fedify OrderedCollection, serializes with jsonLdResponse, and maps failures through errorResponse. * @invariant successful responses contain the following id derived from the resolved federation context. * @precondition request headers or configured env provide a non-empty public origin. * @postcondition successful responses contain a JSON-LD OrderedCollection document with HTTP 200. @@ -755,14 +715,8 @@ export const federationFollowingDocumentResponse = () => Effect.gen(function*(_) { const request = yield* _(HttpServerRequest.HttpServerRequest) const context = yield* _(resolveFederationContext(request)) - return yield* _( - validatedJsonLdResponse( - ActivityPubOrderedCollectionSchema, - makeFederationFollowingCollection(context), - "Federation following collection", - 200 - ) - ) + const document = yield* _(makeFedifyFollowingJsonLd(context)) + return yield* _(jsonLdResponse(document, 200)) }).pipe(Effect.catchAll(errorResponse)) /** @@ -771,7 +725,7 @@ export const federationFollowingDocumentResponse = () => * @returns Effect that yields the local ActivityPub liked collection response. * * @pure false - * @effect Reads HttpServerRequest, resolves federation context, renders makeFederationLikedCollection, serializes with jsonLdResponse, and maps failures through errorResponse. + * @effect Reads HttpServerRequest, resolves federation context, renders a Fedify OrderedCollection, serializes with jsonLdResponse, and maps failures through errorResponse. * @invariant successful responses contain the liked collection id derived from the resolved federation context. * @precondition request headers or configured env provide a non-empty public origin. * @postcondition successful responses contain a JSON-LD OrderedCollection document with HTTP 200. @@ -782,14 +736,17 @@ export const federationLikedDocumentResponse = () => Effect.gen(function*(_) { const request = yield* _(HttpServerRequest.HttpServerRequest) const context = yield* _(resolveFederationContext(request)) - return yield* _( - validatedJsonLdResponse( - ActivityPubOrderedCollectionSchema, - makeFederationLikedCollection(context), - "Federation liked collection", - 200 - ) - ) + const document = yield* _(makeFedifyLikedJsonLd(context)) + return yield* _(jsonLdResponse(document, 200)) + }).pipe(Effect.catchAll(errorResponse)) + +export const federationWebFingerResponse = () => + Effect.gen(function*(_) { + const request = yield* _(HttpServerRequest.HttpServerRequest) + const context = yield* _(resolveFederationContext(request)) + const webRequest = yield* _(HttpServerRequest.toWeb(request)) + const response = yield* _(fetchFedifyWebFinger(webRequest, context)) + return HttpServerResponse.fromWeb(response) }).pipe(Effect.catchAll(errorResponse)) const terminalWebSocketUpgradeResponse = Effect.gen(function*(_) { @@ -1084,6 +1041,10 @@ export const makeRouter = () => { Effect.catchAll(errorResponse) ) ), + HttpRouter.get( + "/.well-known/webfinger", + federationWebFingerResponse() + ), HttpRouter.get( "/federation/actor", federationActorDocumentResponse() diff --git a/packages/api/src/services/federation.ts b/packages/api/src/services/federation.ts index 86586124..c7985476 100644 --- a/packages/api/src/services/federation.ts +++ b/packages/api/src/services/federation.ts @@ -13,7 +13,6 @@ import { dirname, join } from "node:path" import type { JsonValue } from "../api/activitypub-schema.js" import type { ActivityPubFollowActivity, - ActivityPubPublicKey, AgentProvider, AgentSession, CreateFollowRequest, @@ -31,14 +30,11 @@ import type { ForgeFedTicket, ForgeFedTicketSource, LocalActivityPubOrderedCollection, - LocalActivityPubOrderedCollectionPage, - LocalActivityPubPerson, ProjectDetails } from "../api/contracts.js" import { activityForgeFedJsonLdContext, activityStreamsJsonLdContext, - actorJsonLdContext, federationJsonLdContentType, forgeFedJsonLdContext } from "../api/contracts.js" @@ -48,7 +44,7 @@ import { createProjectFromRequest } from "./projects.js" type JsonRecord = { readonly [key: string]: unknown } -type LocalActorKeys = { +export type LocalActorKeys = { readonly publicKeyPem: string readonly privateKeyPem: string } @@ -581,6 +577,9 @@ export const initializeFederationState = () => const ensureStateLoaded = () => stateLoaded ? Effect.void : initializeFederationState() +export const readLocalActorKeys = (): Effect.Effect => + ensureStateLoaded().pipe(Effect.map(() => ensureLocalActorKeys())) + export const makeFederationContext = ( input: FederationContextInput ): Effect.Effect => @@ -611,103 +610,6 @@ const defaultFederationContext = () => actorUsername: process.env["DOCKER_GIT_FEDERATION_ACTOR"] ?? defaultActorUsername }) -const publicKeyForContext = (context: FederationContext): ActivityPubPublicKey => ({ - id: `${context.actorId}#main-key`, - owner: context.actorId, - publicKeyPem: ensureLocalActorKeys().publicKeyPem -}) - -const followActivityJson = (activity: ActivityPubFollowActivity): JsonValue => ({ - "@context": [...activity["@context"]], - id: activity.id, - type: activity.type, - actor: activity.actor, - object: activity.object, - ...(activity.to === undefined ? {} : { to: [...activity.to] }), - ...(activity.capability === undefined ? {} : { capability: activity.capability }) -}) - -export const makeFederationActorDocument = ( - context: FederationContext -): LocalActivityPubPerson => ({ - "@context": actorJsonLdContext, - type: "Person", - id: context.actorId, - name: "docker-git task feed", - preferredUsername: context.actorUsername, - summary: "docker-git ActivityPub actor for task and issue stream subscriptions.", - inbox: context.inbox, - outbox: context.outbox, - followers: context.followers, - following: context.following, - liked: context.liked, - publicKey: publicKeyForContext(context), - endpoints: { - sharedInbox: context.inbox - } -}) - -export const makeFederationOutboxCollection = ( - context: FederationContext -): LocalActivityPubOrderedCollection => { - const orderedItems = listFollowSubscriptions().map((subscription) => followActivityJson(subscription.activity)) - return { - "@context": activityForgeFedJsonLdContext, - type: "OrderedCollection", - id: context.outbox, - totalItems: orderedItems.length, - orderedItems - } -} - -export const makeFederationFollowersCollection = ( - context: FederationContext -): LocalActivityPubOrderedCollection => ({ - "@context": activityForgeFedJsonLdContext, - type: "OrderedCollection", - id: context.followers, - totalItems: 0, - first: `${context.followers}?page=1`, - orderedItems: [] -}) - -export const makeFederationFollowersPageCollection = ( - context: FederationContext -): LocalActivityPubOrderedCollectionPage => ({ - "@context": activityForgeFedJsonLdContext, - type: "OrderedCollectionPage", - id: `${context.followers}?page=1`, - totalItems: 0, - partOf: context.followers, - orderedItems: [] -}) - -export const makeFederationFollowingCollection = ( - context: FederationContext -): LocalActivityPubOrderedCollection => { - const orderedItems = listFollowSubscriptions() - .filter((subscription) => subscription.status === "accepted") - .map((subscription) => subscription.object) - - return { - "@context": activityForgeFedJsonLdContext, - type: "OrderedCollection", - id: context.following, - totalItems: orderedItems.length, - orderedItems - } -} - -export const makeFederationLikedCollection = ( - context: FederationContext -): LocalActivityPubOrderedCollection => ({ - "@context": activityForgeFedJsonLdContext, - type: "OrderedCollection", - id: context.liked, - totalItems: 0, - orderedItems: [] -}) - const readTicketSource = (payload: JsonRecord): string | ForgeFedTicketSource | undefined => { const raw = payload["source"] if (typeof raw === "string" && raw.trim().length > 0) { diff --git a/packages/api/src/services/fedify-federation.ts b/packages/api/src/services/fedify-federation.ts new file mode 100644 index 00000000..b05de346 --- /dev/null +++ b/packages/api/src/services/fedify-federation.ts @@ -0,0 +1,247 @@ +import { createFederation, MemoryKvStore } from "@fedify/fedify" +import { + CryptographicKey, + Endpoints, + Follow, + Object as ActivityObject, + OrderedCollection, + OrderedCollectionPage, + Person +} from "@fedify/vocab" +import { Effect } from "effect" +import { createPublicKey, webcrypto } from "node:crypto" + +import { + activityStreamsJsonLdContext, + actorJsonLdContext, + type ActivityPubFollowActivity +} from "../api/contracts.js" +import { ApiInternalError } from "../api/errors.js" +import { + listFollowSubscriptions, + readLocalActorKeys, + type FederationContext +} from "./federation.js" + +const actorIdentifier = "actor" + +type FedifyContextData = { + readonly context: FederationContext +} + +const url = (value: string): URL => new URL(value) + +const importPublicKey = (publicKeyPem: string): Effect.Effect => + Effect.tryPromise({ + try: async () => { + const jwk = createPublicKey(publicKeyPem).export({ format: "jwk" }) + return await webcrypto.subtle.importKey( + "jwk", + jwk, + { + name: "RSASSA-PKCS1-v1_5", + hash: "SHA-256" + }, + true, + ["verify"] + ) + }, + catch: (cause) => + new ApiInternalError({ + message: "Failed to import federation public key for Fedify serialization.", + cause + }) + }) + +const makeFedifyPublicKey = ( + context: FederationContext +): Effect.Effect => + Effect.gen(function*(_) { + const keys = yield* _(readLocalActorKeys()) + const publicKey = yield* _(importPublicKey(keys.publicKeyPem)) + return new CryptographicKey({ + id: url(`${context.actorId}#main-key`), + owner: url(context.actorId), + publicKey + }) + }) + +export const makeFedifyActor = ( + context: FederationContext +): Effect.Effect => + Effect.gen(function*(_) { + const publicKey = yield* _(makeFedifyPublicKey(context)) + return new Person({ + id: url(context.actorId), + name: "docker-git task feed", + preferredUsername: context.actorUsername, + summary: "docker-git ActivityPub actor for task and issue stream subscriptions.", + inbox: url(context.inbox), + outbox: url(context.outbox), + followers: url(context.followers), + following: url(context.following), + liked: url(context.liked), + publicKey, + endpoints: new Endpoints({ + sharedInbox: url(context.inbox) + }) + }) + }) + +const makeFedifyFollowActivity = (activity: ActivityPubFollowActivity): Follow => + new Follow({ + id: url(activity.id), + actor: url(activity.actor), + object: url(activity.object), + tos: activity.to?.map(url) ?? [] + }) + +export const makeFedifyOutboxCollection = ( + context: FederationContext +): OrderedCollection => { + const items = listFollowSubscriptions().map((subscription) => + makeFedifyFollowActivity(subscription.activity) + ) + return new OrderedCollection({ + id: url(context.outbox), + totalItems: items.length, + items + }) +} + +export const makeFedifyFollowersCollection = ( + context: FederationContext +): OrderedCollection => + new OrderedCollection({ + id: url(context.followers), + totalItems: 0, + first: url(`${context.followers}?page=1`), + items: [] + }) + +export const makeFedifyFollowersPageCollection = ( + context: FederationContext +): OrderedCollectionPage => + new OrderedCollectionPage({ + id: url(`${context.followers}?page=1`), + totalItems: 0, + partOf: url(context.followers), + items: [] + }) + +export const makeFedifyFollowingCollection = ( + context: FederationContext +): OrderedCollection => { + const items = listFollowSubscriptions() + .filter((subscription) => subscription.status === "accepted") + .map((subscription) => url(subscription.object)) + + return new OrderedCollection({ + id: url(context.following), + totalItems: items.length, + items + }) +} + +export const makeFedifyLikedCollection = ( + context: FederationContext +): OrderedCollection => + new OrderedCollection({ + id: url(context.liked), + totalItems: 0, + items: [] + }) + +const serializeFedifyObject = ( + object: ActivityObject, + context: string | ReadonlyArray +): Effect.Effect => + Effect.tryPromise({ + try: () => { + const jsonLdContext: string | string[] = + typeof context === "string" ? context : Array.from(context) + return object.toJsonLd({ + format: "compact", + context: jsonLdContext + }) + }, + catch: (cause) => + new ApiInternalError({ + message: "Failed to serialize ActivityPub document with Fedify.", + cause + }) + }) + +export const makeFedifyActorJsonLd = ( + context: FederationContext +): Effect.Effect => + makeFedifyActor(context).pipe( + Effect.flatMap((actor) => serializeFedifyObject(actor, actorJsonLdContext)) + ) + +export const makeFedifyOutboxJsonLd = ( + context: FederationContext +): Effect.Effect => + serializeFedifyObject(makeFedifyOutboxCollection(context), activityStreamsJsonLdContext) + +export const makeFedifyFollowersJsonLd = ( + context: FederationContext +): Effect.Effect => + serializeFedifyObject(makeFedifyFollowersCollection(context), activityStreamsJsonLdContext) + +export const makeFedifyFollowersPageJsonLd = ( + context: FederationContext +): Effect.Effect => + serializeFedifyObject(makeFedifyFollowersPageCollection(context), activityStreamsJsonLdContext) + +export const makeFedifyFollowingJsonLd = ( + context: FederationContext +): Effect.Effect => + serializeFedifyObject(makeFedifyFollowingCollection(context), activityStreamsJsonLdContext) + +export const makeFedifyLikedJsonLd = ( + context: FederationContext +): Effect.Effect => + serializeFedifyObject(makeFedifyLikedCollection(context), activityStreamsJsonLdContext) + +const createWebFingerFederation = (context: FederationContext) => { + const federation = createFederation({ + kv: new MemoryKvStore(), + manuallyStartQueue: true, + origin: context.publicOrigin + }) + + federation + .setActorDispatcher("/federation/{identifier}", (_ctx, identifier) => + identifier === actorIdentifier + ? Effect.runPromise(makeFedifyActor(context)) + : null + ) + .mapHandle((_ctx, username) => + username === context.actorUsername ? actorIdentifier : null + ) + .mapAlias((_ctx, resource) => + resource.href === context.actorId + ? { identifier: actorIdentifier } + : null + ) + + return federation +} + +export const fetchFedifyWebFinger = ( + request: Request, + context: FederationContext +): Effect.Effect => + Effect.tryPromise({ + try: () => + createWebFingerFederation(context).fetch(request, { + contextData: { context }, + onNotFound: () => new Response("Not found", { status: 404 }) + }), + catch: (cause) => + new ApiInternalError({ + message: "Fedify WebFinger request failed.", + cause + }) + }) diff --git a/packages/api/tests/activitypub-schema-parity.test.ts b/packages/api/tests/activitypub-schema-parity.test.ts index 775dfa39..791ee109 100644 --- a/packages/api/tests/activitypub-schema-parity.test.ts +++ b/packages/api/tests/activitypub-schema-parity.test.ts @@ -1,558 +1,224 @@ +import { Follow, OrderedCollection, OrderedCollectionPage, Person } from "@fedify/vocab" import { describe, expect, it } from "@effect/vitest" -import { Effect, Either, ParseResult, Schema } from "effect" -import fc from "fast-check" +import { Effect, Either, Schema } from "effect" import { - ActivityPubOrderedCollectionPageSchema, - ActivityPubOrderedCollectionSchema, - ActivityPubPersonSchema, - exactActivityPubParseOptions -} from "../src/api/schema.js" - -const decodeActivityPubEither = (schema: Schema.Schema, value: unknown) => - Schema.decodeUnknownEither(schema, exactActivityPubParseOptions)(value) - -const isRecord = (value: unknown): value is Record => - typeof value === "object" && value !== null && !Array.isArray(value) - -const expectDecodedMatchesFixtureKeys = (decoded: object, fixture: object): void => { - expect(Object.keys(decoded).sort()).toEqual(Object.keys(fixture).sort()) -} - -const activityForgeFedContextArbitrary = fc.constant([ - "https://www.w3.org/ns/activitystreams", - "https://forgefed.org/ns" -] as const) - -const activityPubActorContextArbitrary = fc.constant([ - "https://www.w3.org/ns/activitystreams", - "https://w3id.org/security/v1", - "https://forgefed.org/ns" -] as const) - -const schemaStringArbitrary = fc.string() - -const nonPersonTypeArbitrary = fc.string().filter((value) => value !== "Person") - -const nonOrderedCollectionTypeArbitrary = fc - .string() - .filter((value) => value !== "OrderedCollection") - -const nonOrderedCollectionPageTypeArbitrary = fc - .string() - .filter((value) => value !== "OrderedCollectionPage") - -const activityPubPublicKeyArbitrary = fc.record({ - id: schemaStringArbitrary, - owner: schemaStringArbitrary, - publicKeyPem: schemaStringArbitrary -}) - -const activityPubEndpointsArbitrary = fc.record({ - sharedInbox: schemaStringArbitrary -}) - -const activityPubPersonRequiredFieldsArbitrary = fc.record({ - "@context": activityPubActorContextArbitrary, - type: fc.constant("Person"), - id: schemaStringArbitrary, - name: schemaStringArbitrary, - preferredUsername: schemaStringArbitrary, - summary: schemaStringArbitrary, - inbox: schemaStringArbitrary, - outbox: schemaStringArbitrary, - followers: schemaStringArbitrary, - following: schemaStringArbitrary, - liked: schemaStringArbitrary, - publicKey: activityPubPublicKeyArbitrary, - endpoints: activityPubEndpointsArbitrary -}) - -const activityPubPersonMissingRequiredFieldsArbitrary = fc.record({ - "@context": activityPubActorContextArbitrary, - type: fc.constant("Person"), - id: schemaStringArbitrary -}) - -const activityPubOrderedCollectionRequiredFieldsArbitrary = fc.record({ - "@context": activityForgeFedContextArbitrary, - type: fc.constant("OrderedCollection"), - id: schemaStringArbitrary, - totalItems: fc.integer({ min: 0 }), - orderedItems: fc.array(fc.oneof(fc.string(), fc.integer(), fc.boolean(), fc.constant(null))) -}) - -const activityPubOrderedCollectionMissingRequiredFieldsArbitrary = fc.record({ - "@context": activityForgeFedContextArbitrary, - type: fc.constant("OrderedCollection"), - id: schemaStringArbitrary, - totalItems: fc.integer({ min: 0 }) -}) - -const activityPubOrderedCollectionPageRequiredFieldsArbitrary = fc.record({ - "@context": activityForgeFedContextArbitrary, - type: fc.constant("OrderedCollectionPage"), - id: schemaStringArbitrary, - totalItems: fc.integer({ min: 0 }), - partOf: schemaStringArbitrary, - orderedItems: fc.array(fc.oneof(fc.string(), fc.integer(), fc.boolean(), fc.constant(null))) -}) - -const activityPubOrderedCollectionPageMissingRequiredFieldsArbitrary = fc.record({ - "@context": activityForgeFedContextArbitrary, - type: fc.constant("OrderedCollectionPage"), - id: schemaStringArbitrary, - totalItems: fc.integer({ min: 0 }), - orderedItems: fc.array(fc.oneof(fc.string(), fc.integer(), fc.boolean(), fc.constant(null))) -}) + activityStreamsJsonLdContext, + actorJsonLdContext +} from "../src/api/contracts.js" +import { ForgeFedTicketSchema } from "../src/api/schema.js" +import { + clearFederationState, + createFollowSubscription, + ingestFederationInbox, + makeFederationContext +} from "../src/services/federation.js" +import { + makeFedifyActorJsonLd, + makeFedifyFollowersJsonLd, + makeFedifyFollowersPageJsonLd, + makeFedifyFollowingJsonLd, + makeFedifyOutboxJsonLd +} from "../src/services/fedify-federation.js" -const mastodonActorContextExtensionsFixture = { - manuallyApprovesFollowers: "as:manuallyApprovesFollowers", - toot: "http://joinmastodon.org/ns#", - featured: { - "@id": "toot:featured", - "@type": "@id" - }, - featuredTags: { - "@id": "toot:featuredTags", - "@type": "@id" - }, - alsoKnownAs: { - "@id": "as:alsoKnownAs", - "@type": "@id" - }, - movedTo: { - "@id": "as:movedTo", - "@type": "@id" - }, - schema: "http://schema.org/#", - PropertyValue: "schema:PropertyValue", - value: "schema:value", - discoverable: "toot:discoverable", - suspended: "toot:suspended", - memorial: "toot:memorial", - indexable: "toot:indexable", - attributionDomains: { - "@id": "toot:attributionDomains", - "@type": "@id" - }, - showFeatured: "toot:showFeatured", - showMedia: "toot:showMedia", - showRepliesInMedia: "toot:showRepliesInMedia", - gts: "https://gotosocial.org/ns#", - interactionPolicy: { - "@id": "gts:interactionPolicy", - "@type": "@id" - }, - canQuote: { - "@id": "gts:canQuote", - "@type": "@id" - }, - automaticApproval: { - "@id": "gts:automaticApproval", - "@type": "@id" - }, - manualApproval: { - "@id": "gts:manualApproval", - "@type": "@id" - } -} +type JsonRecord = Record -const mastodonActorContextFixture = [ - "https://www.w3.org/ns/activitystreams", - "https://w3id.org/security/v1", +const unsupportedMastodonTerms = [ "https://purl.archive.org/socialweb/webfinger", - mastodonActorContextExtensionsFixture + "http://joinmastodon.org/ns#", + "toot:", + "featuredTags", + "alsoKnownAs", + "movedTo", + "manuallyApprovesFollowers", + "discoverable", + "suspended", + "memorial", + "indexable", + "interactionPolicy", + "canQuote", + "automaticApproval", + "manualApproval", + "showFeatured", + "showMedia", + "showRepliesInMedia" ] as const -const mastodonActorFixture = { - "@context": mastodonActorContextFixture, - id: "https://mastodon.social/users/GordonFreeman", - webfinger: "GordonFreeman@mastodon.social", - type: "Person", - following: "https://mastodon.social/users/GordonFreeman/following", - followers: "https://mastodon.social/users/GordonFreeman/followers", - inbox: "https://mastodon.social/users/GordonFreeman/inbox", - outbox: "https://mastodon.social/users/GordonFreeman/outbox", - featured: "https://mastodon.social/users/GordonFreeman/collections/featured", - featuredTags: "https://mastodon.social/users/GordonFreeman/collections/tags", - preferredUsername: "GordonFreeman", - name: "GordonFreeman", - summary: "", - url: "https://mastodon.social/@GordonFreeman", - manuallyApprovesFollowers: false, - discoverable: false, - indexable: false, - published: "2022-05-11T00:00:00Z", - memorial: false, - showFeatured: true, - showMedia: true, - showRepliesInMedia: true, - interactionPolicy: { - canFeature: { - automaticApproval: ["https://mastodon.social/users/GordonFreeman"] - } - }, - featuredCollections: "https://mastodon.social/ap/users/108283196203417442/featured_collections", - publicKey: { - id: "https://mastodon.social/users/GordonFreeman#main-key", - owner: "https://mastodon.social/users/GordonFreeman", - publicKeyPem: - "-----BEGIN PUBLIC KEY-----\nMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAtest\n-----END PUBLIC KEY-----\n" - }, - tag: [], - attachment: [], - endpoints: { - sharedInbox: "https://mastodon.social/inbox" +const asRecord = (value: unknown): JsonRecord => { + if (typeof value !== "object" || value === null || Array.isArray(value)) { + throw new Error("Expected JSON object.") } + return value as JsonRecord } -const mastodonFollowersCollectionFixture = { - "@context": "https://www.w3.org/ns/activitystreams", - id: "https://mastodon.social/users/nixCraft/followers", - type: "OrderedCollection", - totalItems: 114133, - first: "https://mastodon.social/users/nixCraft/followers?page=1" -} +const readField = (record: JsonRecord, key: string): unknown => + Reflect.get(record, key) -const mastodonFollowersPageFixture = { - "@context": "https://www.w3.org/ns/activitystreams", - id: "https://mastodon.social/users/nixCraft/followers?page=1", - type: "OrderedCollectionPage", - totalItems: 114133, - partOf: "https://mastodon.social/users/nixCraft/followers", - next: "https://mastodon.social/users/nixCraft/followers?max_id=123&page=1", - orderedItems: [ - "https://mastodon.social/users/GordonFreeman", - "https://mastodon.social/users/example" - ] +const assertNoMastodonTerms = (value: unknown): void => { + const serialized = JSON.stringify(value) + for (const term of unsupportedMastodonTerms) { + expect(serialized.includes(term)).toBe(false) + } } -const mastodonActorWithContextExtensions = ( - extensions: unknown -): object => ({ - ...mastodonActorFixture, - "@context": [ - "https://www.w3.org/ns/activitystreams", - "https://w3id.org/security/v1", - "https://purl.archive.org/socialweb/webfinger", - extensions - ] -}) - -describe("ActivityPub and ForgeFed schema parity", () => { - it.effect("decodes a Mastodon actor Person fixture", () => - Effect.sync(() => { - const result = decodeActivityPubEither(ActivityPubPersonSchema, mastodonActorFixture) - - Either.match(result, { - onLeft: (error) => { - throw new Error(ParseResult.TreeFormatter.formatIssueSync(error.issue)) - }, - onRight: (decoded) => { - expectDecodedMatchesFixtureKeys(decoded, mastodonActorFixture) - const decodedContext = Reflect.get(decoded, "@context") - expect(Array.isArray(decodedContext)).toBe(true) - if (!Array.isArray(decodedContext)) { - throw new Error("Decoded Mastodon actor context is not an array.") - } - const decodedExtensions = decodedContext[3] - expect(isRecord(decodedExtensions)).toBe(true) - if (!isRecord(decodedExtensions)) { - throw new Error("Decoded Mastodon actor context extensions are not an object.") - } - expect(Object.keys(decodedExtensions).sort()).toEqual( - Object.keys(mastodonActorContextExtensionsFixture).sort() - ) - } - }) - })) - - it.effect("decodes a Mastodon followers OrderedCollection fixture", () => - Effect.sync(() => { - const result = decodeActivityPubEither( - ActivityPubOrderedCollectionSchema, - mastodonFollowersCollectionFixture - ) - - Either.match(result, { - onLeft: (error) => { - throw new Error(ParseResult.TreeFormatter.formatIssueSync(error.issue)) - }, - onRight: (decoded) => { - expectDecodedMatchesFixtureKeys(decoded, mastodonFollowersCollectionFixture) - } - }) - })) - - it.effect("decodes a Mastodon followers OrderedCollectionPage fixture", () => - Effect.sync(() => { - const result = decodeActivityPubEither( - ActivityPubOrderedCollectionPageSchema, - mastodonFollowersPageFixture - ) - - Either.match(result, { - onLeft: (error) => { - throw new Error(ParseResult.TreeFormatter.formatIssueSync(error.issue)) - }, - onRight: (decoded) => { - expectDecodedMatchesFixtureKeys(decoded, mastodonFollowersPageFixture) - } - }) - })) - - it.effect("rejects ActivityPub objects with wrong literal types", () => - Effect.sync(() => { - const personResult = decodeActivityPubEither(ActivityPubPersonSchema, { - ...mastodonActorFixture, - type: "Service" - }) - const collectionResult = decodeActivityPubEither(ActivityPubOrderedCollectionSchema, { - ...mastodonFollowersCollectionFixture, - type: "Collection" - }) - const pageResult = decodeActivityPubEither(ActivityPubOrderedCollectionPageSchema, { - ...mastodonFollowersPageFixture, - type: "OrderedCollection" - }) - - expect(Either.isLeft(personResult)).toBe(true) - expect(Either.isLeft(collectionResult)).toBe(true) - expect(Either.isLeft(pageResult)).toBe(true) - })) - - it.effect("rejects ActivityPub objects missing required fields", () => - Effect.sync(() => { - const personResult = decodeActivityPubEither(ActivityPubPersonSchema, { - type: "Person", - id: "https://mastodon.social/users/missing" - }) - const collectionResult = decodeActivityPubEither(ActivityPubOrderedCollectionSchema, { - type: "OrderedCollection", - id: "https://mastodon.social/users/nixCraft/followers" - }) - const pageResult = decodeActivityPubEither(ActivityPubOrderedCollectionPageSchema, { - type: "OrderedCollectionPage", - id: "https://mastodon.social/users/nixCraft/followers?page=1", - orderedItems: [] - }) - - expect(Either.isLeft(personResult)).toBe(true) - expect(Either.isLeft(collectionResult)).toBe(true) - expect(Either.isLeft(pageResult)).toBe(true) - })) - - it.effect("accepts structured ActivityPub Person actor tag and attachment values", () => - Effect.sync(() => { - const result = decodeActivityPubEither(ActivityPubPersonSchema, { - ...mastodonActorFixture, - tag: [ - { - type: "Hashtag", - name: "#activitypub", - href: "https://mastodon.social/tags/activitypub" - }, - { - type: "Emoji", - id: "https://mastodon.social/emojis/party", - name: ":party:", - updated: "2026-05-21T00:00:00Z", - icon: { - type: "Image", - mediaType: "image/png", - url: "https://mastodon.social/system/custom_emojis/images/party.png" - } - } - ], - attachment: [ - { - type: "PropertyValue", - name: "Website", - value: "https://example.com" - } - ] - }) - - expect(Either.isRight(result)).toBe(true) - })) - - it.effect("rejects structurally invalid ActivityPub Person actor extensions", () => - Effect.sync(() => { - const invalidActors = [ - { ...mastodonActorFixture, icon: {} }, - { ...mastodonActorFixture, image: { type: "Image" } }, - { ...mastodonActorFixture, tag: [{}] }, - { - ...mastodonActorFixture, - tag: [{ type: "Emoji", id: "https://mastodon.social/emojis/party", name: ":party:" }] - }, - { ...mastodonActorFixture, attachment: [{}] }, - { - ...mastodonActorFixture, - attachment: [{ type: "Note", name: "Website", value: "https://example.com" }] - }, - { ...mastodonActorFixture, interactionPolicy: {} }, - { ...mastodonActorFixture, interactionPolicy: { arbitrary: true } }, - { ...mastodonActorFixture, interactionPolicy: { canFeature: { arbitrary: true } } } - ] - - invalidActors.forEach((actor) => { - expect(Either.isLeft(decodeActivityPubEither(ActivityPubPersonSchema, actor))).toBe(true) - }) - })) - - it.effect("rejects non-exact ActivityPub fixture shapes", () => - Effect.sync(() => { - const contextWithoutManualApproval = Object.fromEntries( - Object.entries(mastodonActorContextExtensionsFixture).filter( - ([key]) => key !== "manualApproval" - ) - ) - const contextWithFeaturedWithoutType = { - ...mastodonActorContextExtensionsFixture, - featured: { - "@id": mastodonActorContextExtensionsFixture.featured["@id"] - } - } - const contextWithExtraKey = { - ...mastodonActorContextExtensionsFixture, - extraContextTerm: "toot:extraContextTerm" - } - const invalidDocuments = [ - { ...mastodonActorFixture, extraField: "not in the issue fixture" }, - mastodonActorWithContextExtensions(contextWithoutManualApproval), - mastodonActorWithContextExtensions(contextWithFeaturedWithoutType), - mastodonActorWithContextExtensions(contextWithExtraKey), - { ...mastodonFollowersCollectionFixture, orderedItems: [] }, - { ...mastodonFollowersPageFixture, prev: "https://mastodon.social/users/nixCraft/followers" }, - { ...mastodonFollowersPageFixture, orderedItems: ["ok", { id: "not-a-link" }] } - ] - - invalidDocuments.slice(0, 4).forEach((document) => { - expect(Either.isLeft(decodeActivityPubEither(ActivityPubPersonSchema, document))).toBe(true) - }) - expect( - Either.isLeft(decodeActivityPubEither(ActivityPubOrderedCollectionSchema, invalidDocuments[4])) - ).toBe(true) - expect( - Either.isLeft(decodeActivityPubEither(ActivityPubOrderedCollectionPageSchema, invalidDocuments[5])) - ).toBe(true) - expect( - Either.isLeft(decodeActivityPubEither(ActivityPubOrderedCollectionPageSchema, invalidDocuments[6])) - ).toBe(true) - })) - - it.effect("accepts ActivityPub Person objects with required fields and correct type", () => - Effect.sync(() => { - fc.assert( - fc.property(activityPubPersonRequiredFieldsArbitrary, (person) => { - expect(Either.isRight(decodeActivityPubEither(ActivityPubPersonSchema, person))).toBe(true) +const parsePerson = (payload: unknown) => + Effect.tryPromise({ + try: () => Person.fromJsonLd(payload), + catch: (cause) => new Error(String(cause)) + }) + +const parseOrderedCollection = (payload: unknown) => + Effect.tryPromise({ + try: () => OrderedCollection.fromJsonLd(payload), + catch: (cause) => new Error(String(cause)) + }) + +const parseOrderedCollectionPage = (payload: unknown) => + Effect.tryPromise({ + try: () => OrderedCollectionPage.fromJsonLd(payload), + catch: (cause) => new Error(String(cause)) + }) + +const parseFollow = (payload: unknown) => + Effect.tryPromise({ + try: () => Follow.fromJsonLd(payload), + catch: (cause) => new Error(String(cause)) + }) + +describe("ActivityPub and ForgeFed protocol parity", () => { + it.effect("serializes the local actor through Fedify without Mastodon extension context", () => + Effect.gen(function*(_) { + clearFederationState() + const context = yield* _( + makeFederationContext({ + publicOrigin: "https://social.provercoder.ai", + actorUsername: "tasks" }) ) - })) - - it.effect("rejects ActivityPub Person objects with wrong type", () => - Effect.sync(() => { - fc.assert( - fc.property(activityPubPersonRequiredFieldsArbitrary, nonPersonTypeArbitrary, (person, type) => { - expect( - Either.isLeft(decodeActivityPubEither(ActivityPubPersonSchema, { ...person, type })) - ).toBe(true) - }) - ) - })) - it.effect("rejects ActivityPub Person objects missing required fields", () => - Effect.sync(() => { - fc.assert( - fc.property(activityPubPersonMissingRequiredFieldsArbitrary, (person) => { - expect(Either.isLeft(decodeActivityPubEither(ActivityPubPersonSchema, person))).toBe(true) + const payload = yield* _(makeFedifyActorJsonLd(context)) + const actor = asRecord(payload) + const publicKey = asRecord(readField(actor, "publicKey")) + + expect(readField(actor, "@context")).toEqual(actorJsonLdContext) + expect(readField(actor, "type")).toBe("Person") + expect(readField(actor, "id")).toBe("https://social.provercoder.ai/federation/actor") + expect(readField(actor, "preferredUsername")).toBe("tasks") + expect(readField(actor, "followers")).toBe("https://social.provercoder.ai/federation/followers") + expect(readField(publicKey, "owner")).toBe("https://social.provercoder.ai/federation/actor") + expect(typeof readField(publicKey, "publicKeyPem")).toBe("string") + assertNoMastodonTerms(payload) + + const parsed = yield* _(parsePerson(payload)) + expect(parsed.id?.href).toBe("https://social.provercoder.ai/federation/actor") + expect(parsed.preferredUsername).toBe("tasks") + })) + + it.effect("serializes followers collection and page through Fedify ActivityStreams objects", () => + Effect.gen(function*(_) { + clearFederationState() + const context = yield* _( + makeFederationContext({ + publicOrigin: "https://social.provercoder.ai", + actorUsername: "tasks" }) ) - })) - it.effect("accepts ActivityPub OrderedCollection objects with required fields and correct type", () => - Effect.sync(() => { - fc.assert( - fc.property(activityPubOrderedCollectionRequiredFieldsArbitrary, (collection) => { - expect( - Either.isRight(decodeActivityPubEither(ActivityPubOrderedCollectionSchema, collection)) - ).toBe(true) + const collectionPayload = yield* _(makeFedifyFollowersJsonLd(context)) + const collection = asRecord(collectionPayload) + expect(readField(collection, "@context")).toBe(activityStreamsJsonLdContext) + expect(readField(collection, "type")).toBe("OrderedCollection") + expect(readField(collection, "id")).toBe("https://social.provercoder.ai/federation/followers") + expect(readField(collection, "first")).toBe("https://social.provercoder.ai/federation/followers?page=1") + assertNoMastodonTerms(collectionPayload) + yield* _(parseOrderedCollection(collectionPayload)) + + const pagePayload = yield* _(makeFedifyFollowersPageJsonLd(context)) + const page = asRecord(pagePayload) + expect(readField(page, "@context")).toBe(activityStreamsJsonLdContext) + expect(readField(page, "type")).toBe("OrderedCollectionPage") + expect(readField(page, "id")).toBe("https://social.provercoder.ai/federation/followers?page=1") + expect(readField(page, "partOf")).toBe("https://social.provercoder.ai/federation/followers") + assertNoMastodonTerms(pagePayload) + yield* _(parseOrderedCollectionPage(pagePayload)) + })) + + it.effect("serializes follow activities and accepted following state through Fedify", () => + Effect.gen(function*(_) { + clearFederationState() + const context = yield* _( + makeFederationContext({ + publicOrigin: "https://social.provercoder.ai", + actorUsername: "tasks" }) ) - })) - it.effect("rejects ActivityPub OrderedCollection objects with wrong type", () => - Effect.sync(() => { - fc.assert( - fc.property( - activityPubOrderedCollectionRequiredFieldsArbitrary, - nonOrderedCollectionTypeArbitrary, - (collection, type) => { - expect( - Either.isLeft( - decodeActivityPubEither(ActivityPubOrderedCollectionSchema, { - ...collection, - type - }) - ) - ).toBe(true) - } + const created = yield* _( + createFollowSubscription( + { + object: "https://tracker.provercoder.ai/issues/followers" + }, + context ) ) - })) - it.effect("rejects ActivityPub OrderedCollection objects missing required fields", () => - Effect.sync(() => { - fc.assert( - fc.property(activityPubOrderedCollectionMissingRequiredFieldsArbitrary, (collection) => { - expect( - Either.isLeft(decodeActivityPubEither(ActivityPubOrderedCollectionSchema, collection)) - ).toBe(true) + const outboxPayload = yield* _(makeFedifyOutboxJsonLd(context)) + const outbox = asRecord(outboxPayload) + const orderedItems = readField(outbox, "orderedItems") + expect(readField(outbox, "@context")).toBe(activityStreamsJsonLdContext) + expect(readField(outbox, "type")).toBe("OrderedCollection") + expect(Array.isArray(orderedItems)).toBe(true) + if (!Array.isArray(orderedItems)) { + throw new Error("Expected outbox orderedItems.") + } + const follow = asRecord(orderedItems[0]) + expect(readField(follow, "type")).toBe("Follow") + expect(readField(follow, "id")).toBe(created.activity.id) + expect(readField(follow, "actor")).toBe("https://social.provercoder.ai/federation/actor") + expect(readField(follow, "object")).toBe("https://tracker.provercoder.ai/issues/followers") + assertNoMastodonTerms(outboxPayload) + yield* _(parseFollow(follow)) + + yield* _( + ingestFederationInbox({ + "@context": [ + "https://www.w3.org/ns/activitystreams", + "https://forgefed.org/ns" + ], + type: "Accept", + object: created.activity.id }) ) - })) - it.effect("accepts ActivityPub OrderedCollectionPage objects with required fields and correct type", () => - Effect.sync(() => { - fc.assert( - fc.property(activityPubOrderedCollectionPageRequiredFieldsArbitrary, (page) => { - expect( - Either.isRight(decodeActivityPubEither(ActivityPubOrderedCollectionPageSchema, page)) - ).toBe(true) - }) - ) + const followingPayload = yield* _(makeFedifyFollowingJsonLd(context)) + const following = asRecord(followingPayload) + expect(readField(following, "type")).toBe("OrderedCollection") + expect(readField(following, "totalItems")).toBe(1) + expect(readField(following, "orderedItems")).toEqual([ + "https://tracker.provercoder.ai/issues/followers" + ]) + assertNoMastodonTerms(followingPayload) + yield* _(parseOrderedCollection(followingPayload)) })) - it.effect("rejects ActivityPub OrderedCollectionPage objects with wrong type", () => + it.effect("keeps ForgeFed Ticket validation at the JSON boundary", () => Effect.sync(() => { - fc.assert( - fc.property( - activityPubOrderedCollectionPageRequiredFieldsArbitrary, - nonOrderedCollectionPageTypeArbitrary, - (page, type) => { - expect( - Either.isLeft( - decodeActivityPubEither(ActivityPubOrderedCollectionPageSchema, { - ...page, - type - }) - ) - ).toBe(true) + const decoded = Schema.decodeUnknownEither(ForgeFedTicketSchema)({ + id: "https://tracker.example/issues/42", + attributedTo: "https://tracker.example/users/alice", + summary: "Implement protocol proof", + content: "Use ActivityPub and ForgeFed boundary validation.", + attachment: [ + { + type: "Document", + url: "https://tracker.example/issues/42/log" } - ) - ) - })) + ], + raw: { + type: "Ticket" + } + }) - it.effect("rejects ActivityPub OrderedCollectionPage objects missing required fields", () => - Effect.sync(() => { - fc.assert( - fc.property(activityPubOrderedCollectionPageMissingRequiredFieldsArbitrary, (page) => { - expect( - Either.isLeft(decodeActivityPubEither(ActivityPubOrderedCollectionPageSchema, page)) - ).toBe(true) - }) - ) + expect(Either.isRight(decoded)).toBe(true) })) }) diff --git a/packages/api/tests/federation.test.ts b/packages/api/tests/federation.test.ts index bcd561e4..3bd8b277 100644 --- a/packages/api/tests/federation.test.ts +++ b/packages/api/tests/federation.test.ts @@ -4,9 +4,12 @@ import { vi } from "vitest" import { activityForgeFedJsonLdContext, - actorJsonLdContext, federationJsonLdContentType } from "../src/api/contracts.js" +import { + makeFedifyActorJsonLd, + makeFedifyFollowingJsonLd +} from "../src/services/fedify-federation.js" import { clearFederationState, createFollowSubscription, @@ -15,13 +18,21 @@ import { listFederationIssues, listExchangeSubscriptions, listFollowSubscriptions, - makeFederationActorDocument, makeFederationContext, makeFederationExchangeStatus, - makeFederationFollowingCollection, pollExchangeOutboxes } from "../src/services/federation.js" +const asRecord = (value: unknown): Record => { + if (typeof value !== "object" || value === null || Array.isArray(value)) { + throw new Error("Expected JSON object.") + } + return value as Record +} + +const readField = (record: Record, key: string): unknown => + Reflect.get(record, key) + describe("federation service", () => { it.effect("ingests ForgeFed Offer with Ticket payload", () => Effect.gen(function*(_) { @@ -130,7 +141,7 @@ describe("federation service", () => { expect(created.subscription.inbox).toBe("https://social.provercoder.ai/federation/inbox") })) - it.effect("builds person and following collections in activitypub shape", () => + it.effect("builds person and following collections through Fedify", () => Effect.gen(function*(_) { clearFederationState() @@ -141,12 +152,11 @@ describe("federation service", () => { }) ) - const person = makeFederationActorDocument(context) - expect(person.type).toBe("Person") - expect(person["@context"]).toEqual(actorJsonLdContext) - expect(person.id).toBe("https://social.provercoder.ai/federation/actor") - expect(person.preferredUsername).toBe("tasks") - expect(person.followers).toBe("https://social.provercoder.ai/federation/followers") + const person = asRecord(yield* _(makeFedifyActorJsonLd(context))) + expect(readField(person, "type")).toBe("Person") + expect(readField(person, "id")).toBe("https://social.provercoder.ai/federation/actor") + expect(readField(person, "preferredUsername")).toBe("tasks") + expect(readField(person, "followers")).toBe("https://social.provercoder.ai/federation/followers") const created = yield* _( createFollowSubscription( @@ -165,10 +175,12 @@ describe("federation service", () => { }) ) - const following = makeFederationFollowingCollection(context) - expect(following.type).toBe("OrderedCollection") - expect(following.totalItems).toBe(1) - expect(following.orderedItems[0]).toBe("https://tracker.provercoder.ai/issues/followers") + const following = asRecord(yield* _(makeFedifyFollowingJsonLd(context))) + expect(readField(following, "type")).toBe("OrderedCollection") + expect(readField(following, "totalItems")).toBe(1) + expect(readField(following, "orderedItems")).toEqual([ + "https://tracker.provercoder.ai/issues/followers" + ]) })) it.effect("rejects duplicate pending follow subscription", () => diff --git a/packages/api/tests/http-config.test.ts b/packages/api/tests/http-config.test.ts index 20353327..2058bac7 100644 --- a/packages/api/tests/http-config.test.ts +++ b/packages/api/tests/http-config.test.ts @@ -1,20 +1,14 @@ import * as HttpApp from "@effect/platform/HttpApp" import * as HttpRouter from "@effect/platform/HttpRouter" import { describe, expect, it } from "@effect/vitest" -import { Effect, Either, ParseResult, Schema } from "effect" +import { Effect } from "effect" import fc from "fast-check" import { - activityForgeFedJsonLdContext, + activityStreamsJsonLdContext, actorJsonLdContext, federationJsonLdResponseContentType } from "../src/api/contracts.js" -import { - ActivityPubOrderedCollectionPageSchema, - ActivityPubOrderedCollectionSchema, - ActivityPubPersonSchema, - exactActivityPubParseOptions -} from "../src/api/schema.js" import { federationActorDocumentResponse, federationExchangeStatusResponse, @@ -22,6 +16,7 @@ import { federationFollowingDocumentResponse, federationLikedDocumentResponse, federationOutboxDocumentResponse, + federationWebFingerResponse, resolveConfiguredFederationPublicOrigin } from "../src/http.js" import { clearFederationState } from "../src/services/federation.js" @@ -63,6 +58,7 @@ const federationDocumentHandler = HttpApp.toWebHandler( Effect.flatten( HttpRouter.toHttpApp( HttpRouter.empty.pipe( + HttpRouter.get("/.well-known/webfinger", federationWebFingerResponse()), HttpRouter.get("/federation/actor", federationActorDocumentResponse()), HttpRouter.get("/federation/outbox", federationOutboxDocumentResponse()), HttpRouter.get("/federation/followers", federationFollowersDocumentResponse()), @@ -138,20 +134,24 @@ const readNestedField = (value: object | null, parent: string, key: string): unk return typeof nested === "object" && nested !== null ? Reflect.get(nested, key) : undefined } -const decodeOrThrow = (schema: Schema.Schema, value: unknown): A => - Either.match(Schema.decodeUnknownEither(schema, exactActivityPubParseOptions)(value), { - onLeft: (error) => { - throw new Error(ParseResult.TreeFormatter.formatIssueSync(error.issue)) - }, - onRight: (decoded) => decoded - }) +const unsupportedMastodonTerms = [ + "https://purl.archive.org/socialweb/webfinger", + "http://joinmastodon.org/ns#", + "toot:", + "featuredTags", + "alsoKnownAs", + "movedTo", + "manuallyApprovesFollowers", + "discoverable", + "suspended", + "interactionPolicy" +] as const -const decodeFederationDocument = (expectedType: string, payload: object | null): void => { - if (expectedType === "Person") { - decodeOrThrow(ActivityPubPersonSchema, payload) - return +const assertNoMastodonTerms = (payload: object | null): void => { + const serialized = JSON.stringify(payload) + for (const term of unsupportedMastodonTerms) { + expect(serialized.includes(term)).toBe(false) } - decodeOrThrow(ActivityPubOrderedCollectionSchema, payload) } const federationDocumentCases: ReadonlyArray<{ @@ -168,25 +168,25 @@ const federationDocumentCases: ReadonlyArray<{ }, { path: "/federation/outbox", - expectedContext: activityForgeFedJsonLdContext, + expectedContext: activityStreamsJsonLdContext, expectedId: "https://public.example.test/federation/outbox", expectedType: "OrderedCollection" }, { path: "/federation/followers", - expectedContext: activityForgeFedJsonLdContext, + expectedContext: activityStreamsJsonLdContext, expectedId: "https://public.example.test/federation/followers", expectedType: "OrderedCollection" }, { path: "/federation/following", - expectedContext: activityForgeFedJsonLdContext, + expectedContext: activityStreamsJsonLdContext, expectedId: "https://public.example.test/federation/following", expectedType: "OrderedCollection" }, { path: "/federation/liked", - expectedContext: activityForgeFedJsonLdContext, + expectedContext: activityStreamsJsonLdContext, expectedId: "https://public.example.test/federation/liked", expectedType: "OrderedCollection" } @@ -267,11 +267,11 @@ describe("api http config", () => { expect(readField(payload, "@context")).toEqual(documentCase.expectedContext) expect(readField(payload, "type")).toBe(documentCase.expectedType) expect(readField(payload, "id")).toBe(documentCase.expectedId) - decodeFederationDocument(documentCase.expectedType, payload) + assertNoMastodonTerms(payload) })) } - it.effect("serves followers page as typed ActivityPub JSON-LD", () => + it.effect("serves followers page as Fedify ActivityPub JSON-LD", () => Effect.gen(function*(_) { yield* _(Effect.sync(() => clearFederationState())) @@ -280,11 +280,40 @@ describe("api http config", () => { expect(document.status).toBe(200) expect(document.contentType).toBe(federationJsonLdResponseContentType) - expect(readField(payload, "@context")).toEqual(activityForgeFedJsonLdContext) + expect(readField(payload, "@context")).toBe(activityStreamsJsonLdContext) expect(readField(payload, "type")).toBe("OrderedCollectionPage") expect(readField(payload, "id")).toBe("https://public.example.test/federation/followers?page=1") expect(readField(payload, "partOf")).toBe("https://public.example.test/federation/followers") - decodeOrThrow(ActivityPubOrderedCollectionPageSchema, payload) + assertNoMastodonTerms(payload) + })) + + it.effect("serves WebFinger through Fedify", () => + Effect.gen(function*(_) { + yield* _(Effect.sync(() => clearFederationState())) + + const document = yield* _( + readFederationDocumentRoute( + "/.well-known/webfinger?resource=acct:docker-git@public.example.test" + ) + ) + const payload = parseJsonObject(document.body) + const links = readField(payload, "links") + + expect(document.status).toBe(200) + expect(document.contentType).toBe("application/jrd+json") + expect(readField(payload, "subject")).toBe("acct:docker-git@public.example.test") + expect(readField(payload, "aliases")).toEqual([ + "https://public.example.test/federation/actor" + ]) + expect(Array.isArray(links)).toBe(true) + if (!Array.isArray(links)) { + throw new Error("Expected WebFinger links.") + } + expect(links[0]).toEqual({ + rel: "self", + href: "https://public.example.test/federation/actor", + type: "application/activity+json" + }) })) it.effect("rejects unsupported followers pages", () => From 79a2e2161b46078679a3a0f2cde6e7999c794bea Mon Sep 17 00:00:00 2001 From: skulidropek <66840575+skulidropek@users.noreply.github.com> Date: Thu, 21 May 2026 08:35:40 +0000 Subject: [PATCH 2/5] test(api): strengthen Fedify protocol invariants --- .../tests/activitypub-schema-parity.test.ts | 7 +- packages/api/tests/federation.test.ts | 116 ++++++++++++++- packages/api/tests/http-config.test.ts | 136 ++++++++++++++++-- 3 files changed, 243 insertions(+), 16 deletions(-) diff --git a/packages/api/tests/activitypub-schema-parity.test.ts b/packages/api/tests/activitypub-schema-parity.test.ts index 791ee109..651b5f7e 100644 --- a/packages/api/tests/activitypub-schema-parity.test.ts +++ b/packages/api/tests/activitypub-schema-parity.test.ts @@ -23,6 +23,9 @@ import { type JsonRecord = Record +const isJsonRecord = (value: unknown): value is JsonRecord => + typeof value === "object" && value !== null && !Array.isArray(value) + const unsupportedMastodonTerms = [ "https://purl.archive.org/socialweb/webfinger", "http://joinmastodon.org/ns#", @@ -45,10 +48,10 @@ const unsupportedMastodonTerms = [ ] as const const asRecord = (value: unknown): JsonRecord => { - if (typeof value !== "object" || value === null || Array.isArray(value)) { + if (!isJsonRecord(value)) { throw new Error("Expected JSON object.") } - return value as JsonRecord + return value } const readField = (record: JsonRecord, key: string): unknown => diff --git a/packages/api/tests/federation.test.ts b/packages/api/tests/federation.test.ts index 3bd8b277..fb7cc30e 100644 --- a/packages/api/tests/federation.test.ts +++ b/packages/api/tests/federation.test.ts @@ -1,5 +1,7 @@ +import { Person } from "@fedify/vocab" import { describe, expect, it } from "@effect/vitest" import { Effect } from "effect" +import fc from "fast-check" import { vi } from "vitest" import { @@ -23,16 +25,38 @@ import { pollExchangeOutboxes } from "../src/services/federation.js" -const asRecord = (value: unknown): Record => { - if (typeof value !== "object" || value === null || Array.isArray(value)) { +type JsonRecord = Record + +const isJsonRecord = (value: unknown): value is JsonRecord => + typeof value === "object" && value !== null && !Array.isArray(value) + +const asRecord = (value: unknown): JsonRecord => { + if (!isJsonRecord(value)) { throw new Error("Expected JSON object.") } - return value as Record + return value } -const readField = (record: Record, key: string): unknown => +const readField = (record: JsonRecord, key: string): unknown => Reflect.get(record, key) +const countKey = (value: unknown, key: string): number => { + if (Array.isArray(value)) { + return value.reduce((count, item) => count + countKey(item, key), 0) + } + if (!isJsonRecord(value)) { + return 0 + } + const current = Reflect.has(value, key) ? 1 : 0 + return Object.values(value).reduce((count, item) => count + countKey(item, key), current) +} + +const actorUsernameArbitrary = fc + .string({ minLength: 1, maxLength: 20 }) + .filter((value) => /^[a-z0-9_-]+$/i.test(value)) + +const publicOriginArbitrary = fc.webUrl().map((value) => new URL(value).origin) + describe("federation service", () => { it.effect("ingests ForgeFed Offer with Ticket payload", () => Effect.gen(function*(_) { @@ -183,6 +207,90 @@ describe("federation service", () => { ]) })) + it.effect("satisfies Fedify actor JSON-LD property invariants", () => + Effect.tryPromise({ + try: () => + fc.assert( + fc.asyncProperty( + fc.record({ + publicOrigin: publicOriginArbitrary, + actorUsername: actorUsernameArbitrary + }), + async ({ publicOrigin, actorUsername }) => { + clearFederationState() + const context = await Effect.runPromise( + makeFederationContext({ publicOrigin, actorUsername }) + ) + const payload = await Effect.runPromise(makeFedifyActorJsonLd(context)) + const actor = asRecord(payload) + const parsed = await Person.fromJsonLd(payload) + + expect(parsed.id?.href).toBe(`${context.publicOrigin}/federation/actor`) + expect(parsed.preferredUsername).toBe(context.actorUsername) + expect(countKey(actor, "@context")).toBe(1) + expect(readField(actor, "id")).toBe(`${context.publicOrigin}/federation/actor`) + expect(readField(actor, "inbox")).toBe(`${context.publicOrigin}/federation/inbox`) + expect(readField(actor, "outbox")).toBe(`${context.publicOrigin}/federation/outbox`) + expect(readField(actor, "followers")).toBe(`${context.publicOrigin}/federation/followers`) + expect(readField(actor, "following")).toBe(`${context.publicOrigin}/federation/following`) + expect(readField(actor, "liked")).toBe(`${context.publicOrigin}/federation/liked`) + } + ), + { numRuns: 10 } + ), + catch: (cause) => cause instanceof Error ? cause : new Error(String(cause)) + })) + + it.effect("satisfies Fedify following collection property invariants", () => + Effect.tryPromise({ + try: () => + fc.assert( + fc.asyncProperty( + fc.record({ + targetIds: fc.uniqueArray(fc.integer({ min: 1, max: 10_000 }), { maxLength: 5 }) + }), + async ({ targetIds }) => { + clearFederationState() + const context = await Effect.runPromise( + makeFederationContext({ + publicOrigin: "https://social.provercoder.ai", + actorUsername: "tasks" + }) + ) + + for (const targetId of targetIds) { + const created = await Effect.runPromise( + createFollowSubscription( + { + object: `https://tracker${targetId}.example.test/issues/followers` + }, + context + ) + ) + await Effect.runPromise( + ingestFederationInbox({ + "@context": activityForgeFedJsonLdContext, + type: "Accept", + object: created.activity.id + }) + ) + } + + const payload = await Effect.runPromise(makeFedifyFollowingJsonLd(context)) + const following = asRecord(payload) + const orderedItems = readField(following, "orderedItems") + const items = Array.isArray(orderedItems) ? orderedItems : [] + + expect(readField(following, "id")).toBe(`${context.publicOrigin}/federation/following`) + expect(readField(following, "totalItems")).toBe(items.length) + expect(countKey(following, "@context")).toBe(1) + } + ), + { numRuns: 10 } + ), + catch: (cause) => cause instanceof Error ? cause : new Error(String(cause)) + })) + it.effect("rejects duplicate pending follow subscription", () => Effect.gen(function*(_) { clearFederationState() diff --git a/packages/api/tests/http-config.test.ts b/packages/api/tests/http-config.test.ts index 2058bac7..5efd4f69 100644 --- a/packages/api/tests/http-config.test.ts +++ b/packages/api/tests/http-config.test.ts @@ -1,5 +1,6 @@ import * as HttpApp from "@effect/platform/HttpApp" import * as HttpRouter from "@effect/platform/HttpRouter" +import { OrderedCollectionPage } from "@fedify/vocab" import { describe, expect, it } from "@effect/vitest" import { Effect } from "effect" import fc from "fast-check" @@ -134,26 +135,82 @@ const readNestedField = (value: object | null, parent: string, key: string): unk return typeof nested === "object" && nested !== null ? Reflect.get(nested, key) : undefined } -const unsupportedMastodonTerms = [ +type JsonRecord = Record + +const isJsonRecord = (value: unknown): value is JsonRecord => + typeof value === "object" && value !== null && !Array.isArray(value) + +const unsupportedMastodonContextTerms = [ "https://purl.archive.org/socialweb/webfinger", "http://joinmastodon.org/ns#", - "toot:", + "toot:" +] as const + +const unsupportedMastodonKeys = [ + "toot", + "featured", "featuredTags", "alsoKnownAs", "movedTo", "manuallyApprovesFollowers", "discoverable", "suspended", - "interactionPolicy" + "interactionPolicy", + "canQuote", + "automaticApproval", + "manualApproval" ] as const +const assertNoMastodonContextTerms = (value: unknown): void => { + if (typeof value === "string") { + for (const term of unsupportedMastodonContextTerms) { + expect(value.includes(term)).toBe(false) + } + return + } + if (Array.isArray(value)) { + for (const item of value) { + assertNoMastodonContextTerms(item) + } + return + } + if (isJsonRecord(value)) { + for (const [key, item] of Object.entries(value)) { + expect(unsupportedMastodonKeys.some((unsupportedKey) => unsupportedKey === key)).toBe(false) + assertNoMastodonContextTerms(item) + } + } +} + +const assertNoMastodonKeys = (value: unknown): void => { + if (Array.isArray(value)) { + for (const item of value) { + assertNoMastodonKeys(item) + } + return + } + if (isJsonRecord(value)) { + for (const [key, item] of Object.entries(value)) { + expect(unsupportedMastodonKeys.some((unsupportedKey) => unsupportedKey === key)).toBe(false) + assertNoMastodonKeys(item) + } + } +} + const assertNoMastodonTerms = (payload: object | null): void => { - const serialized = JSON.stringify(payload) - for (const term of unsupportedMastodonTerms) { - expect(serialized.includes(term)).toBe(false) + if (payload === null) { + return } + assertNoMastodonContextTerms(Reflect.get(payload, "@context")) + assertNoMastodonKeys(payload) } +const parseOrderedCollectionPage = (payload: unknown) => + Effect.tryPromise({ + try: () => OrderedCollectionPage.fromJsonLd(payload), + catch: (cause) => new Error(String(cause)) + }) + const federationDocumentCases: ReadonlyArray<{ readonly path: string readonly expectedContext: unknown @@ -192,6 +249,11 @@ const federationDocumentCases: ReadonlyArray<{ } ] +const webFingerResourceArbitrary = fc.constantFrom( + "acct:docker-git@public.example.test", + "https://public.example.test/federation/actor" +) + describe("api http config", () => { it.effect("ignores empty federation public origin values", () => Effect.sync(() => { @@ -280,10 +342,10 @@ describe("api http config", () => { expect(document.status).toBe(200) expect(document.contentType).toBe(federationJsonLdResponseContentType) - expect(readField(payload, "@context")).toBe(activityStreamsJsonLdContext) - expect(readField(payload, "type")).toBe("OrderedCollectionPage") - expect(readField(payload, "id")).toBe("https://public.example.test/federation/followers?page=1") - expect(readField(payload, "partOf")).toBe("https://public.example.test/federation/followers") + const page = yield* _(parseOrderedCollectionPage(payload)) + expect(page.id?.href).toBe("https://public.example.test/federation/followers?page=1") + expect(page.partOfId?.href).toBe("https://public.example.test/federation/followers") + expect(page.totalItems).toBe(0) assertNoMastodonTerms(payload) })) @@ -316,6 +378,60 @@ describe("api http config", () => { }) })) + it.effect("satisfies WebFinger invariants for supported actor resources", () => + Effect.tryPromise({ + try: () => + fc.assert( + fc.asyncProperty(webFingerResourceArbitrary, async (resource) => { + clearFederationState() + const document = await Effect.runPromise( + readFederationDocumentRoute( + `/.well-known/webfinger?resource=${encodeURIComponent(resource)}` + ) + ) + const payload = parseJsonObject(document.body) + if (payload === null) { + throw new Error("Expected WebFinger JSON object.") + } + const aliases = readField(payload, "aliases") + const links = readField(payload, "links") + + expect(document.status).toBe(200) + expect(readField(payload, "subject")).toBe(resource) + expect(Array.isArray(aliases)).toBe(true) + if (!Array.isArray(aliases)) { + throw new Error("Expected WebFinger aliases.") + } + for (const alias of aliases) { + expect(new URL(String(alias)).href).toBe(String(alias)) + } + + expect(Array.isArray(links)).toBe(true) + if (!Array.isArray(links)) { + throw new Error("Expected WebFinger links.") + } + const selfLink = links + .filter(isJsonRecord) + .find((link) => + readField(link, "rel") === "self" && + readField(link, "type") === "application/activity+json") + if (selfLink === undefined) { + throw new Error("Expected WebFinger self link.") + } + const actorHref = readField(selfLink, "href") + expect(actorHref).toBe("https://public.example.test/federation/actor") + + const actor = await Effect.runPromise(readFederationDocumentRoute("/federation/actor")) + const actorPayload = parseJsonObject(actor.body) + expect(actor.status).toBe(200) + expect(readField(actorPayload, "id")).toBe(actorHref) + expect(readField(actorPayload, "type")).toBe("Person") + }), + { numRuns: 4 } + ), + catch: (cause) => cause instanceof Error ? cause : new Error(String(cause)) + })) + it.effect("rejects unsupported followers pages", () => Effect.gen(function*(_) { yield* _(Effect.sync(() => clearFederationState())) From 0444748f0a26b7423db71a1d7202336da16d3f06 Mon Sep 17 00:00:00 2001 From: skulidropek <66840575+skulidropek@users.noreply.github.com> Date: Thu, 21 May 2026 08:39:09 +0000 Subject: [PATCH 3/5] docs(api): document Fedify WebFinger proof --- packages/api/README.md | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/packages/api/README.md b/packages/api/README.md index e562564e..b28122a5 100644 --- a/packages/api/README.md +++ b/packages/api/README.md @@ -82,6 +82,7 @@ Optional env: - `GET /health` - `POST /federation/inbox` (ForgeFed `Ticket` / `Offer(Ticket)`, ActivityPub `Accept` / `Reject`) +- `GET /.well-known/webfinger` (Fedify WebFinger document for the local federation actor) - `GET /federation/issues` - `GET /federation/actor` (ActivityPub `Person`) - `GET /federation/outbox` @@ -116,6 +117,14 @@ Optional env: Exchange targets must be explicit. Use `https://exchange.lefine.pro`, an actor URL, or a handle like `code@exchange.lefine.pro`; the API resolves the code actor document, stores its `inbox/outbox/followers/publicKey`, sends `Follow`, and polls the stored `outbox`. +Local ActivityPub documents are serialized with Fedify and use only the supported ActivityStreams and security JSON-LD contexts. Mastodon-specific extension contexts and keys such as `https://purl.archive.org/socialweb/webfinger`, `toot`, `featured`, `featuredTags`, `alsoKnownAs`, `movedTo`, and `interactionPolicy` are not emitted by docker-git. + +The local actor is discoverable through WebFinger: + +```bash +./ctl request GET '/.well-known/webfinger?resource=acct:docker-git@social.provercoder.ai' +``` + ```bash ./ctl request POST /federation/exchange/subscriptions '{ "domain":"https://social.provercoder.ai", From 2ed13f71f08bb9d620b90cbe66cb0233ad8ed04a Mon Sep 17 00:00:00 2001 From: skulidropek <66840575+skulidropek@users.noreply.github.com> Date: Thu, 21 May 2026 08:45:12 +0000 Subject: [PATCH 4/5] test(api): keep federation properties in Effect --- packages/api/tests/federation.test.ts | 119 ++++++++++++++----------- packages/api/tests/http-config.test.ts | 99 +++++++++++--------- 2 files changed, 124 insertions(+), 94 deletions(-) diff --git a/packages/api/tests/federation.test.ts b/packages/api/tests/federation.test.ts index fb7cc30e..4b14e54d 100644 --- a/packages/api/tests/federation.test.ts +++ b/packages/api/tests/federation.test.ts @@ -57,6 +57,12 @@ const actorUsernameArbitrary = fc const publicOriginArbitrary = fc.webUrl().map((value) => new URL(value).origin) +const parsePersonJsonLd = (payload: unknown) => + Effect.tryPromise({ + try: () => Person.fromJsonLd(payload), + catch: (cause) => cause instanceof Error ? cause : new Error(String(cause)) + }) + describe("federation service", () => { it.effect("ingests ForgeFed Offer with Ticket payload", () => Effect.gen(function*(_) { @@ -216,25 +222,28 @@ describe("federation service", () => { publicOrigin: publicOriginArbitrary, actorUsername: actorUsernameArbitrary }), - async ({ publicOrigin, actorUsername }) => { - clearFederationState() - const context = await Effect.runPromise( - makeFederationContext({ publicOrigin, actorUsername }) + ({ publicOrigin, actorUsername }) => + Effect.runPromise( + Effect.gen(function*(_) { + yield* _(Effect.sync(() => clearFederationState())) + const context = yield* _( + makeFederationContext({ publicOrigin, actorUsername }) + ) + const payload = yield* _(makeFedifyActorJsonLd(context)) + const actor = asRecord(payload) + const parsed = yield* _(parsePersonJsonLd(payload)) + + expect(parsed.id?.href).toBe(`${context.publicOrigin}/federation/actor`) + expect(parsed.preferredUsername).toBe(context.actorUsername) + expect(countKey(actor, "@context")).toBe(1) + expect(readField(actor, "id")).toBe(`${context.publicOrigin}/federation/actor`) + expect(readField(actor, "inbox")).toBe(`${context.publicOrigin}/federation/inbox`) + expect(readField(actor, "outbox")).toBe(`${context.publicOrigin}/federation/outbox`) + expect(readField(actor, "followers")).toBe(`${context.publicOrigin}/federation/followers`) + expect(readField(actor, "following")).toBe(`${context.publicOrigin}/federation/following`) + expect(readField(actor, "liked")).toBe(`${context.publicOrigin}/federation/liked`) + }) ) - const payload = await Effect.runPromise(makeFedifyActorJsonLd(context)) - const actor = asRecord(payload) - const parsed = await Person.fromJsonLd(payload) - - expect(parsed.id?.href).toBe(`${context.publicOrigin}/federation/actor`) - expect(parsed.preferredUsername).toBe(context.actorUsername) - expect(countKey(actor, "@context")).toBe(1) - expect(readField(actor, "id")).toBe(`${context.publicOrigin}/federation/actor`) - expect(readField(actor, "inbox")).toBe(`${context.publicOrigin}/federation/inbox`) - expect(readField(actor, "outbox")).toBe(`${context.publicOrigin}/federation/outbox`) - expect(readField(actor, "followers")).toBe(`${context.publicOrigin}/federation/followers`) - expect(readField(actor, "following")).toBe(`${context.publicOrigin}/federation/following`) - expect(readField(actor, "liked")).toBe(`${context.publicOrigin}/federation/liked`) - } ), { numRuns: 10 } ), @@ -249,42 +258,52 @@ describe("federation service", () => { fc.record({ targetIds: fc.uniqueArray(fc.integer({ min: 1, max: 10_000 }), { maxLength: 5 }) }), - async ({ targetIds }) => { - clearFederationState() - const context = await Effect.runPromise( - makeFederationContext({ - publicOrigin: "https://social.provercoder.ai", - actorUsername: "tasks" - }) - ) + ({ targetIds }) => + Effect.runPromise( + Effect.gen(function*(_) { + yield* _(Effect.sync(() => clearFederationState())) + const context = yield* _( + makeFederationContext({ + publicOrigin: "https://social.provercoder.ai", + actorUsername: "tasks" + }) + ) - for (const targetId of targetIds) { - const created = await Effect.runPromise( - createFollowSubscription( - { - object: `https://tracker${targetId}.example.test/issues/followers` - }, - context + yield* _( + Effect.forEach( + targetIds, + (targetId) => + Effect.gen(function*(_) { + const created = yield* _( + createFollowSubscription( + { + object: `https://tracker${targetId}.example.test/issues/followers` + }, + context + ) + ) + yield* _( + ingestFederationInbox({ + "@context": activityForgeFedJsonLdContext, + type: "Accept", + object: created.activity.id + }) + ) + }), + { discard: true } + ) ) - ) - await Effect.runPromise( - ingestFederationInbox({ - "@context": activityForgeFedJsonLdContext, - type: "Accept", - object: created.activity.id - }) - ) - } - const payload = await Effect.runPromise(makeFedifyFollowingJsonLd(context)) - const following = asRecord(payload) - const orderedItems = readField(following, "orderedItems") - const items = Array.isArray(orderedItems) ? orderedItems : [] + const payload = yield* _(makeFedifyFollowingJsonLd(context)) + const following = asRecord(payload) + const orderedItems = readField(following, "orderedItems") + const items = Array.isArray(orderedItems) ? orderedItems : [] - expect(readField(following, "id")).toBe(`${context.publicOrigin}/federation/following`) - expect(readField(following, "totalItems")).toBe(items.length) - expect(countKey(following, "@context")).toBe(1) - } + expect(readField(following, "id")).toBe(`${context.publicOrigin}/federation/following`) + expect(readField(following, "totalItems")).toBe(items.length) + expect(countKey(following, "@context")).toBe(1) + }) + ) ), { numRuns: 10 } ), diff --git a/packages/api/tests/http-config.test.ts b/packages/api/tests/http-config.test.ts index 5efd4f69..c39c4647 100644 --- a/packages/api/tests/http-config.test.ts +++ b/packages/api/tests/http-config.test.ts @@ -382,51 +382,62 @@ describe("api http config", () => { Effect.tryPromise({ try: () => fc.assert( - fc.asyncProperty(webFingerResourceArbitrary, async (resource) => { - clearFederationState() - const document = await Effect.runPromise( - readFederationDocumentRoute( - `/.well-known/webfinger?resource=${encodeURIComponent(resource)}` - ) + fc.asyncProperty(webFingerResourceArbitrary, (resource) => + Effect.runPromise( + Effect.gen(function*(_) { + yield* _(Effect.sync(() => clearFederationState())) + const document = yield* _( + readFederationDocumentRoute( + `/.well-known/webfinger?resource=${encodeURIComponent(resource)}` + ) + ) + const payload = parseJsonObject(document.body) + if (payload === null) { + throw new Error("Expected WebFinger JSON object.") + } + const aliases = readField(payload, "aliases") + const links = readField(payload, "links") + + expect(document.status).toBe(200) + expect(readField(payload, "subject")).toBe(resource) + expect(Array.isArray(aliases)).toBe(true) + if (!Array.isArray(aliases)) { + throw new Error("Expected WebFinger aliases.") + } + yield* _( + Effect.forEach( + aliases, + (alias) => + Effect.sync(() => { + expect(new URL(String(alias)).href).toBe(String(alias)) + }), + { discard: true } + ) + ) + + expect(Array.isArray(links)).toBe(true) + if (!Array.isArray(links)) { + throw new Error("Expected WebFinger links.") + } + const selfLink = links + .filter(isJsonRecord) + .find((link) => + readField(link, "rel") === "self" && + readField(link, "type") === "application/activity+json") + if (selfLink === undefined) { + throw new Error("Expected WebFinger self link.") + } + const actorHref = readField(selfLink, "href") + expect(actorHref).toBe("https://public.example.test/federation/actor") + + const actor = yield* _(readFederationDocumentRoute("/federation/actor")) + const actorPayload = parseJsonObject(actor.body) + expect(actor.status).toBe(200) + expect(readField(actorPayload, "id")).toBe(actorHref) + expect(readField(actorPayload, "type")).toBe("Person") + }) ) - const payload = parseJsonObject(document.body) - if (payload === null) { - throw new Error("Expected WebFinger JSON object.") - } - const aliases = readField(payload, "aliases") - const links = readField(payload, "links") - - expect(document.status).toBe(200) - expect(readField(payload, "subject")).toBe(resource) - expect(Array.isArray(aliases)).toBe(true) - if (!Array.isArray(aliases)) { - throw new Error("Expected WebFinger aliases.") - } - for (const alias of aliases) { - expect(new URL(String(alias)).href).toBe(String(alias)) - } - - expect(Array.isArray(links)).toBe(true) - if (!Array.isArray(links)) { - throw new Error("Expected WebFinger links.") - } - const selfLink = links - .filter(isJsonRecord) - .find((link) => - readField(link, "rel") === "self" && - readField(link, "type") === "application/activity+json") - if (selfLink === undefined) { - throw new Error("Expected WebFinger self link.") - } - const actorHref = readField(selfLink, "href") - expect(actorHref).toBe("https://public.example.test/federation/actor") - - const actor = await Effect.runPromise(readFederationDocumentRoute("/federation/actor")) - const actorPayload = parseJsonObject(actor.body) - expect(actor.status).toBe(200) - expect(readField(actorPayload, "id")).toBe(actorHref) - expect(readField(actorPayload, "type")).toBe("Person") - }), + ), { numRuns: 4 } ), catch: (cause) => cause instanceof Error ? cause : new Error(String(cause)) From 3c72a3b64f6b4379a75046ba22e4303ebbce21e2 Mon Sep 17 00:00:00 2001 From: skulidropek <66840575+skulidropek@users.noreply.github.com> Date: Thu, 21 May 2026 10:21:46 +0000 Subject: [PATCH 5/5] fix(app): expose WebFinger at browser root --- .../app/scripts/serve-dist-web-routing.mjs | 53 +++++++++++++++++++ packages/app/scripts/serve-dist-web.mjs | 44 ++------------- .../tests/docker-git/serve-dist-web.test.ts | 15 ++++++ packages/app/vite.web.config.ts | 6 +++ 4 files changed, 77 insertions(+), 41 deletions(-) create mode 100644 packages/app/scripts/serve-dist-web-routing.mjs create mode 100644 packages/app/tests/docker-git/serve-dist-web.test.ts diff --git a/packages/app/scripts/serve-dist-web-routing.mjs b/packages/app/scripts/serve-dist-web-routing.mjs new file mode 100644 index 00000000..8faf94ba --- /dev/null +++ b/packages/app/scripts/serve-dist-web-routing.mjs @@ -0,0 +1,53 @@ +const dbGateOwnedPathPrefixes = [ + "/admin", + "/admin-license", + "/build/", + "/bulma.css", + "/connections", + "/database-connections", + "/dimensions.css", + "/favicon.ico", + "/forgot-password", + "/global.css", + "/icon-colors.css", + "/license", + "/login", + "/manifest.json", + "/oauth", + "/plugins", + "/redirect", + "/reset-password", + "/runners", + "/scheduler", + "/set-admin-password", + "/storage", + "/tokens.css" +] + +const isDbGateOwnedPath = (pathname) => + dbGateOwnedPathPrefixes.some((prefix) => pathname === prefix || pathname.startsWith(prefix)) + +const isFederationPath = (pathname) => + pathname === "/federation" || pathname.startsWith("/federation/") + +// CHANGE: expose the standards-defined WebFinger endpoint at the public root. +// WHY: ActivityPub discovery queries /.well-known/webfinger on the actor domain, not under /api. +// QUOTE(ТЗ): "В руте .well-known должен быть" +// REF: issue-334-webfinger-root-route +// SOURCE: https://www.rfc-editor.org/rfc/rfc7033 +// FORMAT THEOREM: forall p: p = "/.well-known/webfinger" -> proxyTarget(p) = API(p) +// PURITY: CORE +// INVARIANT: only the WebFinger well-known path is claimed by the browser shell. +// COMPLEXITY: O(1)/O(1) +const isWellKnownWebFingerPath = (pathname) => + pathname === "/.well-known/webfinger" + +export const shouldProxyHttpPath = (pathname) => + pathname === "/api" || + pathname.startsWith("/api/") || + isFederationPath(pathname) || + isWellKnownWebFingerPath(pathname) || + pathname.startsWith("/p/") || + pathname.startsWith("/b/") || + pathname.startsWith("/d/") || + isDbGateOwnedPath(pathname) diff --git a/packages/app/scripts/serve-dist-web.mjs b/packages/app/scripts/serve-dist-web.mjs index ffd625c1..818b6f0d 100644 --- a/packages/app/scripts/serve-dist-web.mjs +++ b/packages/app/scripts/serve-dist-web.mjs @@ -7,6 +7,8 @@ import { fileURLToPath } from "node:url" import { WebSocket, WebSocketServer } from "ws" +import { shouldProxyHttpPath } from "./serve-dist-web-routing.mjs" + const appRoot = normalize(join(fileURLToPath(new URL(".", import.meta.url)), "..")) const staticRoot = join(appRoot, "dist-web") const trimTrailingSlashes = (value) => value.replace(/\/+$/u, "") @@ -63,35 +65,6 @@ const reachableUrls = (host, port) => ? [`http://127.0.0.1:${port}`, ...hostNetworkUrls(port)] : [`http://${host}:${port}`] -const dbGateOwnedPathPrefixes = [ - "/admin", - "/admin-license", - "/build/", - "/bulma.css", - "/connections", - "/database-connections", - "/dimensions.css", - "/favicon.ico", - "/forgot-password", - "/global.css", - "/icon-colors.css", - "/license", - "/login", - "/manifest.json", - "/oauth", - "/plugins", - "/redirect", - "/reset-password", - "/runners", - "/scheduler", - "/set-admin-password", - "/storage", - "/tokens.css" -] - -const isDbGateOwnedPath = (pathname) => - dbGateOwnedPathPrefixes.some((prefix) => pathname === prefix || pathname.startsWith(prefix)) - const resolveStaticPath = (pathname) => { const normalized = normalize(pathname) return normalized.startsWith(staticRoot) @@ -123,9 +96,6 @@ const resolveUpstreamPath = (url) => { return `${pathname}${parsed.search}` } -const isFederationPath = (pathname) => - pathname === "/federation" || pathname.startsWith("/federation/") - const firstHeader = (value) => Array.isArray(value) ? value[0] : value const proxyForwardHeaders = (request, forwardedPrefix) => { @@ -273,15 +243,7 @@ const bridgeWebSockets = (clientSocket, upstream) => { const server = createServer((request, response) => { const parsed = new URL(request.url ?? "/", "http://localhost") - if ( - parsed.pathname === "/api" || - parsed.pathname.startsWith("/api/") || - isFederationPath(parsed.pathname) || - parsed.pathname.startsWith("/p/") || - parsed.pathname.startsWith("/b/") || - parsed.pathname.startsWith("/d/") || - isDbGateOwnedPath(parsed.pathname) - ) { + if (shouldProxyHttpPath(parsed.pathname)) { proxyHttp(request, response) return } diff --git a/packages/app/tests/docker-git/serve-dist-web.test.ts b/packages/app/tests/docker-git/serve-dist-web.test.ts new file mode 100644 index 00000000..7d2978a4 --- /dev/null +++ b/packages/app/tests/docker-git/serve-dist-web.test.ts @@ -0,0 +1,15 @@ +import { describe, expect, it } from "@effect/vitest" +import { Effect } from "effect" + +import { shouldProxyHttpPath } from "../../scripts/serve-dist-web-routing.mjs" + +describe("serve-dist-web routing", () => { + it.effect("proxies root WebFinger to API", () => + Effect.sync(() => { + expect(shouldProxyHttpPath("/.well-known/webfinger")).toBe(true) + expect(shouldProxyHttpPath("/.well-known/webfinger/extra")).toBe(false) + expect(shouldProxyHttpPath("/api/.well-known/webfinger")).toBe(true) + expect(shouldProxyHttpPath("/federation/actor")).toBe(true) + expect(shouldProxyHttpPath("/unknown-route")).toBe(false) + })) +}) diff --git a/packages/app/vite.web.config.ts b/packages/app/vite.web.config.ts index 66fb11d1..88f34c05 100644 --- a/packages/app/vite.web.config.ts +++ b/packages/app/vite.web.config.ts @@ -18,6 +18,12 @@ const noStoreHeaders = { const webSocketHeartbeatIntervalMs = 25_000 const createProxy = (apiTarget: string) => ({ + "/.well-known/webfinger": { + target: apiTarget, + changeOrigin: false, + xfwd: true, + ws: false + }, "/b": { target: apiTarget, changeOrigin: false,