diff --git a/.changeset/plain-pumas-relax.md b/.changeset/plain-pumas-relax.md new file mode 100644 index 000000000..81dcb51c5 --- /dev/null +++ b/.changeset/plain-pumas-relax.md @@ -0,0 +1,5 @@ +--- +"@exactly/server": patch +--- + +✨ migrate webhook subscription to queue diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b73fe7120..d187a6e48 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -880,6 +880,9 @@ importers: prool: specifier: ^0.2.2 version: 0.2.2(debug@4.4.3) + redis-memory-server: + specifier: ^0.16.0 + version: 0.16.0 tsx: specifier: ^4.21.0 version: 4.21.0 @@ -4106,6 +4109,10 @@ packages: '@pix.js/validator@1.1.0': resolution: {integrity: sha512-NIYcYwuFblA8/cx7YpNdEEujNjKsnA985jsNgIMcYtY2AVUz646IUbisgTgFu7erN7X5eeQGzELgRnFoPcInVw==} + '@pkgjs/parseargs@0.11.0': + resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==} + engines: {node: '>=14'} + '@pkgr/core@0.1.2': resolution: {integrity: sha512-fdDH1LSGfZdTH2sxdpVMw31BanV28K/Gry0cVFxaNP77neJSkd82mM8ErPNYs9e+0O7SdHBLTDzDgwUuy18RnQ==} engines: {node: ^12.20.0 || ^14.18.0 || >=16.0.0} @@ -6012,6 +6019,9 @@ packages: '@types/yargs@17.0.35': resolution: {integrity: sha512-qUHkeCyQFxMXg79wQfTtfndEC+N9ZZg76HJftDJp+qH2tV7Gj4OJi7l+PiWwJ+pWtW8GwSmqsDj/oymhrTWXjg==} + '@types/yauzl@2.10.3': + resolution: {integrity: sha512-oJoftv0LSuaDZE3Le4DbKX+KS9G36NzOeSap90UIK0yMA/NhKJhqlSGtNDORNRaIbQfzjXDrQa0ytJ6mNRGz/Q==} + '@typescript-eslint/eslint-plugin@8.53.1': resolution: {integrity: sha512-cFYYFZ+oQFi6hUnBTbLRXfTJiaQtYE3t4O692agbBl+2Zy+eqSKWtPjhPXJu1G7j4RLjKgeJPDdq3EqOwmX5Ag==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} @@ -6854,6 +6864,9 @@ packages: resolution: {integrity: sha512-Rqf0ly5H4HGt+ki/n3m7GxoR2uIGtNqezPlOLX8Vuo13j5/tfPuVvAr84eoGF7sYm6lKdbGnT/3q8qmzuT5Y9w==} engines: {node: '>= 0.4.0'} + buffer-crc32@0.2.13: + resolution: {integrity: sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==} + buffer-from@1.1.2: resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==} @@ -8707,6 +8720,11 @@ packages: extendable-error@0.1.7: resolution: {integrity: sha512-UOiS2in6/Q0FK0R0q6UY9vYpQ21mr/Qn1KOnte7vsACuNJf514WvCCUHSRCPcgjPT2bAhNIJdlE6bVap1GKmeg==} + extract-zip@2.0.1: + resolution: {integrity: sha512-GDhU9ntwuKyGXdZBUgTIe+vXnWj0fppUEtMDL0+idd5Sta8TGpHssn/eusA9mrPr9qNDym6SxAYZjNvCn/9RBg==} + engines: {node: '>= 10.17.0'} + hasBin: true + fast-deep-equal@3.1.3: resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} @@ -8751,6 +8769,9 @@ packages: fbjs@3.0.5: resolution: {integrity: sha512-ztsSx77JBtkuMrEypfhgc3cI0+0h+svqeie7xHbh1k/IKdcydnvadp/mUaGgjAOXQmQSxsqgaRhS3q9fy+1kxg==} + fd-slicer@1.1.0: + resolution: {integrity: sha512-cE1qsB/VwyQozZ+q1dGxR8LBYNZeofhEdUNGSMbQD3Gw2lAzX9Zb3uIU6Ebc/Fmyjo9AWWfnn0AUCHqtevs/8g==} + fdir@6.5.0: resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} engines: {node: '>=12.0.0'} @@ -8809,6 +8830,9 @@ packages: resolution: {integrity: sha512-wXZV5emFEjrridIgED11OoUKLxiYjAcqot/NJdAkOhlJ+vGzwhOAfcG5OX1jP+S0PcjEn8bdMJv+g2jwQ3Onig==} engines: {node: '>=8'} + find-package-json@1.2.0: + resolution: {integrity: sha512-+SOGcLGYDJHtyqHd87ysBhmaeQ95oWspDKnMXBrnQ9Eq4OkLNqejgoaD8xVWu6GPa0B6roa6KinCMEMcVeqONw==} + find-root@1.1.0: resolution: {integrity: sha512-NKfW6bec6GfKc0SGx1e07QZY9PE99u0Bft/0rzSD5k3sO/vwkVUpDUKVm5Gpp5Ue3YfShPFTX2070tDs5kB9Ng==} @@ -9008,6 +9032,10 @@ packages: resolution: {integrity: sha512-pjzuKtY64GYfWizNAJ0fr9VqttZkNiK2iS430LtIHzjBEr6bX8Am2zm4sW4Ro5wjWW5cAlRL1qAMTcXbjNAO2Q==} engines: {node: '>=8.0.0'} + get-port@5.1.1: + resolution: {integrity: sha512-g/Q1aTSDOxFpchXC4i8ZWvxA1lnPqx/JHqcpIw0/LX9T8x/GBbi6YnlN5nhaKIFkT8oFsscUKgDJYxfwfS6QsQ==} + engines: {node: '>=8'} + get-port@7.1.0: resolution: {integrity: sha512-QB9NKEeDg3xxVwCCwJQ9+xycaz6pBB6iQ76wiWMl1927n0Kir6alPiP+yuiICLLU4jpMe08dXfpebuQppFA2zw==} engines: {node: '>=16'} @@ -9016,6 +9044,10 @@ packages: resolution: {integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==} engines: {node: '>= 0.4'} + get-stream@5.2.0: + resolution: {integrity: sha512-nBF+F1rAZVCu/p7rjzgA+Yb4lfYXrpl7a6VmJrU8wF9I1CKvP/QwPNZHnOlwbTkY6dvtFIzFMSyQXbLoTQPRpA==} + engines: {node: '>=8'} + get-stream@6.0.1: resolution: {integrity: sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==} engines: {node: '>=10'} @@ -9053,6 +9085,11 @@ packages: resolution: {integrity: sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==} engines: {node: '>=10.13.0'} + glob@10.5.0: + resolution: {integrity: sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==} + deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me + hasBin: true + glob@11.1.0: resolution: {integrity: sha512-vuNwKSaKiqm7g0THUBu2x7ckSs3XJLXE+2ssL7/MfTGPLLcrJQ/4Uq1CjPTtO5cCIiRxqvN6Twy1qOwhL0Xjcw==} engines: {node: 20 || >=22} @@ -9783,6 +9820,9 @@ packages: resolution: {integrity: sha512-H0dkQoCa3b2VEeKQBOxFph+JAbcrQdE7KC0UkqwpLmv2EC4P41QXP+rqo9wYodACiG5/WM5s9oDApTU8utwj9g==} engines: {node: '>= 0.4'} + jackspeak@3.4.3: + resolution: {integrity: sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==} + jackspeak@4.1.1: resolution: {integrity: sha512-zptv57P3GpL+O0I7VdMJNBZCu+BPHVQUk55Ft8/QCJjTVxrnJHuVuX/0Bl2A6/+2oyR/ZMEuFKwmzqqZ/U5nPQ==} engines: {node: 20 || >=22} @@ -10105,6 +10145,9 @@ packages: resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==} engines: {node: '>=10'} + lockfile@1.0.4: + resolution: {integrity: sha512-cvbTwETRfsFh4nHsL1eGWapU1XFi5Ot9E85sWAwia7Y7EgB7vfqcZhTKZ+l7hCGxSPoushMv5GKhT5PdLv03WA==} + lodash-es@4.17.23: resolution: {integrity: sha512-kVI48u3PZr38HdYz98UmfPnXl2DXrpdctLrFLCd3kOx1xUkOmpFPx7gCWWM5MPkL/fD8zb+Ph0QzjGFs4+hHWg==} @@ -11185,6 +11228,9 @@ packages: pathe@2.0.3: resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} + pend@1.2.0: + resolution: {integrity: sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==} + persona@5.5.0: resolution: {integrity: sha512-sSayn72ppan7RGhMhBHbfUjQtIeWGGKL/AKA/2+bXGEBhNgkzaLBuLd+urcRLXmqjoZhFXsWgG5oVxTFvABhcw==} @@ -11439,6 +11485,9 @@ packages: proxy-from-env@1.1.0: resolution: {integrity: sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==} + pump@3.0.4: + resolution: {integrity: sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==} + punycode.js@2.3.1: resolution: {integrity: sha512-uxFIHU0YlHYhDQtV4R9J6a52SLx28BCjT+4ieh7IGbgwVJWO+km431c4yRlREUAsAmt/uMjQUyQHNEPf0M39CA==} engines: {node: '>=6'} @@ -11751,6 +11800,11 @@ packages: resolution: {integrity: sha512-1qny3OExCf0UvUV/5wpYKf2YwPcOqXzkwKKSmKHiE6ZMQs5heeE/c8eXK+PNllPvmjgAbfnsbpkGZWy8cBpn9w==} engines: {node: '>=4'} + redis-memory-server@0.16.0: + resolution: {integrity: sha512-NumE5HHR5ku8k3hD54xUjBhv2XoKQd/UC8gtlAh3jFKqhH3kSn3C8pB3MBS5K3hj7rhgiJbXu+2Xhm+Mqi+SWQ==} + engines: {node: '>=18'} + hasBin: true + redis-parser@3.0.0: resolution: {integrity: sha512-DJnGAeenTdpMEH6uAJRK/uiyEIH9WVsUmoLwzudwGJUwZPp80PDBWPHXSAGNPwNvIXAbe7MSUB1zQFugFml66A==} engines: {node: '>=4'} @@ -11978,6 +12032,10 @@ packages: deprecated: Rimraf versions prior to v4 are no longer supported hasBin: true + rimraf@5.0.10: + resolution: {integrity: sha512-l0OE8wL34P4nJH/H2ffoaniAokM2qSmrtXHmlpvYr5AVVX8msAyW0l8NVJFDxlSK4u3Uh/f41cQheDVdnYijwQ==} + hasBin: true + robust-predicates@3.0.2: resolution: {integrity: sha512-IXgzBWvWQwE6PrDI05OvmXUIruQTcoMDzRsOd5CDvHCVLcLHMTSYvOK5Cm46kWqlV3yAbuSpBZdJ5oP5OUoStg==} @@ -13049,6 +13107,10 @@ packages: resolution: {integrity: sha512-DPSke0pXhTZgoF/d+WSt2QaKMCFSfx7QegxEWT+JOuHF5aWrKEn0G+ztjuJg/gG8/ItK+rbPCD/yNv8yyih6Cg==} hasBin: true + uuid@8.3.2: + resolution: {integrity: sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==} + hasBin: true + uuidv7@1.1.0: resolution: {integrity: sha512-2VNnOC0+XQlwogChUDzy6pe8GQEys9QFZBGOh54l6qVfwoCUwwRvk7rDTgaIsRgsF5GFa5oiNg8LqXE3jofBBg==} hasBin: true @@ -13518,6 +13580,9 @@ packages: resolution: {integrity: sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==} engines: {node: '>=12'} + yauzl@2.10.0: + resolution: {integrity: sha512-p4a9I6X6nu6IhoGmBqAcbJy1mlC4j27vEPZX9F4L4/vZT3Lyq1VkFHw/V/PUcB9Buo+DG3iHkT0x3Qya58zc3g==} + yocto-queue@0.1.0: resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} engines: {node: '>=10'} @@ -17484,6 +17549,9 @@ snapshots: transitivePeerDependencies: - buffer + '@pkgjs/parseargs@0.11.0': + optional: true + '@pkgr/core@0.1.2': {} '@pkgr/core@0.2.9': {} @@ -20217,6 +20285,11 @@ snapshots: dependencies: '@types/yargs-parser': 21.0.3 + '@types/yauzl@2.10.3': + dependencies: + '@types/node': 25.0.9 + optional: true + '@typescript-eslint/eslint-plugin@8.53.1(@typescript-eslint/parser@8.53.1(eslint@9.39.2)(typescript@5.9.3))(eslint@9.39.2)(typescript@5.9.3)': dependencies: '@eslint-community/regexpp': 4.12.2 @@ -21359,6 +21432,8 @@ snapshots: once: 1.4.0 sliced: 1.0.1 + buffer-crc32@0.2.13: {} + buffer-from@1.1.2: {} buffer@5.7.1: @@ -23641,6 +23716,16 @@ snapshots: extendable-error@0.1.7: {} + extract-zip@2.0.1: + dependencies: + debug: 4.4.3 + get-stream: 5.2.0 + yauzl: 2.10.0 + optionalDependencies: + '@types/yauzl': 2.10.3 + transitivePeerDependencies: + - supports-color + fast-deep-equal@3.1.3: {} fast-diff@1.3.0: {} @@ -23693,6 +23778,10 @@ snapshots: transitivePeerDependencies: - encoding + fd-slicer@1.1.0: + dependencies: + pend: 1.2.0 + fdir@6.5.0(picomatch@3.0.1): optionalDependencies: picomatch: 3.0.1 @@ -23768,6 +23857,8 @@ snapshots: make-dir: 3.1.0 pkg-dir: 4.2.0 + find-package-json@1.2.0: {} + find-root@1.1.0: {} find-up-simple@1.0.1: {} @@ -23975,6 +24066,8 @@ snapshots: get-package-type@0.1.0: {} + get-port@5.1.1: {} + get-port@7.1.0: {} get-proto@1.0.1: @@ -23982,6 +24075,10 @@ snapshots: dunder-proto: 1.0.1 es-object-atoms: 1.1.1 + get-stream@5.2.0: + dependencies: + pump: 3.0.4 + get-stream@6.0.1: {} get-stream@9.0.1: @@ -24015,6 +24112,15 @@ snapshots: dependencies: is-glob: 4.0.3 + glob@10.5.0: + dependencies: + foreground-child: 3.3.1 + jackspeak: 3.4.3 + minimatch: 9.0.9 + minipass: 7.1.2 + package-json-from-dist: 1.0.1 + path-scurry: 1.11.1 + glob@11.1.0: dependencies: foreground-child: 3.3.1 @@ -24862,6 +24968,12 @@ snapshots: has-symbols: 1.1.0 set-function-name: 2.0.2 + jackspeak@3.4.3: + dependencies: + '@isaacs/cliui': 8.0.2 + optionalDependencies: + '@pkgjs/parseargs': 0.11.0 + jackspeak@4.1.1: dependencies: '@isaacs/cliui': 8.0.2 @@ -25190,6 +25302,10 @@ snapshots: dependencies: p-locate: 5.0.0 + lockfile@1.0.4: + dependencies: + signal-exit: 3.0.7 + lodash-es@4.17.23: {} lodash._baseflatten@3.1.4: @@ -26887,6 +27003,8 @@ snapshots: pathe@2.0.3: {} + pend@1.2.0: {} + persona@5.5.0: dependencies: lodash.kebabcase: 4.1.1 @@ -27134,6 +27252,11 @@ snapshots: proxy-from-env@1.1.0: {} + pump@3.0.4: + dependencies: + end-of-stream: 1.4.5 + once: 1.4.0 + punycode.js@2.3.1: {} punycode@2.3.1: {} @@ -27517,6 +27640,25 @@ snapshots: redis-errors@1.2.0: {} + redis-memory-server@0.16.0: + dependencies: + camelcase: 6.3.0 + cross-spawn: 7.0.6 + debug: 4.4.3 + extract-zip: 2.0.1 + find-cache-dir: 3.3.2 + find-package-json: 1.2.0 + get-port: 5.1.1 + https-proxy-agent: 7.0.6 + lockfile: 1.0.4 + rimraf: 5.0.10 + semver: 7.7.3 + tar: 7.5.10 + tmp: 0.2.5 + uuid: 8.3.2 + transitivePeerDependencies: + - supports-color + redis-parser@3.0.0: dependencies: redis-errors: 1.2.0 @@ -27810,6 +27952,10 @@ snapshots: dependencies: glob: 7.2.3 + rimraf@5.0.10: + dependencies: + glob: 10.5.0 + robust-predicates@3.0.2: {} rollup-pluginutils@2.8.2: @@ -29069,6 +29215,8 @@ snapshots: uuid@7.0.3: {} + uuid@8.3.2: {} + uuidv7@1.1.0: {} valibot@1.2.0(typescript@5.9.3): @@ -29505,6 +29653,11 @@ snapshots: y18n: 5.0.8 yargs-parser: 21.1.1 + yauzl@2.10.0: + dependencies: + buffer-crc32: 0.2.13 + fd-slicer: 1.1.0 + yocto-queue@0.1.0: {} yocto-queue@1.2.2: {} diff --git a/server/Dockerfile b/server/Dockerfile index fb5ca6d15..7eb641f16 100644 --- a/server/Dockerfile +++ b/server/Dockerfile @@ -16,6 +16,8 @@ RUN foundryup -i v1.5.1 WORKDIR /usr/src/app COPY . . ENV NX_DAEMON=false +# cspell:ignore REDISMS +ENV REDISMS_DISABLE_POSTINSTALL=true RUN --mount=type=cache,id=pnpm,target=/root/.local/share/pnpm/store \ pnpm install --frozen-lockfile RUN --mount=type=cache,id=nx,target=/usr/src/app/node_modules/.cache/nx \ diff --git a/server/api/auth/authentication.ts b/server/api/auth/authentication.ts index fe2cfb5e2..fe9765487 100644 --- a/server/api/auth/authentication.ts +++ b/server/api/auth/authentication.ts @@ -44,11 +44,11 @@ import database, { credentials } from "../../database"; import androidOrigins from "../../utils/android/origins"; import appOrigin from "../../utils/appOrigin"; import authSecret from "../../utils/authSecret"; -import createCredential from "../../utils/createCredential"; +import createCredential, { WebhookNotReadyError } from "../../utils/createCredential"; import decodePublicKey from "../../utils/decodePublicKey"; import getIntercomToken from "../../utils/intercom"; import publicClient from "../../utils/publicClient"; -import redis from "../../utils/redis"; +import { redis } from "../../utils/redis"; import validatorHook from "../../utils/validatorHook"; const Cookie = object({ @@ -341,6 +341,14 @@ Submit the signed SIWE message to prove ownership of an Ethereum address. The se 200, ); } catch (error) { + if (error instanceof WebhookNotReadyError) { + // cspell:ignore retriable + captureException(error, { level: "warning", tags: { retriable: true } }); + return c.json( + { code: "service unavailable", legacy: "service temporarily unavailable, please retry" }, + 503, + ); + } captureException(error, { level: "error", tags: { unhandled: true } }); return c.json({ code: "ouch", legacy: "ouch" }, 500); } diff --git a/server/api/auth/registration.ts b/server/api/auth/registration.ts index acaff073e..45186ee54 100644 --- a/server/api/auth/registration.ts +++ b/server/api/auth/registration.ts @@ -40,10 +40,10 @@ import { Address, Base64URL, Hex } from "@exactly/common/validation"; import { Authentication } from "./authentication"; import androidOrigins from "../../utils/android/origins"; import appOrigin from "../../utils/appOrigin"; -import createCredential from "../../utils/createCredential"; +import createCredential, { WebhookNotReadyError } from "../../utils/createCredential"; import getIntercomToken from "../../utils/intercom"; import publicClient from "../../utils/publicClient"; -import redis from "../../utils/redis"; +import { redis } from "../../utils/redis"; import validatorHook from "../../utils/validatorHook"; const Cookie = object({ @@ -371,6 +371,11 @@ export default new Hono() 200, ); } catch (error) { + if (error instanceof WebhookNotReadyError) { + // cspell:ignore retriable + captureException(error, { level: "warning", tags: { retriable: true } }); + return c.json({ code: "service unavailable", legacy: "service temporarily unavailable, please retry" }, 503); + } captureException(error, { level: "error", tags: { unhandled: true } }); return c.json({ code: "ouch", legacy: "ouch" }, 500); } diff --git a/server/hooks/activity.ts b/server/hooks/activity.ts index 1e1dfa3b1..dd2339770 100644 --- a/server/hooks/activity.ts +++ b/server/hooks/activity.ts @@ -284,10 +284,15 @@ findWebhook(({ webhook_type, webhook_url }) => webhook_type === "ADDRESS_ACTIVIT .then(async (currentHook) => { if (currentHook) { webhookId = currentHook.id; + debug("alchemy webhook initialized with existing hook: %s", webhookId); return signingKeys.add(currentHook.signing_key); } const newHook = await createWebhook({ webhook_type: "ADDRESS_ACTIVITY", webhook_url: url, addresses: [] }); webhookId = newHook.id; + debug("alchemy webhook initialized with new hook: %s", webhookId); signingKeys.add(newHook.signing_key); }) - .catch((error: unknown) => captureException(error)); + .catch((error: unknown) => { + debug("failed to initialize alchemy webhook: %o", error); + captureException(error); + }); diff --git a/server/hooks/block.ts b/server/hooks/block.ts index 82a4b0bef..702d3f8f7 100644 --- a/server/hooks/block.ts +++ b/server/hooks/block.ts @@ -52,7 +52,7 @@ import ensClient from "../utils/ensClient"; import keeper from "../utils/keeper"; import { sendPushNotification } from "../utils/onesignal"; import publicClient from "../utils/publicClient"; -import redis from "../utils/redis"; +import { redis } from "../utils/redis"; import revertFingerprint from "../utils/revertFingerprint"; import validatorHook from "../utils/validatorHook"; diff --git a/server/index.ts b/server/index.ts index e65940c38..5e63257b4 100644 --- a/server/index.ts +++ b/server/index.ts @@ -19,6 +19,8 @@ import panda from "./hooks/panda"; import persona from "./hooks/persona"; import androidFingerprints from "./utils/android/fingerprints"; import appOrigin from "./utils/appOrigin"; +import { closeQueue as closeAccountQueue } from "./utils/createCredential"; +import { closeRedis } from "./utils/redis"; import { closeAndFlush as closeSegment } from "./utils/segment"; import type { UnofficialStatusCode } from "hono/utils/http-status"; @@ -319,8 +321,9 @@ const server = serve(app); export async function close() { return new Promise((resolve, reject) => { server.close((error) => { - Promise.allSettled([closeSentry(), closeSegment(), database.$client.end()]) - .then((results) => { + Promise.allSettled([closeSentry(), closeSegment(), database.$client.end(), closeAccountQueue()]) + .then(async (results) => { + await closeRedis(); if (error) reject(error); else if (results.some((result) => result.status === "rejected")) reject(new Error("closing services failed")); else resolve(null); diff --git a/server/package.json b/server/package.json index cda9fc932..125e986ec 100644 --- a/server/package.json +++ b/server/package.json @@ -83,6 +83,7 @@ "openapi-types": "^12.1.3", "pkgroll": "^2.21.5", "prool": "^0.2.2", + "redis-memory-server": "^0.16.0", "tsx": "^4.21.0", "typescript": "catalog:", "vite": "^7.3.1", diff --git a/server/test/api/auth.test.ts b/server/test/api/auth.test.ts index 6e66eeeba..1f0c155a8 100644 --- a/server/test/api/auth.test.ts +++ b/server/test/api/auth.test.ts @@ -4,6 +4,7 @@ import "../mocks/redis"; import customer from "../mocks/sardine"; import "../mocks/sentry"; +import { captureException } from "@sentry/node"; import { verifyAuthenticationResponse, verifyRegistrationResponse } from "@simplewebauthn/server"; import { eq } from "drizzle-orm"; import { testClient } from "hono/testing"; @@ -20,8 +21,9 @@ import { Address } from "@exactly/common/validation"; import app, { type Authentication } from "../../api/auth/authentication"; import registrationApp from "../../api/auth/registration"; import database, { credentials } from "../../database"; +import { WebhookNotReadyError } from "../../utils/createCredential"; import * as publicClient from "../../utils/publicClient"; -import redis from "../../utils/redis"; +import { redis } from "../../utils/redis"; import type * as SimpleWebAuthn from "@simplewebauthn/server"; import type * as SimpleWebAuthnHelpers from "@simplewebauthn/server/helpers"; @@ -30,6 +32,14 @@ import type * as ViemSiwe from "viem/siwe"; const appClient = testClient(app); const registrationAppClient = testClient(registrationApp); +const mocks = vi.hoisted(() => ({ activityWebhookId: "activity" as string | undefined })); + +vi.mock("../../hooks/activity", () => ({ + get webhookId() { + return mocks.activityWebhookId; + }, +})); + describe("authentication", () => { beforeAll(async () => { await database.insert(credentials).values([ @@ -44,7 +54,10 @@ describe("authentication", () => { ]); }); - afterEach(() => vi.clearAllMocks()); + afterEach(() => { + vi.clearAllMocks(); + mocks.activityWebhookId = "activity"; + }); it("returns intercom token on successful login", async () => { const response = await appClient.index.$post( @@ -378,6 +391,27 @@ describe("authentication", () => { expect(secondResponse.status).toBe(400); expect(await secondResponse.json()).toEqual(expect.objectContaining({ code: "no authentication" })); }); + + it("returns 503 when webhook not ready for new siwe credential", async () => { + mocks.activityWebhookId = undefined; + vi.spyOn(publicClient.default, "verifySiweMessage").mockResolvedValue(true); + const id = "0x1234567890123456789012345678901234567899"; + + const response = await appClient.index.$post( + { json: { method: "siwe", id, signature: "0xdeadbeef" } }, + { headers: { cookie: "session_id=test-session" } }, + ); + + expect(response.status).toBe(503); + expect(await response.json()).toStrictEqual({ + code: "service unavailable", + legacy: "service temporarily unavailable, please retry", + }); + expect(vi.mocked(captureException)).toHaveBeenCalledWith(expect.any(WebhookNotReadyError), { + level: "warning", + tags: { retriable: true }, // cspell:ignore retriable + }); + }); }); describe("registration", () => { @@ -615,12 +649,13 @@ vi.mock("@simplewebauthn/server", async (importOriginal) => { }; }); -vi.mock("../../utils/redis", () => ({ - default: { +vi.mock("../../utils/redis", () => { + const redisMock = { getdel: vi.fn<() => Promise>().mockResolvedValue("test-challenge"), set: vi.fn<() => Promise>().mockResolvedValue(true), - }, -})); + }; + return { queue: redisMock, redis: redisMock, closeRedis: vi.fn() }; +}); vi.mock("@simplewebauthn/server/helpers", async (importOriginal) => { const original = await importOriginal(); diff --git a/server/test/hooks/activity.test.ts b/server/test/hooks/activity.test.ts index 5358eae9d..d507a2f00 100644 --- a/server/test/hooks/activity.test.ts +++ b/server/test/hooks/activity.test.ts @@ -1,4 +1,4 @@ -import "../mocks/alchemy"; +import { findWebhook as findWebhookMock } from "../mocks/alchemy"; import "../mocks/deployments"; import "../mocks/keeper"; import "../mocks/onesignal"; @@ -912,3 +912,23 @@ const mockERC20Abi = [ stateMutability: "nonpayable", }, ] as const; + +describe("webhook initialization", () => { + beforeEach(() => vi.resetModules()); + + it("sets webhookId when existing hook is found", async () => { + vi.mocked(findWebhookMock).mockResolvedValueOnce({ id: "existing-hook-id", signing_key: "existing-signing-key" }); + const activity = await import("../../hooks/activity"); + await vi.waitUntil(() => activity.webhookId === "existing-hook-id", 5000); + expect(activity.webhookId).toBe("existing-hook-id"); + }); + + it("captures exception when webhook initialization fails", async () => { + const error = new Error("alchemy error"); + vi.mocked(findWebhookMock).mockRejectedValueOnce(error); + const { captureException: ce } = await import("@sentry/node"); + await import("../../hooks/activity"); + await vi.waitUntil(() => vi.mocked(ce).mock.calls.some(([error_]) => error_ === error), 5000); + expect(ce).toHaveBeenCalledWith(error); + }); +}); diff --git a/server/test/hooks/block.test.ts b/server/test/hooks/block.test.ts index f3322200d..5b5713b6b 100644 --- a/server/test/hooks/block.test.ts +++ b/server/test/hooks/block.test.ts @@ -52,7 +52,7 @@ import ensClient from "../../utils/ensClient"; import keeper from "../../utils/keeper"; import * as onesignal from "../../utils/onesignal"; import publicClient from "../../utils/publicClient"; -import redis from "../../utils/redis"; +import { redis } from "../../utils/redis"; import revertFingerprint from "../../utils/revertFingerprint"; import anvilClient from "../anvilClient"; diff --git a/server/test/mocks/alchemy.ts b/server/test/mocks/alchemy.ts index fdbf58272..12a11d506 100644 --- a/server/test/mocks/alchemy.ts +++ b/server/test/mocks/alchemy.ts @@ -1,10 +1,16 @@ import { validator } from "hono/validator"; import { vi } from "vitest"; +const { findWebhook, createWebhook } = vi.hoisted(() => ({ + findWebhook: vi.fn().mockResolvedValue({ id: "activity", signing_key: "mock-signing-key" }), + createWebhook: vi.fn().mockResolvedValue({ id: "mock-webhook-id", signing_key: "mock-signing-key" }), +})); + vi.mock("../../utils/alchemy", async (importOriginal) => ({ ...(await importOriginal()), headerValidator: () => validator("header", () => undefined), - findWebhook: () => Promise.resolve(), - createWebhook: () => Promise.resolve({ id: "mock-webhook-id", signing_key: "mock-signing-key" }), - updateWebhookAddresses: () => Promise.resolve(), + findWebhook, + createWebhook, })); + +export { createWebhook, findWebhook }; diff --git a/server/test/mocks/redis.ts b/server/test/mocks/redis.ts index 734541d41..31330e3db 100644 --- a/server/test/mocks/redis.ts +++ b/server/test/mocks/redis.ts @@ -2,4 +2,12 @@ import Redis from "ioredis-mock"; import { vi } from "vitest"; -vi.mock("ioredis", () => ({ Redis })); +vi.mock("ioredis", () => ({ default: Redis, Redis })); +vi.mock("bullmq", () => ({ + Queue: vi.fn(function () { + return { add: vi.fn().mockResolvedValue({}), close: vi.fn().mockResolvedValue(undefined) }; // eslint-disable-line unicorn/no-useless-undefined + }), + Worker: vi.fn(function () { + return { on: vi.fn().mockReturnThis(), close: vi.fn().mockResolvedValue(undefined) }; // eslint-disable-line unicorn/no-useless-undefined + }), +})); diff --git a/server/test/mocks/sentry.ts b/server/test/mocks/sentry.ts index 547ca9aca..1cb59fae9 100644 --- a/server/test/mocks/sentry.ts +++ b/server/test/mocks/sentry.ts @@ -1,6 +1,8 @@ import "../../instrument.cjs"; import { close } from "@sentry/node"; -import { afterAll } from "vitest"; +import { afterAll, vi } from "vitest"; + +vi.mock("@sentry/node", { spy: true }); afterAll(() => close()); diff --git a/server/test/redis.ts b/server/test/redis.ts new file mode 100644 index 000000000..5b367ad68 --- /dev/null +++ b/server/test/redis.ts @@ -0,0 +1,13 @@ +import RedisMemoryServer from "redis-memory-server"; + +export default async function setup() { + const server = new RedisMemoryServer({ instance: { port: 8479 } }); + await server.start(); + const host = await server.getHost(); + const port = await server.getPort(); + process.env.REDIS_URL = `redis://${host}:${String(port)}`; + + return async function teardown() { + await server.stop(); + }; +} diff --git a/server/test/utils/createCredential.test.ts b/server/test/utils/createCredential.test.ts new file mode 100644 index 000000000..a48866c3d --- /dev/null +++ b/server/test/utils/createCredential.test.ts @@ -0,0 +1,168 @@ +import "../mocks/sardine"; +import "../mocks/sentry"; + +import { captureException, startSpan } from "@sentry/node"; +import { Queue } from "bullmq"; +import { Redis } from "ioredis"; +import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; + +import { AccountJob, closeQueue, processor, type AccountJobData } from "../../utils/createCredential"; +import createCredential, { WebhookNotReadyError } from "../../utils/createCredential"; +import { closeRedis } from "../../utils/redis"; + +import type { Job } from "bullmq"; +import type { Context } from "hono"; + +const mocks = vi.hoisted(() => ({ + webhookId: { value: "webhook-id" as string | undefined }, +})); + +vi.mock("hono/cookie", () => ({ setSignedCookie: vi.fn() })); +vi.mock("../../utils/segment", () => ({ identify: vi.fn() })); +vi.mock("../../utils/authSecret", () => ({ default: "secret" })); + +vi.mock("../../utils/alchemy", () => ({ + headers: { "X-Alchemy-Token": "mock-token" }, +})); + +vi.mock("../../database", () => ({ + default: { + insert: vi.fn().mockReturnValue({ values: vi.fn() }), + }, + credentials: {}, +})); + +vi.mock("../../hooks/activity", () => ({ + get webhookId() { + return mocks.webhookId.value; + }, +})); + +vi.spyOn(globalThis, "fetch").mockResolvedValue({ + ok: true, + json: () => Promise.resolve({}), + text: () => Promise.resolve(""), +} as Response); + +let testRedis: Redis; + +describe("createCredential - job queue", () => { + const credentialId = "0x1234567890123456789012345678901234567890"; + + beforeAll(() => { + if (!process.env.REDIS_URL) throw new Error("missing REDIS_URL"); + testRedis = new Redis(process.env.REDIS_URL); + }); + + afterAll(async () => { + await closeQueue(); + await closeRedis(); + await testRedis.quit(); + }); + + beforeEach(async () => { + vi.clearAllMocks(); + mocks.webhookId.value = "webhook-id"; + await testRedis.flushdb(); + }); + + it("should process credential job through real queue when credential is created", async () => { + await createCredential({} as Context, credentialId); + + await vi.waitFor( + () => { + expect(fetch).toHaveBeenCalledWith( + "https://dashboard.alchemy.com/api/update-webhook-addresses", + expect.objectContaining({ + method: "PATCH", + headers: expect.objectContaining({ "X-Alchemy-Token": "mock-token" }) as Record, + body: expect.stringContaining("webhook-id") as string, + }), + ); + }, + { timeout: 5000, interval: 50 }, + ); + }); + + it("should throw WebhookNotReadyError when webhookId is undefined", async () => { + mocks.webhookId.value = undefined; + + await expect(createCredential({} as Context, credentialId)).rejects.toThrow(WebhookNotReadyError); + }); + + it("should capture exception when queue.add fails", async () => { + const error = new Error("queue error"); + const spy = vi.spyOn(Queue.prototype, "add").mockRejectedValueOnce(error); + + await createCredential({} as Context, credentialId); + await vi.waitFor( + () => { + expect(vi.mocked(captureException)).toHaveBeenCalledWith( + error, + expect.objectContaining({ + level: "error", + extra: expect.objectContaining({ + job: AccountJob.CREATE, + webhookId: "webhook-id", + }) as Record, + }), + ); + }, + { timeout: 5000, interval: 50 }, + ); + + spy.mockRestore(); + }); +}); + +describe("credential queue processor", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("should call Alchemy API to update webhook addresses", async () => { + const job = { + name: AccountJob.CREATE, + data: { account: "0x123", webhookId: "hook-123" }, + } as unknown as Job; + + await processor(job); + + expect(fetch).toHaveBeenCalledWith( + "https://dashboard.alchemy.com/api/update-webhook-addresses", + expect.objectContaining({ + method: "PATCH", + headers: expect.objectContaining({ "X-Alchemy-Token": "mock-token" }) as Record, + body: JSON.stringify({ + webhook_id: "hook-123", + addresses_to_add: ["0x123"], + addresses_to_remove: [], + }), + }), + ); + expect(startSpan).toHaveBeenCalledWith( + expect.objectContaining({ name: "credential.processor", op: "queue.process" }), + expect.any(Function), + ); + }); + + it("should throw an error for unknown job names", async () => { + const job = { name: "unknown", data: {} } as unknown as Job; + await expect(processor(job)).rejects.toThrow("Unknown job name: unknown"); + }); + + it("should throw an error if Alchemy API call fails", async () => { + vi.spyOn(globalThis, "fetch").mockResolvedValueOnce({ + ok: false, + status: 500, + text: () => Promise.resolve("Internal Server Error"), + } as Response); + + const job = { + name: AccountJob.CREATE, + data: { account: "0x123", webhookId: "hook-123" }, + } as unknown as Job; + + await expect(processor(job)).rejects.toThrow("500 Internal Server Error"); + }); +}); diff --git a/server/utils/alchemy.ts b/server/utils/alchemy.ts index 6c768fdab..1cc2515bc 100644 --- a/server/utils/alchemy.ts +++ b/server/utils/alchemy.ts @@ -8,8 +8,6 @@ import chain from "@exactly/common/generated/chain"; import ServiceError from "./ServiceError"; import verifySignature from "./verifySignature"; -import type { Address } from "@exactly/common/validation"; - if (!process.env.ALCHEMY_WEBHOOKS_KEY) throw new Error("missing alchemy webhooks key"); export const headers = { "Content-Type": "application/json", "X-Alchemy-Token": process.env.ALCHEMY_WEBHOOKS_KEY }; @@ -59,16 +57,6 @@ export async function createWebhook( return parse(WebhookResponse, await create.json()).data; } -export async function updateWebhookAddresses(id: string | undefined, add: Address[], remove: Address[] = []) { - if (!id) return; - const update = await fetch("https://dashboard.alchemy.com/api/update-webhook-addresses", { - headers, - method: "PATCH", - body: JSON.stringify({ webhook_id: id, addresses_to_add: add, addresses_to_remove: remove }), - }); - if (!update.ok) throw new ServiceError("Alchemy", update.status, await update.text()); -} - const Webhook = object({ id: string(), network: picklist(["OPT_MAINNET", "OPT_SEPOLIA", "BASE_MAINNET", "BASE_SEPOLIA"]), diff --git a/server/utils/createCredential.ts b/server/utils/createCredential.ts index 9d6652ebe..3466d759d 100644 --- a/server/utils/createCredential.ts +++ b/server/utils/createCredential.ts @@ -1,4 +1,6 @@ -import { captureException, setUser } from "@sentry/core"; +import { SPAN_STATUS_ERROR } from "@sentry/core"; +import { addBreadcrumb, captureException, setUser, startSpan, type Span } from "@sentry/node"; +import { Queue, Worker, type Job } from "bullmq"; import { setSignedCookie } from "hono/cookie"; import { parse } from "valibot"; import { hexToBytes, isAddress } from "viem"; @@ -9,9 +11,10 @@ import domain from "@exactly/common/domain"; import { exaAccountFactoryAddress } from "@exactly/common/generated/chain"; import { Address } from "@exactly/common/validation"; -import { updateWebhookAddresses } from "./alchemy"; +import { headers } from "./alchemy"; import authSecret from "./authSecret"; import decodePublicKey from "./decodePublicKey"; +import { queue as redisConnection } from "./redis"; import { customer } from "./sardine"; import { identify } from "./segment"; import database from "../database"; @@ -26,6 +29,8 @@ export default async function createCredential( credentialId: C, options?: { source?: string; webauthn?: WebAuthnCredential }, ) { + if (!webhookId) throw new WebhookNotReadyError(); + const publicKey = options?.webauthn?.publicKey ?? (isAddress(credentialId) ? new Uint8Array(hexToBytes(credentialId)) : undefined); if (!publicKey) throw new Error("bad credential"); @@ -45,6 +50,7 @@ export default async function createCredential( source: options?.source, }, ]); + await Promise.all([ setSignedCookie(c, "credential_id", credentialId, authSecret, { expires, @@ -53,7 +59,6 @@ export default async function createCredential( ? { sameSite: "lax", secure: false } : { domain, sameSite: "none", secure: true, partitioned: true }), }), - updateWebhookAddresses(webhookId, [account]).catch((error: unknown) => captureException(error)), customer({ flow: { name: "signup", type: "signup" }, customer: { @@ -62,6 +67,81 @@ export default async function createCredential( }, }).catch((error: unknown) => captureException(error, { level: "error" })), ]); + + queue.add(AccountJob.CREATE, { account, webhookId }).catch((error: unknown) => + captureException(error, { + level: "error", + extra: { job: AccountJob.CREATE, account, webhookId, credentialId }, + }), + ); + identify({ userId: account }); return { credentialId, factory: parse(Address, exaAccountFactoryAddress), x, y, auth: expires.getTime() }; } + +const queueName = "account"; + +export const AccountJob = { CREATE: "create" } as const; + +export type AccountJobData = { account: string; webhookId: string }; + +const queue = new Queue(queueName, { connection: redisConnection }); + +export async function processor(job: Job) { + return startSpan( + { name: "credential.processor", op: "queue.process", attributes: { job: job.name, ...job.data } }, + async (span: Span) => { + switch (job.name) { + case AccountJob.CREATE: { + const { account, webhookId: webhook } = job.data; + const response = await fetch("https://dashboard.alchemy.com/api/update-webhook-addresses", { + method: "PATCH", + headers, + body: JSON.stringify({ webhook_id: webhook, addresses_to_add: [account], addresses_to_remove: [] }), + }); + if (!response.ok) { + const text = await response.text(); + span.setStatus({ code: SPAN_STATUS_ERROR, message: text }); + throw new Error(`${response.status} ${text}`); + } + break; + } + default: { + const message = `Unknown job name: ${job.name}`; + span.setStatus({ code: SPAN_STATUS_ERROR, message }); + throw new Error(message); + } + } + }, + ); +} + +const worker = new Worker(queueName, processor, { + connection: redisConnection, + limiter: { max: 10, duration: 1000 }, +}); + +worker + .on("failed", (job: Job | undefined, error: Error) => { + captureException(error, { level: "error", extra: { job: job?.data } }); + }) + .on("completed", (job: Job) => { + addBreadcrumb({ category: "queue", message: `Job ${job.id} completed`, level: "info", data: { job: job.data } }); + }) + .on("active", (job: Job) => { + addBreadcrumb({ category: "queue", message: `Job ${job.id} active`, level: "info", data: { job: job.data } }); + }) + .on("error", (error: Error) => { + captureException(error, { level: "error", tags: { queue: queueName } }); + }); + +export async function closeQueue() { + await Promise.all([worker.close(), queue.close()]); +} + +export class WebhookNotReadyError extends Error { + constructor() { + super("alchemy webhook not initialized yet, retry credential creation"); + this.name = "WebhookNotReadyError"; + } +} diff --git a/server/utils/redis.ts b/server/utils/redis.ts index 9afd8d0ab..3a2f00aa7 100644 --- a/server/utils/redis.ts +++ b/server/utils/redis.ts @@ -2,4 +2,10 @@ import { Redis } from "ioredis"; if (!process.env.REDIS_URL) throw new Error("missing redis url"); -export default new Redis(process.env.REDIS_URL); +export const queue = new Redis(process.env.REDIS_URL, { maxRetriesPerRequest: null }); + +export const redis = new Redis(process.env.REDIS_URL); + +export async function closeRedis() { + await Promise.all([queue.quit(), redis.quit()]); +} diff --git a/server/vitest.config.mts b/server/vitest.config.mts index 9b1893b71..9bfdc4a8a 100644 --- a/server/vitest.config.mts +++ b/server/vitest.config.mts @@ -4,7 +4,7 @@ import { defineConfig } from "vitest/config"; export default defineConfig({ test: { - globalSetup: ["test/anvil.ts", "test/database.ts", "test/spotlight.ts"], + globalSetup: ["test/anvil.ts", "test/database.ts", "test/redis.ts", "test/spotlight.ts"], coverage: { enabled: true, reporter: ["lcov"] }, reporters: ["default", "junit"], outputFile: { junit: "coverage/junit.xml" }, @@ -50,7 +50,7 @@ VuNOZKwaXFtqgA== PERSONA_URL: "https://persona.test", PERSONA_WEBHOOK_SECRET: "persona", POSTGRES_URL: "postgres://postgres:postgres@localhost:8432/postgres?sslmode=disable", // cspell:ignore sslmode - REDIS_URL: "redis", + REDIS_URL: "redis://localhost:8479", SARDINE_API_KEY: "sardine", SARDINE_API_URL: "https://api.sardine.ai", SEGMENT_WRITE_KEY: "segment",