From 036512d5805799f0a45f29e4cc0e8ce88ab20bcd Mon Sep 17 00:00:00 2001 From: Harvmaster Date: Mon, 11 May 2026 13:36:12 +0000 Subject: [PATCH] Initial Commit --- .gitignore | 1 + package-lock.json | 1308 ++++++++++++++++++++++++++++++ package.json | 29 + src/app.ts | 40 + src/routes/index.ts | 1 + src/routes/invitations.ts | 89 ++ src/services/http-router.ts | 138 ++++ src/services/invitation-store.ts | 46 ++ src/services/sse-broadcast.ts | 270 ++++++ src/utils/ext-json.ts | 124 +++ src/utils/invitation-parser.ts | 66 ++ 11 files changed, 2112 insertions(+) create mode 100644 .gitignore create mode 100644 package-lock.json create mode 100644 package.json create mode 100644 src/app.ts create mode 100644 src/routes/index.ts create mode 100644 src/routes/invitations.ts create mode 100644 src/services/http-router.ts create mode 100644 src/services/invitation-store.ts create mode 100644 src/services/sse-broadcast.ts create mode 100644 src/utils/ext-json.ts create mode 100644 src/utils/invitation-parser.ts diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..30bc162 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +/node_modules \ No newline at end of file diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..659416e --- /dev/null +++ b/package-lock.json @@ -0,0 +1,1308 @@ +{ + "name": "sync-server", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "sync-server", + "version": "1.0.0", + "license": "ISC", + "dependencies": { + "@bitauth/libauth": "^3.0.0", + "@fastify/cors": "^11.2.0", + "debug": "^4.4.3", + "fastify": "^5.7.2", + "zod": "^4.3.6" + }, + "devDependencies": { + "@types/debug": "^4.1.12", + "@xo-cash/types": "file:../types", + "tsx": "^4.21.0", + "typescript": "^5.9.3" + } + }, + "../types": { + "name": "@xo-cash/types", + "version": "0.0.1", + "dev": true, + "license": "MIT", + "dependencies": { + "@bitauth/libauth": "^3.1.0-next.8" + }, + "devDependencies": { + "@chalp/eslint-airbnb": "^1.3.0", + "@generalprotocols/cspell-dictionary": "^1.0.1", + "@stylistic/eslint-plugin": "^5.7.0", + "@typescript-eslint/eslint-plugin": "^8.53.1", + "@typescript-eslint/parser": "^8.53.1", + "@vitest/coverage-v8": "^4.0.17", + "@viz-kit/esbuild-analyzer": "^1.0.0", + "@xo-cash/eslint-config": "1.0.1", + "cspell": "^9.6.0", + "eslint": "^9.39.2", + "prettier": "^3.6.2", + "tsdown": "^0.20.0-beta.4", + "typedoc": "^0.28.16", + "typedoc-plugin-coverage": "^4.0.2", + "typescript": "^5.3.2", + "typescript-eslint": "^8.53.1", + "vitest": "^4.0.17" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@bitauth/libauth": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@bitauth/libauth/-/libauth-3.0.0.tgz", + "integrity": "sha512-3yoL31XpnhAnf5nDVMFk4xPqebxDwXrgYAwpa31ARJnV5A/eXWlpNYvCd6FTZPFM4VvKfjCBi+jRCrw1hOZ0Jg==", + "license": "MIT", + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.2.tgz", + "integrity": "sha512-GZMB+a0mOMZs4MpDbj8RJp4cw+w1WV5NYD6xzgvzUJ5Ek2jerwfO2eADyI6ExDSUED+1X8aMbegahsJi+8mgpw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.2.tgz", + "integrity": "sha512-DVNI8jlPa7Ujbr1yjU2PfUSRtAUZPG9I1RwW4F4xFB1Imiu2on0ADiI/c3td+KmDtVKNbi+nffGDQMfcIMkwIA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.2.tgz", + "integrity": "sha512-pvz8ZZ7ot/RBphf8fv60ljmaoydPU12VuXHImtAs0XhLLw+EXBi2BLe3OYSBslR4rryHvweW5gmkKFwTiFy6KA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.2.tgz", + "integrity": "sha512-z8Ank4Byh4TJJOh4wpz8g2vDy75zFL0TlZlkUkEwYXuPSgX8yzep596n6mT7905kA9uHZsf/o2OJZubl2l3M7A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.2.tgz", + "integrity": "sha512-davCD2Zc80nzDVRwXTcQP/28fiJbcOwvdolL0sOiOsbwBa72kegmVU0Wrh1MYrbuCL98Omp5dVhQFWRKR2ZAlg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.2.tgz", + "integrity": "sha512-ZxtijOmlQCBWGwbVmwOF/UCzuGIbUkqB1faQRf5akQmxRJ1ujusWsb3CVfk/9iZKr2L5SMU5wPBi1UWbvL+VQA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.2.tgz", + "integrity": "sha512-lS/9CN+rgqQ9czogxlMcBMGd+l8Q3Nj1MFQwBZJyoEKI50XGxwuzznYdwcav6lpOGv5BqaZXqvBSiB/kJ5op+g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.2.tgz", + "integrity": "sha512-tAfqtNYb4YgPnJlEFu4c212HYjQWSO/w/h/lQaBK7RbwGIkBOuNKQI9tqWzx7Wtp7bTPaGC6MJvWI608P3wXYA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.2.tgz", + "integrity": "sha512-vWfq4GaIMP9AIe4yj1ZUW18RDhx6EPQKjwe7n8BbIecFtCQG4CfHGaHuh7fdfq+y3LIA2vGS/o9ZBGVxIDi9hw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.2.tgz", + "integrity": "sha512-hYxN8pr66NsCCiRFkHUAsxylNOcAQaxSSkHMMjcpx0si13t1LHFphxJZUiGwojB1a/Hd5OiPIqDdXONia6bhTw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.2.tgz", + "integrity": "sha512-MJt5BRRSScPDwG2hLelYhAAKh9imjHK5+NE/tvnRLbIqUWa+0E9N4WNMjmp/kXXPHZGqPLxggwVhz7QP8CTR8w==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.2.tgz", + "integrity": "sha512-lugyF1atnAT463aO6KPshVCJK5NgRnU4yb3FUumyVz+cGvZbontBgzeGFO1nF+dPueHD367a2ZXe1NtUkAjOtg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.2.tgz", + "integrity": "sha512-nlP2I6ArEBewvJ2gjrrkESEZkB5mIoaTswuqNFRv/WYd+ATtUpe9Y09RnJvgvdag7he0OWgEZWhviS1OTOKixw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.2.tgz", + "integrity": "sha512-C92gnpey7tUQONqg1n6dKVbx3vphKtTHJaNG2Ok9lGwbZil6DrfyecMsp9CrmXGQJmZ7iiVXvvZH6Ml5hL6XdQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.2.tgz", + "integrity": "sha512-B5BOmojNtUyN8AXlK0QJyvjEZkWwy/FKvakkTDCziX95AowLZKR6aCDhG7LeF7uMCXEJqwa8Bejz5LTPYm8AvA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.2.tgz", + "integrity": "sha512-p4bm9+wsPwup5Z8f4EpfN63qNagQ47Ua2znaqGH6bqLlmJ4bx97Y9JdqxgGZ6Y8xVTixUnEkoKSHcpRlDnNr5w==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.2.tgz", + "integrity": "sha512-uwp2Tip5aPmH+NRUwTcfLb+W32WXjpFejTIOWZFw/v7/KnpCDKG66u4DLcurQpiYTiYwQ9B7KOeMJvLCu/OvbA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.2.tgz", + "integrity": "sha512-Kj6DiBlwXrPsCRDeRvGAUb/LNrBASrfqAIok+xB0LxK8CHqxZ037viF13ugfsIpePH93mX7xfJp97cyDuTZ3cw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.2.tgz", + "integrity": "sha512-HwGDZ0VLVBY3Y+Nw0JexZy9o/nUAWq9MlV7cahpaXKW6TOzfVno3y3/M8Ga8u8Yr7GldLOov27xiCnqRZf0tCA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.2.tgz", + "integrity": "sha512-DNIHH2BPQ5551A7oSHD0CKbwIA/Ox7+78/AWkbS5QoRzaqlev2uFayfSxq68EkonB+IKjiuxBFoV8ESJy8bOHA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.2.tgz", + "integrity": "sha512-/it7w9Nb7+0KFIzjalNJVR5bOzA9Vay+yIPLVHfIQYG/j+j9VTH84aNB8ExGKPU4AzfaEvN9/V4HV+F+vo8OEg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.2.tgz", + "integrity": "sha512-LRBbCmiU51IXfeXk59csuX/aSaToeG7w48nMwA6049Y4J4+VbWALAuXcs+qcD04rHDuSCSRKdmY63sruDS5qag==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.2.tgz", + "integrity": "sha512-kMtx1yqJHTmqaqHPAzKCAkDaKsffmXkPHThSfRwZGyuqyIeBvf08KSsYXl+abf5HDAPMJIPnbBfXvP2ZC2TfHg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.2.tgz", + "integrity": "sha512-Yaf78O/B3Kkh+nKABUF++bvJv5Ijoy9AN1ww904rOXZFLWVc5OLOfL56W+C8F9xn5JQZa3UX6m+IktJnIb1Jjg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.2.tgz", + "integrity": "sha512-Iuws0kxo4yusk7sw70Xa2E2imZU5HoixzxfGCdxwBdhiDgt9vX9VUCBhqcwY7/uh//78A1hMkkROMJq9l27oLQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.2.tgz", + "integrity": "sha512-sRdU18mcKf7F+YgheI/zGf5alZatMUTKj/jNS6l744f9u3WFu4v7twcUI9vu4mknF4Y9aDlblIie0IM+5xxaqQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@fastify/ajv-compiler": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/@fastify/ajv-compiler/-/ajv-compiler-4.0.5.tgz", + "integrity": "sha512-KoWKW+MhvfTRWL4qrhUwAAZoaChluo0m0vbiJlGMt2GXvL4LVPQEjt8kSpHI3IBq5Rez8fg+XeH3cneztq+C7A==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "ajv": "^8.12.0", + "ajv-formats": "^3.0.1", + "fast-uri": "^3.0.0" + } + }, + "node_modules/@fastify/cors": { + "version": "11.2.0", + "resolved": "https://registry.npmjs.org/@fastify/cors/-/cors-11.2.0.tgz", + "integrity": "sha512-LbLHBuSAdGdSFZYTLVA3+Ch2t+sA6nq3Ejc6XLAKiQ6ViS2qFnvicpj0htsx03FyYeLs04HfRNBsz/a8SvbcUw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "fastify-plugin": "^5.0.0", + "toad-cache": "^3.7.0" + } + }, + "node_modules/@fastify/error": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/@fastify/error/-/error-4.2.0.tgz", + "integrity": "sha512-RSo3sVDXfHskiBZKBPRgnQTtIqpi/7zhJOEmAxCiBcM7d0uwdGdxLlsCaLzGs8v8NnxIRlfG0N51p5yFaOentQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT" + }, + "node_modules/@fastify/fast-json-stringify-compiler": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/@fastify/fast-json-stringify-compiler/-/fast-json-stringify-compiler-5.0.3.tgz", + "integrity": "sha512-uik7yYHkLr6fxd8hJSZ8c+xF4WafPK+XzneQDPU+D10r5X19GW8lJcom2YijX2+qtFF1ENJlHXKFM9ouXNJYgQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "fast-json-stringify": "^6.0.0" + } + }, + "node_modules/@fastify/forwarded": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/@fastify/forwarded/-/forwarded-3.0.1.tgz", + "integrity": "sha512-JqDochHFqXs3C3Ml3gOY58zM7OqO9ENqPo0UqAjAjH8L01fRZqwX9iLeX34//kiJubF7r2ZQHtBRU36vONbLlw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT" + }, + "node_modules/@fastify/merge-json-schemas": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/@fastify/merge-json-schemas/-/merge-json-schemas-0.2.1.tgz", + "integrity": "sha512-OA3KGBCy6KtIvLf8DINC5880o5iBlDX4SxzLQS8HorJAbqluzLRn80UXU0bxZn7UOFhFgpRJDasfwn9nG4FG4A==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "dequal": "^2.0.3" + } + }, + "node_modules/@fastify/proxy-addr": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/@fastify/proxy-addr/-/proxy-addr-5.1.0.tgz", + "integrity": "sha512-INS+6gh91cLUjB+PVHfu1UqcB76Sqtpyp7bnL+FYojhjygvOPA9ctiD/JDKsyD9Xgu4hUhCSJBPig/w7duNajw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "@fastify/forwarded": "^3.0.0", + "ipaddr.js": "^2.1.0" + } + }, + "node_modules/@pinojs/redact": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/@pinojs/redact/-/redact-0.4.0.tgz", + "integrity": "sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==", + "license": "MIT" + }, + "node_modules/@types/debug": { + "version": "4.1.12", + "resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.12.tgz", + "integrity": "sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/ms": "*" + } + }, + "node_modules/@types/ms": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz", + "integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@xo-cash/types": { + "resolved": "../types", + "link": true + }, + "node_modules/abstract-logging": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/abstract-logging/-/abstract-logging-2.0.1.tgz", + "integrity": "sha512-2BjRTZxTPvheOvGbBslFSYOUkr+SjPtOnrLP33f+VIWLzezQpZcqVg7ja3L4dBXmzzgwT+a029jRx5PCi3JuiA==", + "license": "MIT" + }, + "node_modules/ajv": { + "version": "8.17.1", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz", + "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ajv-formats": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz", + "integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==", + "license": "MIT", + "dependencies": { + "ajv": "^8.0.0" + }, + "peerDependencies": { + "ajv": "^8.0.0" + }, + "peerDependenciesMeta": { + "ajv": { + "optional": true + } + } + }, + "node_modules/atomic-sleep": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/atomic-sleep/-/atomic-sleep-1.0.0.tgz", + "integrity": "sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==", + "license": "MIT", + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/avvio": { + "version": "9.1.0", + "resolved": "https://registry.npmjs.org/avvio/-/avvio-9.1.0.tgz", + "integrity": "sha512-fYASnYi600CsH/j9EQov7lECAniYiBFiiAtBNuZYLA2leLe9qOvZzqYHFjtIj6gD2VMoMLP14834LFWvr4IfDw==", + "license": "MIT", + "dependencies": { + "@fastify/error": "^4.0.0", + "fastq": "^1.17.1" + } + }, + "node_modules/cookie": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz", + "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/dequal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/esbuild": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.2.tgz", + "integrity": "sha512-HyNQImnsOC7X9PMNaCIeAm4ISCQXs5a5YasTXVliKv4uuBo1dKrG0A+uQS8M5eXjVMnLg3WgXaKvprHlFJQffw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "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" + } + }, + "node_modules/fast-decode-uri-component": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/fast-decode-uri-component/-/fast-decode-uri-component-1.0.1.tgz", + "integrity": "sha512-WKgKWg5eUxvRZGwW8FvfbaH7AXSh2cL+3j5fMGzUMCxWBJ3dV3a7Wz8y2f/uQ0e3B6WmodD3oS54jTQ9HVTIIg==", + "license": "MIT" + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "license": "MIT" + }, + "node_modules/fast-json-stringify": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/fast-json-stringify/-/fast-json-stringify-6.2.0.tgz", + "integrity": "sha512-Eaf/KNIDwHkzfyeQFNfLXJnQ7cl1XQI3+zRqmPlvtkMigbXnAcasTrvJQmquBSxKfFGeRA6PFog8t+hFmpDoWw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "@fastify/merge-json-schemas": "^0.2.0", + "ajv": "^8.12.0", + "ajv-formats": "^3.0.1", + "fast-uri": "^3.0.0", + "json-schema-ref-resolver": "^3.0.0", + "rfdc": "^1.2.0" + } + }, + "node_modules/fast-querystring": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/fast-querystring/-/fast-querystring-1.1.2.tgz", + "integrity": "sha512-g6KuKWmFXc0fID8WWH0jit4g0AGBoJhCkJMb1RmbsSEUNvQ+ZC8D6CUZ+GtF8nMzSPXnhiePyyqqipzNNEnHjg==", + "license": "MIT", + "dependencies": { + "fast-decode-uri-component": "^1.0.1" + } + }, + "node_modules/fast-uri": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.0.tgz", + "integrity": "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/fastify": { + "version": "5.7.2", + "resolved": "https://registry.npmjs.org/fastify/-/fastify-5.7.2.tgz", + "integrity": "sha512-dBJolW+hm6N/yJVf6J5E1BxOBNkuXNl405nrfeR8SpvGWG3aCC2XDHyiFBdow8Win1kj7sjawQc257JlYY6M/A==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "@fastify/ajv-compiler": "^4.0.5", + "@fastify/error": "^4.0.0", + "@fastify/fast-json-stringify-compiler": "^5.0.0", + "@fastify/proxy-addr": "^5.0.0", + "abstract-logging": "^2.0.1", + "avvio": "^9.0.0", + "fast-json-stringify": "^6.0.0", + "find-my-way": "^9.0.0", + "light-my-request": "^6.0.0", + "pino": "^10.1.0", + "process-warning": "^5.0.0", + "rfdc": "^1.3.1", + "secure-json-parse": "^4.0.0", + "semver": "^7.6.0", + "toad-cache": "^3.7.0" + } + }, + "node_modules/fastify-plugin": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/fastify-plugin/-/fastify-plugin-5.1.0.tgz", + "integrity": "sha512-FAIDA8eovSt5qcDgcBvDuX/v0Cjz0ohGhENZ/wpc3y+oZCY2afZ9Baqql3g/lC+OHRnciQol4ww7tuthOb9idw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT" + }, + "node_modules/fastq": { + "version": "1.20.1", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz", + "integrity": "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==", + "license": "ISC", + "dependencies": { + "reusify": "^1.0.4" + } + }, + "node_modules/find-my-way": { + "version": "9.4.0", + "resolved": "https://registry.npmjs.org/find-my-way/-/find-my-way-9.4.0.tgz", + "integrity": "sha512-5Ye4vHsypZRYtS01ob/iwHzGRUDELlsoCftI/OZFhcLs1M0tkGPcXldE80TAZC5yYuJMBPJQQ43UHlqbJWiX2w==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-querystring": "^1.0.0", + "safe-regex2": "^5.0.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/get-tsconfig": { + "version": "4.13.0", + "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.13.0.tgz", + "integrity": "sha512-1VKTZJCwBrvbd+Wn3AOgQP/2Av+TfTCOlE4AcRJE72W1ksZXbAx8PPBR9RzgTeSPzlPMHrbANMH3LbltH73wxQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "resolve-pkg-maps": "^1.0.0" + }, + "funding": { + "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" + } + }, + "node_modules/ipaddr.js": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-2.3.0.tgz", + "integrity": "sha512-Zv/pA+ciVFbCSBBjGfaKUya/CcGmUHzTydLMaTwrUUEM2DIEO3iZvueGxmacvmN50fGpGVKeTXpb2LcYQxeVdg==", + "license": "MIT", + "engines": { + "node": ">= 10" + } + }, + "node_modules/json-schema-ref-resolver": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/json-schema-ref-resolver/-/json-schema-ref-resolver-3.0.0.tgz", + "integrity": "sha512-hOrZIVL5jyYFjzk7+y7n5JDzGlU8rfWDuYyHwGa2WA8/pcmMHezp2xsVwxrebD/Q9t8Nc5DboieySDpCp4WG4A==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "dequal": "^2.0.3" + } + }, + "node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "license": "MIT" + }, + "node_modules/light-my-request": { + "version": "6.6.0", + "resolved": "https://registry.npmjs.org/light-my-request/-/light-my-request-6.6.0.tgz", + "integrity": "sha512-CHYbu8RtboSIoVsHZ6Ye4cj4Aw/yg2oAFimlF7mNvfDV192LR7nDiKtSIfCuLT7KokPSTn/9kfVLm5OGN0A28A==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause", + "dependencies": { + "cookie": "^1.0.1", + "process-warning": "^4.0.0", + "set-cookie-parser": "^2.6.0" + } + }, + "node_modules/light-my-request/node_modules/process-warning": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/process-warning/-/process-warning-4.0.1.tgz", + "integrity": "sha512-3c2LzQ3rY9d0hc1emcsHhfT9Jwz0cChib/QN89oME2R451w5fy3f0afAhERFZAwrbDU43wk12d0ORBpDVME50Q==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT" + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/on-exit-leak-free": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/on-exit-leak-free/-/on-exit-leak-free-2.1.2.tgz", + "integrity": "sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA==", + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/pino": { + "version": "10.3.0", + "resolved": "https://registry.npmjs.org/pino/-/pino-10.3.0.tgz", + "integrity": "sha512-0GNPNzHXBKw6U/InGe79A3Crzyk9bcSyObF9/Gfo9DLEf5qj5RF50RSjsu0W1rZ6ZqRGdzDFCRBQvi9/rSGPtA==", + "license": "MIT", + "dependencies": { + "@pinojs/redact": "^0.4.0", + "atomic-sleep": "^1.0.0", + "on-exit-leak-free": "^2.1.0", + "pino-abstract-transport": "^3.0.0", + "pino-std-serializers": "^7.0.0", + "process-warning": "^5.0.0", + "quick-format-unescaped": "^4.0.3", + "real-require": "^0.2.0", + "safe-stable-stringify": "^2.3.1", + "sonic-boom": "^4.0.1", + "thread-stream": "^4.0.0" + }, + "bin": { + "pino": "bin.js" + } + }, + "node_modules/pino-abstract-transport": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/pino-abstract-transport/-/pino-abstract-transport-3.0.0.tgz", + "integrity": "sha512-wlfUczU+n7Hy/Ha5j9a/gZNy7We5+cXp8YL+X+PG8S0KXxw7n/JXA3c46Y0zQznIJ83URJiwy7Lh56WLokNuxg==", + "license": "MIT", + "dependencies": { + "split2": "^4.0.0" + } + }, + "node_modules/pino-std-serializers": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/pino-std-serializers/-/pino-std-serializers-7.1.0.tgz", + "integrity": "sha512-BndPH67/JxGExRgiX1dX0w1FvZck5Wa4aal9198SrRhZjH3GxKQUKIBnYJTdj2HDN3UQAS06HlfcSbQj2OHmaw==", + "license": "MIT" + }, + "node_modules/process-warning": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/process-warning/-/process-warning-5.0.0.tgz", + "integrity": "sha512-a39t9ApHNx2L4+HBnQKqxxHNs1r7KF+Intd8Q/g1bUh6q0WIp9voPXJ/x0j+ZL45KF1pJd9+q2jLIRMfvEshkA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT" + }, + "node_modules/quick-format-unescaped": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/quick-format-unescaped/-/quick-format-unescaped-4.0.4.tgz", + "integrity": "sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==", + "license": "MIT" + }, + "node_modules/real-require": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/real-require/-/real-require-0.2.0.tgz", + "integrity": "sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg==", + "license": "MIT", + "engines": { + "node": ">= 12.13.0" + } + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/resolve-pkg-maps": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz", + "integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" + } + }, + "node_modules/ret": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/ret/-/ret-0.5.0.tgz", + "integrity": "sha512-I1XxrZSQ+oErkRR4jYbAyEEu2I0avBvvMM5JN+6EBprOGRCs63ENqZ3vjavq8fBw2+62G5LF5XelKwuJpcvcxw==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/reusify": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", + "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", + "license": "MIT", + "engines": { + "iojs": ">=1.0.0", + "node": ">=0.10.0" + } + }, + "node_modules/rfdc": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/rfdc/-/rfdc-1.4.1.tgz", + "integrity": "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==", + "license": "MIT" + }, + "node_modules/safe-regex2": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/safe-regex2/-/safe-regex2-5.0.0.tgz", + "integrity": "sha512-YwJwe5a51WlK7KbOJREPdjNrpViQBI3p4T50lfwPuDhZnE3XGVTlGvi+aolc5+RvxDD6bnUmjVsU9n1eboLUYw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "ret": "~0.5.0" + } + }, + "node_modules/safe-stable-stringify": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/safe-stable-stringify/-/safe-stable-stringify-2.5.0.tgz", + "integrity": "sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/secure-json-parse": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/secure-json-parse/-/secure-json-parse-4.1.0.tgz", + "integrity": "sha512-l4KnYfEyqYJxDwlNVyRfO2E4NTHfMKAWdUuA8J0yve2Dz/E/PdBepY03RvyJpssIpRFwJoCD55wA+mEDs6ByWA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/semver": { + "version": "7.7.3", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", + "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/set-cookie-parser": { + "version": "2.7.2", + "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz", + "integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==", + "license": "MIT" + }, + "node_modules/sonic-boom": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/sonic-boom/-/sonic-boom-4.2.0.tgz", + "integrity": "sha512-INb7TM37/mAcsGmc9hyyI6+QR3rR1zVRu36B0NeGXKnOOLiZOfER5SA+N7X7k3yUYRzLWafduTDvJAfDswwEww==", + "license": "MIT", + "dependencies": { + "atomic-sleep": "^1.0.0" + } + }, + "node_modules/split2": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz", + "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==", + "license": "ISC", + "engines": { + "node": ">= 10.x" + } + }, + "node_modules/thread-stream": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/thread-stream/-/thread-stream-4.0.0.tgz", + "integrity": "sha512-4iMVL6HAINXWf1ZKZjIPcz5wYaOdPhtO8ATvZ+Xqp3BTdaqtAwQkNmKORqcIo5YkQqGXq5cwfswDwMqqQNrpJA==", + "license": "MIT", + "dependencies": { + "real-require": "^0.2.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/toad-cache": { + "version": "3.7.0", + "resolved": "https://registry.npmjs.org/toad-cache/-/toad-cache-3.7.0.tgz", + "integrity": "sha512-/m8M+2BJUpoJdgAHoG+baCwBT+tf2VraSfkBgl0Y00qIWt41DJ8R5B8nsEw0I58YwF5IZH6z24/2TobDKnqSWw==", + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/tsx": { + "version": "4.21.0", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.21.0.tgz", + "integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "~0.27.0", + "get-tsconfig": "^4.7.5" + }, + "bin": { + "tsx": "dist/cli.mjs" + }, + "engines": { + "node": ">=18.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/zod": { + "version": "4.3.6", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz", + "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..cdbe5ae --- /dev/null +++ b/package.json @@ -0,0 +1,29 @@ +{ + "name": "sync-server", + "version": "1.0.0", + "main": "index.js", + "type": "module", + "scripts": { + "start": "DEBUG=xo:* tsx src/app.ts", + "dev": "DEBUG=xo:* tsx watch src/app.ts", + "build": "tsc", + "test": "echo \"Error: no test specified\" && exit 1" + }, + "keywords": [], + "author": "", + "license": "ISC", + "description": "", + "devDependencies": { + "@types/debug": "^4.1.12", + "@xo-cash/types": "file:../types", + "tsx": "^4.21.0", + "typescript": "^5.9.3" + }, + "dependencies": { + "@bitauth/libauth": "^3.0.0", + "@fastify/cors": "^11.2.0", + "debug": "^4.4.3", + "fastify": "^5.7.2", + "zod": "^4.3.6" + } +} diff --git a/src/app.ts b/src/app.ts new file mode 100644 index 0000000..fea7b30 --- /dev/null +++ b/src/app.ts @@ -0,0 +1,40 @@ +import { HTTPService } from './services/http-router'; +import { InvitationsRoute } from './routes/invitations'; +import { InvitationStore } from './services/invitation-store'; +import { SSEBroadcaster } from './services/sse-broadcast'; + +export class App { + static async create() { + // Create the invitation store (this is a in-memory store for now) + const invitationStore = new InvitationStore(); + + // Create the SSE Broadcaster + const sseBroadcaster = new SSEBroadcaster(); + + // Create the Invitation route, passing in the invitation store and sse broadcaster + const invitationsRoute = new InvitationsRoute(invitationStore, sseBroadcaster); + + // Create the HTTP service, passing in the invitation route + const http = new HTTPService([ + invitationsRoute, + ]); + + // Create the app instance, passing in the HTTP service + return new App(http); + } + + /** + * Create a new instance of App. + * @param http - The HTTP service instance. + */ + constructor(private readonly http: HTTPService) {} + + async start() { + // Start the HTTP service + await this.http.start(); + } +} + +// Create the app instance and start it +const app = await App.create(); +await app.start(); diff --git a/src/routes/index.ts b/src/routes/index.ts new file mode 100644 index 0000000..5ebfa5c --- /dev/null +++ b/src/routes/index.ts @@ -0,0 +1 @@ +export * from './invitations.js'; \ No newline at end of file diff --git a/src/routes/invitations.ts b/src/routes/invitations.ts new file mode 100644 index 0000000..1ee0452 --- /dev/null +++ b/src/routes/invitations.ts @@ -0,0 +1,89 @@ +import type { FastifyRequest, FastifyReply, RouteOptions } from 'fastify'; + +import type { SSEBroadcaster } from '../services/sse-broadcast'; +import type { InvitationStore } from '../services/invitation-store'; +import { parseInvitation } from '../utils/invitation-parser'; + +export class InvitationsRoute { + constructor( + private readonly invitationStore: InvitationStore, + private readonly sseBroadcaster: SSEBroadcaster, + ) {} + + async getRoutes(): Promise> { + return [ + { + method: 'GET', + url: '/invitations', + handler: this.getInvitation.bind(this), + }, + { + method: 'POST', + url: '/invitations', + handler: this.updateInvitation.bind(this), + } + ]; + } + + async getInvitation(request: FastifyRequest, reply: FastifyReply) { + // Get the invitation identifier from the query + const { invitationIdentifier } = request.query as { invitationIdentifier?: string }; + + // If the invitation identifier is not provided, return an error. + if (!invitationIdentifier) { + return reply.status(400).send({ error: 'Invitation Identifier is required' }); + } + + // Get the invitation from the store + const storedInvitation = await this.invitationStore.getInvitation(invitationIdentifier); + + if (request.headers['accept'] === 'text/event-stream') { + // Subscribe the client to the SSE stream. + await this.sseBroadcaster.subscribe(request, reply); + + // If the invitation doesn't exist, don't send anything. + if (!storedInvitation) { + return; + } + + // Send the invitation to the client as if it was a get request. + this.sseBroadcaster.sendEvent(reply, 'invitation-updated', storedInvitation); + + // Return early + return; + } + + if(!storedInvitation) { + return reply.status(200).send({}); + } + + // If the client is not subscribing to the SSE stream, return the invitation. + return reply.status(200).send(storedInvitation); + } + + async updateInvitation(request: FastifyRequest, reply: FastifyReply) { + console.log('updateInvitation', request.body); + + // Parse the invitation + const invitation = parseInvitation.parse(request.body); + + // If the invitation doesnt exist yet, create it + if (!await this.invitationStore.getInvitation(invitation.invitationIdentifier)) { + await this.invitationStore.storeInvitation({ ...invitation, commits: [] }); + } + + // Store each commit individually (I dont know) + for(const commit of invitation.commits) { + await this.invitationStore.updateInvitation(invitation.invitationIdentifier, commit); + } + + // Broadcast the invitation update (We send down the whole invitation. Clients will have to compare commitIds) + await this.sseBroadcaster.broadcast(invitation.invitationIdentifier, 'invitation-updated', invitation); + + console.log('Invitation updated successfully'); + + return reply.status(200).send(invitation); + } + + static parseInvitation = parseInvitation +} \ No newline at end of file diff --git a/src/services/http-router.ts b/src/services/http-router.ts new file mode 100644 index 0000000..4d173cc --- /dev/null +++ b/src/services/http-router.ts @@ -0,0 +1,138 @@ +import debug from "debug"; +import fastify, { type FastifyInstance, type RouteOptions } from "fastify"; +import cors from "@fastify/cors"; +import { z } from "zod"; +import { + decodeExtendedJsonObject, + encodeExtendedJsonObject, +} from "../utils/ext-json"; + +// Interface to add to our route classes so that we can register them. +// NOTE: I hate this pattern. But ExpressJS is odd in that it is structured as a singleton that still needs registration. +export interface APIRoutes { + getRoutes(): Promise>; +} + +export class HTTPService { + private debug: debug.Debugger; + private server: FastifyInstance; + + constructor( + private routes: Array = [], + private port: number = 3000, + private host: string = "0.0.0.0", + ) { + this.debug = debug("xo:http-router"); + + this.server = fastify({ + logger: false, + }); + } + + async start(): Promise { + this.debug(`Starting on http://${this.host}:${this.port}`); + + // Setup ExtJSON handling. + this.handleExtJSON(); + + // Setup Error Handling (to give more verbose Zod errors) + this.handleErrors(); + + // Allow CORS requests. This allows requests from any origin/domain. + // Capacitor apps (like XO Wallet) use localhost:3000 as the origin, making it difficult to meaningfully restrict requests by origin for security. + await this.server.register(cors); + + // Register your routes here before starting the server + this.server.get("/health", async () => { + return { status: "ok" }; + }); + + // Register each route. + for (const routes of this.routes) { + for (const routeOptions of await routes.getRoutes()) { + this.server.route(routeOptions); + } + } + + await this.server.ready(); + + await this.server.listen({ + port: this.port, + host: this.host, + }); + + this.debug(`Started on http://${this.host}:${this.port}`); + } + + // Helper method to access the server instance + getInstance(): FastifyInstance { + return this.server; + } + + private handleErrors() { + // Customize our error handler to give better errors. + // NOTE: This will nicely format the Zod validation errors. + this.server.setErrorHandler((error, _request, reply) => { + if (error instanceof z.ZodError) { + const formattedErrors = error.issues.map((issue) => ({ + path: issue.path.join("."), + message: issue.message, + })); + + this.debug(`Error: ${error}`); + + return reply.status(400).send({ + statusCode: 400, + error: "Validation Error", + details: formattedErrors, + }); + } + + this.debug(`Error: ${error}`); + + // Handle other types of errors + reply.status(500).send({ error: "Internal Server Error" }); + }); + } + + private handleExtJSON() { + // Add onRequest hook to decode requests from ExtJSON + this.server.addHook("onRequest", async (request, _reply) => { + this.debug(`Request: ${JSON.stringify(request.body)}`); + this.debug(`Request URL: ${request.method} ${request.url}`); + // Only transform JSON requests + if ( + request.headers["content-type"]?.includes("application/json") && + request.body + ) { + try { + // Decode ExtJSON body + request.body = decodeExtendedJsonObject(request.body); + } catch (error) { + request.log.error( + { + err: error, + body: request.body, + }, + "Failed to decode ExtJSON request body", + ); + throw new Error("Invalid JSON in request body"); + } + } + }); + + // Add onSend hook to encode responses to ExtJSON + this.server.addHook("onSend", async (_request, reply, payload) => { + // Only transform JSON responses + if ( + reply.getHeader("content-type")?.toString().includes("application/json") + ) { + // If payload is a string (already serialized), parse it first + const data = + typeof payload === "string" ? JSON.parse(payload) : payload; + return JSON.stringify(encodeExtendedJsonObject(data)); + } + return payload; + }); + } +} diff --git a/src/services/invitation-store.ts b/src/services/invitation-store.ts new file mode 100644 index 0000000..4fd7a36 --- /dev/null +++ b/src/services/invitation-store.ts @@ -0,0 +1,46 @@ +import type { XOInvitation, XOInvitationCommit } from '@xo-cash/types'; + +export class InvitationStore { + + private readonly invitations: Map = new Map(); + + constructor() { + this.invitations = new Map(); + } + + async getInvitation(invitationIdentifier: string): Promise { + return this.invitations.get(invitationIdentifier); + } + + async storeInvitation(invitation: XOInvitation): Promise { + const invitationIdentifier = invitation.invitationIdentifier; + this.invitations.set(invitationIdentifier, invitation); + } + + async deleteInvitation(invitationIdentifier: string): Promise { + this.invitations.delete(invitationIdentifier); + } + + /** + * TODO: This should maybe merge? I dont know. Currently, setting is not the best idea + * @param invitation + */ + async updateInvitation(id: string, commit: XOInvitationCommit): Promise { + // Get the invitation identifier + const invitation = await this.getInvitation(id); + if (!invitation) { + throw new Error(`Invitation not found: ${id}`); + } + + // If the commit already exists, return the invitation + if (invitation.commits.some(c => c.commitIdentifier === commit.commitIdentifier)) { + return invitation; + } + + // Update the invitation with the commit + invitation.commits.push(commit); + this.invitations.set(id, invitation); + + return invitation; + } +} \ No newline at end of file diff --git a/src/services/sse-broadcast.ts b/src/services/sse-broadcast.ts new file mode 100644 index 0000000..db82bbb --- /dev/null +++ b/src/services/sse-broadcast.ts @@ -0,0 +1,270 @@ +import type { FastifyReply, FastifyRequest } from "fastify"; + +import debug, { type Debugger } from "debug"; + +/** + * Represents an event stored in the history buffer. + * Used for replaying missed events to reconnecting clients. + */ +interface HistoricalEvent { + /** The event topic/type (e.g., 'invitation-created', 'invitation-updated') */ + topic: string; + /** The event payload data */ + data: unknown; + /** Unix timestamp in milliseconds when the event was created */ + timestamp: number; +} + +/** + * Options for configuring the SSE service. + */ +interface SSEOptions { + /** Maximum age of events to keep in history (in milliseconds). Default: 5 minutes */ + maxHistoryAge?: number; + /** Maximum number of events to keep per user. Default: 1000 */ + maxHistorySize?: number; +} + +/** + * Server-Sent Events broadcaster with event history support. + * + * Maintains a per-user event history buffer that allows clients to replay + * missed events when reconnecting. This makes reconnections robust against + * network interruptions. + */ +export class SSEBroadcaster { + /** + * Factory method to create and start an SSE broadcaster. + * @param options - Configuration options for the SSE service + * @returns A started SSE instance + */ + static async from(options?: SSEOptions) { + const broadcaster = new SSEBroadcaster(options); + await broadcaster.start(); + return broadcaster; + } + + /** Map of Invitation IDs to their connected SSE response streams */ + private clients: Map<(string), Set> = new Map(); + + /** Map of Invitation IDs to their event history buffers */ + private eventHistory: Map = new Map(); + + /** Maximum age of events to keep in history (in milliseconds) */ + private maxHistoryAge: number; + + /** Maximum number of events to keep per user */ + private maxHistorySize: number; + + private debug: Debugger; + + constructor(options?: SSEOptions) { + this.clients = new Map(); + this.eventHistory = new Map(); + this.maxHistoryAge = options?.maxHistoryAge ?? 20 * 60 * 1000; // 20 minutes default + this.maxHistorySize = options?.maxHistorySize ?? 1000; // 1000 events default + this.debug = debug('xo:sse'); + } + + /** + * Starts the SSE broadcaster. + * @returns The SSE instance for chaining + */ + start() { + this.debug('SSE broadcaster is running (maxHistoryAge: %dms, maxHistorySize: %d)', + this.maxHistoryAge, this.maxHistorySize); + return this; + } + + /** + * Sends an event to a client. + * + * @param client - The client to send the event to + * @param topic - The event topic/type + * @param data - The event payload data + */ + static sendEvent(client: FastifyReply, topic: string, data: unknown) { + const timestamp = Date.now(); + client.raw.write(`id: ${timestamp}\n`); + client.raw.write(`event: ${topic}\n`); + client.raw.write(`data: ${JSON.stringify(data)}\n\n`); + } + + /** + * Sends an event to a client. + * + * @param client - The client to send the event to + * @param topic - The event topic/type + * @param data - The event payload data + */ + sendEvent(client: FastifyReply, topic: string, data: unknown) { + try { + SSEBroadcaster.sendEvent(client, topic, data); + } catch (error) { + this.debug('Error sending event to client', error); + } + } + + /** + * Broadcasts an event to all connected clients for a user and stores it in history. + * + * @param clientId - The user ID to broadcast to + * @param topic - The event topic/type + * @param data - The event payload data + */ + async broadcast(clientId: string, topic: string, data: unknown) { + const timestamp = Date.now(); + + // Store the event in history for potential replay + this.storeEvent(clientId, topic, data, timestamp); + + // Broadcast to all connected clients + this.clients.get(clientId)?.forEach((client: FastifyReply) => { + try { + this.sendEvent(client, topic, data); + } catch (error) { + this.debug('Error sending event to client', error); + } + }); + + this.debug('SSE broadcasted message', topic, data); + } + + /** + * Subscribes a client to receive SSE events. + * + * If lastEventTime is provided, all events that occurred after that timestamp + * will be replayed to the client before starting the live stream. + * + * @param req - The authenticated request containing the user ID + * @param res - The Express response object to use for SSE streaming + * @param lastEventTime - Optional timestamp to replay events from (in milliseconds) + */ + async subscribe(req: FastifyRequest, res: FastifyReply, lastEventTime?: number) { + // Get the invitation ID from the request + const { invitationIdentifier } = req.query as { invitationIdentifier?: string }; + if (!invitationIdentifier) { + throw new Error('Invitation Identifier is required'); + } + + // Initialize client set for this user if needed + if (!this.clients.has(invitationIdentifier)) { + this.clients.set(invitationIdentifier, new Set()); + } + + // Manually include the CORS header since `writeHead` bypasses the auto-injection by the @fastify/cors plugin. + // This statement grabs the CORS header that the CORS plugin would have added to the response, configured in the HTTP service. + const corsHeader = res.getHeader("access-control-allow-origin"); + + // Disable timeout for the connection. Without this, the connection will drop and the client will have to reconnect. + req.raw.socket.setTimeout(0); + + // Set up SSE headers + // Set headers for SSE + res.raw.writeHead(200, { + "Content-Type": "text/event-stream", + "Cache-Control": "no-cache", + Connection: "keep-alive", + "Access-Control-Allow-Origin": corsHeader, + }); + + // Force the headers to be dispatched to the client. + // NOTE: This is very important: A `fetch` call will NOT resolve until it has received the headers. + // And Fastify, unless otherwise specified, will not send the headers until it sends the body. + res.raw.flushHeaders(); + + // Set retry interval for automatic reconnection + res.raw.write('retry: 3000\n\n'); + + // Replay missed events if a lastEventTime was provided + if (lastEventTime !== undefined) { + const missedEvents = this.getEventsAfter(invitationIdentifier, lastEventTime); + this.debug('SSE replaying %d missed events for invitation %s (since %d)', + missedEvents.length, invitationIdentifier, lastEventTime); + + for (const event of missedEvents) { + try { + await this.sendEvent(res, event.topic, event.data); + } catch (error) { + this.debug('Error sending event to client', error); + } + } + } + + // Add client to the set for live updates + this.clients.get(invitationIdentifier)?.add(res); + + this.debug('SSE subscribed to client (invitationId: %s, lastEventTime: %s)', + invitationIdentifier, lastEventTime ?? 'none'); + + // Clean up when client disconnects + res.raw.on('close', () => { + this.clients.get(invitationIdentifier)?.delete(res); + this.debug('SSE client disconnected (invitationIdentifier: %s)', invitationIdentifier); + }); + } + + /** + * Stores an event in the user's history buffer. + * Automatically prunes old events based on age and size limits. + * + * @param invitationId - The invitation ID to store the event for + * @param topic - The event topic/type + * @param data - The event payload data + * @param timestamp - The event timestamp + */ + private storeEvent(invitationId: string, topic: string, data: unknown, timestamp: number) { + // Initialize history array for this user if needed + if (!this.eventHistory.has(invitationId)) { + this.eventHistory.set(invitationId, []); + } + + const history = this.eventHistory.get(invitationId)!; + + // Add the new event + history.push({ topic, data, timestamp }); + + // Prune old events + this.pruneHistory(invitationId, timestamp); + } + + /** + * Removes old events from a invitation's history based on age and size limits. + * + * @param invitationId - The invitation ID whose history to prune + * @param currentTime - The current timestamp for age calculations + */ + private pruneHistory(invitationId: string, currentTime: number) { + const history = this.eventHistory.get(invitationId); + if (!history) return; + + const cutoffTime = currentTime - this.maxHistoryAge; + + // Remove events older than maxHistoryAge + const prunedByAge = history.filter(event => event.timestamp > cutoffTime); + + // If still over size limit, remove oldest events + const prunedBySize = prunedByAge.length > this.maxHistorySize + ? prunedByAge.slice(-this.maxHistorySize) + : prunedByAge; + + this.eventHistory.set(invitationId, prunedBySize); + } + + /** + * Retrieves all events for a user that occurred after a given timestamp. + * + * @param invitationId - The invitation ID to get events for + * @param afterTimestamp - The timestamp to get events after (exclusive) + * @returns Array of events that occurred after the timestamp + */ + private getEventsAfter(invitationId: string, afterTimestamp: number): HistoricalEvent[] { + const history = this.eventHistory.get(invitationId); + if (!history) return []; + + // First prune old events to ensure we don't return stale data + this.pruneHistory(invitationId, Date.now()); + + return history.filter(event => event.timestamp > afterTimestamp); + } +} \ No newline at end of file diff --git a/src/utils/ext-json.ts b/src/utils/ext-json.ts new file mode 100644 index 0000000..7170460 --- /dev/null +++ b/src/utils/ext-json.ts @@ -0,0 +1,124 @@ +/** + * TODO: These are intended as temporary stand-ins until this functionality has been implemented directly in LibAuth. + * We are doing this so that we may better standardize with the rest of the BCH eco-system in future. + * See: https://github.com/bitauth/libauth/pull/108 + */ + +import { binToHex, hexToBin } from '@bitauth/libauth'; + +export const extendedJsonReplacer = function (value: any): any { + if (typeof value === 'bigint') { + return ``; + } else if (value instanceof Uint8Array) { + return ``; + } + + return value; +}; + +export const extendedJsonReviver = function (value: any): any { + // Define RegEx that matches our Extended JSON fields. + const bigIntRegex = /^[+-]?[0-9]*)n>$/; + const uint8ArrayRegex = /^[a-f0-9]*)>$/; + + // Only perform a check if the value is a string. + // NOTE: We can skip all other values as all Extended JSON encoded fields WILL be a string. + if (typeof value === 'string') { + // Check if this value matches an Extended JSON encoded bigint. + const bigintMatch = value.match(bigIntRegex); + if (bigintMatch) { + // Access the named group directly instead of using array indices + const { bigint } = bigintMatch.groups!; + + // Return the value casted to bigint. + return BigInt(bigint); + } + + const uint8ArrayMatch = value.match(uint8ArrayRegex); + if (uint8ArrayMatch) { + // Access the named group directly instead of using array indices + const { hex } = uint8ArrayMatch.groups!; + + // Return the value casted to bigint. + return hexToBin(hex); + } + } + + // Return the original value. + return value; +}; + +export const encodeExtendedJsonObject = function (value: any): any { + // If this is an object type (and it is not null - which is technically an "object")... + // ... and it is not an ArrayBuffer (e.g. Uint8Array) which is also technically an "object... + if ( + typeof value === 'object' && + value !== null && + !ArrayBuffer.isView(value) + ) { + // If this is an array, recursively call this function on each value. + if (Array.isArray(value)) { + return value.map(encodeExtendedJsonObject); + } + + // Declare object to store extended JSON entries. + const encodedObject: any = {}; + + // Iterate through each entry and encode it to extended JSON. + for (const [key, valueToEncode] of Object.entries(value)) { + encodedObject[key] = encodeExtendedJsonObject(valueToEncode); + } + + // Return the extended JSON encoded object. + return encodedObject; + } + + // Return the replaced value. + return extendedJsonReplacer(value); +}; + +export const decodeExtendedJsonObject = function (value: any): any { + // If this is an object type (and it is not null - which is technically an "object")... + // ... and it is not an ArrayBuffer (e.g. Uint8Array) which is also technically an "object... + if ( + typeof value === 'object' && + value !== null && + !ArrayBuffer.isView(value) + ) { + // If this is an array, recursively call this function on each value. + if (Array.isArray(value)) { + return value.map(decodeExtendedJsonObject); + } + + // Declare object to store decoded JSON entries. + const decodedObject: any = {}; + + // Iterate through each entry and decode it from extended JSON. + for (const [key, valueToEncode] of Object.entries(value)) { + decodedObject[key] = decodeExtendedJsonObject(valueToEncode); + } + + // Return the extended JSON encoded object. + return decodedObject; + } + + // Return the revived value. + return extendedJsonReviver(value); +}; + +export const encodeExtendedJson = function ( + value: any, + space: number | undefined = undefined, +): string { + const replacedObject = encodeExtendedJsonObject(value); + const stringifiedObject = JSON.stringify(replacedObject, null, space); + + return stringifiedObject; +}; + +export const decodeExtendedJson = function (json: string): any { + const parsedObject = JSON.parse(json); + const revivedObject = decodeExtendedJsonObject(parsedObject); + + return revivedObject; +}; diff --git a/src/utils/invitation-parser.ts b/src/utils/invitation-parser.ts new file mode 100644 index 0000000..26d4622 --- /dev/null +++ b/src/utils/invitation-parser.ts @@ -0,0 +1,66 @@ +import { z } from 'zod'; + +/** + * Zod schemas for invitation validation. + * + * IMPORTANT: We use .passthrough() on all object schemas to preserve fields + * that aren't explicitly defined. This is critical because: + * 1. Invitations are signed based on stringify(commit.data) + * 2. If we strip fields, the signature verification will fail + * 3. The actual XOInvitation types have many more fields than we validate here + */ + +const variableSchema = z.object({ + variableIdentifier: z.string(), + roleIdentifier: z.string().optional(), + value: z.number().or(z.string()).or(z.boolean()).or(z.bigint()), +}).passthrough(); + +const mergesWithSchema = z.object({ + commitIdentifier: z.string(), + index: z.number(), +}).passthrough(); + +const inputSchema = z.object({ + inputIdentifier: z.string().optional(), + transactionIndex: z.number().optional(), + roleIdentifier: z.string().optional(), + mergesWith: mergesWithSchema.optional(), + // Additional fields preserved via passthrough: + // outpointTransactionHash, outpointIndex, sequenceNumber, unlockingBytecode, etc. +}).passthrough(); + +const outputSchema = z.object({ + outputIdentifier: z.string().optional(), + roleIdentifier: z.string().optional(), + secretIdentifier: z.string().optional(), + transactionIndex: z.number().optional(), + mergesWith: mergesWithSchema.optional(), + // Additional fields preserved via passthrough: + // valueSatoshis, lockingBytecode, token, etc. +}).passthrough(); + +const dataSchema = z.object({ + transactionVersion: z.number().optional(), + transactionLocktime: z.number().optional(), + variables: z.array(variableSchema).optional(), + inputs: z.array(inputSchema).optional(), + outputs: z.array(outputSchema).optional(), +}).passthrough(); + +const commitSchema = z.object({ + commitIdentifier: z.string(), + previousCommitIdentifier: z.string().or(z.undefined()), + entityIdentifier: z.string(), + data: dataSchema, + signature: z.string(), + expiresAtTimestamp: z.number(), +}).passthrough(); + +export const parseInvitation = z.object({ + invitationIdentifier: z.string(), + commits: z.array(commitSchema), + createdAtTimestamp: z.number(), + templateIdentifier: z.string(), + actionIdentifier: z.string(), +}).passthrough(); \ No newline at end of file