diff --git a/.env.example b/.env.example index c1431fc..336827c 100644 --- a/.env.example +++ b/.env.example @@ -42,6 +42,11 @@ STELLAR_NETWORK=testnet STELLAR_HORIZON_URL=https://horizon-testnet.stellar.org STELLAR_SOROBAN_RPC_URL=https://soroban-testnet.stellar.org +# Webhook settings +WEBHOOK_MAX_PER_CREATOR=5 +WEBHOOK_RETRY_MAX_ATTEMPTS=3 +WEBHOOK_RETRY_BASE_DELAY_MS=1000 + # Ownership snapshot cleanup job OWNERSHIP_SNAPSHOT_TABLE_NAME=creator_ownership_snapshots OWNERSHIP_SNAPSHOT_CLEANUP_DRY_RUN=true diff --git a/jest.config.js b/jest.config.js index d4ce0ec..31c41b8 100644 --- a/jest.config.js +++ b/jest.config.js @@ -8,6 +8,9 @@ module.exports = { transform: { ...tsJestTransformCfg, }, + moduleNameMapper: { + "^chalk$": "/src/__mocks__/chalk.ts", + }, roots: ["/src"], setupFiles: ["./jest.setup.ts"], }; \ No newline at end of file diff --git a/package.json b/package.json index 2e89993..bf472d8 100644 --- a/package.json +++ b/package.json @@ -44,6 +44,7 @@ "dependencies": { "@json2csv/node": "^7.0.6", "@prisma/client": "^6.19.1", + "@stellar/stellar-base": "^15.0.0", "@types/js-yaml": "^4.0.9", "@types/pdfkit": "^0.17.3", "@types/puppeteer": "^5.4.7", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f6383ec..9e12d75 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -14,6 +14,9 @@ importers: '@prisma/client': specifier: ^6.19.1 version: 6.19.2(prisma@6.19.2(typescript@5.9.3))(typescript@5.9.3) + '@stellar/stellar-base': + specifier: ^15.0.0 + version: 15.0.0 '@types/js-yaml': specifier: ^4.0.9 version: 4.0.9 @@ -463,105 +466,89 @@ packages: resolution: {integrity: sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==} cpu: [arm64] os: [linux] - libc: [glibc] '@img/sharp-libvips-linux-arm@1.2.4': resolution: {integrity: sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==} cpu: [arm] os: [linux] - libc: [glibc] '@img/sharp-libvips-linux-ppc64@1.2.4': resolution: {integrity: sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==} cpu: [ppc64] os: [linux] - libc: [glibc] '@img/sharp-libvips-linux-riscv64@1.2.4': resolution: {integrity: sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==} cpu: [riscv64] os: [linux] - libc: [glibc] '@img/sharp-libvips-linux-s390x@1.2.4': resolution: {integrity: sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==} cpu: [s390x] os: [linux] - libc: [glibc] '@img/sharp-libvips-linux-x64@1.2.4': resolution: {integrity: sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==} cpu: [x64] os: [linux] - libc: [glibc] '@img/sharp-libvips-linuxmusl-arm64@1.2.4': resolution: {integrity: sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==} cpu: [arm64] os: [linux] - libc: [musl] '@img/sharp-libvips-linuxmusl-x64@1.2.4': resolution: {integrity: sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==} cpu: [x64] os: [linux] - libc: [musl] '@img/sharp-linux-arm64@0.34.5': resolution: {integrity: sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [arm64] os: [linux] - libc: [glibc] '@img/sharp-linux-arm@0.34.5': resolution: {integrity: sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [arm] os: [linux] - libc: [glibc] '@img/sharp-linux-ppc64@0.34.5': resolution: {integrity: sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [ppc64] os: [linux] - libc: [glibc] '@img/sharp-linux-riscv64@0.34.5': resolution: {integrity: sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [riscv64] os: [linux] - libc: [glibc] '@img/sharp-linux-s390x@0.34.5': resolution: {integrity: sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [s390x] os: [linux] - libc: [glibc] '@img/sharp-linux-x64@0.34.5': resolution: {integrity: sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [x64] os: [linux] - libc: [glibc] '@img/sharp-linuxmusl-arm64@0.34.5': resolution: {integrity: sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [arm64] os: [linux] - libc: [musl] '@img/sharp-linuxmusl-x64@0.34.5': resolution: {integrity: sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [x64] os: [linux] - libc: [musl] '@img/sharp-wasm32@0.34.5': resolution: {integrity: sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==} @@ -714,6 +701,10 @@ packages: '@napi-rs/wasm-runtime@0.2.12': resolution: {integrity: sha512-ZVWUcfwY4E/yPitQJl481FjFo3K22D6qF0DuFH6Y/nbnE11GY5uguDxZMGXPQ8WQ0128MXQD7TnfHyK4oWoIJQ==} + '@noble/curves@1.9.7': + resolution: {integrity: sha512-gbKGcRUYIjA3/zCCNaWDciTMFI0dCkvou3TL8Zmy5Nc7sJ47a0jtOeZoTaMxkuqRo9cRhjOdZJXegxYE5FN/xw==} + engines: {node: ^14.21.3 || >=16} + '@noble/hashes@1.8.0': resolution: {integrity: sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==} engines: {node: ^14.21.3 || >=16} @@ -785,6 +776,14 @@ packages: '@standard-schema/spec@1.1.0': resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} + '@stellar/js-xdr@4.0.0': + resolution: {integrity: sha512-+NmNa7Tk5BI5XFdy/6xGTqAN4J9a9KgCrCGhj2uEUTCBhLkch0M+QbKzNH8zEnejWe0p8w+0q5hUVX6L3OzoVA==} + engines: {node: '>=20.0.0', pnpm: '>=9.0.0'} + + '@stellar/stellar-base@15.0.0': + resolution: {integrity: sha512-XQhxUr9BYiEcFcgc4oWcCMR9QJCny/GmmGsuwPKf/ieIcOeb5149KLHYx9mJCA0ea8QbucR2/GzV58QbXOTxQA==} + engines: {node: '>=20.0.0'} + '@streamparser/json@0.0.20': resolution: {integrity: sha512-VqAAkydywPpkw63WQhPVKCD3SdwXuihCUVZbbiY3SfSTGQyHmwRoq27y4dmJdZuJwd5JIlQoMPyGvMbUPY0RKQ==} @@ -1047,49 +1046,41 @@ packages: resolution: {integrity: sha512-34gw7PjDGB9JgePJEmhEqBhWvCiiWCuXsL9hYphDF7crW7UgI05gyBAi6MF58uGcMOiOqSJ2ybEeCvHcq0BCmQ==} cpu: [arm64] os: [linux] - libc: [glibc] '@unrs/resolver-binding-linux-arm64-musl@1.11.1': resolution: {integrity: sha512-RyMIx6Uf53hhOtJDIamSbTskA99sPHS96wxVE/bJtePJJtpdKGXO1wY90oRdXuYOGOTuqjT8ACccMc4K6QmT3w==} cpu: [arm64] os: [linux] - libc: [musl] '@unrs/resolver-binding-linux-ppc64-gnu@1.11.1': resolution: {integrity: sha512-D8Vae74A4/a+mZH0FbOkFJL9DSK2R6TFPC9M+jCWYia/q2einCubX10pecpDiTmkJVUH+y8K3BZClycD8nCShA==} cpu: [ppc64] os: [linux] - libc: [glibc] '@unrs/resolver-binding-linux-riscv64-gnu@1.11.1': resolution: {integrity: sha512-frxL4OrzOWVVsOc96+V3aqTIQl1O2TjgExV4EKgRY09AJ9leZpEg8Ak9phadbuX0BA4k8U5qtvMSQQGGmaJqcQ==} cpu: [riscv64] os: [linux] - libc: [glibc] '@unrs/resolver-binding-linux-riscv64-musl@1.11.1': resolution: {integrity: sha512-mJ5vuDaIZ+l/acv01sHoXfpnyrNKOk/3aDoEdLO/Xtn9HuZlDD6jKxHlkN8ZhWyLJsRBxfv9GYM2utQ1SChKew==} cpu: [riscv64] os: [linux] - libc: [musl] '@unrs/resolver-binding-linux-s390x-gnu@1.11.1': resolution: {integrity: sha512-kELo8ebBVtb9sA7rMe1Cph4QHreByhaZ2QEADd9NzIQsYNQpt9UkM9iqr2lhGr5afh885d/cB5QeTXSbZHTYPg==} cpu: [s390x] os: [linux] - libc: [glibc] '@unrs/resolver-binding-linux-x64-gnu@1.11.1': resolution: {integrity: sha512-C3ZAHugKgovV5YvAMsxhq0gtXuwESUKc5MhEtjBpLoHPLYM+iuwSj3lflFwK3DPm68660rZ7G8BMcwSro7hD5w==} cpu: [x64] os: [linux] - libc: [glibc] '@unrs/resolver-binding-linux-x64-musl@1.11.1': resolution: {integrity: sha512-rV0YSoyhK2nZ4vEswT/QwqzqQXw5I6CjoaYMOX0TqBlWhojUf8P94mvI7nuJTeaCkkds3QE4+zS8Ko+GdXuZtA==} cpu: [x64] os: [linux] - libc: [musl] '@unrs/resolver-binding-wasm32-wasi@1.11.1': resolution: {integrity: sha512-5u4RkfxJm+Ng7IWgkzi3qrFOvLvQYnPBmjmZQ8+szTK/b31fQCnleNl1GgEt7nIsZRIf5PLhPwT0WM+q45x/UQ==} @@ -1198,6 +1189,10 @@ packages: resolution: {integrity: sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==} engines: {node: '>=8.0.0'} + available-typed-arrays@1.0.7: + resolution: {integrity: sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==} + engines: {node: '>= 0.4'} + b4a@1.8.0: resolution: {integrity: sha512-qRuSmNSkGQaHwNbM7J78Wwy+ghLEYF1zNrSeMxj4Kgw6y33O3mXcQ6Ie9fRvfU/YnxWkOchPXbaLb73TkIsfdg==} peerDependencies: @@ -1276,6 +1271,10 @@ packages: bare-url@2.4.0: resolution: {integrity: sha512-NSTU5WN+fy/L0DDenfE8SXQna4voXuW0FHM7wH8i3/q9khUSchfPbPezO4zSFMnDGIf9YE+mt/RWhZgNRKRIXA==} + base32.js@0.1.0: + resolution: {integrity: sha512-n3TkB02ixgBOhTvANakDb4xaMXnYUVkNoRFJjQflcqMQhyEKxEHdj3E6N8t8sUQ0mjH/3/JxzlXuz3ul/J90pQ==} + engines: {node: '>=0.12.0'} + base64-js@0.0.8: resolution: {integrity: sha512-3XSA2cR/h/73EzlXXdU6YNycmYI7+kicTxks4eJg2g39biHR84slg2+des+p7iHYhbRg/udIS4TD53WabcOUkw==} engines: {node: '>= 0.4'} @@ -1349,6 +1348,9 @@ packages: buffer-from@1.1.2: resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==} + buffer@6.0.3: + resolution: {integrity: sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==} + busboy@1.6.0: resolution: {integrity: sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==} engines: {node: '>=10.16.0'} @@ -1369,6 +1371,10 @@ packages: resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==} engines: {node: '>= 0.4'} + call-bind@1.0.9: + resolution: {integrity: sha512-a/hy+pNsFUTR+Iz8TCJvXudKVLAnz/DyeSUo10I5yvFDQJBFU2s9uqQpoSrJlroHUKoKqzg+epxyP9lqFdzfBQ==} + engines: {node: '>= 0.4'} + call-bound@1.0.4: resolution: {integrity: sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==} engines: {node: '>= 0.4'} @@ -1607,6 +1613,10 @@ packages: resolution: {integrity: sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==} engines: {node: '>=0.10.0'} + define-data-property@1.1.4: + resolution: {integrity: sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==} + engines: {node: '>= 0.4'} + defu@6.1.4: resolution: {integrity: sha512-mEQCMmwJu317oSz8CwdIOdwf3xMif1ttiM8LTufzc3g6kR+9Pe236twL8j3IYT1F7GfRgGcW6MWxzZjLIkuHIg==} @@ -1925,6 +1935,10 @@ packages: fontkit@2.0.4: resolution: {integrity: sha512-syetQadaUEDNdxdugga9CpEYVaQIxOwk7GlwZWWZ19//qW4zE5bknOKeMBDYAASwnpaSHKJITRLMF9m1fp3s6g==} + for-each@0.3.5: + resolution: {integrity: sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==} + engines: {node: '>= 0.4'} + foreground-child@3.3.1: resolution: {integrity: sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==} engines: {node: '>=14'} @@ -2074,6 +2088,9 @@ packages: resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} engines: {node: '>=8'} + has-property-descriptors@1.0.2: + resolution: {integrity: sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==} + has-symbols@1.1.0: resolution: {integrity: sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==} engines: {node: '>= 0.4'} @@ -2135,6 +2152,9 @@ packages: resolution: {integrity: sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==} engines: {node: '>=0.10.0'} + ieee754@1.2.1: + resolution: {integrity: sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==} + ignore-by-default@1.0.1: resolution: {integrity: sha512-Ius2VYcGNk7T90CppJqcIkS5ooHUZyIQK+ClZfMfMNFEF9VSE73Fq+906u/CWu92x4gzZMWOwfFYckPObzdEbA==} @@ -2181,6 +2201,10 @@ packages: resolution: {integrity: sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==} engines: {node: '>=8'} + is-callable@1.2.7: + resolution: {integrity: sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==} + engines: {node: '>= 0.4'} + is-extglob@2.1.1: resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==} engines: {node: '>=0.10.0'} @@ -2224,9 +2248,16 @@ packages: resolution: {integrity: sha512-LnQR4bZ9IADDRSkvpqMGvt/tEJWclzklNgSw48V5EAaAeDd6qGvN8ei6k5p0tvxSR171VmGyHuTiAOfxAbr8kA==} engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + is-typed-array@1.1.15: + resolution: {integrity: sha512-p3EcsicXjit7SaskXHs1hA91QxgTw46Fv6EFKKGS5DRFLD8yKnohjF3hxoju94b/OcMZoQukzpPpBE9uLVKzgQ==} + engines: {node: '>= 0.4'} + isarray@1.0.0: resolution: {integrity: sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==} + isarray@2.0.5: + resolution: {integrity: sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==} + isexe@2.0.0: resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} @@ -2869,6 +2900,10 @@ packages: png-js@1.0.0: resolution: {integrity: sha512-k+YsbhpA9e+EFfKjTCH3VW6aoKlyNYI6NYdTfDL4CIvFnvsuO84ttonmZE7rc+v23SLTH8XX+5w/Ak9v0xGY4g==} + possible-typed-array-names@1.1.0: + resolution: {integrity: sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==} + engines: {node: '>= 0.4'} + postal-mime@2.7.3: resolution: {integrity: sha512-MjhXadAJaWgYzevi46+3kLak8y6gbg0ku14O1gO/LNOuay8dO+1PtcSGvAdgDR0DoIsSaiIA8y/Ddw6MnrO0Tw==} @@ -3049,9 +3084,18 @@ packages: resolution: {integrity: sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==} engines: {node: '>= 18'} + set-function-length@1.2.2: + resolution: {integrity: sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==} + engines: {node: '>= 0.4'} + setprototypeof@1.2.0: resolution: {integrity: sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==} + sha.js@2.4.12: + resolution: {integrity: sha512-8LzC5+bvI45BjpfXU8V5fdU2mfeKiQe1D1gIMn7XUlF3OTUrpdJpPPH4EMAnF0DsHHdSZqCdSss5qCmJKuiO3w==} + engines: {node: '>= 0.10'} + hasBin: true + sharp@0.34.5: resolution: {integrity: sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} @@ -3292,6 +3336,10 @@ packages: tmpl@1.0.5: resolution: {integrity: sha512-3f0uOEAQwIqGuWW2MVzYg8fV/QNnc/IpuJNG837rLuczAaLVHslWHZQj4IGiEl5Hs3kkbhwL9Ab7Hrsmuj+Smw==} + to-buffer@1.2.2: + resolution: {integrity: sha512-db0E3UJjcFhpDhAF4tLo03oli3pwl3dbnzXOUIlRKrp+ldk/VUxzpWYZENsw2SZiuBjHAk7DfB0VU7NKdpb6sw==} + engines: {node: '>= 0.4'} + to-regex-range@5.0.1: resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} engines: {node: '>=8.0'} @@ -3385,6 +3433,10 @@ packages: resolution: {integrity: sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==} engines: {node: '>= 0.6'} + typed-array-buffer@1.0.3: + resolution: {integrity: sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw==} + engines: {node: '>= 0.4'} + typed-query-selector@2.12.1: resolution: {integrity: sha512-uzR+FzI8qrUEIu96oaeBJmd9E7CFEiQ3goA5qCVgc4s5llSubcfGHq9yUstZx/k4s9dXHVKsE35YWoFyvEqEHA==} @@ -3478,6 +3530,10 @@ packages: webdriver-bidi-protocol@0.4.1: resolution: {integrity: sha512-ARrjNjtWRRs2w4Tk7nqrf2gBI0QXWuOmMCx2hU+1jUt6d00MjMxURrhxhGbrsoiZKJrhTSTzbIrc554iKI10qw==} + which-typed-array@1.1.22: + resolution: {integrity: sha512-fvO4ExWMFsqyhG3AiPAObMuY1lxaqgYcxbc49CNdWDDECOJNgQyvsOWVwbZc+qf3rzRtxojBK+CMEv0Ld5CYpw==} + engines: {node: '>= 0.4'} + which@2.0.2: resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} engines: {node: '>= 8'} @@ -4214,6 +4270,10 @@ snapshots: '@tybys/wasm-util': 0.10.1 optional: true + '@noble/curves@1.9.7': + dependencies: + '@noble/hashes': 1.8.0 + '@noble/hashes@1.8.0': {} '@paralleldrive/cuid2@2.3.1': @@ -4293,6 +4353,17 @@ snapshots: '@standard-schema/spec@1.1.0': {} + '@stellar/js-xdr@4.0.0': {} + + '@stellar/stellar-base@15.0.0': + dependencies: + '@noble/curves': 1.9.7 + '@stellar/js-xdr': 4.0.0 + base32.js: 0.1.0 + bignumber.js: 9.3.1 + buffer: 6.0.3 + sha.js: 2.4.12 + '@streamparser/json@0.0.20': {} '@swc/helpers@0.5.19': @@ -4708,6 +4779,10 @@ snapshots: atomic-sleep@1.0.0: {} + available-typed-arrays@1.0.7: + dependencies: + possible-typed-array-names: 1.1.0 + b4a@1.8.0: {} babel-jest@30.3.0(@babel/core@7.29.0): @@ -4799,6 +4874,8 @@ snapshots: dependencies: bare-path: 3.0.0 + base32.js@0.1.0: {} + base64-js@0.0.8: {} base64-js@1.5.1: {} @@ -4877,6 +4954,11 @@ snapshots: buffer-from@1.1.2: {} + buffer@6.0.3: + dependencies: + base64-js: 1.5.1 + ieee754: 1.2.1 + busboy@1.6.0: dependencies: streamsearch: 1.1.0 @@ -4903,6 +4985,13 @@ snapshots: es-errors: 1.3.0 function-bind: 1.1.2 + call-bind@1.0.9: + 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 + call-bound@1.0.4: dependencies: call-bind-apply-helpers: 1.0.2 @@ -5089,6 +5178,12 @@ snapshots: deepmerge@4.3.1: {} + define-data-property@1.1.4: + dependencies: + es-define-property: 1.0.1 + es-errors: 1.3.0 + gopd: 1.2.0 + defu@6.1.4: {} degenerator@5.0.1: @@ -5456,6 +5551,10 @@ snapshots: unicode-properties: 1.4.1 unicode-trie: 2.0.0 + for-each@0.3.5: + dependencies: + is-callable: 1.2.7 + foreground-child@3.3.1: dependencies: cross-spawn: 7.0.6 @@ -5634,6 +5733,10 @@ snapshots: has-flag@4.0.0: {} + has-property-descriptors@1.0.2: + dependencies: + es-define-property: 1.0.1 + has-symbols@1.1.0: {} has-tostringtag@1.0.2: @@ -5700,6 +5803,8 @@ snapshots: dependencies: safer-buffer: 2.1.2 + ieee754@1.2.1: {} + ignore-by-default@1.0.1: {} ignore@5.3.2: {} @@ -5735,6 +5840,8 @@ snapshots: dependencies: binary-extensions: 2.3.0 + is-callable@1.2.7: {} + is-extglob@2.1.1: {} is-fullwidth-code-point@3.0.0: {} @@ -5761,8 +5868,14 @@ snapshots: is-stream@3.0.0: {} + is-typed-array@1.1.15: + dependencies: + which-typed-array: 1.1.22 + isarray@1.0.0: {} + isarray@2.0.5: {} + isexe@2.0.0: {} istanbul-lib-coverage@3.2.2: {} @@ -6571,6 +6684,8 @@ snapshots: png-js@1.0.0: {} + possible-typed-array-names@1.1.0: {} + postal-mime@2.7.3: {} prelude-ls@1.2.1: {} @@ -6778,8 +6893,23 @@ snapshots: transitivePeerDependencies: - supports-color + set-function-length@1.2.2: + dependencies: + define-data-property: 1.1.4 + es-errors: 1.3.0 + function-bind: 1.1.2 + get-intrinsic: 1.3.0 + gopd: 1.2.0 + has-property-descriptors: 1.0.2 + setprototypeof@1.2.0: {} + sha.js@2.4.12: + dependencies: + inherits: 2.0.4 + safe-buffer: 5.2.1 + to-buffer: 1.2.2 + sharp@0.34.5: dependencies: '@img/colour': 1.1.0 @@ -7104,6 +7234,12 @@ snapshots: tmpl@1.0.5: {} + to-buffer@1.2.2: + dependencies: + isarray: 2.0.5 + safe-buffer: 5.2.1 + typed-array-buffer: 1.0.3 + to-regex-range@5.0.1: dependencies: is-number: 7.0.0 @@ -7213,6 +7349,12 @@ snapshots: media-typer: 1.1.0 mime-types: 3.0.2 + typed-array-buffer@1.0.3: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + is-typed-array: 1.1.15 + typed-query-selector@2.12.1: {} typedarray@0.0.6: {} @@ -7316,6 +7458,16 @@ snapshots: webdriver-bidi-protocol@0.4.1: {} + which-typed-array@1.1.22: + dependencies: + available-typed-arrays: 1.0.7 + call-bind: 1.0.9 + call-bound: 1.0.4 + for-each: 0.3.5 + get-proto: 1.0.1 + gopd: 1.2.0 + has-tostringtag: 1.0.2 + which@2.0.2: dependencies: isexe: 2.0.0 diff --git a/prisma/schema/migrations/20260618000000_add_webhooks/migration.sql b/prisma/schema/migrations/20260618000000_add_webhooks/migration.sql new file mode 100644 index 0000000..c3a53cc --- /dev/null +++ b/prisma/schema/migrations/20260618000000_add_webhooks/migration.sql @@ -0,0 +1,49 @@ +-- CreateEnum +CREATE TYPE "WebhookEventStatus" AS ENUM ('PENDING', 'DELIVERED', 'FAILED'); + +-- CreateEnum +CREATE TYPE "WebhookEventType" AS ENUM ('BUY', 'SELL'); + +-- CreateTable +CREATE TABLE "Webhook" ( + "id" TEXT NOT NULL, + "creatorId" TEXT NOT NULL, + "callbackUrl" TEXT NOT NULL, + "events" "WebhookEventType"[], + "isActive" BOOLEAN NOT NULL DEFAULT true, + "isFailing" BOOLEAN NOT NULL DEFAULT false, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "Webhook_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "WebhookEvent" ( + "id" TEXT NOT NULL, + "webhookId" TEXT NOT NULL, + "eventType" "WebhookEventType" NOT NULL, + "payload" JSONB NOT NULL, + "status" "WebhookEventStatus" NOT NULL DEFAULT 'PENDING', + "retryCount" INTEGER NOT NULL DEFAULT 0, + "lastError" TEXT, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "WebhookEvent_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE INDEX "Webhook_creatorId_idx" ON "Webhook"("creatorId"); + +-- CreateIndex +CREATE INDEX "Webhook_isActive_idx" ON "Webhook"("isActive"); + +-- CreateIndex +CREATE INDEX "WebhookEvent_webhookId_idx" ON "WebhookEvent"("webhookId"); + +-- CreateIndex +CREATE INDEX "WebhookEvent_status_idx" ON "WebhookEvent"("status"); + +-- AddForeignKey +ALTER TABLE "WebhookEvent" ADD CONSTRAINT "WebhookEvent_webhookId_fkey" FOREIGN KEY ("webhookId") REFERENCES "Webhook"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/prisma/schema/webhook.prisma b/prisma/schema/webhook.prisma new file mode 100644 index 0000000..4d33232 --- /dev/null +++ b/prisma/schema/webhook.prisma @@ -0,0 +1,45 @@ +// prisma/schema/webhook.prisma + +enum WebhookEventStatus { + PENDING + DELIVERED + FAILED +} + +enum WebhookEventType { + BUY + SELL +} + +model Webhook { + id String @id @default(cuid()) + creatorId String + callbackUrl String + events WebhookEventType[] + isActive Boolean @default(true) + isFailing Boolean @default(false) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + events_dispatched WebhookEvent[] + + @@index([creatorId]) + @@index([isActive]) +} + +model WebhookEvent { + id String @id @default(cuid()) + webhookId String + eventType WebhookEventType + payload Json + status WebhookEventStatus @default(PENDING) + retryCount Int @default(0) + lastError String? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + webhook Webhook @relation(fields: [webhookId], references: [id], onDelete: Cascade) + + @@index([webhookId]) + @@index([status]) +} diff --git a/src/__mocks__/chalk.ts b/src/__mocks__/chalk.ts new file mode 100644 index 0000000..d748f95 --- /dev/null +++ b/src/__mocks__/chalk.ts @@ -0,0 +1,12 @@ +const chalk = new Proxy( + (s: string) => s, + { + get(_target, prop) { + if (prop === 'default' || prop === 'chalk') return chalk; + if (typeof prop === 'string') return chalk; + return chalk; + }, + } +); + +export default chalk; diff --git a/src/config.schema.ts b/src/config.schema.ts index 4f5bf4e..3d23ea7 100644 --- a/src/config.schema.ts +++ b/src/config.schema.ts @@ -108,10 +108,15 @@ export const envSchema = z .int() .positive() .default(300000), - INDEXER_HEARTBEAT_STALE_THRESHOLD_MS: z.coerce - .number() - .positive() - .default(300000), + INDEXER_HEARTBEAT_STALE_THRESHOLD_MS: z.coerce + .number() + .positive() + .default(300000), + + // Webhook settings + WEBHOOK_MAX_PER_CREATOR: z.coerce.number().int().positive().default(5), + WEBHOOK_RETRY_MAX_ATTEMPTS: z.coerce.number().int().positive().default(3), + WEBHOOK_RETRY_BASE_DELAY_MS: z.coerce.number().int().positive().default(1000), // Indexer feature flags ENABLE_INDEXER_DEDUPE: booleanCoerce.default(true), diff --git a/src/modules/index.ts b/src/modules/index.ts index 0ace237..dd4cec5 100644 --- a/src/modules/index.ts +++ b/src/modules/index.ts @@ -9,6 +9,7 @@ import ledgerRouter from './ledger/ledger.routes'; import adminRouter from './admin/admin.routes'; import activityRouter from './activity/activity.routes'; import ownershipRouter from './ownership/ownership.routes'; +import webhookRouter from './webhooks/webhook.router'; import { BASE as CREATORS_BASE } from '../constants/creator.constants'; const router = Router(); @@ -23,5 +24,6 @@ router.use('/ledger', ledgerRouter); router.use('/admin', adminRouter); router.use('/activity', activityRouter); router.use('/ownership', ownershipRouter); +router.use(CREATORS_BASE, webhookRouter); export default router; diff --git a/src/modules/webhooks/index.ts b/src/modules/webhooks/index.ts new file mode 100644 index 0000000..9e599a3 --- /dev/null +++ b/src/modules/webhooks/index.ts @@ -0,0 +1,3 @@ +export { default as webhookRouter } from './webhook.router'; +export * from './webhook.types'; +export { dispatchWebhookEvent } from './webhook.service'; diff --git a/src/modules/webhooks/webhook-signature.middleware.ts b/src/modules/webhooks/webhook-signature.middleware.ts new file mode 100644 index 0000000..1f85f3a --- /dev/null +++ b/src/modules/webhooks/webhook-signature.middleware.ts @@ -0,0 +1,122 @@ +import type { Request, Response, NextFunction } from 'express'; +import { Keypair } from '@stellar/stellar-base'; +import { StellarAddressSchema } from '../wallet/wallet.schemas'; +import { sendError } from '../../utils/api-response.utils'; +import { ErrorCode } from '../../constants/error.constants'; +import { prisma } from '../../utils/prisma.utils'; +import { logger } from '../../utils/logger.utils'; +import { createHash } from 'crypto'; + +export interface WalletSignedRequest extends Request { + walletAddress?: string; + creatorId?: string; +} + +const SIGNATURE_TIMESTAMP_TOLERANCE_MS = 5 * 60 * 1000; + +function readHeader(req: Request, name: string): string | undefined { + const raw = req.headers[name]; + if (Array.isArray(raw)) return raw[0]?.trim() || undefined; + return typeof raw === 'string' ? raw.trim() || undefined : undefined; +} + +function buildMessage( + method: string, + path: string, + creatorId: string, + timestamp: string +): Buffer { + const payload = `${method.toUpperCase()}:${path}:${creatorId}:${timestamp}`; + return createHash('sha256').update(payload, 'utf8').digest(); +} + +export function requireWalletSignature() { + return async ( + req: WalletSignedRequest, + res: Response, + next: NextFunction + ): Promise => { + const address = readHeader(req, 'x-wallet-address'); + const signature = readHeader(req, 'x-signature'); + const timestamp = readHeader(req, 'x-timestamp'); + + if (!address || !signature || !timestamp) { + sendError( + res, + 401, + ErrorCode.UNAUTHORIZED, + 'Missing required headers: x-wallet-address, x-signature, x-timestamp' + ); + return; + } + + const addressValidation = StellarAddressSchema.safeParse(address); + if (!addressValidation.success) { + sendError( + res, + 400, + ErrorCode.BAD_REQUEST, + 'Invalid wallet address format' + ); + return; + } + + const ts = parseInt(timestamp, 10); + if (isNaN(ts) || Date.now() - ts > SIGNATURE_TIMESTAMP_TOLERANCE_MS) { + sendError( + res, + 401, + ErrorCode.UNAUTHORIZED, + 'Signature timestamp is invalid or expired' + ); + return; + } + + const rawCreatorId = req.params.id; + const creatorId = Array.isArray(rawCreatorId) ? rawCreatorId[0] : rawCreatorId; + if (!creatorId) { + sendError(res, 400, ErrorCode.BAD_REQUEST, 'Missing creator ID in path'); + return; + } + + try { + const creatorProfile = await prisma.creatorProfile.findUnique({ + where: { id: creatorId }, + select: { id: true }, + }); + + if (!creatorProfile) { + sendError(res, 404, ErrorCode.NOT_FOUND, 'Creator not found'); + return; + } + + const message = buildMessage(req.method, req.originalUrl, creatorId, timestamp); + const signatureBuffer = Buffer.from(signature, 'base64'); + const keypair = Keypair.fromPublicKey(address); + const verified = keypair.verify(message, signatureBuffer); + + if (!verified) { + sendError( + res, + 403, + ErrorCode.FORBIDDEN, + 'Invalid signature — wallet does not own this creator' + ); + return; + } + } catch (error) { + logger.error({ error, address, creatorId }, 'Signature verification failed'); + sendError( + res, + 403, + ErrorCode.FORBIDDEN, + 'Signature verification failed' + ); + return; + } + + req.walletAddress = address; + req.creatorId = creatorId; + next(); + }; +} diff --git a/src/modules/webhooks/webhook.controllers.test.ts b/src/modules/webhooks/webhook.controllers.test.ts new file mode 100644 index 0000000..ecf192d --- /dev/null +++ b/src/modules/webhooks/webhook.controllers.test.ts @@ -0,0 +1,134 @@ +import type { Response } from 'express'; +import { + registerWebhookHandler, + listWebhooksHandler, + deleteWebhookHandler, +} from './webhook.controllers'; +import * as webhookService from './webhook.service'; + +jest.mock('./webhook.service'); + +const mockService = webhookService as jest.Mocked; + +function createMockSignedRequest(creatorId = 'creator-1', params: Record = {}) { + return { + body: {}, + params: { id: creatorId, ...params }, + creatorId, + method: 'POST', + originalUrl: `/api/v1/creators/${creatorId}/webhooks`, + } as any; +} + +function createMockResponse() { + const res: Partial = {}; + res.status = jest.fn().mockReturnValue(res); + res.json = jest.fn().mockReturnValue(res); + res.setHeader = jest.fn().mockReturnValue(res); + return res as Response; +} + +beforeEach(() => { + jest.clearAllMocks(); +}); + +describe('registerWebhookHandler', () => { + it('returns 400 for invalid body', async () => { + const req = createMockSignedRequest(); + req.body = { callback_url: 'not-a-url', events: [] }; + const res = createMockResponse(); + + await registerWebhookHandler(req, res); + + expect(res.status).toHaveBeenCalledWith(400); + }); + + it('returns 201 on success', async () => { + mockService.createWebhook.mockResolvedValue({ + id: 'wh-1', + creatorId: 'creator-1', + callbackUrl: 'https://example.com/hook', + events: ['buy', 'sell'], + isActive: true, + isFailing: false, + createdAt: new Date(), + updatedAt: new Date(), + }); + + const req = createMockSignedRequest(); + req.body = { callback_url: 'https://example.com/hook', events: ['buy', 'sell'] }; + const res = createMockResponse(); + + await registerWebhookHandler(req, res); + + expect(res.status).toHaveBeenCalledWith(201); + expect(mockService.createWebhook).toHaveBeenCalledWith('creator-1', { + callbackUrl: 'https://example.com/hook', + events: ['buy', 'sell'], + }); + }); + + it('returns 409 when max webhooks reached', async () => { + mockService.createWebhook.mockRejectedValue( + Object.assign(new Error('Max webhooks reached'), { statusCode: 409, code: 'MAX_WEBHOOKS_REACHED' }) + ); + + const req = createMockSignedRequest(); + req.body = { callback_url: 'https://example.com/hook', events: ['buy'] }; + const res = createMockResponse(); + + await registerWebhookHandler(req, res); + + expect(res.status).toHaveBeenCalledWith(409); + }); +}); + +describe('listWebhooksHandler', () => { + it('returns 200 with webhooks list', async () => { + mockService.listWebhooks.mockResolvedValue([ + { + id: 'wh-1', + creatorId: 'creator-1', + callbackUrl: 'https://example.com/hook', + events: ['buy'], + isActive: true, + isFailing: false, + createdAt: new Date(), + updatedAt: new Date(), + }, + ]); + + const req = createMockSignedRequest(); + const res = createMockResponse(); + + await listWebhooksHandler(req, res); + + expect(res.status).toHaveBeenCalledWith(200); + expect(mockService.listWebhooks).toHaveBeenCalledWith('creator-1'); + }); +}); + +describe('deleteWebhookHandler', () => { + it('returns 404 for non-existent webhook', async () => { + mockService.deleteWebhook.mockResolvedValue(null); + + const req = createMockSignedRequest('creator-1', { webhookId: 'wh-nonexistent' }); + const res = createMockResponse(); + + await deleteWebhookHandler(req, res); + + expect(res.status).toHaveBeenCalledWith(404); + }); + + it('returns 200 on successful deletion', async () => { + mockService.deleteWebhook.mockResolvedValue({ id: 'wh-1' }); + + const req = createMockSignedRequest('creator-1', { webhookId: 'wh-1' }); + const res = createMockResponse(); + + await deleteWebhookHandler(req, res); + + expect(res.status).toHaveBeenCalledWith(200); + expect(mockService.deleteWebhook).toHaveBeenCalledWith('wh-1', 'creator-1'); + }); +}); diff --git a/src/modules/webhooks/webhook.controllers.ts b/src/modules/webhooks/webhook.controllers.ts new file mode 100644 index 0000000..fb3ce05 --- /dev/null +++ b/src/modules/webhooks/webhook.controllers.ts @@ -0,0 +1,90 @@ +import type { Response } from 'express'; +import { + sendSuccess, + sendError, + sendValidationError, + sendNotFound, +} from '../../utils/api-response.utils'; +import { ErrorCode } from '../../constants/error.constants'; +import { CreateWebhookSchema } from './webhook.schemas'; +import * as webhookService from './webhook.service'; +import type { WalletSignedRequest } from './webhook-signature.middleware'; + +export async function registerWebhookHandler( + req: WalletSignedRequest, + res: Response +) { + const parseResult = CreateWebhookSchema.safeParse(req.body); + if (!parseResult.success) { + sendValidationError( + res, + 'Invalid webhook registration data', + parseResult.error.issues.map((issue) => ({ + field: issue.path.join('.'), + message: issue.message, + })) + ); + return; + } + + try { + const result = await webhookService.createWebhook( + req.creatorId!, + { + callbackUrl: parseResult.data.callback_url, + events: parseResult.data.events, + } + ); + sendSuccess(res, result, 201, 'Webhook registered successfully'); + } catch (error) { + if (error instanceof Error && 'statusCode' in error) { + sendError( + res, + (error as any).statusCode, + (error as any).code, + error.message + ); + return; + } + sendError(res, 500, ErrorCode.INTERNAL_ERROR, 'Failed to register webhook'); + } +} + +export async function listWebhooksHandler( + req: WalletSignedRequest, + res: Response +) { + try { + const webhooks = await webhookService.listWebhooks(req.creatorId!); + sendSuccess(res, webhooks); + } catch { + sendError(res, 500, ErrorCode.INTERNAL_ERROR, 'Failed to list webhooks'); + } +} + +export async function deleteWebhookHandler( + req: WalletSignedRequest, + res: Response +) { + const rawWebhookId = req.params.webhookId; + const webhookId = Array.isArray(rawWebhookId) ? rawWebhookId[0] : rawWebhookId; + + if (!webhookId) { + sendError(res, 400, ErrorCode.BAD_REQUEST, 'Missing webhook ID in path'); + return; + } + + try { + const result = await webhookService.deleteWebhook( + webhookId, + req.creatorId! + ); + if (!result) { + sendNotFound(res, 'Webhook'); + return; + } + sendSuccess(res, result, 200, 'Webhook deleted successfully'); + } catch { + sendError(res, 500, ErrorCode.INTERNAL_ERROR, 'Failed to delete webhook'); + } +} diff --git a/src/modules/webhooks/webhook.integration.test.ts b/src/modules/webhooks/webhook.integration.test.ts new file mode 100644 index 0000000..b2777cd --- /dev/null +++ b/src/modules/webhooks/webhook.integration.test.ts @@ -0,0 +1,310 @@ +import supertest from 'supertest'; +import app from '../../app'; +import { prisma } from '../../utils/prisma.utils'; +import { Keypair } from '@stellar/stellar-base'; +import { createHash } from 'crypto'; +import { envConfig } from '../../config'; + +const keypair = Keypair.random(); +const walletAddress = keypair.publicKey(); +const testUserId = 'webhook-test-user-id'; +const creatorId = 'webhook-test-creator-id'; +function signMessage(method: string, path: string, creatorId: string, timestamp: string): string { + const payload = `${method.toUpperCase()}:${path}:${creatorId}:${timestamp}`; + const hash = createHash('sha256').update(payload, 'utf8').digest(); + return keypair.sign(hash).toString('base64'); +} + +function authHeaders(method: string, path: string, cId: string) { + const timestamp = Date.now().toString(); + const signature = signMessage(method, path, cId, timestamp); + return { + 'x-wallet-address': walletAddress, + 'x-signature': signature, + 'x-timestamp': timestamp, + }; +} + +beforeAll(async () => { + await prisma.user.create({ + data: { + id: testUserId, + email: 'webhook-test@example.com', + passwordHash: 'dummy-hash', + firstName: 'Webhook', + lastName: 'Test', + }, + }); + + await prisma.stellarWallet.create({ + data: { + address: walletAddress, + userId: testUserId, + }, + }); + + await prisma.creatorProfile.create({ + data: { + id: creatorId, + userId: testUserId, + handle: 'webhook-test-creator', + displayName: 'Webhook Test Creator', + }, + }); +}); + +afterAll(async () => { + await prisma.webhookEvent.deleteMany({ + where: { webhook: { creatorId } }, + }); + await prisma.webhook.deleteMany({ where: { creatorId } }); + await prisma.creatorProfile.delete({ where: { id: creatorId } }).catch(() => {}); + await prisma.stellarWallet.delete({ where: { address: walletAddress } }).catch(() => {}); + await prisma.user.delete({ where: { id: testUserId } }).catch(() => {}); + await prisma.$disconnect(); +}); + +describe('POST /api/v1/creators/:id/webhooks', () => { + const basePath = `/api/v1/creators/${creatorId}/webhooks`; + + it('registers a webhook with valid signature and data', async () => { + const res = await supertest(app) + .post(basePath) + .set(authHeaders('POST', basePath, creatorId)) + .send({ callback_url: 'https://example.com/hook', events: ['buy', 'sell'] }); + + expect(res.status).toBe(201); + expect(res.body.success).toBe(true); + expect(res.body.data.callbackUrl).toBe('https://example.com/hook'); + expect(res.body.data.events).toEqual(['buy', 'sell']); + }); + + it('returns 401 when signature headers missing', async () => { + const res = await supertest(app) + .post(basePath) + .send({ callback_url: 'https://example.com/hook', events: ['buy'] }); + + expect(res.status).toBe(401); + }); + + it('returns 400 on invalid body', async () => { + const res = await supertest(app) + .post(basePath) + .set(authHeaders('POST', basePath, creatorId)) + .send({ callback_url: 'not-a-url', events: ['invalid'] }); + + expect(res.status).toBe(400); + }); + + it('returns 409 when max webhooks reached', async () => { + const existingCount = await prisma.webhook.count({ + where: { creatorId, isActive: true }, + }); + + const remaining = envConfig.WEBHOOK_MAX_PER_CREATOR - existingCount; + for (let i = 0; i < remaining; i++) { + await supertest(app) + .post(basePath) + .set(authHeaders('POST', basePath, creatorId)) + .send({ callback_url: `https://example.com/hook-${i}`, events: ['buy'] }); + } + + const res = await supertest(app) + .post(basePath) + .set(authHeaders('POST', basePath, creatorId)) + .send({ callback_url: 'https://example.com/too-many', events: ['buy'] }); + + expect(res.status).toBe(409); + expect(res.body.error.code).toBe('MAX_WEBHOOKS_REACHED'); + }); +}); + +describe('GET /api/v1/creators/:id/webhooks', () => { + const basePath = `/api/v1/creators/${creatorId}/webhooks`; + + it('lists webhooks for the creator', async () => { + const res = await supertest(app) + .get(basePath) + .set(authHeaders('GET', basePath, creatorId)); + + expect(res.status).toBe(200); + expect(res.body.success).toBe(true); + expect(Array.isArray(res.body.data)).toBe(true); + expect(res.body.data.length).toBeGreaterThan(0); + }); + + it('returns 401 without auth', async () => { + const res = await supertest(app).get(basePath); + expect(res.status).toBe(401); + }); +}); + +describe('DELETE /api/v1/creators/:id/webhooks/:webhookId', () => { + it('deletes a webhook', async () => { + const listRes = await supertest(app) + .get(`/api/v1/creators/${creatorId}/webhooks`) + .set(authHeaders('GET', `/api/v1/creators/${creatorId}/webhooks`, creatorId)); + + const webhookId = listRes.body.data[0].id; + + const deleteRes = await supertest(app) + .delete(`/api/v1/creators/${creatorId}/webhooks/${webhookId}`) + .set(authHeaders('DELETE', `/api/v1/creators/${creatorId}/webhooks/${webhookId}`, creatorId)); + + expect(deleteRes.status).toBe(200); + expect(deleteRes.body.success).toBe(true); + + const verifyRes = await supertest(app) + .get(`/api/v1/creators/${creatorId}/webhooks`) + .set(authHeaders('GET', `/api/v1/creators/${creatorId}/webhooks`, creatorId)); + + const ids = verifyRes.body.data.map((w: any) => w.id); + expect(ids).not.toContain(webhookId); + }); + + it('returns 404 for non-existent webhook', async () => { + const res = await supertest(app) + .delete(`/api/v1/creators/${creatorId}/webhooks/non-existent-id`) + .set(authHeaders('DELETE', `/api/v1/creators/${creatorId}/webhooks/non-existent-id`, creatorId)); + + expect(res.status).toBe(404); + }); +}); + +describe('webhook dispatch', () => { + let webhookId: string; + + beforeAll(async () => { + const webhook = await prisma.webhook.create({ + data: { + id: 'webhook-dispatch-test', + creatorId, + callbackUrl: 'https://httpbin.org/post', + events: { set: ['BUY', 'SELL'] }, + }, + }); + webhookId = webhook.id; + }); + + afterAll(async () => { + await prisma.webhookEvent.deleteMany({ where: { webhookId } }); + await prisma.webhook.delete({ where: { id: webhookId } }).catch(() => {}); + }); + + it('dispatches a buy event and creates a WebhookEvent record', async () => { + const { dispatchWebhookEvent } = await import('./webhook.service'); + + await dispatchWebhookEvent({ + type: 'buy', + creatorId, + buyerOrSellerAddress: 'GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF', + amount: '100', + price: '10.5', + feePaid: '0.5', + timestamp: new Date().toISOString(), + }); + + const events = await prisma.webhookEvent.findMany({ + where: { webhookId, eventType: 'BUY' }, + orderBy: { createdAt: 'desc' }, + }); + + expect(events.length).toBeGreaterThan(0); + }); + + it('dispatches a sell event and creates a WebhookEvent record', async () => { + const { dispatchWebhookEvent } = await import('./webhook.service'); + + await dispatchWebhookEvent({ + type: 'sell', + creatorId, + buyerOrSellerAddress: 'GBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBWBH', + amount: '50', + price: '20.0', + feePaid: '1.0', + timestamp: new Date().toISOString(), + }); + + const events = await prisma.webhookEvent.findMany({ + where: { webhookId, eventType: 'SELL' }, + orderBy: { createdAt: 'desc' }, + }); + + expect(events.length).toBeGreaterThan(0); + }); + + it('respects event type filter — buy-only webhook does not receive sell events', async () => { + const buyOnlyWebhook = await prisma.webhook.create({ + data: { + id: 'webhook-filter-buy', + creatorId, + callbackUrl: 'https://httpbin.org/post', + events: { set: ['BUY'] }, + }, + }); + + const { dispatchWebhookEvent } = await import('./webhook.service'); + + await dispatchWebhookEvent({ + type: 'sell', + creatorId, + buyerOrSellerAddress: 'GCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCF', + amount: '25', + price: '30.0', + feePaid: '0.75', + timestamp: new Date().toISOString(), + }); + + const events = await prisma.webhookEvent.findMany({ + where: { webhookId: buyOnlyWebhook.id, eventType: 'SELL' }, + }); + + expect(events.length).toBe(0); + + await prisma.webhookEvent.deleteMany({ where: { webhookId: buyOnlyWebhook.id } }); + await prisma.webhook.delete({ where: { id: buyOnlyWebhook.id } }); + }); + + it('retries delivery on failure and flags webhook as failing after exhaustion', async () => { + const failingWebhook = await prisma.webhook.create({ + data: { + id: 'webhook-retry-test', + creatorId, + callbackUrl: 'https://nonexistent.example.com/fail', + events: { set: ['BUY'] }, + }, + }); + + const { dispatchWebhookEvent } = await import('./webhook.service'); + + await dispatchWebhookEvent({ + type: 'buy', + creatorId, + buyerOrSellerAddress: 'GDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDH', + amount: '10', + price: '5.0', + feePaid: '0.25', + timestamp: new Date().toISOString(), + }); + + await new Promise((resolve) => setTimeout(resolve, 15000)); + + const updated = await prisma.webhook.findUnique({ + where: { id: failingWebhook.id }, + select: { isFailing: true }, + }); + + expect(updated?.isFailing).toBe(true); + + const events = await prisma.webhookEvent.findMany({ + where: { webhookId: failingWebhook.id }, + }); + + expect(events.length).toBeGreaterThan(0); + expect(events[0].status).toBe('FAILED'); + expect(events[0].retryCount).toBe(envConfig.WEBHOOK_RETRY_MAX_ATTEMPTS); + + await prisma.webhookEvent.deleteMany({ where: { webhookId: failingWebhook.id } }); + await prisma.webhook.delete({ where: { id: failingWebhook.id } }); + }, 30000); +}); diff --git a/src/modules/webhooks/webhook.router.ts b/src/modules/webhooks/webhook.router.ts new file mode 100644 index 0000000..9d14e21 --- /dev/null +++ b/src/modules/webhooks/webhook.router.ts @@ -0,0 +1,29 @@ +import { Router } from 'express'; +import { requireWalletSignature } from './webhook-signature.middleware'; +import { + registerWebhookHandler, + listWebhooksHandler, + deleteWebhookHandler, +} from './webhook.controllers'; + +const router = Router(); + +router.post( + '/:id/webhooks', + requireWalletSignature(), + registerWebhookHandler +); + +router.get( + '/:id/webhooks', + requireWalletSignature(), + listWebhooksHandler +); + +router.delete( + '/:id/webhooks/:webhookId', + requireWalletSignature(), + deleteWebhookHandler +); + +export default router; diff --git a/src/modules/webhooks/webhook.schemas.ts b/src/modules/webhooks/webhook.schemas.ts new file mode 100644 index 0000000..f3a4454 --- /dev/null +++ b/src/modules/webhooks/webhook.schemas.ts @@ -0,0 +1,12 @@ +import { z } from 'zod'; + +export const WebhookEventEnum = z.enum(['buy', 'sell']); + +export const CreateWebhookSchema = z.object({ + callback_url: z.string().url('callback_url must be a valid URL'), + events: z + .array(WebhookEventEnum, { required_error: 'events is required' }) + .min(1, 'At least one event type is required'), +}); + +export type CreateWebhookType = z.infer; diff --git a/src/modules/webhooks/webhook.service.test.ts b/src/modules/webhooks/webhook.service.test.ts new file mode 100644 index 0000000..6de82bc --- /dev/null +++ b/src/modules/webhooks/webhook.service.test.ts @@ -0,0 +1,277 @@ +import { prisma } from '../../utils/prisma.utils'; +import { envConfig } from '../../config'; +import * as webhookService from './webhook.service'; + +jest.mock('../../utils/prisma.utils', () => ({ + prisma: { + webhook: { + count: jest.fn(), + create: jest.fn(), + findMany: jest.fn(), + findFirst: jest.fn(), + findUnique: jest.fn(), + delete: jest.fn(), + update: jest.fn(), + }, + webhookEvent: { + create: jest.fn(), + findMany: jest.fn(), + updateMany: jest.fn(), + }, + }, +})); + +jest.mock('../../utils/logger.utils', () => ({ + logger: { info: jest.fn(), warn: jest.fn(), error: jest.fn() }, +})); + +const mockPrisma = prisma as unknown as { + webhook: { + count: jest.Mock; + create: jest.Mock; + findMany: jest.Mock; + findFirst: jest.Mock; + findUnique: jest.Mock; + delete: jest.Mock; + update: jest.Mock; + }; + webhookEvent: { + create: jest.Mock; + findMany: jest.Mock; + updateMany: jest.Mock; + }; +}; + +beforeEach(() => { + jest.clearAllMocks(); +}); + +describe('createWebhook', () => { + it('creates a webhook when under the max limit', async () => { + mockPrisma.webhook.count.mockResolvedValue(0); + mockPrisma.webhook.create.mockResolvedValue({ + id: 'wh-1', + creatorId: 'creator-1', + callbackUrl: 'https://example.com/hook', + events: ['BUY', 'SELL'], + isActive: true, + isFailing: false, + createdAt: new Date(), + updatedAt: new Date(), + }); + + const result = await webhookService.createWebhook('creator-1', { + callbackUrl: 'https://example.com/hook', + events: ['buy', 'sell'], + }); + + expect(result.events).toEqual(['buy', 'sell']); + expect(result.callbackUrl).toBe('https://example.com/hook'); + expect(mockPrisma.webhook.count).toHaveBeenCalledWith({ + where: { creatorId: 'creator-1', isActive: true }, + }); + }); + + it('rejects creation when max webhooks reached', async () => { + mockPrisma.webhook.count.mockResolvedValue(envConfig.WEBHOOK_MAX_PER_CREATOR); + + await expect( + webhookService.createWebhook('creator-1', { + callbackUrl: 'https://example.com/hook', + events: ['buy'], + }) + ).rejects.toMatchObject({ + statusCode: 409, + code: 'MAX_WEBHOOKS_REACHED', + }); + }); +}); + +describe('listWebhooks', () => { + it('returns denormalized event names', async () => { + mockPrisma.webhook.findMany.mockResolvedValue([ + { + id: 'wh-1', + creatorId: 'creator-1', + callbackUrl: 'https://example.com/hook', + events: ['BUY'], + isActive: true, + isFailing: false, + createdAt: new Date(), + updatedAt: new Date(), + }, + ]); + + const result = await webhookService.listWebhooks('creator-1'); + expect(result[0].events).toEqual(['buy']); + }); +}); + +describe('deleteWebhook', () => { + it('deletes a webhook owned by the creator', async () => { + mockPrisma.webhook.findFirst.mockResolvedValue({ + id: 'wh-1', + creatorId: 'creator-1', + }); + mockPrisma.webhook.delete.mockResolvedValue({ id: 'wh-1' }); + + const result = await webhookService.deleteWebhook('wh-1', 'creator-1'); + expect(result).toEqual({ id: 'wh-1' }); + expect(mockPrisma.webhook.delete).toHaveBeenCalledWith({ + where: { id: 'wh-1' }, + }); + }); + + it('returns null for non-existent webhook', async () => { + mockPrisma.webhook.findFirst.mockResolvedValue(null); + + const result = await webhookService.deleteWebhook('wh-1', 'creator-1'); + expect(result).toBeNull(); + }); +}); + +describe('dispatchWebhookEvent', () => { + beforeEach(() => { + jest.useFakeTimers(); + global.fetch = jest.fn(); + }); + + afterEach(() => { + jest.useRealTimers(); + jest.restoreAllMocks(); + }); + + it('does nothing when no matching webhooks', async () => { + mockPrisma.webhook.findMany.mockResolvedValue([]); + + await webhookService.dispatchWebhookEvent({ + type: 'buy', + creatorId: 'creator-1', + buyerOrSellerAddress: 'G...', + amount: '100', + price: '10', + feePaid: '0.5', + timestamp: new Date().toISOString(), + }); + + expect(mockPrisma.webhook.findMany).toHaveBeenCalled(); + expect(mockPrisma.webhookEvent.create).not.toHaveBeenCalled(); + }); + + it('creates WebhookEvent and dispatches for matching webhooks', async () => { + mockPrisma.webhook.findMany.mockResolvedValue([ + { + id: 'wh-1', + creatorId: 'creator-1', + callbackUrl: 'https://example.com/hook', + events: ['BUY'], + isActive: true, + isFailing: false, + }, + ]); + mockPrisma.webhookEvent.create.mockResolvedValue({ id: 'we-1' }); + mockPrisma.webhookEvent.updateMany.mockResolvedValue({ count: 1 }); + + const mockFetch = jest.fn().mockResolvedValue({ + ok: true, + status: 200, + statusText: 'OK', + }); + (global.fetch as jest.Mock) = mockFetch; + + await webhookService.dispatchWebhookEvent({ + type: 'buy', + creatorId: 'creator-1', + buyerOrSellerAddress: 'G...', + amount: '100', + price: '10', + feePaid: '0.5', + timestamp: new Date().toISOString(), + }); + + expect(mockPrisma.webhookEvent.create).toHaveBeenCalled(); + expect(mockFetch).toHaveBeenCalled(); + }); + + it('respects event type filter — buy webhook does not fire for sell events', async () => { + mockPrisma.webhook.findMany.mockImplementation( + (args: { where: { events: { has: string } } }) => { + if (args?.where?.events?.has === 'BUY') { + return Promise.resolve([ + { + id: 'wh-buy', + creatorId: 'creator-1', + callbackUrl: 'https://example.com/hook-buy', + events: ['BUY'], + isActive: true, + isFailing: false, + }, + ]); + } + return Promise.resolve([]); + } + ); + + await webhookService.dispatchWebhookEvent({ + type: 'sell', + creatorId: 'creator-1', + buyerOrSellerAddress: 'G...', + amount: '50', + price: '20', + feePaid: '1.0', + timestamp: new Date().toISOString(), + }); + + expect(mockPrisma.webhook.findMany).toHaveBeenCalled(); + expect(mockPrisma.webhookEvent.create).not.toHaveBeenCalled(); + }); + + it('retries delivery up to max attempts and flags webhook as failing', async () => { + mockPrisma.webhook.findMany.mockResolvedValue([ + { + id: 'wh-1', + creatorId: 'creator-1', + callbackUrl: 'https://nonexistent.example.com/fail', + events: ['SELL'], + isActive: true, + isFailing: false, + }, + ]); + mockPrisma.webhookEvent.create.mockResolvedValue({ id: 'we-1' }); + mockPrisma.webhookEvent.updateMany.mockResolvedValue({ count: 1 }); + mockPrisma.webhook.update.mockResolvedValue({}); + mockPrisma.webhook.findUnique.mockResolvedValue({ + callbackUrl: 'https://nonexistent.example.com/fail', + }); + + const mockFetch = jest.fn().mockRejectedValue(new Error('Network error')); + (global.fetch as jest.Mock) = mockFetch; + + const dispatchPromise = webhookService.dispatchWebhookEvent({ + type: 'sell', + creatorId: 'creator-1', + buyerOrSellerAddress: 'G...', + amount: '10', + price: '5', + feePaid: '0.25', + timestamp: new Date().toISOString(), + }); + + for (let i = 0; i < envConfig.WEBHOOK_RETRY_MAX_ATTEMPTS; i++) { + await jest.advanceTimersByTimeAsync(Math.pow(2, i + 1) * 1000); + } + + await dispatchPromise; + + expect(mockFetch).toHaveBeenCalledTimes(envConfig.WEBHOOK_RETRY_MAX_ATTEMPTS); + expect(mockPrisma.webhook.update).toHaveBeenCalledWith({ + where: { id: 'wh-1' }, + data: { isFailing: true }, + }); + expect(mockPrisma.webhookEvent.updateMany).toHaveBeenLastCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ status: 'FAILED' }), + }) + ); + }); +}); diff --git a/src/modules/webhooks/webhook.service.ts b/src/modules/webhooks/webhook.service.ts new file mode 100644 index 0000000..7678b03 --- /dev/null +++ b/src/modules/webhooks/webhook.service.ts @@ -0,0 +1,176 @@ +import { prisma } from '../../utils/prisma.utils'; +import { logger } from '../../utils/logger.utils'; +import { envConfig } from '../../config'; +import type { CreateWebhookInput, TradeEvent, WebhookEventPayload, WebhookEventName } from './webhook.types'; + +function normalizeEvents(events: string[]): ('BUY' | 'SELL')[] { + return events.map((e) => (e === 'buy' ? 'BUY' : 'SELL')); +} + +function denormalizeEvents(events: ('BUY' | 'SELL')[]): WebhookEventName[] { + return events.map((e) => (e === 'BUY' ? 'buy' : 'sell')); +} + +export async function createWebhook( + creatorId: string, + input: CreateWebhookInput +) { + const count = await prisma.webhook.count({ + where: { creatorId, isActive: true }, + }); + + if (count >= envConfig.WEBHOOK_MAX_PER_CREATOR) { + throw Object.assign( + new Error( + `Maximum of ${envConfig.WEBHOOK_MAX_PER_CREATOR} active webhooks per creator reached` + ), + { statusCode: 409, code: 'MAX_WEBHOOKS_REACHED' } + ); + } + + const webhook = await prisma.webhook.create({ + data: { + creatorId, + callbackUrl: input.callbackUrl, + events: { + set: normalizeEvents(input.events), + }, + }, + }); + + return { + ...webhook, + events: denormalizeEvents(webhook.events as ('BUY' | 'SELL')[]), + }; +} + +export async function listWebhooks(creatorId: string) { + const webhooks = await prisma.webhook.findMany({ + where: { creatorId }, + orderBy: { createdAt: 'desc' }, + }); + + return webhooks.map((w) => ({ + ...w, + events: denormalizeEvents(w.events as ('BUY' | 'SELL')[]), + })); +} + +export async function deleteWebhook(webhookId: string, creatorId: string) { + const webhook = await prisma.webhook.findFirst({ + where: { id: webhookId, creatorId }, + }); + + if (!webhook) { + return null; + } + + await prisma.webhook.delete({ where: { id: webhookId } }); + return { id: webhookId }; +} + +export async function dispatchWebhookEvent(tradeEvent: TradeEvent) { + const eventName: 'BUY' | 'SELL' = + tradeEvent.type === 'buy' ? 'BUY' : 'SELL'; + + const webhooks = await prisma.webhook.findMany({ + where: { + creatorId: tradeEvent.creatorId, + isActive: true, + isFailing: false, + events: { has: eventName }, + }, + }); + + if (webhooks.length === 0) return; + + for (const webhook of webhooks) { + const payload: WebhookEventPayload = { + event_type: tradeEvent.type, + creator_id: tradeEvent.creatorId, + buyer_or_seller_address: tradeEvent.buyerOrSellerAddress, + amount: tradeEvent.amount, + price: tradeEvent.price, + fee_paid: tradeEvent.feePaid, + timestamp: tradeEvent.timestamp, + }; + + await prisma.webhookEvent.create({ + data: { + webhookId: webhook.id, + eventType: eventName, + payload: payload as unknown as Record, + status: 'PENDING', + }, + }); + + attemptDelivery(webhook.id, webhook.callbackUrl, payload).catch((err) => { + logger.error({ webhookId: webhook.id, error: err.message }, 'Webhook delivery failed'); + }); + } +} + +async function attemptDelivery( + webhookId: string, + callbackUrl: string, + payload: WebhookEventPayload, + attempt = 1 +): Promise { + const maxAttempts = envConfig.WEBHOOK_RETRY_MAX_ATTEMPTS; + + try { + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), 5000); + + const response = await fetch(callbackUrl, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload), + signal: controller.signal, + }); + + clearTimeout(timeout); + + if (response.ok) { + await prisma.webhookEvent.updateMany({ + where: { webhookId, status: 'PENDING' }, + data: { status: 'DELIVERED', retryCount: attempt }, + }); + return; + } + + throw new Error(`HTTP ${response.status}: ${response.statusText}`); + } catch (error) { + const errMsg = error instanceof Error ? error.message : 'Unknown error'; + + await prisma.webhookEvent.updateMany({ + where: { webhookId, status: 'PENDING' }, + data: { retryCount: attempt, lastError: errMsg }, + }); + + if (attempt < maxAttempts) { + const delay = Math.pow(2, attempt) * 1000; + logger.warn( + { webhookId, attempt, maxAttempts, nextRetryMs: delay, error: errMsg }, + 'Webhook delivery failed, retrying' + ); + await new Promise((resolve) => setTimeout(resolve, delay)); + return attemptDelivery(webhookId, callbackUrl, payload, attempt + 1); + } + + await prisma.webhook.update({ + where: { id: webhookId }, + data: { isFailing: true }, + }); + + await prisma.webhookEvent.updateMany({ + where: { webhookId, status: 'PENDING' }, + data: { status: 'FAILED', retryCount: attempt }, + }); + + logger.error( + { webhookId, attempt, maxAttempts, error: errMsg }, + 'Webhook delivery exhausted all retries, flagged as failing' + ); + } +} diff --git a/src/modules/webhooks/webhook.types.ts b/src/modules/webhooks/webhook.types.ts new file mode 100644 index 0000000..847ffde --- /dev/null +++ b/src/modules/webhooks/webhook.types.ts @@ -0,0 +1,37 @@ +export type WebhookEventName = 'buy' | 'sell'; + +export interface CreateWebhookInput { + callbackUrl: string; + events: WebhookEventName[]; +} + +export interface WebhookResponse { + id: string; + creatorId: string; + callbackUrl: string; + events: WebhookEventName[]; + isActive: boolean; + isFailing: boolean; + createdAt: Date; + updatedAt: Date; +} + +export interface WebhookEventPayload { + event_type: WebhookEventName; + creator_id: string; + buyer_or_seller_address: string; + amount: string; + price: string; + fee_paid: string; + timestamp: string; +} + +export interface TradeEvent { + type: WebhookEventName; + creatorId: string; + buyerOrSellerAddress: string; + amount: string; + price: string; + feePaid: string; + timestamp: string; +}