diff --git a/package-lock.json b/package-lock.json index b0f20de..c5d4e5b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -90,7 +90,7 @@ "@types/bull": "^3.15.9", "@types/express": "^4.17.25", "@types/jest": "^29.5.14", - "@types/node": "^26.0.1", + "@types/node": "^26.2.0", "@types/nodemailer": "^6.4.14", "@types/passport-jwt": "^3.0.8", "@types/serve-favicon": "^2.5.7", @@ -1376,6 +1376,7 @@ "version": "1.1.3", "resolved": "https://registry.npmjs.org/@gar/promisify/-/promisify-1.1.3.tgz", "integrity": "sha512-k2Ty1JcVojjJFwrg/ThKi2ujJ7XNLYaFGNB/bWT9wGR+oSMJHMa5w+CUq6p/pVrKeNNgA7pCqEcjSnHVoqJQFw==", + "dev": true, "license": "MIT", "optional": true }, @@ -3269,6 +3270,7 @@ "version": "1.1.1", "resolved": "https://registry.npmjs.org/@npmcli/fs/-/fs-1.1.1.tgz", "integrity": "sha512-8KG5RD0GVP4ydEzRn/I4BNDuxDtqVbOdm8675T49OIG/NGhaK0pjPX7ZcDlvKYbA+ulvVK3ztfcF4uBdOxuJbQ==", + "dev": true, "license": "ISC", "optional": true, "dependencies": { @@ -3281,6 +3283,7 @@ "resolved": "https://registry.npmjs.org/@npmcli/move-file/-/move-file-1.1.2.tgz", "integrity": "sha512-1SUf/Cg2GzGDyaf15aR9St9TWlb+XvbZXWpDx8YKs7MLzMH/BCeopv+y9vzrzgkfykCGuWOlSu3mZhj2+FQcrg==", "deprecated": "This functionality has been moved to @npmcli/fs", + "dev": true, "license": "MIT", "optional": true, "dependencies": { @@ -3295,6 +3298,7 @@ "version": "1.1.15", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.15.tgz", "integrity": "sha512-EwOCDEex4quD37XhqM3omwtMoJjr//isUZz1JopUNWms+4Z2ViyM/k1YIRePpoVNnQhENnxtFjLaxNHrT7xIUg==", + "dev": true, "license": "MIT", "optional": true, "dependencies": { @@ -3307,6 +3311,7 @@ "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", "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", + "dev": true, "license": "ISC", "optional": true, "dependencies": { @@ -3328,6 +3333,7 @@ "version": "3.1.5", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "dev": true, "license": "ISC", "optional": true, "dependencies": { @@ -3342,6 +3348,7 @@ "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz", "integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==", "deprecated": "Rimraf versions prior to v4 are no longer supported", + "dev": true, "license": "ISC", "optional": true, "dependencies": { @@ -6841,6 +6848,7 @@ "version": "1.1.2", "resolved": "https://registry.npmjs.org/@tootallnate/once/-/once-1.1.2.tgz", "integrity": "sha512-RbzJvlNzmRq5c3O09UipeuXno4tA1FE6ikOjxZK0tuxVv3412l64l5t1W5pj4+rJq9vpkm/kwiR07aZXnsKPxw==", + "dev": true, "license": "MIT", "optional": true, "engines": { @@ -7171,9 +7179,9 @@ } }, "node_modules/@types/node": { - "version": "26.0.1", - "resolved": "https://registry.npmjs.org/@types/node/-/node-26.0.1.tgz", - "integrity": "sha512-fc3KiUoBt6kie0N9bIW3E47vZsuaMf0PM2AaUpLCLT0s/LvX1nxAim6Fc049cNxODPpGm6qRAuUOB86SkRuPQw==", + "version": "26.2.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-26.2.0.tgz", + "integrity": "sha512-5IviulTZeRNp2vAJ514cc/HUlY5nZ9fCbq9DMyC52BrhFZACo3nI0R7qBxhQmo/d27NFe96ur/b7Wwxklda+kg==", "license": "MIT", "dependencies": { "undici-types": "~8.3.0" @@ -7816,6 +7824,7 @@ "version": "1.1.1", "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-1.1.1.tgz", "integrity": "sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q==", + "dev": true, "license": "ISC", "optional": true }, @@ -7912,6 +7921,7 @@ "version": "4.6.0", "resolved": "https://registry.npmjs.org/agentkeepalive/-/agentkeepalive-4.6.0.tgz", "integrity": "sha512-kja8j7PjmncONqaTsB8fQ+wE2mSU2DJ9D4XKoJ5PFWIdRMa6SLSN1ff4mOr4jCbfRSsxR4keIiySJU0N9T5hIQ==", + "dev": true, "license": "MIT", "optional": true, "dependencies": { @@ -7925,6 +7935,7 @@ "version": "3.1.0", "resolved": "https://registry.npmjs.org/aggregate-error/-/aggregate-error-3.1.0.tgz", "integrity": "sha512-4I7Td01quW/RpocfNayFdFVk1qSuoh0E7JrbRJ16nH01HhKFQ88INq9Sd+nd72zqRySlr9BmDA8xlEJ6vJMrYA==", + "dev": true, "license": "MIT", "optional": true, "dependencies": { @@ -8116,6 +8127,7 @@ "version": "2.1.0", "resolved": "https://registry.npmjs.org/aproba/-/aproba-2.1.0.tgz", "integrity": "sha512-tLIEcj5GuR2RSTnxNKdkK0dJ/GrC7P38sUkiDmDuHfsHmbagTFAxDVIBltoklXEVIQ/f14IL8IMJ5pn9Hez1Ew==", + "dev": true, "license": "ISC", "optional": true }, @@ -8124,6 +8136,7 @@ "resolved": "https://registry.npmjs.org/are-we-there-yet/-/are-we-there-yet-3.0.1.tgz", "integrity": "sha512-QZW4EDmGwlYur0Yyf/b2uGucHQMa8aFUP7eu9ddR73vvhFyt4V0Vl3QHPcTNJ8l6qYOBdxgXdnBXQrHilfRQBg==", "deprecated": "This package is no longer supported.", + "dev": true, "license": "ISC", "optional": true, "dependencies": { @@ -8860,6 +8873,7 @@ "version": "15.3.0", "resolved": "https://registry.npmjs.org/cacache/-/cacache-15.3.0.tgz", "integrity": "sha512-VVdYzXEn+cnbXpFgWs5hTT7OScegHVmLhJIR8Ufqk3iFD6A6j5iSX1KuBTfNEv4tdJWE2PzA6IVFtcLC7fN9wQ==", + "dev": true, "license": "ISC", "optional": true, "dependencies": { @@ -8890,6 +8904,7 @@ "version": "1.1.15", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.15.tgz", "integrity": "sha512-EwOCDEex4quD37XhqM3omwtMoJjr//isUZz1JopUNWms+4Z2ViyM/k1YIRePpoVNnQhENnxtFjLaxNHrT7xIUg==", + "dev": true, "license": "MIT", "optional": true, "dependencies": { @@ -8902,6 +8917,7 @@ "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", "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", + "dev": true, "license": "ISC", "optional": true, "dependencies": { @@ -8923,6 +8939,7 @@ "version": "6.0.0", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", + "dev": true, "license": "ISC", "optional": true, "dependencies": { @@ -8936,6 +8953,7 @@ "version": "3.1.5", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "dev": true, "license": "ISC", "optional": true, "dependencies": { @@ -8949,6 +8967,7 @@ "version": "3.3.6", "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", + "dev": true, "license": "ISC", "optional": true, "dependencies": { @@ -8963,6 +8982,7 @@ "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz", "integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==", "deprecated": "Rimraf versions prior to v4 are no longer supported", + "dev": true, "license": "ISC", "optional": true, "dependencies": { @@ -8979,6 +8999,7 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", + "dev": true, "license": "ISC", "optional": true }, @@ -9211,6 +9232,7 @@ "version": "2.2.0", "resolved": "https://registry.npmjs.org/clean-stack/-/clean-stack-2.2.0.tgz", "integrity": "sha512-4diC9HaTE+KRAMWhDhrGOECgWZxoevMc5TlkObMqNSsVU62PYzXZ/SMTjzyGAFF1YusgxGcSWTEXBhp0CPwQ1A==", + "dev": true, "license": "MIT", "optional": true, "engines": { @@ -9388,6 +9410,7 @@ "version": "1.1.3", "resolved": "https://registry.npmjs.org/color-support/-/color-support-1.1.3.tgz", "integrity": "sha512-qiBjkpbMLO/HL68y+lh4q0/O1MZFj2RX6X/KmMa3+gJD3z+WwI1ZzDHysvqHGS3mP6mznPckpXmw1nI9cJjyRg==", + "dev": true, "license": "ISC", "optional": true, "bin": { @@ -9475,7 +9498,7 @@ "version": "0.0.1", "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", - "devOptional": true, + "dev": true, "license": "MIT" }, "node_modules/concat-stream": { @@ -9503,6 +9526,7 @@ "version": "1.1.0", "resolved": "https://registry.npmjs.org/console-control-strings/-/console-control-strings-1.1.0.tgz", "integrity": "sha512-ty/fTekppD2fIwRvnZAVdeOiGd1c7YXEixbgJTNzqcxJWKQnjJ/V1bNEEE6hygpM3WjwHFUVK6HTjWSzV4a8sQ==", + "dev": true, "license": "ISC", "optional": true }, @@ -9919,6 +9943,7 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/delegates/-/delegates-1.0.0.tgz", "integrity": "sha512-bd2L678uiWATM6m5Z1VzNCErI3jiGzt6HGY8OVICs40JQq/HALfbyNJmp0UDakEY4pMMaN0Ly5om/B1VI/+xfQ==", + "dev": true, "license": "MIT", "optional": true }, @@ -10268,6 +10293,7 @@ "version": "2.2.1", "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-2.2.1.tgz", "integrity": "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==", + "dev": true, "license": "MIT", "optional": true, "engines": { @@ -10278,6 +10304,7 @@ "version": "2.0.3", "resolved": "https://registry.npmjs.org/err-code/-/err-code-2.0.3.tgz", "integrity": "sha512-2bmlRpNKBxT/CRmPOlyISQpNj+qSeYvcym/uT0Jx2bMOlKLtSy1ZmLuVxSEKKyor/N5yhvp/ZiG1oE3DEYMSFA==", + "dev": true, "license": "MIT", "optional": true }, @@ -11876,7 +11903,7 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", - "devOptional": true, + "dev": true, "license": "ISC" }, "node_modules/fsevents": { @@ -11942,6 +11969,7 @@ "resolved": "https://registry.npmjs.org/gauge/-/gauge-4.0.4.tgz", "integrity": "sha512-f9m+BEN5jkg6a0fZjleidjN51VE1X+mPFQ2DJ0uv1V39oCLCbsGe6yjbBnp7eK7z/+GAon99a3nHuqbuuthyPg==", "deprecated": "This package is no longer supported.", + "dev": true, "license": "ISC", "optional": true, "dependencies": { @@ -11962,6 +11990,7 @@ "version": "3.0.7", "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", + "dev": true, "license": "ISC", "optional": true }, @@ -12278,7 +12307,7 @@ "version": "4.2.11", "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", - "devOptional": true, + "dev": true, "license": "ISC" }, "node_modules/graphemer": { @@ -12411,6 +12440,7 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/has-unicode/-/has-unicode-2.0.1.tgz", "integrity": "sha512-8Rf9Y83NBReMnx0gFzA8JImQACstCYWUplepDa9xprwwtmgEZUF0h/i5xSA625zB/I37EtrswSST6OXxwaaIJQ==", + "dev": true, "license": "ISC", "optional": true }, @@ -12503,6 +12533,7 @@ "version": "4.2.0", "resolved": "https://registry.npmjs.org/http-cache-semantics/-/http-cache-semantics-4.2.0.tgz", "integrity": "sha512-dTxcvPXqPvXBQpq5dUr6mEMJX4oIEFv6bwom3FDwKRDsuIjjJGANqhBuoAn9c1RQJIdAKav33ED65E2ys+87QQ==", + "dev": true, "license": "BSD-2-Clause", "optional": true }, @@ -12530,6 +12561,7 @@ "version": "4.0.1", "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-4.0.1.tgz", "integrity": "sha512-k0zdNgqWTGA6aeIRVpvfVob4fL52dTfaehylg0Y4UvSySvOq/Y+BOyPrgpUrA7HylqvU8vIZGsRuXmspskV0Tg==", + "dev": true, "license": "MIT", "optional": true, "dependencies": { @@ -12568,6 +12600,7 @@ "version": "1.2.1", "resolved": "https://registry.npmjs.org/humanize-ms/-/humanize-ms-1.2.1.tgz", "integrity": "sha512-Fl70vYtsAFb/C06PTS9dZBo7ihau+Tu/DNCk/OyHhea07S+aeMWpFFkUaXRa8fI+ScZbEI8dfSxwY7gxZ9SAVQ==", + "dev": true, "license": "MIT", "optional": true, "dependencies": { @@ -12676,7 +12709,7 @@ "version": "0.1.4", "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", - "devOptional": true, + "dev": true, "license": "MIT", "engines": { "node": ">=0.8.19" @@ -12686,6 +12719,7 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz", "integrity": "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==", + "dev": true, "license": "MIT", "optional": true, "engines": { @@ -12696,6 +12730,7 @@ "version": "1.0.4", "resolved": "https://registry.npmjs.org/infer-owner/-/infer-owner-1.0.4.tgz", "integrity": "sha512-IClj+Xz94+d7irH5qRyfJonOdfTzuDaifE6ZPWfx0N0+/ATZCbuTPq2prFl526urkQd90WyUKIh1DfBQ2hMz9A==", + "dev": true, "license": "ISC", "optional": true }, @@ -12704,7 +12739,7 @@ "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", "deprecated": "This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.", - "devOptional": true, + "dev": true, "license": "ISC", "dependencies": { "once": "^1.3.0", @@ -13082,6 +13117,7 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/is-lambda/-/is-lambda-1.0.1.tgz", "integrity": "sha512-z7CMFGNrENq5iFB9Bqo64Xk6Y9sg+epq1myIcdHaGnbMTYOxvzsEtdYqQUylB7LxfkvgrrjP32T6Ywciio9UIQ==", + "dev": true, "license": "MIT", "optional": true }, @@ -14595,6 +14631,7 @@ "version": "9.1.0", "resolved": "https://registry.npmjs.org/make-fetch-happen/-/make-fetch-happen-9.1.0.tgz", "integrity": "sha512-+zopwDy7DNknmwPQplem5lAZX/eCOzSvSNNcSKm5eVwTkOBzoktEfXsa9L23J/GIRhxRsaxzkPEhrJEpE2F4Gg==", + "dev": true, "license": "ISC", "optional": true, "dependencies": { @@ -14623,6 +14660,7 @@ "version": "6.0.0", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", + "dev": true, "license": "ISC", "optional": true, "dependencies": { @@ -14636,6 +14674,7 @@ "version": "3.3.6", "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", + "dev": true, "license": "ISC", "optional": true, "dependencies": { @@ -14649,6 +14688,7 @@ "version": "0.6.4", "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.4.tgz", "integrity": "sha512-myRT3DiWPHqho5PrJaIRyaMv2kgYf0mUVgBNOYMuCH5Ki1yEiQaf/ZJuQ62nvpc44wL5WDbTX7yGJi1Neevw8w==", + "dev": true, "license": "MIT", "optional": true, "engines": { @@ -14659,6 +14699,7 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", + "dev": true, "license": "ISC", "optional": true }, @@ -14876,6 +14917,7 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/minipass-collect/-/minipass-collect-1.0.2.tgz", "integrity": "sha512-6T6lH0H8OG9kITm/Jm6tdooIbogG9e0tLgpY6mphXSm/A9u8Nq1ryBG+Qspiub9LjWlBPsPS3tWQ/Botq4FdxA==", + "dev": true, "license": "ISC", "optional": true, "dependencies": { @@ -14889,6 +14931,7 @@ "version": "3.3.6", "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", + "dev": true, "license": "ISC", "optional": true, "dependencies": { @@ -14902,6 +14945,7 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", + "dev": true, "license": "ISC", "optional": true }, @@ -14909,6 +14953,7 @@ "version": "1.4.1", "resolved": "https://registry.npmjs.org/minipass-fetch/-/minipass-fetch-1.4.1.tgz", "integrity": "sha512-CGH1eblLq26Y15+Azk7ey4xh0J/XfJfrCox5LDJiKqI2Q2iwOLOKrlmIaODiSQS8d18jalF6y2K2ePUm0CmShw==", + "dev": true, "license": "MIT", "optional": true, "dependencies": { @@ -14927,6 +14972,7 @@ "version": "3.3.6", "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", + "dev": true, "license": "ISC", "optional": true, "dependencies": { @@ -14940,6 +14986,7 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", + "dev": true, "license": "ISC", "optional": true }, @@ -14947,6 +14994,7 @@ "version": "1.0.7", "resolved": "https://registry.npmjs.org/minipass-flush/-/minipass-flush-1.0.7.tgz", "integrity": "sha512-TbqTz9cUwWyHS2Dy89P3ocAGUGxKjjLuR9z8w4WUTGAVgEj17/4nhgo2Du56i0Fm3Pm30g4iA8Lcqctc76jCzA==", + "dev": true, "license": "BlueOak-1.0.0", "optional": true, "dependencies": { @@ -14960,6 +15008,7 @@ "version": "3.3.6", "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", + "dev": true, "license": "ISC", "optional": true, "dependencies": { @@ -14973,6 +15022,7 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", + "dev": true, "license": "ISC", "optional": true }, @@ -14980,6 +15030,7 @@ "version": "1.2.4", "resolved": "https://registry.npmjs.org/minipass-pipeline/-/minipass-pipeline-1.2.4.tgz", "integrity": "sha512-xuIq7cIOt09RPRJ19gdi4b+RiNvDFYe5JH+ggNvBqGqpQXcru3PcRmOZuHBKWK1Txf9+cQ+HMVN4d6z46LZP7A==", + "dev": true, "license": "ISC", "optional": true, "dependencies": { @@ -14993,6 +15044,7 @@ "version": "3.3.6", "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", + "dev": true, "license": "ISC", "optional": true, "dependencies": { @@ -15006,6 +15058,7 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", + "dev": true, "license": "ISC", "optional": true }, @@ -15013,6 +15066,7 @@ "version": "1.0.3", "resolved": "https://registry.npmjs.org/minipass-sized/-/minipass-sized-1.0.3.tgz", "integrity": "sha512-MbkQQ2CTiBMlA2Dm/5cY+9SWFEN8pzzOXi6rlM5Xxq0Yqbda5ZQy9sU75a673FE9ZK0Zsbr6Y5iP6u9nktfg2g==", + "dev": true, "license": "ISC", "optional": true, "dependencies": { @@ -15026,6 +15080,7 @@ "version": "3.3.6", "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", + "dev": true, "license": "ISC", "optional": true, "dependencies": { @@ -15039,6 +15094,7 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", + "dev": true, "license": "ISC", "optional": true }, @@ -15296,6 +15352,7 @@ "version": "8.4.1", "resolved": "https://registry.npmjs.org/node-gyp/-/node-gyp-8.4.1.tgz", "integrity": "sha512-olTJRgUtAb/hOXG0E93wZDs5YiJlgbXxTwQAFHyNlRsXQnYzUaF2aGgujZbw+hR8aF4ZG/rST57bWMWD16jr9w==", + "dev": true, "license": "MIT", "optional": true, "dependencies": { @@ -15347,6 +15404,7 @@ "version": "1.1.15", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.15.tgz", "integrity": "sha512-EwOCDEex4quD37XhqM3omwtMoJjr//isUZz1JopUNWms+4Z2ViyM/k1YIRePpoVNnQhENnxtFjLaxNHrT7xIUg==", + "dev": true, "license": "MIT", "optional": true, "dependencies": { @@ -15359,6 +15417,7 @@ "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", "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", + "dev": true, "license": "ISC", "optional": true, "dependencies": { @@ -15380,6 +15439,7 @@ "version": "3.1.5", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "dev": true, "license": "ISC", "optional": true, "dependencies": { @@ -15394,6 +15454,7 @@ "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz", "integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==", "deprecated": "Rimraf versions prior to v4 are no longer supported", + "dev": true, "license": "ISC", "optional": true, "dependencies": { @@ -15526,6 +15587,7 @@ "version": "5.0.0", "resolved": "https://registry.npmjs.org/nopt/-/nopt-5.0.0.tgz", "integrity": "sha512-Tbj67rffqceeLpcRXrT7vKAN8CwfPeIBgM7E6iBkmKLV7bEMwpGgYLGv0jACUsECaa/vuxP0IjEont6umdMgtQ==", + "dev": true, "license": "ISC", "optional": true, "dependencies": { @@ -15566,6 +15628,7 @@ "resolved": "https://registry.npmjs.org/npmlog/-/npmlog-6.0.2.tgz", "integrity": "sha512-/vBvz5Jfr9dT/aFWd0FIRf+T/Q2WBsLENygUaFUqstqsycmZAP/t5BvFJTK0viFmSUxiUKTUplWy5vt+rvKIxg==", "deprecated": "This package is no longer supported.", + "dev": true, "license": "ISC", "optional": true, "dependencies": { @@ -15896,6 +15959,7 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/p-map/-/p-map-4.0.0.tgz", "integrity": "sha512-/bjOqmgETBYB5BoEeGVea8dmvHb2m9GLy1E9W43yeyfP6QQCZGFNa+XRceJEuDB6zqr+gKpIAmlLebMpykw/MQ==", + "dev": true, "license": "MIT", "optional": true, "dependencies": { @@ -16023,7 +16087,7 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==", - "devOptional": true, + "dev": true, "license": "MIT", "engines": { "node": ">=0.10.0" @@ -16119,6 +16183,7 @@ "version": "1.4.0", "resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.4.0.tgz", "integrity": "sha512-Vo7z/6rrQYxpNRylp4Tlob2elzbh+N/MOQbxFVWCxS7oEx6jF53GTJFxK2WWpKuBRkmiin4Mt+xofFDjx09R0A==", + "dev": true, "license": "MIT", "optional": true }, @@ -16558,6 +16623,7 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/promise-inflight/-/promise-inflight-1.0.1.tgz", "integrity": "sha512-6zWPyEOFaQBJYcGMHBKTKJ3u6TBsnMFOIZSa6ce1e/ZrrsOlnHRHbabMjLiBYKp+n44X9eUI6VUPaukCXHuG4g==", + "dev": true, "license": "ISC", "optional": true }, @@ -16565,6 +16631,7 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/promise-retry/-/promise-retry-2.0.1.tgz", "integrity": "sha512-y+WKFlBR8BGXnsNlIHFGPZmyDf3DFMoLhaflAnyZgV6rG6xu+JwesTo2Q9R6XwYmtmwAFCkAk3e35jEdoeh/3g==", + "dev": true, "license": "MIT", "optional": true, "dependencies": { @@ -17180,6 +17247,7 @@ "version": "0.12.0", "resolved": "https://registry.npmjs.org/retry/-/retry-0.12.0.tgz", "integrity": "sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow==", + "dev": true, "license": "MIT", "optional": true, "engines": { @@ -17800,6 +17868,7 @@ "version": "4.2.0", "resolved": "https://registry.npmjs.org/smart-buffer/-/smart-buffer-4.2.0.tgz", "integrity": "sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==", + "dev": true, "license": "MIT", "optional": true, "engines": { @@ -17911,6 +17980,7 @@ "version": "2.8.9", "resolved": "https://registry.npmjs.org/socks/-/socks-2.8.9.tgz", "integrity": "sha512-LJhUYUvItdQ0LkJTmPeaEObWXAqFyfmP85x0tch/ez9cahmhlBBLbIqDFnvBnUJGagb0JbIQrkBs1wJ+yRYpEw==", + "dev": true, "license": "MIT", "optional": true, "dependencies": { @@ -17926,6 +17996,7 @@ "version": "6.2.1", "resolved": "https://registry.npmjs.org/socks-proxy-agent/-/socks-proxy-agent-6.2.1.tgz", "integrity": "sha512-a6KW9G+6B3nWZ1yB8G7pJwL3ggLy1uTzKAgCb7ttblwqdz9fMGJUuTy3uFzEP48FAs9FLILlmzDlE2JJhVQaXQ==", + "dev": true, "license": "MIT", "optional": true, "dependencies": { @@ -18057,6 +18128,7 @@ "version": "8.0.1", "resolved": "https://registry.npmjs.org/ssri/-/ssri-8.0.1.tgz", "integrity": "sha512-97qShzy1AiyxvPNIkLWoGua7xoQzzPjQ0HAH4B0rWKo7SZ6USuPcrUiAFrws0UH8RrbWmgq3LMTObhPIHbbBeQ==", + "dev": true, "license": "ISC", "optional": true, "dependencies": { @@ -18070,6 +18142,7 @@ "version": "3.3.6", "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", + "dev": true, "license": "ISC", "optional": true, "dependencies": { @@ -18083,6 +18156,7 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", + "dev": true, "license": "ISC", "optional": true }, @@ -19583,6 +19657,7 @@ "version": "1.1.1", "resolved": "https://registry.npmjs.org/unique-filename/-/unique-filename-1.1.1.tgz", "integrity": "sha512-Vmp0jIp2ln35UTXuryvjzkjGdRyf9b2lTXuSYUiPmzRcl3FDtYqAwOnTJkAngD9SWhnoJzDbTKwaOrZ+STtxNQ==", + "dev": true, "license": "ISC", "optional": true, "dependencies": { @@ -19593,6 +19668,7 @@ "version": "2.0.2", "resolved": "https://registry.npmjs.org/unique-slug/-/unique-slug-2.0.2.tgz", "integrity": "sha512-zoWr9ObaxALD3DOPfjPSqxt4fnZiWblxHIgeWqW8x7UqDzEtHEQLzji2cuJYQFCU6KmoJikOYAZlrTHHebjx2w==", + "dev": true, "license": "ISC", "optional": true, "dependencies": { @@ -20020,6 +20096,7 @@ "version": "1.1.5", "resolved": "https://registry.npmjs.org/wide-align/-/wide-align-1.1.5.tgz", "integrity": "sha512-eDMORYaPNZ4sQIuuYPDHdQvf4gyCF9rEEV/yPxGfwPkRodwEgiMUUXTx/dex+Me0wxx53S+NgUHaP7y3MGlDmg==", + "dev": true, "license": "ISC", "optional": true, "dependencies": { diff --git a/package.json b/package.json index 5de009e..30e248f 100644 --- a/package.json +++ b/package.json @@ -120,7 +120,7 @@ "@types/bull": "^3.15.9", "@types/express": "^4.17.25", "@types/jest": "^29.5.14", - "@types/node": "^26.0.1", + "@types/node": "^26.2.0", "@types/nodemailer": "^6.4.14", "@types/passport-jwt": "^3.0.8", "@types/serve-favicon": "^2.5.7", @@ -129,12 +129,12 @@ "@types/uuid": "^9.0.7", "@typescript-eslint/eslint-plugin": "^6.17.0", "@typescript-eslint/parser": "^6.17.0", + "cross-env": "^7.0.3", "eslint": "^8.56.0", "eslint-config-prettier": "^9.1.0", "eslint-plugin-import": "^2.32.0", "eslint-plugin-prettier": "^5.1.2", "jest": "^29.7.0", - "cross-env": "^7.0.3", "nodemon": "^3.1.11", "pg": "^8.22.0", "pino-pretty": "^13.1.3", diff --git a/src/app.module.ts b/src/app.module.ts index c22ac1a..abdf1ba 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -96,6 +96,14 @@ import { AlertPreference } from "./growth/alerts/entities/alert-preference.entit import { AgentReview } from "./discovery/reviews/entities/agent-review.entity"; import { AgentReviewsModule } from "./discovery/reviews/agent-reviews.module"; +// Webhook entities +import { WebhookSubscription } from "./infrastructure/webhooks/entities/webhook-subscription.entity"; +import { WebhookEvent } from "./infrastructure/webhooks/entities/webhook-event.entity"; +import { WebhookDelivery } from "./infrastructure/webhooks/entities/webhook-delivery.entity"; +import { WebhookDeadLetter } from "./infrastructure/webhooks/entities/webhook-dead-letter.entity"; +// Modules – webhooks +import { WebhookModule } from "./infrastructure/webhooks/webhook.module"; + // Guards import { APP_FILTER } from "@nestjs/core"; import { QuotaGuard } from "./common/guard/quota.guard"; @@ -180,6 +188,10 @@ import { ProfilingMiddleware } from "./profiling/profiling.middleware"; AlertPreference, EmailLog, AgentReview, + WebhookSubscription, + WebhookEvent, + WebhookDelivery, + WebhookDeadLetter, ], synchronize: true, logging: true, @@ -211,6 +223,7 @@ import { ProfilingMiddleware } from "./profiling/profiling.middleware"; ProfilingModule, EmailModule, AgentReviewsModule, + WebhookModule, LoggerModule.forRootAsync({ inject: [ConfigService], useFactory: (cfg: ConfigService) => ({ diff --git a/src/config/env.validation.ts b/src/config/env.validation.ts index 3224ddc..6cffa11 100644 --- a/src/config/env.validation.ts +++ b/src/config/env.validation.ts @@ -383,4 +383,58 @@ export class EnvironmentVariables { @IsBoolean() @Transform(({ value }) => value === "true") REFERRAL_ENABLE_VPN_DETECTION?: boolean = false; + + // ── Webhook & Reliable Event Delivery ───────────────────────────── + + /** Default max retries for webhook deliveries. Default 5. */ + @IsOptional() + @IsNumber() + @Transform(({ value }) => parseInt(value, 10) || 5) + WEBHOOK_DEFAULT_MAX_RETRIES?: number = 5; + + /** Default base retry delay in ms. Default 1000. */ + @IsOptional() + @IsNumber() + @Transform(({ value }) => parseInt(value, 10) || 1000) + WEBHOOK_DEFAULT_RETRY_DELAY_MS?: number = 1000; + + /** Default backoff multiplier. Default 2. */ + @IsOptional() + @IsNumber() + @Transform(({ value }) => parseFloat(value) || 2) + WEBHOOK_BACKOFF_MULTIPLIER?: number = 2; + + /** Default HTTP request timeout in ms. Default 30000. */ + @IsOptional() + @IsNumber() + @Transform(({ value }) => parseInt(value, 10) || 30000) + WEBHOOK_TIMEOUT_MS?: number = 30000; + + /** Default max deliveries per minute per subscription. Default 10. */ + @IsOptional() + @IsNumber() + @Transform(({ value }) => parseInt(value, 10) || 10) + WEBHOOK_RATE_LIMIT_PER_MINUTE?: number = 10; + + /** Max concurrent delivery workers. Default 5. */ + @IsOptional() + @IsNumber() + @Transform(({ value }) => parseInt(value, 10) || 5) + WEBHOOK_CONCURRENCY?: number = 5; + + /** Redis host for webhook Bull queue (falls back to REDIS_HOST). */ + @IsOptional() + @IsString() + REDIS_HOST?: string; + + /** Redis port for webhook Bull queue (falls back to 6379). */ + @IsOptional() + @IsNumber() + @Transform(({ value }) => parseInt(value, 10) || 6379) + REDIS_PORT?: number; + + /** Redis password for webhook Bull queue. */ + @IsOptional() + @IsString() + REDIS_PASSWORD?: string; } diff --git a/src/infrastructure/webhooks/dto/webhook.dto.ts b/src/infrastructure/webhooks/dto/webhook.dto.ts new file mode 100644 index 0000000..f4e6300 --- /dev/null +++ b/src/infrastructure/webhooks/dto/webhook.dto.ts @@ -0,0 +1,192 @@ +import { + IsString, + IsUrl, + IsArray, + IsOptional, + IsEnum, + IsInt, + IsNumber, + IsObject, + Min, + Max, + MaxLength, + MinLength, +} from "class-validator"; +import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger"; +import { WebhookSubscriptionStatus } from "../entities/webhook-subscription.entity"; + +export class CreateWebhookSubscriptionDto { + @ApiProperty({ example: "https://example.com/webhook" }) + @IsUrl({}, { message: "url must be a valid URL" }) + @MaxLength(2048) + url: string; + + @ApiProperty({ + example: ["portfolio.rebalanced", "alert.triggered"], + description: + "List of event types to subscribe to. Use '*' to receive all events.", + }) + @IsArray() + @IsString({ each: true }) + events: string[]; + + @ApiPropertyOptional({ example: "Portfolio monitoring webhook" }) + @IsOptional() + @IsString() + @MaxLength(255) + description?: string; + + @ApiPropertyOptional({ + example: 5, + description: "Max retry attempts per delivery", + }) + @IsOptional() + @IsInt() + @Min(1) + @Max(20) + maxRetries?: number; + + @ApiPropertyOptional({ example: 1000, description: "Base retry delay in ms" }) + @IsOptional() + @IsNumber() + @Min(100) + @Max(60000) + retryDelayMs?: number; + + @ApiPropertyOptional({ + example: 2, + description: "Exponential backoff multiplier", + }) + @IsOptional() + @IsNumber() + @Min(1) + @Max(5) + backoffMultiplier?: number; + + @ApiPropertyOptional({ + example: 30000, + description: "HTTP request timeout in ms", + }) + @IsOptional() + @IsNumber() + @Min(1000) + @Max(120000) + timeoutMs?: number; + + @ApiPropertyOptional({ + example: 10, + description: "Max deliveries per minute", + }) + @IsOptional() + @IsInt() + @Min(1) + @Max(1000) + rateLimitPerMinute?: number; + + @ApiPropertyOptional({ example: { "X-Custom-Header": "value" } }) + @IsOptional() + @IsObject() + headers?: Record; + + @ApiPropertyOptional() + @IsOptional() + @IsObject() + metadata?: Record; +} + +export class UpdateWebhookSubscriptionDto { + @ApiPropertyOptional({ example: "https://example.com/new-webhook" }) + @IsOptional() + @IsUrl({}, { message: "url must be a valid URL" }) + @MaxLength(2048) + url?: string; + + @ApiPropertyOptional({ example: ["portfolio.rebalanced"] }) + @IsOptional() + @IsArray() + @IsString({ each: true }) + events?: string[]; + + @ApiPropertyOptional({ enum: WebhookSubscriptionStatus }) + @IsOptional() + @IsEnum(WebhookSubscriptionStatus) + status?: WebhookSubscriptionStatus; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + @MaxLength(255) + description?: string; + + @ApiPropertyOptional() + @IsOptional() + @IsInt() + @Min(1) + @Max(20) + maxRetries?: number; + + @ApiPropertyOptional() + @IsOptional() + @IsNumber() + @Min(100) + @Max(60000) + retryDelayMs?: number; + + @ApiPropertyOptional() + @IsOptional() + @IsNumber() + @Min(1) + @Max(5) + backoffMultiplier?: number; + + @ApiPropertyOptional() + @IsOptional() + @IsNumber() + @Min(1000) + @Max(120000) + timeoutMs?: number; + + @ApiPropertyOptional() + @IsOptional() + @IsInt() + @Min(1) + @Max(1000) + rateLimitPerMinute?: number; + + @ApiPropertyOptional() + @IsOptional() + @IsObject() + headers?: Record; +} + +export class PublishWebhookEventDto { + @ApiProperty({ example: "portfolio.rebalanced" }) + @IsString() + @MinLength(1) + @MaxLength(255) + eventType: string; + + @ApiProperty({ + example: { portfolioId: "abc", oldAllocation: {}, newAllocation: {} }, + }) + @IsObject() + payload: Record; + + @ApiPropertyOptional({ example: "portfolio-123" }) + @IsOptional() + @IsString() + @MaxLength(255) + aggregateId?: string; + + @ApiPropertyOptional() + @IsOptional() + @IsObject() + metadata?: Record; +} + +export class RetryDeadLetterDto { + @ApiPropertyOptional({ description: "Specific dead letter ID to retry" }) + @IsOptional() + @IsString() + deadLetterId?: string; +} diff --git a/src/infrastructure/webhooks/entities/webhook-dead-letter.entity.ts b/src/infrastructure/webhooks/entities/webhook-dead-letter.entity.ts new file mode 100644 index 0000000..fe9f400 --- /dev/null +++ b/src/infrastructure/webhooks/entities/webhook-dead-letter.entity.ts @@ -0,0 +1,61 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + Index, +} from "typeorm"; + +@Entity("webhook_dead_letters") +export class WebhookDeadLetter { + @PrimaryGeneratedColumn("uuid") + id: string; + + @Column({ type: "uuid" }) + @Index() + deliveryId: string; + + @Column({ type: "uuid" }) + @Index() + subscriptionId: string; + + @Column({ type: "uuid" }) + @Index() + eventId: string; + + @Column({ type: "varchar", length: 2048 }) + url: string; + + @Column({ type: "jsonb" }) + eventPayload: Record; + + @Column({ type: "jsonb" }) + requestHeaders: Record; + + @Column({ type: "int", nullable: true }) + lastStatusCode?: number; + + @Column({ type: "text", nullable: true }) + lastErrorMessage?: string; + + @Column({ type: "int" }) + totalAttempts: number; + + @Column({ type: "varchar", length: 255 }) + userId: string; + + @Column({ type: "jsonb", nullable: true }) + allAttempts?: Array<{ + attempt: number; + statusCode?: number; + error?: string; + durationMs: number; + timestamp: string; + }>; + + @Column({ type: "boolean", default: false }) + retried: boolean; + + @CreateDateColumn() + createdAt: Date; +} diff --git a/src/infrastructure/webhooks/entities/webhook-delivery.entity.ts b/src/infrastructure/webhooks/entities/webhook-delivery.entity.ts new file mode 100644 index 0000000..25719ce --- /dev/null +++ b/src/infrastructure/webhooks/entities/webhook-delivery.entity.ts @@ -0,0 +1,79 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + Index, +} from "typeorm"; + +export enum WebhookDeliveryStatus { + PENDING = "pending", + DELIVERING = "delivering", + SUCCESS = "success", + FAILED = "failed", + DEAD_LETTERED = "dead_lettered", +} + +@Entity("webhook_deliveries") +export class WebhookDelivery { + @PrimaryGeneratedColumn("uuid") + id: string; + + @Column({ type: "uuid" }) + @Index() + subscriptionId: string; + + @Column({ type: "uuid" }) + @Index() + eventId: string; + + @Column({ + type: "enum", + enum: WebhookDeliveryStatus, + default: WebhookDeliveryStatus.PENDING, + }) + status: WebhookDeliveryStatus; + + @Column({ type: "int", default: 0 }) + attempts: number; + + @Column({ type: "int", default: 5 }) + maxAttempts: number; + + @Column({ type: "int", nullable: true }) + statusCode?: number; + + @Column({ type: "text", nullable: true }) + responseBody?: string; + + @Column({ type: "text", nullable: true }) + errorMessage?: string; + + @Column({ type: "int", default: 0 }) + durationMs: number; + + @Column({ type: "timestamp", nullable: true }) + lastAttemptAt?: Date; + + @Column({ type: "timestamp", nullable: true }) + nextRetryAt?: Date; + + @Column({ type: "timestamp", nullable: true }) + deliveredAt?: Date; + + @Column({ type: "jsonb", nullable: true }) + requestHeaders?: Record; + + @Column({ type: "jsonb", nullable: true }) + responseHeaders?: Record; + + @Column({ type: "jsonb", nullable: true }) + metadata?: Record; + + @CreateDateColumn() + createdAt: Date; + + @UpdateDateColumn() + updatedAt: Date; +} diff --git a/src/infrastructure/webhooks/entities/webhook-event.entity.ts b/src/infrastructure/webhooks/entities/webhook-event.entity.ts new file mode 100644 index 0000000..fd8c822 --- /dev/null +++ b/src/infrastructure/webhooks/entities/webhook-event.entity.ts @@ -0,0 +1,53 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + Index, +} from "typeorm"; + +export enum WebhookEventStatus { + PENDING = "pending", + DELIVERING = "delivering", + DELIVERED = "delivered", + FAILED = "failed", +} + +@Entity("webhook_events") +export class WebhookEvent { + @PrimaryGeneratedColumn("uuid") + id: string; + + @Column({ type: "varchar", length: 255 }) + @Index() + eventType: string; + + @Column({ type: "jsonb" }) + payload: Record; + + @Column({ type: "varchar", length: 255, nullable: true }) + @Index() + aggregateId?: string; + + @Column({ + type: "enum", + enum: WebhookEventStatus, + default: WebhookEventStatus.PENDING, + }) + status: WebhookEventStatus; + + @Column({ type: "int", default: 0 }) + deliveryCount: number; + + @Column({ type: "int", default: 0 }) + successCount: number; + + @Column({ type: "int", default: 0 }) + failureCount: number; + + @Column({ type: "jsonb", nullable: true }) + metadata?: Record; + + @CreateDateColumn() + createdAt: Date; +} diff --git a/src/infrastructure/webhooks/entities/webhook-subscription.entity.ts b/src/infrastructure/webhooks/entities/webhook-subscription.entity.ts new file mode 100644 index 0000000..e0a8478 --- /dev/null +++ b/src/infrastructure/webhooks/entities/webhook-subscription.entity.ts @@ -0,0 +1,70 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + Index, +} from "typeorm"; + +export enum WebhookSubscriptionStatus { + ACTIVE = "active", + PAUSED = "paused", + DISABLED = "disabled", +} + +@Entity("webhook_subscriptions") +export class WebhookSubscription { + @PrimaryGeneratedColumn("uuid") + id: string; + + @Column({ type: "varchar", length: 255 }) + @Index() + userId: string; + + @Column({ type: "varchar", length: 2048 }) + url: string; + + @Column({ type: "varchar", length: 64, unique: true }) + signingKey: string; + + @Column({ type: "simple-array" }) + events: string[]; + + @Column({ + type: "enum", + enum: WebhookSubscriptionStatus, + default: WebhookSubscriptionStatus.ACTIVE, + }) + status: WebhookSubscriptionStatus; + + @Column({ type: "varchar", length: 255, nullable: true }) + description?: string; + + @Column({ type: "int", default: 5 }) + maxRetries: number; + + @Column({ type: "int", default: 1000 }) + retryDelayMs: number; + + @Column({ type: "float", default: 2 }) + backoffMultiplier: number; + + @Column({ type: "int", default: 30000 }) + timeoutMs: number; + + @Column({ type: "int", default: 10 }) + rateLimitPerMinute: number; + + @Column({ type: "jsonb", nullable: true }) + headers?: Record; + + @Column({ type: "jsonb", nullable: true }) + metadata?: Record; + + @CreateDateColumn() + createdAt: Date; + + @UpdateDateColumn() + updatedAt: Date; +} diff --git a/src/infrastructure/webhooks/services/webhook-delivery.service.ts b/src/infrastructure/webhooks/services/webhook-delivery.service.ts new file mode 100644 index 0000000..43b030d --- /dev/null +++ b/src/infrastructure/webhooks/services/webhook-delivery.service.ts @@ -0,0 +1,437 @@ +import { Injectable, Logger } from "@nestjs/common"; +import { InjectRepository } from "@nestjs/typeorm"; +import { Repository } from "typeorm"; +import { HttpService } from "@nestjs/axios"; +import { firstValueFrom } from "rxjs"; +import { + WebhookDelivery, + WebhookDeliveryStatus, +} from "../entities/webhook-delivery.entity"; +import { WebhookDeadLetter } from "../entities/webhook-dead-letter.entity"; +import { + WebhookSubscription, + WebhookSubscriptionStatus, +} from "../entities/webhook-subscription.entity"; +import { WebhookEvent } from "../entities/webhook-event.entity"; +import { WebhookHmacService } from "./webhook-hmac.service"; + +export interface DeliveryResult { + statusCode?: number; + responseBody?: string; + durationMs: number; + error?: string; + responseHeaders?: Record; +} + +export interface WebhookMetrics { + totalDeliveries: number; + successfulDeliveries: number; + failedDeliveries: number; + deadLetteredCount: number; + pendingRetries: number; + avgDurationMs: number; + deliveriesByEvent: Record< + string, + { total: number; success: number; failed: number } + >; +} + +@Injectable() +export class WebhookDeliveryService { + private readonly logger = new Logger(WebhookDeliveryService.name); + + constructor( + @InjectRepository(WebhookDelivery) + private readonly deliveryRepo: Repository, + @InjectRepository(WebhookDeadLetter) + private readonly deadLetterRepo: Repository, + @InjectRepository(WebhookEvent) + private readonly eventRepo: Repository, + @InjectRepository(WebhookSubscription) + private readonly subscriptionRepo: Repository, + private readonly httpService: HttpService, + private readonly hmacService: WebhookHmacService, + ) {} + + /** + * Create delivery records for an event against all matching subscriptions. + */ + async createDeliveries( + event: WebhookEvent, + subscriptions: WebhookSubscription[], + ): Promise { + const deliveries: WebhookDelivery[] = []; + for (const sub of subscriptions) { + const delivery = this.deliveryRepo.create({ + subscriptionId: sub.id, + eventId: event.id, + status: WebhookDeliveryStatus.PENDING, + maxAttempts: sub.maxRetries, + }); + const saved = await this.deliveryRepo.save(delivery); + deliveries.push(saved); + } + return deliveries; + } + + /** + * Execute a single delivery attempt with HMAC signing. + */ + async executeDelivery(delivery: WebhookDelivery): Promise { + const subscription = await this.subscriptionRepo.findOne({ + where: { id: delivery.subscriptionId }, + }); + const event = await this.eventRepo.findOne({ + where: { id: delivery.eventId }, + }); + if (!subscription || !event) { + return { + durationMs: 0, + error: "Subscription or event not found", + }; + } + + const payload = JSON.stringify({ + id: event.id, + type: event.eventType, + aggregateId: event.aggregateId, + data: event.payload, + timestamp: new Date().toISOString(), + attempt: delivery.attempts + 1, + }); + + const headers = this.hmacService.buildSignedHeaders( + subscription.signingKey, + payload, + event.id, + event.eventType, + subscription.headers, + ); + + // Update delivery status to delivering + delivery.status = WebhookDeliveryStatus.DELIVERING; + delivery.requestHeaders = headers; + delivery.lastAttemptAt = new Date(); + await this.deliveryRepo.save(delivery); + + const startTime = Date.now(); + try { + const response = await firstValueFrom( + this.httpService.post(subscription.url, payload, { + headers, + timeout: subscription.timeoutMs || 30000, + validateStatus: () => true, // Don't throw for 4xx/5xx + }), + ); + + const durationMs = Date.now() - startTime; + const statusCode = response.status; + const responseBody = + typeof response.data === "string" + ? response.data.substring(0, 10000) + : JSON.stringify(response.data).substring(0, 10000); + + const respHeaders: Record = {}; + if (response.headers) { + for (const [k, v] of Object.entries(response.headers)) { + if (typeof v === "string") respHeaders[k] = v; + } + } + + const isSuccess = statusCode >= 200 && statusCode < 300; + return { + statusCode, + responseBody, + durationMs, + responseHeaders: respHeaders, + error: isSuccess ? undefined : `HTTP ${statusCode}`, + }; + } catch (err: any) { + const durationMs = Date.now() - startTime; + return { + durationMs, + error: err.message || "Unknown delivery error", + }; + } + } + + /** + * Process a delivery: attempt, retry on failure, or dead-letter. + */ + async processDelivery(deliveryId: string): Promise { + const delivery = await this.deliveryRepo.findOne({ + where: { id: deliveryId }, + }); + if (!delivery) throw new Error(`Delivery ${deliveryId} not found`); + + const subscription = await this.subscriptionRepo.findOne({ + where: { id: delivery.subscriptionId }, + }); + if (!subscription) + throw new Error(`Subscription ${delivery.subscriptionId} not found`); + + const result = await this.executeDelivery(delivery); + const attemptNum = delivery.attempts + 1; + const isSuccess = + result.statusCode !== undefined && + result.statusCode >= 200 && + result.statusCode < 300; + + // Record attempt in metadata + const attempts = (delivery.metadata?.attempts as any[]) || []; + attempts.push({ + attempt: attemptNum, + statusCode: result.statusCode, + error: result.error, + durationMs: result.durationMs, + timestamp: new Date().toISOString(), + }); + + if (isSuccess) { + delivery.status = WebhookDeliveryStatus.SUCCESS; + delivery.statusCode = result.statusCode; + delivery.durationMs = result.durationMs; + delivery.deliveredAt = new Date(); + delivery.responseBody = result.responseBody; + delivery.responseHeaders = result.responseHeaders; + delivery.attempts = attemptNum; + delivery.metadata = { ...delivery.metadata, attempts }; + await this.deliveryRepo.save(delivery); + + // Update event success count + await this.eventRepo + .createQueryBuilder() + .update(WebhookEvent) + .set({ successCount: () => '"successCount" + 1' }) + .where("id = :id", { id: delivery.eventId }) + .execute(); + + this.logger.log( + `Delivery ${deliveryId} succeeded: HTTP ${result.statusCode} in ${result.durationMs}ms`, + ); + } else { + delivery.attempts = attemptNum; + delivery.statusCode = result.statusCode; + delivery.durationMs = result.durationMs; + delivery.errorMessage = result.error; + delivery.responseBody = result.responseBody; + delivery.responseHeaders = result.responseHeaders; + delivery.metadata = { ...delivery.metadata, attempts }; + + if (attemptNum >= delivery.maxAttempts) { + // Dead-letter + delivery.status = WebhookDeliveryStatus.DEAD_LETTERED; + await this.deliveryRepo.save(delivery); + + const event = await this.eventRepo.findOne({ + where: { id: delivery.eventId }, + }); + + await this.deadLetterRepo.save( + this.deadLetterRepo.create({ + deliveryId: delivery.id, + subscriptionId: delivery.subscriptionId, + eventId: delivery.eventId, + url: subscription.url, + eventPayload: event?.payload || {}, + requestHeaders: delivery.requestHeaders || {}, + lastStatusCode: result.statusCode, + lastErrorMessage: result.error, + totalAttempts: attemptNum, + userId: subscription.userId, + allAttempts: attempts, + }), + ); + + // Update event failure count + await this.eventRepo + .createQueryBuilder() + .update(WebhookEvent) + .set({ failureCount: () => '"failureCount" + 1' }) + .where("id = :id", { id: delivery.eventId }) + .execute(); + + this.logger.warn( + `Delivery ${deliveryId} dead-lettered after ${attemptNum} attempts`, + ); + } else { + // Schedule retry + const delay = this.calculateRetryDelay( + subscription.retryDelayMs, + subscription.backoffMultiplier, + attemptNum, + ); + delivery.status = WebhookDeliveryStatus.FAILED; + delivery.nextRetryAt = new Date(Date.now() + delay); + await this.deliveryRepo.save(delivery); + + this.logger.log( + `Delivery ${deliveryId} attempt ${attemptNum}/${delivery.maxAttempts} failed, retry in ${delay}ms`, + ); + } + } + + return delivery; + } + + /** + * Calculate exponential backoff delay with jitter. + */ + calculateRetryDelay( + baseDelayMs: number, + multiplier: number, + attempt: number, + ): number { + const exponential = baseDelayMs * Math.pow(multiplier, attempt - 1); + const jitter = exponential * 0.2 * Math.random(); + return Math.min(exponential + jitter, 300000); // Cap at 5 minutes + } + + /** + * Get all deliveries for a given event. + */ + async getDeliveriesByEvent(eventId: string): Promise { + return this.deliveryRepo.find({ + where: { eventId }, + order: { createdAt: "ASC" }, + }); + } + + /** + * Get pending deliveries that are due for retry. + */ + async getPendingRetries(): Promise { + return this.deadLetterRepo.find({ + where: { retried: false }, + order: { createdAt: "ASC" }, + take: 50, + }); + } + + /** + * Reattempt a dead-lettered delivery. + */ + async retryDeadLetter(deadLetterId: string): Promise { + const dl = await this.deadLetterRepo.findOne({ + where: { id: deadLetterId }, + }); + if (!dl || dl.retried) return null; + + const subscription = await this.subscriptionRepo.findOne({ + where: { id: dl.subscriptionId }, + }); + if ( + !subscription || + subscription.status !== WebhookSubscriptionStatus.ACTIVE + ) { + return null; + } + + // Create a fresh delivery + const newDelivery = this.deliveryRepo.create({ + subscriptionId: dl.subscriptionId, + eventId: dl.eventId, + status: WebhookDeliveryStatus.PENDING, + maxAttempts: subscription.maxRetries, + }); + const saved = await this.deliveryRepo.save(newDelivery); + + dl.retried = true; + await this.deadLetterRepo.save(dl); + + this.logger.log( + `Dead letter ${deadLetterId} requeued as delivery ${saved.id}`, + ); + return saved; + } + + /** + * Get dead-lettered deliveries with optional filters. + */ + async getDeadLetters( + userId?: string, + limit = 50, + offset = 0, + ): Promise { + const where: any = {}; + if (userId) where.userId = userId; + return this.deadLetterRepo.find({ + where, + order: { createdAt: "DESC" }, + take: limit, + skip: offset, + }); + } + + /** + * Get aggregated webhook delivery metrics. + */ + async getMetrics(userId?: string): Promise { + const deliveries = userId + ? await this.deliveryRepo + .createQueryBuilder("d") + .innerJoin("d.subscriptionId", "s") + .innerJoin( + WebhookSubscription, + "sub", + "sub.id = d.subscriptionId AND sub.userId = :userId", + { userId }, + ) + .getMany() + : await this.deliveryRepo.find(); + + const deadLetters = await this.deadLetterRepo.find({ + where: userId ? { userId } : {}, + }); + + const totalDeliveries = deliveries.length; + const successfulDeliveries = deliveries.filter( + (d) => d.status === WebhookDeliveryStatus.SUCCESS, + ).length; + const failedDeliveries = deliveries.filter( + (d) => d.status === WebhookDeliveryStatus.FAILED, + ).length; + const pendingRetries = deliveries.filter( + (d) => + d.status === WebhookDeliveryStatus.FAILED && + d.nextRetryAt && + d.nextRetryAt > new Date(), + ).length; + + const durations = deliveries + .filter((d) => d.durationMs > 0) + .map((d) => d.durationMs); + const avgDurationMs = + durations.length > 0 + ? durations.reduce((a, b) => a + b, 0) / durations.length + : 0; + + // Group by event type via the event entity + const deliveriesByEvent: Record< + string, + { total: number; success: number; failed: number } + > = {}; + for (const d of deliveries) { + const event = await this.eventRepo.findOne({ where: { id: d.eventId } }); + const type = event?.eventType || "unknown"; + if (!deliveriesByEvent[type]) { + deliveriesByEvent[type] = { total: 0, success: 0, failed: 0 }; + } + deliveriesByEvent[type].total++; + if (d.status === WebhookDeliveryStatus.SUCCESS) + deliveriesByEvent[type].success++; + if (d.status === WebhookDeliveryStatus.FAILED) + deliveriesByEvent[type].failed++; + } + + return { + totalDeliveries, + successfulDeliveries, + failedDeliveries, + deadLetteredCount: deadLetters.length, + pendingRetries, + avgDurationMs: Math.round(avgDurationMs), + deliveriesByEvent, + }; + } +} diff --git a/src/infrastructure/webhooks/services/webhook-event.service.ts b/src/infrastructure/webhooks/services/webhook-event.service.ts new file mode 100644 index 0000000..346e2a4 --- /dev/null +++ b/src/infrastructure/webhooks/services/webhook-event.service.ts @@ -0,0 +1,131 @@ +import { Injectable, Logger } from "@nestjs/common"; +import { InjectRepository } from "@nestjs/typeorm"; +import { Repository } from "typeorm"; +import { InjectQueue } from "@nestjs/bull"; +import { Queue } from "bull"; +import { + WebhookEvent, + WebhookEventStatus, +} from "../entities/webhook-event.entity"; +import { WebhookSubscriptionService } from "./webhook-subscription.service"; +import { WebhookDeliveryService } from "./webhook-delivery.service"; +import { PublishWebhookEventDto } from "../dto/webhook.dto"; + +@Injectable() +export class WebhookEventService { + private readonly logger = new Logger(WebhookEventService.name); + + constructor( + @InjectRepository(WebhookEvent) + private readonly eventRepo: Repository, + private readonly subscriptionService: WebhookSubscriptionService, + private readonly deliveryService: WebhookDeliveryService, + @InjectQueue("webhook-delivery") private readonly deliveryQueue: Queue, + ) {} + + /** + * Publish a new webhook event. Matches active subscribers, creates delivery + * records, and enqueues each delivery for async processing. + */ + async publishEvent(dto: PublishWebhookEventDto): Promise { + // Persist the event + const event = this.eventRepo.create({ + eventType: dto.eventType, + payload: dto.payload, + aggregateId: dto.aggregateId, + metadata: dto.metadata, + status: WebhookEventStatus.PENDING, + }); + const saved = await this.eventRepo.save(event); + this.logger.log(`Event published: ${saved.id} type=${saved.eventType}`); + + // Find matching subscribers + const subscribers = await this.subscriptionService.findActiveForEvent( + dto.eventType, + ); + + if (subscribers.length === 0) { + this.logger.log( + `No active subscribers for event type "${dto.eventType}"`, + ); + saved.status = WebhookEventStatus.DELIVERED; + saved.deliveryCount = 0; + await this.eventRepo.save(saved); + return saved; + } + + // Create delivery records + const deliveries = await this.deliveryService.createDeliveries( + saved, + subscribers, + ); + + // Enqueue each delivery + for (const delivery of deliveries) { + await this.deliveryQueue.add( + "deliver", + { + deliveryId: delivery.id, + eventId: saved.id, + }, + { + attempts: 1, // We handle retries ourselves via backoff + removeOnComplete: 100, + removeOnFail: 200, + }, + ); + } + + saved.status = WebhookEventStatus.DELIVERING; + saved.deliveryCount = deliveries.length; + await this.eventRepo.save(saved); + + this.logger.log( + `Event ${saved.id} enqueued for ${deliveries.length} subscribers`, + ); + + return saved; + } + + /** + * Get event by ID with delivery details. + */ + async getEvent(eventId: string): Promise { + return this.eventRepo.findOne({ where: { id: eventId } }); + } + + /** + * List recent events with optional type filter. + */ + async listEvents( + eventType?: string, + limit = 50, + offset = 0, + ): Promise { + const where: any = {}; + if (eventType) where.eventType = eventType; + return this.eventRepo.find({ + where, + order: { createdAt: "DESC" }, + take: limit, + skip: offset, + }); + } + + /** + * Replay an event — re-publishes it to all current matching subscribers. + */ + async replayEvent(eventId: string): Promise { + const original = await this.eventRepo.findOne({ + where: { id: eventId }, + }); + if (!original) return null; + + return this.publishEvent({ + eventType: original.eventType, + payload: original.payload, + aggregateId: original.aggregateId, + metadata: { ...original.metadata, replayedFrom: original.id }, + }); + } +} diff --git a/src/infrastructure/webhooks/services/webhook-hmac.service.ts b/src/infrastructure/webhooks/services/webhook-hmac.service.ts new file mode 100644 index 0000000..e43ed88 --- /dev/null +++ b/src/infrastructure/webhooks/services/webhook-hmac.service.ts @@ -0,0 +1,53 @@ +import { Injectable } from "@nestjs/common"; +import { createHmac, timingSafeEqual } from "crypto"; + +@Injectable() +export class WebhookHmacService { + /** + * Compute HMAC-SHA256 signature for a webhook payload. + * Returns the hex-encoded signature prefixed with "sha256=". + */ + sign(payload: string, secret: string): string { + const signature = createHmac("sha256", secret) + .update(payload, "utf8") + .digest("hex"); + return `sha256=${signature}`; + } + + /** + * Build the standard webhook request headers including the HMAC signature. + */ + buildSignedHeaders( + signingKey: string, + payload: string, + eventId: string, + eventType: string, + extraHeaders?: Record, + ): Record { + const signature = this.sign(payload, signingKey); + return { + "Content-Type": "application/json", + "X-Webhook-Event-Id": eventId, + "X-Webhook-Event-Type": eventType, + "X-Webhook-Signature": signature, + "X-Webhook-Timestamp": Date.now().toString(), + "User-Agent": "AlianStructure-Webhook/1.0", + ...extraHeaders, + }; + } + + /** + * Verify an incoming HMAC signature (useful for echo/test endpoints). + */ + verify(payload: string, secret: string, receivedSignature: string): boolean { + const expected = this.sign(payload, secret); + try { + return timingSafeEqual( + Buffer.from(expected, "utf8"), + Buffer.from(receivedSignature, "utf8"), + ); + } catch { + return false; + } + } +} diff --git a/src/infrastructure/webhooks/services/webhook-processor.service.ts b/src/infrastructure/webhooks/services/webhook-processor.service.ts new file mode 100644 index 0000000..7340391 --- /dev/null +++ b/src/infrastructure/webhooks/services/webhook-processor.service.ts @@ -0,0 +1,40 @@ +import { Process, Processor } from "@nestjs/bull"; +import { Logger } from "@nestjs/common"; +import { Job } from "bull"; +import { WebhookDeliveryService } from "./webhook-delivery.service"; + +export interface WebhookDeliveryJobData { + deliveryId: string; + eventId: string; +} + +@Processor("webhook-delivery") +export class WebhookProcessor { + private readonly logger = new Logger(WebhookProcessor.name); + + constructor(private readonly deliveryService: WebhookDeliveryService) {} + + @Process("deliver") + async handleDelivery(job: Job) { + const { deliveryId } = job.data; + this.logger.log( + `Processing webhook delivery job ${job.id} for delivery ${deliveryId}`, + ); + + try { + const delivery = await this.deliveryService.processDelivery(deliveryId); + return { + status: delivery.status, + deliveryId: delivery.id, + attempts: delivery.attempts, + statusCode: delivery.statusCode, + durationMs: delivery.durationMs, + }; + } catch (err: any) { + this.logger.error( + `Webhook delivery job ${job.id} failed: ${err.message}`, + ); + throw err; + } + } +} diff --git a/src/infrastructure/webhooks/services/webhook-subscription.service.ts b/src/infrastructure/webhooks/services/webhook-subscription.service.ts new file mode 100644 index 0000000..8197cca --- /dev/null +++ b/src/infrastructure/webhooks/services/webhook-subscription.service.ts @@ -0,0 +1,136 @@ +import { Injectable, Logger, NotFoundException } from "@nestjs/common"; +import { InjectRepository } from "@nestjs/typeorm"; +import { Repository } from "typeorm"; +import { randomBytes } from "crypto"; +import { + WebhookSubscription, + WebhookSubscriptionStatus, +} from "../entities/webhook-subscription.entity"; +import { + CreateWebhookSubscriptionDto, + UpdateWebhookSubscriptionDto, +} from "../dto/webhook.dto"; + +@Injectable() +export class WebhookSubscriptionService { + private readonly logger = new Logger(WebhookSubscriptionService.name); + + constructor( + @InjectRepository(WebhookSubscription) + private readonly subscriptionRepo: Repository, + ) {} + + async create( + userId: string, + dto: CreateWebhookSubscriptionDto, + ): Promise { + const signingKey = this.generateSigningKey(); + const subscription = this.subscriptionRepo.create({ + userId, + url: dto.url, + signingKey, + events: dto.events, + description: dto.description, + maxRetries: dto.maxRetries ?? 5, + retryDelayMs: dto.retryDelayMs ?? 1000, + backoffMultiplier: dto.backoffMultiplier ?? 2, + timeoutMs: dto.timeoutMs ?? 30000, + rateLimitPerMinute: dto.rateLimitPerMinute ?? 10, + headers: dto.headers, + metadata: dto.metadata, + status: WebhookSubscriptionStatus.ACTIVE, + }); + const saved = await this.subscriptionRepo.save(subscription); + this.logger.log( + `Webhook subscription created: ${saved.id} for user ${userId}, url=${dto.url}`, + ); + return { ...saved, signingKey }; + } + + async findAll(userId: string): Promise { + return this.subscriptionRepo.find({ + where: { userId }, + order: { createdAt: "DESC" }, + }); + } + + async findOne(id: string, userId: string): Promise { + const sub = await this.subscriptionRepo.findOne({ + where: { id, userId }, + }); + if (!sub) + throw new NotFoundException(`Webhook subscription ${id} not found`); + return sub; + } + + async findActiveByEvent(eventType: string): Promise { + return this.subscriptionRepo + .createQueryBuilder("sub") + .where("sub.status = :status", { + status: WebhookSubscriptionStatus.ACTIVE, + }) + .andWhere(`(sub.events LIKE '%:event%' OR sub.events LIKE '%*%')`, { + event: eventType, + }) + .getMany(); + } + + async findActiveForEvent(eventType: string): Promise { + // Find all active subscriptions and filter in JS since TypeORM simple-array + // doesn't support LIKE well. In production with many subscriptions, use a + // join table instead. + const allActive = await this.subscriptionRepo.find({ + where: { status: WebhookSubscriptionStatus.ACTIVE }, + }); + return allActive.filter( + (sub) => sub.events.includes("*") || sub.events.includes(eventType), + ); + } + + async update( + id: string, + userId: string, + dto: UpdateWebhookSubscriptionDto, + ): Promise { + const sub = await this.findOne(id, userId); + Object.assign(sub, dto); + const saved = await this.subscriptionRepo.save(sub); + this.logger.log(`Webhook subscription updated: ${id}`); + return saved; + } + + async remove(id: string, userId: string): Promise { + const sub = await this.findOne(id, userId); + sub.status = WebhookSubscriptionStatus.DISABLED; + await this.subscriptionRepo.save(sub); + this.logger.log(`Webhook subscription disabled: ${id}`); + } + + async rotateKey( + id: string, + userId: string, + ): Promise { + const sub = await this.findOne(id, userId); + const newKey = this.generateSigningKey(); + sub.signingKey = newKey; + const saved = await this.subscriptionRepo.save(sub); + this.logger.log(`Signing key rotated for subscription ${id}`); + return { ...saved, newSigningKey: newKey }; + } + + async toggleStatus( + id: string, + userId: string, + status: WebhookSubscriptionStatus, + ): Promise { + const sub = await this.findOne(id, userId); + sub.status = status; + const saved = await this.subscriptionRepo.save(sub); + this.logger.log(`Subscription ${id} status changed to ${status}`); + return saved; + } + + private generateSigningKey(): string { + return `whsec_${randomBytes(32).toString("hex")}`; + } +} diff --git a/src/infrastructure/webhooks/webhook.controller.ts b/src/infrastructure/webhooks/webhook.controller.ts new file mode 100644 index 0000000..a90b783 --- /dev/null +++ b/src/infrastructure/webhooks/webhook.controller.ts @@ -0,0 +1,225 @@ +import { + Controller, + Get, + Post, + Put, + Delete, + Param, + Body, + HttpCode, + HttpStatus, + Logger, + Query, +} from "@nestjs/common"; +import { + ApiTags, + ApiBearerAuth, + ApiOperation, + ApiResponse, + ApiParam, + ApiQuery, +} from "@nestjs/swagger"; +import { WebhookSubscriptionService } from "./services/webhook-subscription.service"; +import { WebhookEventService } from "./services/webhook-event.service"; +import { WebhookDeliveryService } from "./services/webhook-delivery.service"; +import { + CreateWebhookSubscriptionDto, + UpdateWebhookSubscriptionDto, + PublishWebhookEventDto, +} from "./dto/webhook.dto"; + +@ApiTags("Webhooks") +@ApiBearerAuth() +@Controller("webhooks") +export class WebhookController { + private readonly logger = new Logger(WebhookController.name); + + constructor( + private readonly subscriptionService: WebhookSubscriptionService, + private readonly eventService: WebhookEventService, + private readonly deliveryService: WebhookDeliveryService, + ) {} + + // ── Subscriptions ─────────────────────────────────────────────────── + + @Post("subscriptions") + @HttpCode(HttpStatus.CREATED) + @ApiOperation({ summary: "Create a webhook subscription" }) + @ApiResponse({ status: 201, description: "Subscription created with signing key" }) + async createSubscription( + @Body() dto: CreateWebhookSubscriptionDto, + ) { + // TODO: extract userId from auth guard + const userId = "system"; + const sub = await this.subscriptionService.create(userId, dto); + return { + success: true, + subscription: sub, + message: "Store the signingKey securely — it will not be shown again.", + }; + } + + @Get("subscriptions") + @ApiOperation({ summary: "List all webhook subscriptions" }) + async listSubscriptions() { + const userId = "system"; + const subscriptions = await this.subscriptionService.findAll(userId); + // Strip signing keys from response + const safe = subscriptions.map(({ signingKey, ...rest }) => rest); + return { success: true, subscriptions: safe }; + } + + @Get("subscriptions/:id") + @ApiOperation({ summary: "Get a webhook subscription by ID" }) + @ApiParam({ name: "id" }) + async getSubscription(@Param("id") id: string) { + const userId = "system"; + const sub = await this.subscriptionService.findOne(id, userId); + const { signingKey, ...rest } = sub; + return { success: true, subscription: rest }; + } + + @Put("subscriptions/:id") + @ApiOperation({ summary: "Update a webhook subscription" }) + @ApiParam({ name: "id" }) + async updateSubscription( + @Param("id") id: string, + @Body() dto: UpdateWebhookSubscriptionDto, + ) { + const userId = "system"; + const sub = await this.subscriptionService.update(id, userId, dto); + const { signingKey, ...rest } = sub; + return { success: true, subscription: rest }; + } + + @Delete("subscriptions/:id") + @HttpCode(HttpStatus.OK) + @ApiOperation({ summary: "Disable a webhook subscription" }) + @ApiParam({ name: "id" }) + async deleteSubscription(@Param("id") id: string) { + const userId = "system"; + await this.subscriptionService.remove(id, userId); + return { success: true, message: "Subscription disabled" }; + } + + @Post("subscriptions/:id/rotate-key") + @HttpCode(HttpStatus.OK) + @ApiOperation({ summary: "Rotate the signing key for a subscription" }) + @ApiParam({ name: "id" }) + async rotateKey(@Param("id") id: string) { + const userId = "system"; + const result = await this.subscriptionService.rotateKey(id, userId); + return { + success: true, + newSigningKey: result.newSigningKey, + message: "Store the new signingKey securely — it will not be shown again.", + }; + } + + @Put("subscriptions/:id/status") + @ApiOperation({ summary: "Toggle subscription status (active/paused/disabled)" }) + @ApiParam({ name: "id" }) + @ApiQuery({ name: "status", enum: ["active", "paused", "disabled"] }) + async toggleStatus( + @Param("id") id: string, + @Query("status") status: "active" | "paused" | "disabled", + ) { + const userId = "system"; + const sub = await this.subscriptionService.toggleStatus(id, userId, status as any); + const { signingKey, ...rest } = sub; + return { success: true, subscription: rest }; + } + + // ── Events ────────────────────────────────────────────────────────── + + @Post("events") + @HttpCode(HttpStatus.CREATED) + @ApiOperation({ summary: "Publish a webhook event" }) + @ApiResponse({ status: 201, description: "Event published and deliveries enqueued" }) + async publishEvent(@Body() dto: PublishWebhookEventDto) { + const event = await this.eventService.publishEvent(dto); + return { + success: true, + event, + message: `Event enqueued for ${event.deliveryCount} subscribers`, + }; + } + + @Get("events") + @ApiOperation({ summary: "List recent webhook events" }) + @ApiQuery({ name: "type", required: false }) + @ApiQuery({ name: "limit", required: false }) + @ApiQuery({ name: "offset", required: false }) + async listEvents( + @Query("type") type?: string, + @Query("limit") limit?: number, + @Query("offset") offset?: number, + ) { + const events = await this.eventService.listEvents( + type, + limit || 50, + offset || 0, + ); + return { success: true, events }; + } + + @Get("events/:id") + @ApiOperation({ summary: "Get event details with deliveries" }) + @ApiParam({ name: "id" }) + async getEvent(@Param("id") id: string) { + const event = await this.eventService.getEvent(id); + if (!event) return { success: false, message: "Event not found" }; + const deliveries = await this.deliveryService.getDeliveriesByEvent(id); + return { success: true, event, deliveries }; + } + + @Post("events/:id/replay") + @HttpCode(HttpStatus.CREATED) + @ApiOperation({ summary: "Replay an event to current subscribers" }) + @ApiParam({ name: "id" }) + async replayEvent(@Param("id") id: string) { + const event = await this.eventService.replayEvent(id); + if (!event) return { success: false, message: "Event not found" }; + return { success: true, event }; + } + + // ── Dead Letters ──────────────────────────────────────────────────── + + @Get("dead-letters") + @ApiOperation({ summary: "List dead-lettered deliveries" }) + @ApiQuery({ name: "limit", required: false }) + @ApiQuery({ name: "offset", required: false }) + async listDeadLetters( + @Query("limit") limit?: number, + @Query("offset") offset?: number, + ) { + const userId = "system"; + const deadLetters = await this.deliveryService.getDeadLetters( + userId, + limit || 50, + offset || 0, + ); + return { success: true, deadLetters }; + } + + @Post("dead-letters/:id/retry") + @HttpCode(HttpStatus.OK) + @ApiOperation({ summary: "Retry a dead-lettered delivery" }) + @ApiParam({ name: "id" }) + async retryDeadLetter(@Param("id") id: string) { + const delivery = await this.deliveryService.retryDeadLetter(id); + if (!delivery) { + return { success: false, message: "Dead letter not found or already retried" }; + } + return { success: true, delivery }; + } + + // ── Metrics ───────────────────────────────────────────────────────── + + @Get("metrics") + @ApiOperation({ summary: "Get webhook delivery metrics and reliability stats" }) + async getMetrics() { + const metrics = await this.deliveryService.getMetrics(); + return { success: true, metrics }; + } +} diff --git a/src/infrastructure/webhooks/webhook.module.ts b/src/infrastructure/webhooks/webhook.module.ts new file mode 100644 index 0000000..553bf11 --- /dev/null +++ b/src/infrastructure/webhooks/webhook.module.ts @@ -0,0 +1,61 @@ +import { Module } from "@nestjs/common"; +import { TypeOrmModule } from "@nestjs/typeorm"; +import { BullModule } from "@nestjs/bull"; +import { HttpModule } from "@nestjs/axios"; +import { ConfigModule, ConfigService } from "@nestjs/config"; + +import { WebhookSubscription } from "./entities/webhook-subscription.entity"; +import { WebhookEvent } from "./entities/webhook-event.entity"; +import { WebhookDelivery } from "./entities/webhook-delivery.entity"; +import { WebhookDeadLetter } from "./entities/webhook-dead-letter.entity"; + +import { WebhookController } from "./webhook.controller"; +import { WebhookSubscriptionService } from "./services/webhook-subscription.service"; +import { WebhookEventService } from "./services/webhook-event.service"; +import { WebhookDeliveryService } from "./services/webhook-delivery.service"; +import { WebhookHmacService } from "./services/webhook-hmac.service"; +import { WebhookProcessor } from "./services/webhook-processor.service"; + +@Module({ + imports: [ + TypeOrmModule.forFeature([ + WebhookSubscription, + WebhookEvent, + WebhookDelivery, + WebhookDeadLetter, + ]), + BullModule.registerQueueAsync({ + name: "webhook-delivery", + imports: [ConfigModule], + inject: [ConfigService], + useFactory: (configService: ConfigService) => ({ + redis: { + host: configService.get("REDIS_HOST", "localhost"), + port: configService.get("REDIS_PORT", 6379), + password: configService.get("REDIS_PASSWORD"), + }, + defaultJobOptions: { + removeOnComplete: 100, + removeOnFail: 200, + attempts: 1, // We handle retries via delivery service backoff + }, + }), + }), + HttpModule, + ], + controllers: [WebhookController], + providers: [ + WebhookSubscriptionService, + WebhookEventService, + WebhookDeliveryService, + WebhookHmacService, + WebhookProcessor, + ], + exports: [ + WebhookEventService, + WebhookDeliveryService, + WebhookSubscriptionService, + WebhookHmacService, + ], +}) +export class WebhookModule {}