From 26b9972cc237187ba1373fe8c5768cb8768f5733 Mon Sep 17 00:00:00 2001 From: "cursor[bot]" <206951365+cursor[bot]@users.noreply.github.com> Date: Thu, 3 Sep 2026 17:38:13 +0300 Subject: [PATCH 1/5] fix(root): resolve high fast-uri, high browserslist, moderate @humanfs/node vulnerabilities (#12548) Co-authored-by: Cursor Agent Co-authored-by: Dima Grossman --- pnpm-lock.yaml | 283 ++++++++++++++++++++++++++++---------------- pnpm-workspace.yaml | 13 +- 2 files changed, 190 insertions(+), 106 deletions(-) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1a302da93af..6f8d5e7c858 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -146,7 +146,9 @@ overrides: ip-address@<10.3.1: ^10.3.1 mongoose@>=8.0.0 <8.24.1: ^8.24.1 newrelic: 13.19.2 - fast-uri@<3.1.5: ^3.1.5 + fast-uri@<3.1.6: ^3.1.6 + browserslist@<4.28.7: ^4.28.7 + '@humanfs/node@<0.16.8': ^0.16.8 sharp@<0.35.0: ^0.35.3 '@babel/plugin-transform-modules-systemjs@<7.29.4': ^7.29.4 uuid: ^11.1.1 @@ -469,13 +471,13 @@ importers: version: 8.1.6 '@sentry/nestjs': specifier: ^10.63.0 - version: 10.63.0(@nestjs/common@11.1.27(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.27)(@opentelemetry/core@2.9.0(@opentelemetry/api@1.9.1))(@opentelemetry/exporter-trace-otlp-http@0.217.0(@opentelemetry/api@1.9.1)) + version: 10.63.0(@nestjs/common@11.1.27(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.27)(@opentelemetry/core@2.9.0(@opentelemetry/api@1.9.0))(@opentelemetry/exporter-trace-otlp-http@0.217.0(@opentelemetry/api@1.9.0)) '@sentry/node': specifier: ^10.63.0 - version: 10.63.0(@opentelemetry/core@2.9.0(@opentelemetry/api@1.9.1))(@opentelemetry/exporter-trace-otlp-http@0.217.0(@opentelemetry/api@1.9.1)) + version: 10.63.0(@opentelemetry/core@2.9.0(@opentelemetry/api@1.9.0))(@opentelemetry/exporter-trace-otlp-http@0.217.0(@opentelemetry/api@1.9.0)) '@sentry/profiling-node': specifier: ^10.63.0 - version: 10.63.0(@opentelemetry/core@2.9.0(@opentelemetry/api@1.9.1))(@opentelemetry/exporter-trace-otlp-http@0.217.0(@opentelemetry/api@1.9.1)) + version: 10.63.0(@opentelemetry/core@2.9.0(@opentelemetry/api@1.9.0))(@opentelemetry/exporter-trace-otlp-http@0.217.0(@opentelemetry/api@1.9.0)) '@slack/types': specifier: 2.20.1 version: 2.20.1 @@ -595,7 +597,7 @@ importers: version: 3.3.18 nest-raven: specifier: 10.1.0 - version: 10.1.0(@nestjs/common@11.1.27(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.27)(@sentry/node@10.63.0(@opentelemetry/core@2.9.0(@opentelemetry/api@1.9.1))(@opentelemetry/exporter-trace-otlp-http@0.217.0(@opentelemetry/api@1.9.1)))(class-transformer@0.5.1)(class-validator@0.15.1)(graphql@16.9.0)(reflect-metadata@0.2.2)(rxjs@7.8.2)(ts-morph@24.0.0) + version: 10.1.0(@nestjs/common@11.1.27(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.27)(@sentry/node@10.63.0(@opentelemetry/core@2.9.0(@opentelemetry/api@1.9.0))(@opentelemetry/exporter-trace-otlp-http@0.217.0(@opentelemetry/api@1.9.0)))(class-transformer@0.5.1)(class-validator@0.15.1)(graphql@16.9.0)(reflect-metadata@0.2.2)(rxjs@7.8.2)(ts-morph@24.0.0) newrelic: specifier: 13.19.2 version: 13.19.2 @@ -1463,7 +1465,7 @@ importers: version: 4.18.1 nest-raven: specifier: 10.1.0 - version: 10.1.0(@nestjs/common@11.1.27(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.27)(@sentry/node@10.63.0(@opentelemetry/core@2.9.0(@opentelemetry/api@1.9.1))(@opentelemetry/exporter-trace-otlp-http@0.217.0(@opentelemetry/api@1.9.1)))(class-transformer@0.5.1)(class-validator@0.15.1)(graphql@16.9.0)(reflect-metadata@0.2.2)(rxjs@7.8.2)(ts-morph@24.0.0) + version: 10.1.0(@nestjs/common@11.1.27(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.27)(@sentry/node@10.63.0(@opentelemetry/core@2.9.0(@opentelemetry/api@1.9.1))(@opentelemetry/exporter-trace-otlp-http@0.217.0(@opentelemetry/api@1.9.1)))(class-transformer@0.5.1)(class-validator@0.15.1)(graphql@16.9.0)(reflect-metadata@0.2.2)(rxjs@7.8.2) newrelic: specifier: 13.19.2 version: 13.19.2 @@ -1656,7 +1658,7 @@ importers: version: 11.5.1 nest-raven: specifier: 10.1.0 - version: 10.1.0(@nestjs/common@11.1.27(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.27)(@sentry/node@10.63.0(@opentelemetry/core@2.9.0(@opentelemetry/api@1.9.1))(@opentelemetry/exporter-trace-otlp-http@0.217.0(@opentelemetry/api@1.9.1)))(class-transformer@0.5.1)(class-validator@0.15.1)(graphql@16.9.0)(reflect-metadata@0.2.2)(rxjs@7.8.2)(ts-morph@24.0.0) + version: 10.1.0(@nestjs/common@11.1.27(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.27)(@sentry/node@10.63.0(@opentelemetry/core@2.9.0(@opentelemetry/api@1.9.1))(@opentelemetry/exporter-trace-otlp-http@0.217.0(@opentelemetry/api@1.9.1)))(class-transformer@0.5.1)(class-validator@0.15.1)(graphql@16.9.0)(reflect-metadata@0.2.2)(rxjs@7.8.2) newrelic: specifier: 13.19.2 version: 13.19.2 @@ -1847,7 +1849,7 @@ importers: version: 4.18.1 nest-raven: specifier: 10.1.0 - version: 10.1.0(@nestjs/common@11.1.27(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.27)(@sentry/node@10.63.0(@opentelemetry/core@2.9.0(@opentelemetry/api@1.9.1))(@opentelemetry/exporter-trace-otlp-http@0.217.0(@opentelemetry/api@1.9.1)))(class-transformer@0.5.1)(class-validator@0.15.1)(graphql@16.9.0)(reflect-metadata@0.2.2)(rxjs@7.8.2)(ts-morph@24.0.0) + version: 10.1.0(@nestjs/common@11.1.27(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.27)(@sentry/node@10.63.0(@opentelemetry/core@2.9.0(@opentelemetry/api@1.9.1))(@opentelemetry/exporter-trace-otlp-http@0.217.0(@opentelemetry/api@1.9.1)))(class-transformer@0.5.1)(class-validator@0.15.1)(graphql@16.9.0)(reflect-metadata@0.2.2)(rxjs@7.8.2) newrelic: specifier: 13.19.2 version: 13.19.2 @@ -8274,12 +8276,16 @@ packages: peerDependencies: react-hook-form: ^7.55.0 - '@humanfs/core@0.19.1': - resolution: {integrity: sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==} + '@humanfs/core@0.19.2': + resolution: {integrity: sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==} + engines: {node: '>=18.18.0'} + + '@humanfs/node@0.16.8': + resolution: {integrity: sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ==} engines: {node: '>=18.18.0'} - '@humanfs/node@0.16.6': - resolution: {integrity: sha512-YuI2ZHQL78Q5HbhDiBA1X4LmYdXCKCMQIfw0pw7piHJwyREFebJUvrQN4cMssyES6x+vfUbx1CIpaQUKYdQZOw==} + '@humanfs/types@0.15.0': + resolution: {integrity: sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q==} engines: {node: '>=18.18.0'} '@humanwhocodes/module-importer@1.0.1': @@ -8290,10 +8296,6 @@ packages: resolution: {integrity: sha512-RE815I4arJFtt+FVeU1Tgp9/Xvecacji8w/V6XtXsWWH/wz/eNkNbhb+ny/+PlVZjV0rxQpRSQKNKE3lcktHEA==} engines: {node: '>=10.10.0'} - '@humanwhocodes/retry@0.3.0': - resolution: {integrity: sha512-d2CGZR2o7fS6sWB7DG/3a95bGKQyHMACZ5aW8qGkkqQpUoZV6C0X7Pc7l4ZNMZkfNBf4VWNe9E1jRsf0G146Ew==} - engines: {node: '>=18.18'} - '@humanwhocodes/retry@0.4.3': resolution: {integrity: sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==} engines: {node: '>=18.18'} @@ -17928,6 +17930,11 @@ packages: engines: {node: '>=6.0.0'} hasBin: true + baseline-browser-mapping@2.11.20: + resolution: {integrity: sha512-H0ulySigv6icDJ1F7SjtdCD6PrhTpdYCmP0CactWy1+ekh0AFd0o1Wn5T8b+hnTmdBx19u9yhL6wvCylXMY7zw==} + engines: {node: '>=6.0.0'} + hasBin: true + basic-auth@1.1.0: resolution: {integrity: sha512-CtGuTyWf3ig+sgRyC7uP6DM3N+5ur/p8L+FPfsd+BbIfIs74TFfCajZTHnCw6K5dqM0bZEbRIqRy1fAdiUJhTA==} engines: {node: '>= 0.6'} @@ -18113,18 +18120,8 @@ packages: browser-stdout@1.3.1: resolution: {integrity: sha512-qhAVI1+Av2X7qelOfAIYwXONood6XlZE/fXaBSmW/T5SzLAmCgzi+eiWE7fUvbHaeNBQH13UftjpXxsfLkMpgw==} - browserslist@4.23.3: - resolution: {integrity: sha512-btwCFJVjI4YWDNfau8RhZ+B1Q/VLoUITrm3RlP6y1tYGWIOa+InuYiRGXUBXo8nA1qKmHMyLB/iVQg5TT4eFoA==} - engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} - hasBin: true - - browserslist@4.25.2: - resolution: {integrity: sha512-0si2SJK3ooGzIawRu61ZdPCO1IncZwS8IzuX73sPZsXW6EQ/w/DAfPyKI8l1ETTCr2MnvqWitmlCUxgdul45jA==} - engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} - hasBin: true - - browserslist@4.28.1: - resolution: {integrity: sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==} + browserslist@4.28.8: + resolution: {integrity: sha512-V2NpofLblG64mfOtSgDhOJESZEGogzDMBv/q+W6oc4LXWP/q75eOXoOaaOu1EOadB9U4Bwx/e0yzbvwKH8zalA==} engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} hasBin: true @@ -18276,6 +18273,9 @@ packages: caniuse-lite@1.0.30001764: resolution: {integrity: sha512-9JGuzl2M+vPL+pz70gtMF9sHdMFbY9FJaQBi186cHKH3pSzDvzoUJUPV6fqiKIMyXbud9ZLg4F3Yza1vJ1+93g==} + caniuse-lite@1.0.30001810: + resolution: {integrity: sha512-TITQPUkaz+aVk5GL6NhOdwk1aEaNTSDPsGFWrTuhKGtjTF70jL/Oht2W4c6rXUe5fu7Ie19VIahAXHIIiWWNeg==} + caseless@0.12.0: resolution: {integrity: sha512-4tYFyifaFfGacoiObjJegolkwSU4xQNGbVgUiNYVUxbQ2x2lUsFvY4hVgVzGiIe6WLOPqycWXA40l+PWsxthUw==} @@ -19821,8 +19821,8 @@ packages: engines: {node: '>=0.10.0'} hasBin: true - electron-to-chromium@1.5.267: - resolution: {integrity: sha512-0Drusm6MVRXSOJpGbaSVgcQsuB4hEkMpHXaVstcPmhu5LIedxs1xNK/nIxmQIU/RPC0+1/o0AVZfBTkTNJOdUw==} + electron-to-chromium@1.5.417: + resolution: {integrity: sha512-4T+DTDWuMPM4aHlHwWdAVCVWwp7LDilnhzkj+c/Lbj91XSQrLuOmZSLtS9Q4iIqjlPUbPOnC624zDVVHCHaolQ==} elegant-spinner@1.0.1: resolution: {integrity: sha512-B+ZM+RXvRqQaAmkMlO/oSe5nMUOaUnyfGYCEHoR8wrXsZR2mA0XVibsxV1bvTwxdRWah1PkQqso2EzhILGHtEQ==} @@ -20405,8 +20405,8 @@ packages: fast-text-encoding@1.0.6: resolution: {integrity: sha512-VhXlQgj9ioXCqGstD37E/HBeqEGV/qOD/kmbVG8h5xKBYvM1L3lR1Zn4555cQ8GkYbJa8aJSipLPndE1k6zK2w==} - fast-uri@3.1.5: - resolution: {integrity: sha512-gHwA1O9LDIcKunMKhObS/HimwtehO1nPUECKAu5TpKgaO19fcWEl4bliWe1jWxVFvIXztJjjQ4L8XQ1EU9f7Jw==} + fast-uri@3.1.6: + resolution: {integrity: sha512-7Ical1vFEMr0onbVzEDIreM22I4khW+fzyQPwvAFWBp1iwdshSZRsL4jjRvPG9JP1uiqMHRto+YU6R2/CzDz5Q==} fast-url-parser@1.1.3: resolution: {integrity: sha512-5jOCVXADYNuRkKFzNJ0dCCewsZiYo0dz8QNYljkOpFC6r2U4OBmKtvm/Tsuh4w1YYdDqDb31a8TVhBJ2OJKdqQ==} @@ -24462,8 +24462,9 @@ packages: resolution: {integrity: sha512-RM5oyBy45cLEoHqCeh+MNuFAxO0vTFBLskvQbOKnEE7YTTSN4tbN8QWDIPQ6L+WvKsB/qLEGpYe2ZZ9d4W9OIQ==} engines: {node: '>=8'} - node-releases@2.0.27: - resolution: {integrity: sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==} + node-releases@2.0.54: + resolution: {integrity: sha512-YHs7BmmcsdAI5Ozuf8JZo6PT0mv2GIWC9vMfvUC3dp65M8hn7Ux8CPL+2oBI7juNuj9d0ndhTcznq2ODBps9cQ==} + engines: {node: '>=18'} node-rsa@1.1.1: resolution: {integrity: sha512-Jd4cvbJMryN21r5HgxQOpMEqv+ooke/korixNNK3mGqfGJmy0M77WDDzo/05969+OkMy3XW1UuZsSmW9KQm7Fw==} @@ -28813,11 +28814,11 @@ packages: unrs-resolver@1.11.1: resolution: {integrity: sha512-bSjt9pjaEBnNiGgc9rUiHGKv5l4/TGzDmYw3RhnkJGtLhbnnA/5qJj7x3dNDCRx/PJxu774LlH8lCOlB4hEfKg==} - update-browserslist-db@1.2.3: - resolution: {integrity: sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==} + update-browserslist-db@1.3.2: + resolution: {integrity: sha512-UQ+MSxlhRm1bzjhU+DcuXfjFO1FzNtqhK5+9Yvlp90ItDLk5vT932A0rFu619nf7RVS+Y/VeaUW1jaRDqZ8VJw==} hasBin: true peerDependencies: - browserslist: '>= 4.21.0' + browserslist: ^4.28.7 upper-case-first@1.1.2: resolution: {integrity: sha512-wINKYvI3Db8dtjikdAqoBbZoP6Q+PZUyfMR7pmwHzjC2quzSkUq5DmPrTtPEqHaz8AGtmsB4TqwapMTM1QAQOQ==} @@ -32902,7 +32903,7 @@ snapshots: dependencies: '@babel/compat-data': 7.25.4 '@babel/helper-validator-option': 7.27.1 - browserslist: 4.28.1 + browserslist: 4.28.8 lru-cache: 5.1.1 semver: 6.3.1 @@ -32910,7 +32911,7 @@ snapshots: dependencies: '@babel/compat-data': 7.29.7 '@babel/helper-validator-option': 7.29.7 - browserslist: 4.28.1 + browserslist: 4.28.8 lru-cache: 5.1.1 semver: 6.3.1 @@ -35869,19 +35870,22 @@ snapshots: '@standard-schema/utils': 0.3.0 react-hook-form: 7.71.1(react@19.2.3) - '@humanfs/core@0.19.1': {} + '@humanfs/core@0.19.2': + dependencies: + '@humanfs/types': 0.15.0 - '@humanfs/node@0.16.6': + '@humanfs/node@0.16.8': dependencies: - '@humanfs/core': 0.19.1 - '@humanwhocodes/retry': 0.3.0 + '@humanfs/core': 0.19.2 + '@humanfs/types': 0.15.0 + '@humanwhocodes/retry': 0.4.3 + + '@humanfs/types@0.15.0': {} '@humanwhocodes/module-importer@1.0.1': {} '@humanwhocodes/momoa@2.0.4': {} - '@humanwhocodes/retry@0.3.0': {} - '@humanwhocodes/retry@0.4.3': {} '@iconify/types@2.0.0': {} @@ -38627,6 +38631,7 @@ snapshots: dependencies: '@opentelemetry/api': 1.9.1 '@opentelemetry/semantic-conventions': 1.39.0 + optional: true '@opentelemetry/core@2.9.0(@opentelemetry/api@1.9.0)': dependencies: @@ -39128,6 +39133,15 @@ snapshots: transitivePeerDependencies: - supports-color + '@opentelemetry/instrumentation@0.214.0(@opentelemetry/api@1.9.0)': + dependencies: + '@opentelemetry/api': 1.9.0 + '@opentelemetry/api-logs': 0.214.0 + import-in-the-middle: 3.0.1 + require-in-the-middle: 8.0.1 + transitivePeerDependencies: + - supports-color + '@opentelemetry/instrumentation@0.214.0(@opentelemetry/api@1.9.1)': dependencies: '@opentelemetry/api': 1.9.1 @@ -39263,8 +39277,9 @@ snapshots: '@opentelemetry/resources@2.8.0(@opentelemetry/api@1.9.1)': dependencies: '@opentelemetry/api': 1.9.1 - '@opentelemetry/core': 2.8.0(@opentelemetry/api@1.9.1) + '@opentelemetry/core': 2.8.0(@opentelemetry/api@1.9.0) '@opentelemetry/semantic-conventions': 1.39.0 + optional: true '@opentelemetry/sdk-logs@0.201.1(@opentelemetry/api@1.9.0)': dependencies: @@ -39363,8 +39378,8 @@ snapshots: '@opentelemetry/sdk-trace-base@2.8.0(@opentelemetry/api@1.9.1)': dependencies: '@opentelemetry/api': 1.9.1 - '@opentelemetry/core': 2.8.0(@opentelemetry/api@1.9.1) - '@opentelemetry/resources': 2.8.0(@opentelemetry/api@1.9.1) + '@opentelemetry/core': 2.8.0(@opentelemetry/api@1.9.0) + '@opentelemetry/resources': 2.8.0(@opentelemetry/api@1.9.0) '@opentelemetry/semantic-conventions': 1.39.0 '@opentelemetry/sdk-trace-node@2.7.1(@opentelemetry/api@1.9.0)': @@ -44108,6 +44123,20 @@ snapshots: dependencies: '@sentry/core': 10.63.0 + '@sentry/nestjs@10.63.0(@nestjs/common@11.1.27(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.27)(@opentelemetry/core@2.9.0(@opentelemetry/api@1.9.0))(@opentelemetry/exporter-trace-otlp-http@0.217.0(@opentelemetry/api@1.9.0))': + dependencies: + '@nestjs/common': 11.1.27(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/core': 11.1.27(@nestjs/common@11.1.27(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.27)(@nestjs/websockets@11.1.27)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@opentelemetry/api': 1.9.1 + '@opentelemetry/instrumentation': 0.214.0(@opentelemetry/api@1.9.1) + '@sentry/conventions': 0.12.0 + '@sentry/core': 10.63.0 + '@sentry/node': 10.63.0(@opentelemetry/core@2.9.0(@opentelemetry/api@1.9.0))(@opentelemetry/exporter-trace-otlp-http@0.217.0(@opentelemetry/api@1.9.0)) + transitivePeerDependencies: + - '@opentelemetry/core' + - '@opentelemetry/exporter-trace-otlp-http' + - supports-color + '@sentry/nestjs@10.63.0(@nestjs/common@11.1.27(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.27)(@opentelemetry/core@2.9.0(@opentelemetry/api@1.9.1))(@opentelemetry/exporter-trace-otlp-http@0.217.0(@opentelemetry/api@1.9.1))': dependencies: '@nestjs/common': 11.1.27(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2) @@ -44135,6 +44164,19 @@ snapshots: '@opentelemetry/instrumentation': 0.214.0(@opentelemetry/api@1.9.1) '@opentelemetry/sdk-trace-base': 2.8.0(@opentelemetry/api@1.9.0) + '@sentry/node-core@10.63.0(@opentelemetry/api@1.9.1)(@opentelemetry/core@2.9.0(@opentelemetry/api@1.9.0))(@opentelemetry/exporter-trace-otlp-http@0.217.0(@opentelemetry/api@1.9.0))(@opentelemetry/instrumentation@0.214.0(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-trace-base@2.8.0(@opentelemetry/api@1.9.1))': + dependencies: + '@sentry/conventions': 0.12.0 + '@sentry/core': 10.63.0 + '@sentry/opentelemetry': 10.63.0(@opentelemetry/api@1.9.1)(@opentelemetry/core@2.9.0(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@2.8.0(@opentelemetry/api@1.9.1)) + import-in-the-middle: 3.0.1 + optionalDependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.9.0(@opentelemetry/api@1.9.0) + '@opentelemetry/exporter-trace-otlp-http': 0.217.0(@opentelemetry/api@1.9.0) + '@opentelemetry/instrumentation': 0.214.0(@opentelemetry/api@1.9.0) + '@opentelemetry/sdk-trace-base': 2.8.0(@opentelemetry/api@1.9.0) + '@sentry/node-core@10.63.0(@opentelemetry/api@1.9.1)(@opentelemetry/core@2.9.0(@opentelemetry/api@1.9.1))(@opentelemetry/exporter-trace-otlp-http@0.217.0(@opentelemetry/api@1.9.1))(@opentelemetry/instrumentation@0.214.0(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-trace-base@2.8.0(@opentelemetry/api@1.9.1))': dependencies: '@sentry/conventions': 0.12.0 @@ -44170,6 +44212,23 @@ snapshots: - '@opentelemetry/exporter-trace-otlp-http' - supports-color + '@sentry/node@10.63.0(@opentelemetry/core@2.9.0(@opentelemetry/api@1.9.0))(@opentelemetry/exporter-trace-otlp-http@0.217.0(@opentelemetry/api@1.9.0))': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/instrumentation': 0.214.0(@opentelemetry/api@1.9.0) + '@opentelemetry/sdk-trace-base': 2.8.0(@opentelemetry/api@1.9.0) + '@opentelemetry/semantic-conventions': 1.41.1 + '@sentry/conventions': 0.12.0 + '@sentry/core': 10.63.0 + '@sentry/node-core': 10.63.0(@opentelemetry/api@1.9.1)(@opentelemetry/core@2.9.0(@opentelemetry/api@1.9.0))(@opentelemetry/exporter-trace-otlp-http@0.217.0(@opentelemetry/api@1.9.0))(@opentelemetry/instrumentation@0.214.0(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-trace-base@2.8.0(@opentelemetry/api@1.9.1)) + '@sentry/opentelemetry': 10.63.0(@opentelemetry/api@1.9.1)(@opentelemetry/core@2.9.0(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@2.8.0(@opentelemetry/api@1.9.1)) + '@sentry/server-utils': 10.63.0 + import-in-the-middle: 3.0.1 + transitivePeerDependencies: + - '@opentelemetry/core' + - '@opentelemetry/exporter-trace-otlp-http' + - supports-color + '@sentry/node@10.63.0(@opentelemetry/core@2.9.0(@opentelemetry/api@1.9.1))(@opentelemetry/exporter-trace-otlp-http@0.217.0(@opentelemetry/api@1.9.1))': dependencies: '@opentelemetry/api': 1.9.1 @@ -44195,6 +44254,14 @@ snapshots: '@sentry/conventions': 0.12.0 '@sentry/core': 10.63.0 + '@sentry/opentelemetry@10.63.0(@opentelemetry/api@1.9.1)(@opentelemetry/core@2.9.0(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@2.8.0(@opentelemetry/api@1.9.1))': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.9.0(@opentelemetry/api@1.9.0) + '@opentelemetry/sdk-trace-base': 2.8.0(@opentelemetry/api@1.9.0) + '@sentry/conventions': 0.12.0 + '@sentry/core': 10.63.0 + '@sentry/opentelemetry@10.63.0(@opentelemetry/api@1.9.1)(@opentelemetry/core@2.9.0(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-trace-base@2.8.0(@opentelemetry/api@1.9.1))': dependencies: '@opentelemetry/api': 1.9.1 @@ -44203,6 +44270,16 @@ snapshots: '@sentry/conventions': 0.12.0 '@sentry/core': 10.63.0 + '@sentry/profiling-node@10.63.0(@opentelemetry/core@2.9.0(@opentelemetry/api@1.9.0))(@opentelemetry/exporter-trace-otlp-http@0.217.0(@opentelemetry/api@1.9.0))': + dependencies: + '@sentry/core': 10.63.0 + '@sentry/node': 10.63.0(@opentelemetry/core@2.9.0(@opentelemetry/api@1.9.0))(@opentelemetry/exporter-trace-otlp-http@0.217.0(@opentelemetry/api@1.9.0)) + '@sentry/node-cpu-profiler': 2.4.2 + transitivePeerDependencies: + - '@opentelemetry/core' + - '@opentelemetry/exporter-trace-otlp-http' + - supports-color + '@sentry/profiling-node@10.63.0(@opentelemetry/core@2.9.0(@opentelemetry/api@1.9.1))(@opentelemetry/exporter-trace-otlp-http@0.217.0(@opentelemetry/api@1.9.1))': dependencies: '@sentry/core': 10.63.0 @@ -48991,14 +49068,14 @@ snapshots: ajv@8.18.0: dependencies: fast-deep-equal: 3.1.3 - fast-uri: 3.1.5 + fast-uri: 3.1.6 json-schema-traverse: 1.0.0 require-from-string: 2.0.2 ajv@8.20.0: dependencies: fast-deep-equal: 3.1.3 - fast-uri: 3.1.5 + fast-uri: 3.1.6 json-schema-traverse: 1.0.0 require-from-string: 2.0.2 @@ -49376,7 +49453,7 @@ snapshots: autoprefixer@10.4.20(postcss@8.5.25): dependencies: - browserslist: 4.25.2 + browserslist: 4.28.8 caniuse-lite: 1.0.30001734 fraction.js: 4.3.7 normalize-range: 0.1.2 @@ -49386,7 +49463,7 @@ snapshots: autoprefixer@10.4.23(postcss@8.5.25): dependencies: - browserslist: 4.28.1 + browserslist: 4.28.8 caniuse-lite: 1.0.30001764 fraction.js: 5.3.4 picocolors: 1.1.1 @@ -49693,6 +49770,8 @@ snapshots: baseline-browser-mapping@2.10.12: {} + baseline-browser-mapping@2.11.20: {} + basic-auth@1.1.0: {} basic-ftp@5.3.1: {} @@ -49900,27 +49979,13 @@ snapshots: browser-stdout@1.3.1: {} - browserslist@4.23.3: + browserslist@4.28.8: dependencies: - caniuse-lite: 1.0.30001764 - electron-to-chromium: 1.5.267 - node-releases: 2.0.27 - update-browserslist-db: 1.2.3(browserslist@4.23.3) - - browserslist@4.25.2: - dependencies: - caniuse-lite: 1.0.30001764 - electron-to-chromium: 1.5.267 - node-releases: 2.0.27 - update-browserslist-db: 1.2.3(browserslist@4.25.2) - - browserslist@4.28.1: - dependencies: - baseline-browser-mapping: 2.10.12 - caniuse-lite: 1.0.30001764 - electron-to-chromium: 1.5.267 - node-releases: 2.0.27 - update-browserslist-db: 1.2.3(browserslist@4.28.1) + baseline-browser-mapping: 2.11.20 + caniuse-lite: 1.0.30001810 + electron-to-chromium: 1.5.417 + node-releases: 2.0.54 + update-browserslist-db: 1.3.2(browserslist@4.28.8) bs-logger@0.2.6: dependencies: @@ -50095,7 +50160,7 @@ snapshots: caniuse-api@3.0.0: dependencies: - browserslist: 4.28.1 + browserslist: 4.28.8 caniuse-lite: 1.0.30001764 lodash.memoize: 4.1.2 lodash.uniq: 4.5.0 @@ -50104,6 +50169,8 @@ snapshots: caniuse-lite@1.0.30001764: {} + caniuse-lite@1.0.30001810: {} + caseless@0.12.0: {} ccount@2.0.1: {} @@ -50793,7 +50860,7 @@ snapshots: core-js-compat@3.38.1: dependencies: - browserslist: 4.28.1 + browserslist: 4.28.8 core-js-pure@3.50.0: {} @@ -51021,7 +51088,7 @@ snapshots: cssnano-preset-default@7.0.4(postcss@8.5.25): dependencies: - browserslist: 4.28.1 + browserslist: 4.28.8 css-declaration-sorter: 7.2.0(postcss@8.5.25) cssnano-utils: 5.0.0(postcss@8.5.25) postcss: 8.5.25 @@ -51804,7 +51871,7 @@ snapshots: dependencies: jake: 10.8.5 - electron-to-chromium@1.5.267: {} + electron-to-chromium@1.5.417: {} elegant-spinner@1.0.1: {} @@ -52178,7 +52245,7 @@ snapshots: '@eslint/eslintrc': 3.3.5 '@eslint/js': 9.39.4 '@eslint/plugin-kit': 0.4.1 - '@humanfs/node': 0.16.6 + '@humanfs/node': 0.16.8 '@humanwhocodes/module-importer': 1.0.1 '@humanwhocodes/retry': 0.4.3 '@types/estree': 1.0.9 @@ -52637,7 +52704,7 @@ snapshots: fast-text-encoding@1.0.6: {} - fast-uri@3.1.5: {} + fast-uri@3.1.6: {} fast-url-parser@1.1.3: dependencies: @@ -57791,7 +57858,25 @@ snapshots: neo-async@2.6.2: {} - nest-raven@10.1.0(@nestjs/common@11.1.27(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.27)(@sentry/node@10.63.0(@opentelemetry/core@2.9.0(@opentelemetry/api@1.9.1))(@opentelemetry/exporter-trace-otlp-http@0.217.0(@opentelemetry/api@1.9.1)))(class-transformer@0.5.1)(class-validator@0.15.1)(graphql@16.9.0)(reflect-metadata@0.2.2)(rxjs@7.8.2)(ts-morph@24.0.0): + nest-raven@10.1.0(@nestjs/common@11.1.27(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.27)(@sentry/node@10.63.0(@opentelemetry/core@2.9.0(@opentelemetry/api@1.9.0))(@opentelemetry/exporter-trace-otlp-http@0.217.0(@opentelemetry/api@1.9.0)))(class-transformer@0.5.1)(class-validator@0.15.1)(graphql@16.9.0)(reflect-metadata@0.2.2)(rxjs@7.8.2)(ts-morph@24.0.0): + dependencies: + '@nestjs/common': 11.1.27(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@sentry/node': 10.63.0(@opentelemetry/core@2.9.0(@opentelemetry/api@1.9.0))(@opentelemetry/exporter-trace-otlp-http@0.217.0(@opentelemetry/api@1.9.0)) + rxjs: 7.8.2 + optionalDependencies: + '@nestjs/graphql': 12.0.9(@nestjs/common@11.1.27(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.27)(class-transformer@0.5.1)(class-validator@0.15.1)(graphql@16.9.0)(reflect-metadata@0.2.2)(ts-morph@24.0.0) + transitivePeerDependencies: + - '@apollo/subgraph' + - '@nestjs/core' + - bufferutil + - class-transformer + - class-validator + - graphql + - reflect-metadata + - ts-morph + - utf-8-validate + + nest-raven@10.1.0(@nestjs/common@11.1.27(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.27)(@sentry/node@10.63.0(@opentelemetry/core@2.9.0(@opentelemetry/api@1.9.1))(@opentelemetry/exporter-trace-otlp-http@0.217.0(@opentelemetry/api@1.9.1)))(class-transformer@0.5.1)(class-validator@0.15.1)(graphql@16.9.0)(reflect-metadata@0.2.2)(rxjs@7.8.2): dependencies: '@nestjs/common': 11.1.27(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2) '@sentry/node': 10.63.0(@opentelemetry/core@2.9.0(@opentelemetry/api@1.9.1))(@opentelemetry/exporter-trace-otlp-http@0.217.0(@opentelemetry/api@1.9.1)) @@ -58074,7 +58159,7 @@ snapshots: dependencies: process-on-spawn: 1.0.0 - node-releases@2.0.27: {} + node-releases@2.0.54: {} node-rsa@1.1.1: dependencies: @@ -59165,7 +59250,7 @@ snapshots: postcss-colormin@7.0.1(postcss@8.5.25): dependencies: - browserslist: 4.28.1 + browserslist: 4.28.8 caniuse-api: 3.0.0 colord: 2.9.3 postcss: 8.5.25 @@ -59173,7 +59258,7 @@ snapshots: postcss-convert-values@7.0.2(postcss@8.5.25): dependencies: - browserslist: 4.28.1 + browserslist: 4.28.8 postcss: 8.5.25 postcss-value-parser: 4.2.0 @@ -59323,7 +59408,7 @@ snapshots: postcss-merge-rules@7.0.2(postcss@8.5.25): dependencies: - browserslist: 4.28.1 + browserslist: 4.28.8 caniuse-api: 3.0.0 cssnano-utils: 5.0.0(postcss@8.5.25) postcss: 8.5.25 @@ -59343,7 +59428,7 @@ snapshots: postcss-minify-params@7.0.1(postcss@8.5.25): dependencies: - browserslist: 4.28.1 + browserslist: 4.28.8 cssnano-utils: 5.0.0(postcss@8.5.25) postcss: 8.5.25 postcss-value-parser: 4.2.0 @@ -59397,7 +59482,7 @@ snapshots: postcss-normalize-unicode@7.0.1(postcss@8.5.25): dependencies: - browserslist: 4.28.1 + browserslist: 4.28.8 postcss: 8.5.25 postcss-value-parser: 4.2.0 @@ -59471,7 +59556,7 @@ snapshots: '@csstools/postcss-trigonometric-functions': 3.0.10(postcss@8.5.25) '@csstools/postcss-unset-value': 3.0.1(postcss@8.5.25) autoprefixer: 10.4.23(postcss@8.5.25) - browserslist: 4.23.3 + browserslist: 4.28.8 css-blank-pseudo: 6.0.2(postcss@8.5.25) css-has-pseudo: 6.0.5(postcss@8.5.25) css-prefers-color-scheme: 9.0.1(postcss@8.5.25) @@ -59510,7 +59595,7 @@ snapshots: postcss-reduce-initial@7.0.1(postcss@8.5.25): dependencies: - browserslist: 4.28.1 + browserslist: 4.28.8 caniuse-api: 3.0.0 postcss: 8.5.25 @@ -61376,7 +61461,7 @@ snapshots: '@dotenvx/dotenvx': 1.75.1 '@modelcontextprotocol/sdk': 1.30.0(@cfworker/json-schema@4.1.1)(zod@3.25.20) '@types/validate-npm-package-name': 4.0.2 - browserslist: 4.28.1 + browserslist: 4.28.8 commander: 14.0.3 cosmiconfig: 9.0.2(typescript@5.6.2) dedent: 1.6.0(babel-plugin-macros@3.1.0) @@ -62200,7 +62285,7 @@ snapshots: stylehacks@7.0.2(postcss@8.5.25): dependencies: - browserslist: 4.28.1 + browserslist: 4.28.8 postcss: 8.5.25 postcss-selector-parser: 6.1.2 @@ -63548,21 +63633,9 @@ snapshots: '@unrs/resolver-binding-win32-ia32-msvc': 1.11.1 '@unrs/resolver-binding-win32-x64-msvc': 1.11.1 - update-browserslist-db@1.2.3(browserslist@4.23.3): - dependencies: - browserslist: 4.23.3 - escalade: 3.2.0 - picocolors: 1.1.1 - - update-browserslist-db@1.2.3(browserslist@4.25.2): - dependencies: - browserslist: 4.25.2 - escalade: 3.2.0 - picocolors: 1.1.1 - - update-browserslist-db@1.2.3(browserslist@4.28.1): + update-browserslist-db@1.3.2(browserslist@4.28.8): dependencies: - browserslist: 4.28.1 + browserslist: 4.28.8 escalade: 3.2.0 picocolors: 1.1.1 @@ -64251,7 +64324,7 @@ snapshots: '@webassemblyjs/wasm-parser': 1.14.1 acorn: 8.16.0 acorn-import-phases: 1.0.4(acorn@8.16.0) - browserslist: 4.28.1 + browserslist: 4.28.8 chrome-trace-event: 1.0.4 enhanced-resolve: 5.20.1 es-module-lexer: 2.0.0 @@ -64285,7 +64358,7 @@ snapshots: '@webassemblyjs/wasm-parser': 1.14.1 acorn: 8.16.0 acorn-import-phases: 1.0.4(acorn@8.16.0) - browserslist: 4.28.1 + browserslist: 4.28.8 chrome-trace-event: 1.0.4 enhanced-resolve: 5.23.0 es-module-lexer: 2.3.0 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 19b5b6137df..ac2227fd053 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -188,7 +188,18 @@ overrides: ip-address@<10.3.1: ^10.3.1 mongoose@>=8.0.0 <8.24.1: ^8.24.1 newrelic: 13.19.2 - fast-uri@<3.1.5: ^3.1.5 + # GHSA-5jgf-p345-68v8 / GHSA-f65p-4m7j-42xc / GHSA-fph4-wmhf-6fwf / + # GHSA-jqff-g426-hqxp: fast-uri vulnerable to host confusion and SSRF via + # scheme/hostname normalization. Patched in 3.1.6. + fast-uri@<3.1.6: ^3.1.6 + # GHSA-c83g-rgw3-j3cx / GHSA-73wf-gq98-2v4g: browserslist unbounded memory + # growth (OOM) via distinct query results and uncaught crash / prototype + # write via untrusted browserslist-stats.json. Patched in 4.28.7. + browserslist@<4.28.7: ^4.28.7 + # GHSA-p498-v437-472g: @humanfs/node recursive copy follows symlinked files + # and copies data from outside the source tree. Patched in 0.16.8. Only + # pulled transitively via eslint (dev-only tooling). + '@humanfs/node@<0.16.8': ^0.16.8 sharp@<0.35.0: ^0.35.3 '@babel/plugin-transform-modules-systemjs@<7.29.4': ^7.29.4 uuid: ^11.1.1 From c1a97913c1a35e2c544db8d642ff2a652686afe2 Mon Sep 17 00:00:00 2001 From: Adam Chmara Date: Thu, 3 Sep 2026 17:08:04 +0200 Subject: [PATCH 2/5] perf(root): cacheable api build, vite-only dashboard build, fix stale-cache inputs (#12553) --- .github/workflows/reusable-dashboard-e2e.yml | 2 +- .gitignore | 1 + apps/api/package.json | 2 +- apps/api/project.json | 24 ++++++++++++++++---- apps/dashboard/package.json | 19 ++++++++++++++-- apps/worker/project.json | 1 + nx.json | 2 ++ 7 files changed, 43 insertions(+), 8 deletions(-) diff --git a/.github/workflows/reusable-dashboard-e2e.yml b/.github/workflows/reusable-dashboard-e2e.yml index f19859580a9..737de56df2b 100644 --- a/.github/workflows/reusable-dashboard-e2e.yml +++ b/.github/workflows/reusable-dashboard-e2e.yml @@ -98,7 +98,7 @@ jobs: env: NX_NO_CLOUD: ${{ secrets.NX_CLOUD_ACCESS_TOKEN == '' && 'true' || 'false' }} with: - targets: build + targets: build,typecheck projects: '@novu/dashboard,@novu/api-service,@novu/worker' - uses: ./.github/actions/start-localstack diff --git a/.gitignore b/.gitignore index 3d58ce2700a..e52245cd411 100644 --- a/.gitignore +++ b/.gitignore @@ -138,3 +138,4 @@ demos/web-chat-demo/next-env.d.ts # Local agent design/plan artifacts (not part of published docs) superpowers/ +/tmp/ diff --git a/apps/api/package.json b/apps/api/package.json index 67faa74147f..fc2a8883c52 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -10,7 +10,7 @@ }, "scripts": { "prebuild": "rimraf dist", - "build": "pnpm build:metadata && nest build", + "build": "nx build @novu/api-service", "build:generate": "pnpm build:metadata && nest build && pnpm generate:swagger && pnpm generate:sdk", "build:watch": "pnpm build:metadata && nest build --watch", "docker:build": "pnpm --silent --workspace-root pnpm-context -- apps/api/Dockerfile | BULL_MQ_PRO_NPM_TOKEN=${BULL_MQ_PRO_NPM_TOKEN} docker buildx build --load -t novu-api --secret id=BULL_MQ_PRO_NPM_TOKEN --build-arg PACKAGE_PATH=apps/api - $DOCKER_BUILD_ARGUMENTS", diff --git a/apps/api/project.json b/apps/api/project.json index f9c16f0b89e..8814924a771 100644 --- a/apps/api/project.json +++ b/apps/api/project.json @@ -5,11 +5,27 @@ "sourceRoot": "apps/api/src", "projectType": "application", "targets": { - "build": { - "cache": false, + "build-metadata": { + "executor": "nx:run-commands", + "cache": true, "dependsOn": ["^build"], - "inputs": ["default"], - "outputs": ["{projectRoot}/src/metadata.ts"] + "inputs": ["default", "^default"], + "outputs": ["{projectRoot}/src/metadata.ts"], + "options": { + "cwd": "apps/api", + "command": "pnpm run build:metadata" + } + }, + "build": { + "executor": "nx:run-commands", + "cache": true, + "dependsOn": ["build-metadata", "^build"], + "inputs": ["default", "^default"], + "outputs": ["{projectRoot}/dist"], + "options": { + "cwd": "apps/api", + "command": "pnpm exec rimraf dist && pnpm exec nest build" + } }, "lint": { "executor": "nx:run-commands", diff --git a/apps/dashboard/package.json b/apps/dashboard/package.json index 86a91e16d87..260b6ac11d4 100644 --- a/apps/dashboard/package.json +++ b/apps/dashboard/package.json @@ -12,7 +12,7 @@ "start:test": "vite --mode test", "start:static:build": "http-server dist -p 4201 --proxy http://127.0.0.1:4201?", "dev": "pnpm start", - "build": "NODE_OPTIONS='--max-old-space-size=6144' tsc -b && NODE_OPTIONS='--max-old-space-size=6144' vite build", + "build": "NODE_OPTIONS='--max-old-space-size=6144' vite build", "docker:build": "docker buildx build --load -f ./dockerfile -t novu-dashboard ./../.. $DOCKER_BUILD_ARGUMENTS", "lint": "biome lint .", "lint:fix": "biome lint --write .", @@ -27,7 +27,8 @@ "test:e2e:install": "playwright install --with-deps", "test:e2e:codegen": "playwright codegen", "test:e2e:show-report": "npx playwright show-report", - "test:e2e:merge-report": "playwright merge-reports --reporter html" + "test:e2e:merge-report": "playwright merge-reports --reporter html", + "typecheck": "NODE_OPTIONS='--max-old-space-size=6144' tsc -b" }, "dependencies": { "@ai-sdk/react": "^3.0.51", @@ -209,6 +210,20 @@ "options": { "command": "npx biome lint apps/dashboard" } + }, + "typecheck": { + "cache": true, + "dependsOn": [ + "^build" + ], + "inputs": [ + "default", + "^default" + ], + "outputs": [ + "{projectRoot}/tsconfig.app.tsbuildinfo", + "{projectRoot}/tsconfig.node.tsbuildinfo" + ] } } } diff --git a/apps/worker/project.json b/apps/worker/project.json index 76e307359c3..cef3a17e5a4 100644 --- a/apps/worker/project.json +++ b/apps/worker/project.json @@ -9,6 +9,7 @@ "cache": true, "inputs": [ "default", + "^default", "{projectRoot}/**/*", "!{projectRoot}/**/?(*.)+(spec|test).[jt]s?(x)?(.snap)", "!{projectRoot}/tsconfig.spec.json", diff --git a/nx.json b/nx.json index 20b857b2b0c..1c2d858d21a 100644 --- a/nx.json +++ b/nx.json @@ -4,6 +4,7 @@ "build": { "dependsOn": ["^build"], "cache": true, + "inputs": ["default", "^default"], "outputs": ["{projectRoot}/dist", "{projectRoot}/build"] }, "test": { @@ -29,6 +30,7 @@ "!{projectRoot}/biome.json" ], "sharedGlobals": [ + "{workspaceRoot}/tsconfig.json", { "runtime": "node --version" } From 6d913d43a3207298ce42aa6160806cec00e4d4d0 Mon Sep 17 00:00:00 2001 From: Himanshu Garg Date: Thu, 3 Sep 2026 22:14:13 +0530 Subject: [PATCH 3/5] feat(api-service): make Better Auth org limit configurable (#12545) --- .source | 2 +- apps/api/src/.example.env | 4 ++++ 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/.source b/.source index a2ae849a5fd..bca5ef271d1 160000 --- a/.source +++ b/.source @@ -1 +1 @@ -Subproject commit a2ae849a5fdf4114f4f6224d0fa3da2bf95cc6ad +Subproject commit bca5ef271d1551ec451c30d5b252192c1a905a1f diff --git a/apps/api/src/.example.env b/apps/api/src/.example.env index 25a198c1c03..a0ebfc4196e 100644 --- a/apps/api/src/.example.env +++ b/apps/api/src/.example.env @@ -120,6 +120,10 @@ CLICK_HOUSE_USER=default CLICK_HOUSE_PASSWORD= CLICK_HOUSE_DATABASE=novu-local +# Self-hosted EE (Better Auth): max organizations a user can create. Default 10, minimum 1. +# Invalid values log an error and fall back to 10. +# BETTER_AUTH_ORGANIZATION_LIMIT=10 + # When using `pnpm dev:portless`, scripts/portless-dev-env.mjs resolves these at # runtime via `portless get` (worktree-prefixed and proxy-port aware): # API_ROOT_URL, FRONT_BASE_URL, DASHBOARD_URL, BETTER_AUTH_BASE_URL From cce6347f0ce8f7e5ce3208f132df03747a1feb59 Mon Sep 17 00:00:00 2001 From: Himanshu Garg Date: Thu, 3 Sep 2026 22:54:30 +0530 Subject: [PATCH 4/5] fix(shared): make Azure Blob Storage work for outbound email attachments (#12557) Co-authored-by: Cursor Agent --- apps/api/src/.example.env | 11 +++ apps/api/src/config/env.validators.ts | 7 +- apps/worker/src/.example.env | 18 ++-- apps/worker/src/config/env.validators.ts | 6 +- docker/.env.example | 1 - docker/community/.env.example | 1 - .../src/services/storage/index.ts | 6 +- .../storage/storage-helper.service.spec.ts | 5 +- .../services/storage/storage.service.spec.ts | 96 +++++++++++++++++++ .../src/services/storage/storage.service.ts | 26 +++-- 10 files changed, 151 insertions(+), 26 deletions(-) create mode 100644 libs/application-generic/src/services/storage/storage.service.spec.ts diff --git a/apps/api/src/.example.env b/apps/api/src/.example.env index a0ebfc4196e..c9ba67285d1 100644 --- a/apps/api/src/.example.env +++ b/apps/api/src/.example.env @@ -39,11 +39,22 @@ REDIS_CLUSTER_KEY_PREFIX= JWT_SECRET=LOCAL_ONLY_CHANGE_ME +# File storage for email attachments (API uploads, worker downloads before send). +# Default is AWS S3 (Novu Cloud). Self-hosted Azure Blob Storage: set +# STORAGE_SERVICE=AZURE and the AZURE_* vars on BOTH the API and the worker. +# The Azure container must already exist. +# STORAGE_SERVICE=AZURE S3_LOCAL_STACK=http://127.0.0.1:4566 S3_BUCKET_NAME=novu-local S3_REGION=us-east-1 AWS_ACCESS_KEY_ID=test AWS_SECRET_ACCESS_KEY=test +# AZURE_ACCOUNT_NAME= +# AZURE_ACCOUNT_KEY= +# AZURE_HOST_NAME=https://.blob.core.windows.net +# AZURE_CONTAINER_NAME=novu +# GCS_BUCKET_NAME= +# GCS_DOMAIN= NEW_RELIC_ENABLED=false CDN_URL= diff --git a/apps/api/src/config/env.validators.ts b/apps/api/src/config/env.validators.ts index 15fe6bb1cee..3aa0df86365 100644 --- a/apps/api/src/config/env.validators.ts +++ b/apps/api/src/config/env.validators.ts @@ -70,6 +70,7 @@ export const envValidators = { REDIS_CLUSTER_SERVICE_HOST: str({ default: '' }), REDIS_CLUSTER_SERVICE_PORTS: str({ default: '' }), STORE_NOTIFICATION_CONTENT: bool({ default: false }), + STORAGE_SERVICE: str({ default: undefined }), WORKER_DEFAULT_CONCURRENCY: num({ default: undefined }), WORKER_DEFAULT_LOCK_DURATION: num({ default: undefined }), // SQS queue backend (optional - when unset, jobs are produced to BullMQ only) @@ -171,7 +172,7 @@ export const envValidators = { ) as Record>), // Azure validators - ...(processEnv.STORAGE_SERVICE === 'AZURE' && { + ...((processEnv.STORAGE_SERVICE || '').toUpperCase() === 'AZURE' && { AZURE_ACCOUNT_NAME: str(), AZURE_ACCOUNT_KEY: str(), AZURE_HOST_NAME: str({ default: `https://${processEnv.AZURE_ACCOUNT_NAME}.blob.core.windows.net` }), @@ -179,13 +180,13 @@ export const envValidators = { }), // GCS validators - ...(processEnv.STORAGE_SERVICE === 'GCS' && { + ...((processEnv.STORAGE_SERVICE || '').toUpperCase() === 'GCS' && { GCS_BUCKET_NAME: str(), GCS_DOMAIN: str(), }), // AWS validators - ...(processEnv.STORAGE_SERVICE === 'AWS' && { + ...((processEnv.STORAGE_SERVICE || '').toUpperCase() === 'AWS' && { S3_LOCAL_STACK: str({ default: '' }), S3_BUCKET_NAME: str(), S3_REGION: str(), diff --git a/apps/worker/src/.example.env b/apps/worker/src/.example.env index f77b51723aa..ffd8f50a526 100644 --- a/apps/worker/src/.example.env +++ b/apps/worker/src/.example.env @@ -12,8 +12,9 @@ MAX_NOVU_INTEGRATION_SMS_REQUESTS=20 NOVU_SLACK_INTEGRATION_CLIENT_ID= NOVU_SLACK_INTEGRATION_CLIENT_SECRET= -# Storage Service -# STORAGE_SERVICE= +# File storage for email attachments (must match the API). +# Default is AWS S3. Self-hosted Azure Blob Storage: STORAGE_SERVICE=AZURE +# STORAGE_SERVICE=AZURE # Redis REDIS_PORT=6379 @@ -55,13 +56,16 @@ MONGO_MAX_POOL_SIZE=500 S3_LOCAL_STACK=http://127.0.0.1:4566 S3_BUCKET_NAME=novu-local S3_REGION=us-east-1 -# GCS_BUCKET_NAME=novu-local -# AZURE_ACCOUNT_NAME=novu -# AZURE_ACCOUNT_KEY=123456 -# AZURE_HOST_NAME=https://novu.blob.core.windows.net -# AZURE_CONTAINER_NAME=novu-local AWS_ACCESS_KEY_ID=test AWS_SECRET_ACCESS_KEY=test +# Azure Blob Storage (self-hosted). Requires STORAGE_SERVICE=AZURE. +# The container must already exist. +# AZURE_ACCOUNT_NAME= +# AZURE_ACCOUNT_KEY= +# AZURE_HOST_NAME=https://.blob.core.windows.net +# AZURE_CONTAINER_NAME=novu +# GCS_BUCKET_NAME=novu-local +# GCS_DOMAIN= # New Relic NEW_RELIC_APP_NAME="[LOCAL] - worker" diff --git a/apps/worker/src/config/env.validators.ts b/apps/worker/src/config/env.validators.ts index 55a0765b533..f102eb90b7f 100644 --- a/apps/worker/src/config/env.validators.ts +++ b/apps/worker/src/config/env.validators.ts @@ -114,7 +114,7 @@ export const envValidators = { ) as Record>), // Azure validators - ...(processEnv.STORAGE_SERVICE === 'AZURE' && { + ...((processEnv.STORAGE_SERVICE || '').toUpperCase() === 'AZURE' && { AZURE_ACCOUNT_NAME: str(), AZURE_ACCOUNT_KEY: str(), AZURE_HOST_NAME: str({ default: `https://${processEnv.AZURE_ACCOUNT_NAME}.blob.core.windows.net` }), @@ -122,13 +122,13 @@ export const envValidators = { }), // GCS validators - ...(processEnv.STORAGE_SERVICE === 'GCS' && { + ...((processEnv.STORAGE_SERVICE || '').toUpperCase() === 'GCS' && { GCS_BUCKET_NAME: str(), GCS_DOMAIN: str(), }), // AWS validators - ...(processEnv.STORAGE_SERVICE === 'AWS' && { + ...((processEnv.STORAGE_SERVICE || '').toUpperCase() === 'AWS' && { S3_LOCAL_STACK: str({ default: '' }), S3_BUCKET_NAME: str(), S3_REGION: str(), diff --git a/docker/.env.example b/docker/.env.example index db874bf5bb5..94201e1f47e 100644 --- a/docker/.env.example +++ b/docker/.env.example @@ -32,7 +32,6 @@ REDIS_CACHE_SERVICE_HOST= # Set to true when connecting through a proxy that does not support the Redis INFO command REDIS_SKIP_VERSION_CHECK=false -# AWS S3_LOCAL_STACK=$HOST_NAME:4566 S3_BUCKET_NAME=novu-local S3_REGION=us-east-1 diff --git a/docker/community/.env.example b/docker/community/.env.example index 91209d68979..bb34c8f960e 100644 --- a/docker/community/.env.example +++ b/docker/community/.env.example @@ -38,7 +38,6 @@ REDIS_CACHE_SERVICE_HOST= # Set to true when connecting through a proxy that does not support the Redis INFO command REDIS_SKIP_VERSION_CHECK=false -# AWS S3_LOCAL_STACK=${HOST_NAME}:4566 S3_BUCKET_NAME=novu-local S3_REGION=us-east-1 diff --git a/libs/application-generic/src/services/storage/index.ts b/libs/application-generic/src/services/storage/index.ts index e5732f365d7..671ef239fe5 100644 --- a/libs/application-generic/src/services/storage/index.ts +++ b/libs/application-generic/src/services/storage/index.ts @@ -2,8 +2,8 @@ import { AzureBlobStorageService, GCSStorageService, S3StorageService, StorageSe export * from './storage-helper.service'; -function getStorageServiceClass(service: string) { - switch (service) { +export function resolveStorageServiceClass(service?: string) { + switch ((service || '').toUpperCase()) { case 'GCS': return GCSStorageService; case 'AZURE': @@ -15,7 +15,7 @@ function getStorageServiceClass(service: string) { export const storageService = { provide: StorageService, - useClass: getStorageServiceClass(String(process.env.STORAGE_SERVICE)), + useClass: resolveStorageServiceClass(process.env.STORAGE_SERVICE), }; export { StorageService }; diff --git a/libs/application-generic/src/services/storage/storage-helper.service.spec.ts b/libs/application-generic/src/services/storage/storage-helper.service.spec.ts index cf247b95a98..02dd2f8b959 100644 --- a/libs/application-generic/src/services/storage/storage-helper.service.spec.ts +++ b/libs/application-generic/src/services/storage/storage-helper.service.spec.ts @@ -24,6 +24,8 @@ jest.mock('@azure/storage-blob', () => ({ upload: azureUpload, downloadToBuffer: azureDownloadToBuffer, delete: azureDelete, + deleteIfExists: azureDelete, + exists: jest.fn(() => Promise.resolve(true)), })), })), })), @@ -211,8 +213,7 @@ describe('Storage-Helper service', () => { mime: 'image/png', }, ]; - azureDownloadToBuffer.mockImplementationOnce(() => Promise.reject({ statusCode: 404 })); - // sets the file to null if the get-file method throws error with status code 404 + azureDownloadToBuffer.mockImplementationOnce(() => Promise.reject({ code: 'BlobNotFound', statusCode: 404 })); await azureStorageHelperService.getAttachments(azureAttachments2); expect(azureDownloadToBuffer).toHaveBeenCalledTimes(1); diff --git a/libs/application-generic/src/services/storage/storage.service.spec.ts b/libs/application-generic/src/services/storage/storage.service.spec.ts new file mode 100644 index 00000000000..265667d486f --- /dev/null +++ b/libs/application-generic/src/services/storage/storage.service.spec.ts @@ -0,0 +1,96 @@ +import { resolveStorageServiceClass } from './index'; +import { NonExistingFileError } from './non-existing-file.error'; +import { AzureBlobStorageService, GCSStorageService, S3StorageService } from './storage.service'; + +const azureUpload = jest.fn(() => Promise.resolve({ _response: { status: 201 } })); +const azureDownloadToBuffer = jest.fn(); +const azureDeleteIfExists = jest.fn(() => Promise.resolve({ succeeded: true })); +const azureExists = jest.fn(() => Promise.resolve(true)); + +jest.mock('@azure/storage-blob', () => ({ + ...jest.requireActual('@azure/storage-blob'), + StorageSharedKeyCredential: jest.fn(() => ({})), + BlobServiceClient: jest.fn(() => ({ + getContainerClient: jest.fn(() => ({ + getBlockBlobClient: jest.fn(() => ({ + upload: azureUpload, + downloadToBuffer: azureDownloadToBuffer, + deleteIfExists: azureDeleteIfExists, + exists: azureExists, + })), + })), + })), +})); + +describe('resolveStorageServiceClass', () => { + it('selects Azure for STORAGE_SERVICE=AZURE (any case)', () => { + expect(resolveStorageServiceClass('AZURE')).toBe(AzureBlobStorageService); + expect(resolveStorageServiceClass('azure')).toBe(AzureBlobStorageService); + expect(resolveStorageServiceClass('Azure')).toBe(AzureBlobStorageService); + }); + + it('selects GCS for STORAGE_SERVICE=GCS', () => { + expect(resolveStorageServiceClass('GCS')).toBe(GCSStorageService); + }); + + it('defaults to S3 when unset or AWS', () => { + expect(resolveStorageServiceClass(undefined)).toBe(S3StorageService); + expect(resolveStorageServiceClass('')).toBe(S3StorageService); + expect(resolveStorageServiceClass('AWS')).toBe(S3StorageService); + }); +}); + +describe('AzureBlobStorageService email attachment handoff', () => { + const key = 'org-id/env-id/random/invoice.pdf'; + const file = Buffer.from('invoice-bytes'); + let service: AzureBlobStorageService; + + beforeAll(() => { + process.env.AZURE_ACCOUNT_NAME = 'novu'; + process.env.AZURE_ACCOUNT_KEY = '123456'; + process.env.AZURE_CONTAINER_NAME = 'novu-test'; + process.env.AZURE_HOST_NAME = 'https://novu.blob.core.windows.net'; + }); + + beforeEach(() => { + jest.clearAllMocks(); + azureDownloadToBuffer.mockResolvedValue(file); + service = new AzureBlobStorageService(); + }); + + it('uploads an attachment with the given content type', async () => { + await service.uploadFile(key, file, 'application/pdf'); + + expect(azureUpload).toHaveBeenCalledWith(file, file.length, { + blobHTTPHeaders: { + blobContentType: 'application/pdf', + }, + }); + }); + + it('downloads an attachment for the worker send path', async () => { + const downloaded = await service.getFile(key); + + expect(downloaded).toEqual(file); + expect(azureDownloadToBuffer).toHaveBeenCalledTimes(1); + }); + + it('maps BlobNotFound to NonExistingFileError', async () => { + azureDownloadToBuffer.mockRejectedValueOnce({ code: 'BlobNotFound', statusCode: 404 }); + + await expect(service.getFile(key)).rejects.toBeInstanceOf(NonExistingFileError); + }); + + it('does not treat ContainerNotFound as a missing attachment', async () => { + const containerMissing = { code: 'ContainerNotFound', statusCode: 404 }; + azureDownloadToBuffer.mockRejectedValueOnce(containerMissing); + + await expect(service.getFile(key)).rejects.toEqual(containerMissing); + }); + + it('awaits blob deletion after the email is sent', async () => { + await service.deleteFile(key); + + expect(azureDeleteIfExists).toHaveBeenCalledTimes(1); + }); +}); diff --git a/libs/application-generic/src/services/storage/storage.service.ts b/libs/application-generic/src/services/storage/storage.service.ts index a6aa3f70cfd..9aa59563a4f 100644 --- a/libs/application-generic/src/services/storage/storage.service.ts +++ b/libs/application-generic/src/services/storage/storage.service.ts @@ -41,6 +41,20 @@ export abstract class StorageService { abstract deleteFile(key: string): Promise; } +function isAzureBlobNotFound(error: unknown): boolean { + if (!error || typeof error !== 'object') { + return false; + } + + const azureError = error as { code?: string; details?: { errorCode?: string } }; + + return azureError.code === 'BlobNotFound' || azureError.details?.errorCode === 'BlobNotFound'; +} + +function azureSasStartsOn(): Date { + return new Date(Date.now() - 5 * 60 * 1000); +} + async function streamToBuffer(stream: Readable): Promise { return await new Promise((resolve, reject) => { const chunks: Uint8Array[] = []; @@ -258,8 +272,8 @@ export class AzureBlobStorageService implements StorageService { try { return await blockBlobClient.downloadToBuffer(); - } catch (error: any) { - if (error.statusCode === 404) { + } catch (error: unknown) { + if (isAzureBlobNotFound(error)) { throw new NonExistingFileError(); } throw error; @@ -271,7 +285,7 @@ export class AzureBlobStorageService implements StorageService { const containerClient = this.blobServiceClient.getContainerClient(process.env.AZURE_CONTAINER_NAME); const blockBlobClient = containerClient.getBlockBlobClient(key); - blockBlobClient.delete(); + await blockBlobClient.deleteIfExists(); } async getSignedUrl(key: string, contentType: string) { @@ -284,8 +298,8 @@ export class AzureBlobStorageService implements StorageService { containerName, blobName, permissions: BlobSASPermissions.parse('racwd'), - startsOn: new Date(), - expiresOn: new Date(new Date().valueOf() + 60 * 60 * 1000), // 60 minutes + startsOn: azureSasStartsOn(), + expiresOn: new Date(Date.now() + 60 * 60 * 1000), // 60 minutes protocol: SASProtocol.HttpsAndHttp, contentType, }, @@ -315,7 +329,7 @@ export class AzureBlobStorageService implements StorageService { containerName, blobName, permissions: BlobSASPermissions.parse('r'), - startsOn: new Date(), + startsOn: azureSasStartsOn(), expiresOn: new Date(Date.now() + ttlSeconds * 1000), protocol: SASProtocol.Https, }, From 2fa7128eff1f924cb6b7a6d53cf5dec566062d03 Mon Sep 17 00:00:00 2001 From: Himanshu Garg Date: Thu, 3 Sep 2026 23:29:45 +0530 Subject: [PATCH 5/5] feat(worker): support BullMQ on Redis Cluster for self-hosted enterprise (#12558) --- apps/api/src/.example.env | 5 +- apps/api/src/config/env.validators.ts | 5 + .../inbound-mail/src/config/env.validators.ts | 7 + apps/worker/src/.example.env | 10 +- apps/worker/src/config/env.validators.ts | 7 + apps/ws/src/config/env.validators.ts | 7 + .../services/bull-mq/bull-mq.service.spec.ts | 22 +++ .../providers/redis-cluster-provider.spec.ts | 143 ++++++++++++++++++ .../providers/redis-cluster-provider.ts | 109 +++++++++++-- .../providers/redis-master-slave-provider.ts | 4 +- .../providers/variable-mappers.ts | 17 +++ ...orkflow-in-memory-provider.service.spec.ts | 85 +++++++++++ .../workflow-in-memory-provider.service.ts | 67 ++++---- 13 files changed, 440 insertions(+), 48 deletions(-) create mode 100644 libs/application-generic/src/services/in-memory-provider/providers/redis-cluster-provider.spec.ts create mode 100644 libs/application-generic/src/services/in-memory-provider/workflow-in-memory-provider.service.spec.ts diff --git a/apps/api/src/.example.env b/apps/api/src/.example.env index c9ba67285d1..585817e16f4 100644 --- a/apps/api/src/.example.env +++ b/apps/api/src/.example.env @@ -28,9 +28,12 @@ REDIS_CACHE_ENABLE_AUTOPIPELINING= IS_IN_MEMORY_CLUSTER_MODE_ENABLED=false REDIS_CLUSTER_SERVICE_HOST= REDIS_CLUSTER_SERVICE_PORT= +REDIS_CLUSTER_SERVICE_PORTS= +REDIS_CLUSTER_USERNAME= +REDIS_CLUSTER_PASSWORD= +REDIS_CLUSTER_TLS= REDIS_CLUSTER_DB_INDEX= REDIS_CLUSTER_TTL= -REDIS_CLUSTER_PASSWORD= REDIS_CLUSTER_CONNECTION_TIMEOUT= REDIS_CLUSTER_KEEP_ALIVE= REDIS_CLUSTER_FAMILY= diff --git a/apps/api/src/config/env.validators.ts b/apps/api/src/config/env.validators.ts index 3aa0df86365..764b84c5023 100644 --- a/apps/api/src/config/env.validators.ts +++ b/apps/api/src/config/env.validators.ts @@ -68,7 +68,12 @@ export const envValidators = { REDIS_CACHE_SERVICE_PORT: str({ default: '' }), REDIS_CACHE_SERVICE_TLS: json({ default: undefined }), REDIS_CLUSTER_SERVICE_HOST: str({ default: '' }), + REDIS_CLUSTER_SERVICE_PORT: str({ default: '' }), REDIS_CLUSTER_SERVICE_PORTS: str({ default: '' }), + REDIS_CLUSTER_USERNAME: str({ default: undefined }), + REDIS_CLUSTER_PASSWORD: str({ default: undefined }), + REDIS_CLUSTER_TLS: str({ default: undefined }), + IS_IN_MEMORY_CLUSTER_MODE_ENABLED: bool({ default: false }), STORE_NOTIFICATION_CONTENT: bool({ default: false }), STORAGE_SERVICE: str({ default: undefined }), WORKER_DEFAULT_CONCURRENCY: num({ default: undefined }), diff --git a/apps/inbound-mail/src/config/env.validators.ts b/apps/inbound-mail/src/config/env.validators.ts index ac013ad882a..77c75873a36 100644 --- a/apps/inbound-mail/src/config/env.validators.ts +++ b/apps/inbound-mail/src/config/env.validators.ts @@ -14,6 +14,13 @@ export const envValidators = { REDIS_HOST: str(), REDIS_PORT: port(), REDIS_TLS: json({ default: undefined }), + IS_IN_MEMORY_CLUSTER_MODE_ENABLED: bool({ default: false }), + REDIS_CLUSTER_SERVICE_HOST: str({ default: undefined }), + REDIS_CLUSTER_SERVICE_PORT: str({ default: undefined }), + REDIS_CLUSTER_SERVICE_PORTS: str({ default: undefined }), + REDIS_CLUSTER_USERNAME: str({ default: undefined }), + REDIS_CLUSTER_PASSWORD: str({ default: undefined }), + REDIS_CLUSTER_TLS: str({ default: undefined }), WORKER_DEFAULT_CONCURRENCY: num({ default: undefined }), WORKER_DEFAULT_LOCK_DURATION: num({ default: undefined }), INBOUND_PARSE_MAIL_WORKER_CONCURRENCY: num({ default: undefined }), diff --git a/apps/worker/src/.example.env b/apps/worker/src/.example.env index ffd8f50a526..90f4c971f8e 100644 --- a/apps/worker/src/.example.env +++ b/apps/worker/src/.example.env @@ -34,15 +34,21 @@ REDIS_CACHE_FAMILY= REDIS_CACHE_KEY_PREFIX= REDIS_CACHE_SERVICE_TLS= -# Redis (for cluster) +# Redis Cluster (self-hosted enterprise BullMQ) +# Set IS_IN_MEMORY_CLUSTER_MODE_ENABLED=true with NOVU_ENTERPRISE=true and +# REDIS_CLUSTER_SERVICE_HOST plus REDIS_CLUSTER_SERVICE_PORTS (JSON array) or +# REDIS_CLUSTER_SERVICE_PORT. Comma-separated hosts are supported. IS_IN_MEMORY_CLUSTER_MODE_ENABLED=false # ELASTICACHE_CLUSTER_SERVICE_HOST= # ELASTICACHE_CLUSTER_SERVICE_PORT= # REDIS_CLUSTER_SERVICE_HOST=localhost +# REDIS_CLUSTER_SERVICE_PORT=6379 # REDIS_CLUSTER_SERVICE_PORTS=[7000,7001,7002,7003,7004,7005] +# REDIS_CLUSTER_USERNAME= +# REDIS_CLUSTER_PASSWORD= +# REDIS_CLUSTER_TLS= # REDIS_CLUSTER_DB_INDEX= # REDIS_CLUSTER_TTL= -# REDIS_CLUSTER_PASSWORD= # REDIS_CLUSTER_CONNECTION_TIMEOUT= # REDIS_CLUSTER_KEEP_ALIVE= # REDIS_CLUSTER_FAMILY= diff --git a/apps/worker/src/config/env.validators.ts b/apps/worker/src/config/env.validators.ts index f102eb90b7f..1a3abd8135b 100644 --- a/apps/worker/src/config/env.validators.ts +++ b/apps/worker/src/config/env.validators.ts @@ -69,6 +69,13 @@ export const envValidators = { REDIS_MASTER_PORT: str({ default: '' }), REDIS_SLAVE_HOST: str({ default: '' }), REDIS_SLAVE_PORT: str({ default: '' }), + IS_IN_MEMORY_CLUSTER_MODE_ENABLED: bool({ default: false }), + REDIS_CLUSTER_SERVICE_HOST: str({ default: undefined }), + REDIS_CLUSTER_SERVICE_PORT: str({ default: undefined }), + REDIS_CLUSTER_SERVICE_PORTS: str({ default: undefined }), + REDIS_CLUSTER_USERNAME: str({ default: undefined }), + REDIS_CLUSTER_PASSWORD: str({ default: undefined }), + REDIS_CLUSTER_TLS: str({ default: undefined }), MONGO_AUTO_CREATE_INDEXES: bool({ default: false }), MONGO_MAX_IDLE_TIME_IN_MS: num({ default: 1000 * 30 }), MONGO_MAX_POOL_SIZE: num({ default: 50 }), diff --git a/apps/ws/src/config/env.validators.ts b/apps/ws/src/config/env.validators.ts index 2cde5917a90..6919a9d888c 100644 --- a/apps/ws/src/config/env.validators.ts +++ b/apps/ws/src/config/env.validators.ts @@ -19,6 +19,13 @@ export const envValidators = { REDIS_HOST: str(), REDIS_PORT: port(), REDIS_TLS: json({ default: undefined }), + IS_IN_MEMORY_CLUSTER_MODE_ENABLED: bool({ default: false }), + REDIS_CLUSTER_SERVICE_HOST: str({ default: undefined }), + REDIS_CLUSTER_SERVICE_PORT: str({ default: undefined }), + REDIS_CLUSTER_SERVICE_PORTS: str({ default: undefined }), + REDIS_CLUSTER_USERNAME: str({ default: undefined }), + REDIS_CLUSTER_PASSWORD: str({ default: undefined }), + REDIS_CLUSTER_TLS: str({ default: undefined }), REDIS_MASTER_HOST: str({ default: '' }), REDIS_MASTER_PORT: str({ default: '' }), REDIS_SLAVE_HOST: str({ default: '' }), diff --git a/libs/application-generic/src/services/bull-mq/bull-mq.service.spec.ts b/libs/application-generic/src/services/bull-mq/bull-mq.service.spec.ts index 73d55d55efc..f406befd17c 100644 --- a/libs/application-generic/src/services/bull-mq/bull-mq.service.spec.ts +++ b/libs/application-generic/src/services/bull-mq/bull-mq.service.spec.ts @@ -83,6 +83,28 @@ describe('BullMQ Service', () => { const queue = bullMqService.createQueue(JobTopicNameEnum.ACTIVE_JOBS_METRIC, {}); expect(queue.opts.prefix).toEqual('{metric-active-jobs}'); }); + + it('should hash-tag the prefix when the workflow Redis provider is clustered', () => { + const mockInMemoryProvider = { + providerInUseIsInClusterMode: jest.fn(() => true), + }; + + bullMqService = new BullMqService(mockInMemoryProvider as unknown as WorkflowInMemoryProviderService); + + expect((bullMqService as any).generatePrefix(JobTopicNameEnum.ACTIVE_JOBS_METRIC)).toEqual( + '{metric-active-jobs}' + ); + }); + + it('should omit the prefix for a standalone Redis provider', () => { + const mockInMemoryProvider = { + providerInUseIsInClusterMode: jest.fn(() => false), + }; + + bullMqService = new BullMqService(mockInMemoryProvider as unknown as WorkflowInMemoryProviderService); + + expect((bullMqService as any).generatePrefix(JobTopicNameEnum.ACTIVE_JOBS_METRIC)).toBeUndefined(); + }); }); describe('Add job', () => { diff --git a/libs/application-generic/src/services/in-memory-provider/providers/redis-cluster-provider.spec.ts b/libs/application-generic/src/services/in-memory-provider/providers/redis-cluster-provider.spec.ts new file mode 100644 index 00000000000..da64e7faa9e --- /dev/null +++ b/libs/application-generic/src/services/in-memory-provider/providers/redis-cluster-provider.spec.ts @@ -0,0 +1,143 @@ +import Redis from 'ioredis'; +import { + buildRedisClusterInstances, + getRedisCluster, + getRedisClusterProviderConfig, + parseRedisClusterHosts, + parseRedisClusterPorts, + validateRedisClusterProviderConfig, +} from './redis-cluster-provider'; + +describe('Redis Cluster provider config', () => { + const originalEnv = { ...process.env }; + + afterEach(() => { + process.env = { ...originalEnv }; + }); + + describe('parseRedisClusterPorts', () => { + it('parses a JSON array of ports', () => { + expect(parseRedisClusterPorts('[7000,7001,7002]')).toEqual([7000, 7001, 7002]); + }); + + it('parses a JSON number', () => { + expect(parseRedisClusterPorts('6379')).toEqual([6379]); + }); + + it('parses a comma-separated list', () => { + expect(parseRedisClusterPorts('7000, 7001, 7002')).toEqual([7000, 7001, 7002]); + }); + + it('falls back to a singular port', () => { + expect(parseRedisClusterPorts(undefined, '6379')).toEqual([6379]); + }); + }); + + describe('parseRedisClusterHosts', () => { + it('splits comma-separated hosts', () => { + expect(parseRedisClusterHosts('redis-0, redis-1,redis-2')).toEqual(['redis-0', 'redis-1', 'redis-2']); + }); + }); + + describe('buildRedisClusterInstances', () => { + it('maps many ports onto a single host', () => { + expect(buildRedisClusterInstances(['localhost'], [7000, 7001])).toEqual([ + { host: 'localhost', port: 7000 }, + { host: 'localhost', port: 7001 }, + ]); + }); + + it('maps many hosts onto a single port', () => { + expect(buildRedisClusterInstances(['redis-0', 'redis-1'], [6379])).toEqual([ + { host: 'redis-0', port: 6379 }, + { host: 'redis-1', port: 6379 }, + ]); + }); + }); + + describe('getRedisClusterProviderConfig', () => { + it('accepts REDIS_CLUSTER_SERVICE_PORT when PORTS is unset', () => { + process.env.REDIS_CLUSTER_SERVICE_HOST = 'redis.internal'; + delete process.env.REDIS_CLUSTER_SERVICE_PORTS; + process.env.REDIS_CLUSTER_SERVICE_PORT = '6379'; + + const config = getRedisClusterProviderConfig(); + + expect(config.ports).toEqual([6379]); + expect(config.instances).toEqual([{ host: 'redis.internal', port: 6379 }]); + expect(validateRedisClusterProviderConfig()).toEqual(true); + }); + + it('builds one seed node per host when a single port is set', () => { + process.env.REDIS_CLUSTER_SERVICE_HOST = 'redis-0,redis-1,redis-2'; + process.env.REDIS_CLUSTER_SERVICE_PORT = '6379'; + delete process.env.REDIS_CLUSTER_SERVICE_PORTS; + + const config = getRedisClusterProviderConfig(); + + expect(config.instances).toEqual([ + { host: 'redis-0', port: 6379 }, + { host: 'redis-1', port: 6379 }, + { host: 'redis-2', port: 6379 }, + ]); + }); + + it('is invalid without a host', () => { + process.env.REDIS_CLUSTER_SERVICE_HOST = ''; + process.env.REDIS_CLUSTER_SERVICE_PORTS = '[6379]'; + + expect(validateRedisClusterProviderConfig()).toEqual(false); + }); + }); + + describe('TLS flag', () => { + beforeEach(() => { + process.env.REDIS_CLUSTER_SERVICE_HOST = 'redis-0, redis-1'; + process.env.REDIS_CLUSTER_SERVICE_PORTS = '[6379]'; + }); + + it.each(['false', 'FALSE', '0', 'no', 'off'])('leaves TLS off for %s', (value) => { + process.env.REDIS_CLUSTER_TLS = value; + + expect(getRedisClusterProviderConfig().tls).toBeUndefined(); + }); + + it('leaves TLS off when unset', () => { + delete process.env.REDIS_CLUSTER_TLS; + + expect(getRedisClusterProviderConfig().tls).toBeUndefined(); + }); + + it('enables TLS against the first seed host', () => { + process.env.REDIS_CLUSTER_TLS = 'true'; + + expect(getRedisClusterProviderConfig().tls).toEqual({ servername: 'redis-0' }); + }); + }); + + describe('getRedisCluster', () => { + it('uses master reads and disables request retries for BullMQ', () => { + process.env.REDIS_CLUSTER_SERVICE_HOST = 'redis.internal'; + process.env.REDIS_CLUSTER_SERVICE_PORT = '6379'; + delete process.env.REDIS_CLUSTER_SERVICE_PORTS; + + const clusterSpy = jest.spyOn(Redis, 'Cluster').mockReturnValue({} as any); + + try { + getRedisCluster(); + + expect(clusterSpy).toHaveBeenCalledWith( + [{ host: 'redis.internal', port: 6379 }], + expect.objectContaining({ + scaleReads: 'master', + redisOptions: expect.objectContaining({ + maxRetriesPerRequest: null, + }), + }) + ); + } finally { + clusterSpy.mockRestore(); + } + }); + }); +}); diff --git a/libs/application-generic/src/services/in-memory-provider/providers/redis-cluster-provider.ts b/libs/application-generic/src/services/in-memory-provider/providers/redis-cluster-provider.ts index 91b0c216893..64afaaa7eb4 100644 --- a/libs/application-generic/src/services/in-memory-provider/providers/redis-cluster-provider.ts +++ b/libs/application-generic/src/services/in-memory-provider/providers/redis-cluster-provider.ts @@ -2,7 +2,7 @@ import { Logger } from '@nestjs/common'; import Redis, { ChainableCommander, Cluster, ClusterNode, ClusterOptions } from 'ioredis'; import { ConnectionOptions } from 'tls'; -import { convertStringValues } from './variable-mappers'; +import { convertStringValues, isTlsFlagEnabled } from './variable-mappers'; export { ChainableCommander, Cluster, ClusterOptions }; @@ -12,7 +12,6 @@ const DEFAULT_CONNECT_TIMEOUT = 50000; const DEFAULT_KEEP_ALIVE = 30000; const DEFAULT_FAMILY = 4; const DEFAULT_KEY_PREFIX = ''; -const TTL_VARIANT_PERCENTAGE = 0.1; interface IRedisClusterConfig { connectTimeout?: string; @@ -20,7 +19,9 @@ interface IRedisClusterConfig { host?: string; keepAlive?: string; keyPrefix?: string; + username?: string; password?: string; + port?: string; ports?: string; tls?: ConnectionOptions; ttl?: string; @@ -33,31 +34,92 @@ export interface IRedisClusterProviderConfig { instances?: ClusterNode[]; keepAlive: number; keyPrefix: string; + username?: string; password?: string; ports?: number[]; tls?: ConnectionOptions; ttl: number; } +export const parseRedisClusterPorts = (ports?: string, port?: string): number[] => { + if (ports) { + try { + const parsed = JSON.parse(ports); + if (Array.isArray(parsed)) { + return parsed.map(Number); + } + if (typeof parsed === 'number') { + return [parsed]; + } + } catch { + return ports + .split(',') + .map((value) => Number(value.trim())) + .filter((value) => Number.isInteger(value)); + } + } + + if (port) { + const parsedPort = Number(port); + if (Number.isInteger(parsedPort)) { + return [parsedPort]; + } + } + + return []; +}; + +export const parseRedisClusterHosts = (host?: string): string[] => { + if (!host) { + return []; + } + + return host + .split(',') + .map((value) => value.trim()) + .filter(Boolean); +}; + +export const buildRedisClusterInstances = (hosts: string[], ports: number[]): ClusterNode[] => { + if (hosts.length === 0 || ports.length === 0) { + return []; + } + + if (hosts.length === 1) { + return ports.map((port) => ({ host: hosts[0], port })); + } + + if (ports.length === 1) { + return hosts.map((clusterHost) => ({ host: clusterHost, port: ports[0] })); + } + + const length = Math.min(hosts.length, ports.length); + + return hosts.slice(0, length).map((clusterHost, index) => ({ host: clusterHost, port: ports[index] })); +}; + export const getRedisClusterProviderConfig = (): IRedisClusterProviderConfig => { const redisClusterConfig: IRedisClusterConfig = { host: convertStringValues(process.env.REDIS_CLUSTER_SERVICE_HOST), + port: convertStringValues(process.env.REDIS_CLUSTER_SERVICE_PORT), ports: convertStringValues(process.env.REDIS_CLUSTER_SERVICE_PORTS), ttl: convertStringValues(process.env.REDIS_CLUSTER_TTL), + username: convertStringValues(process.env.REDIS_CLUSTER_USERNAME), password: convertStringValues(process.env.REDIS_CLUSTER_PASSWORD), connectTimeout: convertStringValues(process.env.REDIS_CLUSTER_CONNECTION_TIMEOUT), keepAlive: convertStringValues(process.env.REDIS_CLUSTER_KEEP_ALIVE), family: convertStringValues(process.env.REDIS_CLUSTER_FAMILY), keyPrefix: convertStringValues(process.env.REDIS_CLUSTER_KEY_PREFIX), - tls: process.env.REDIS_CLUSTER_TLS + tls: isTlsFlagEnabled(process.env.REDIS_CLUSTER_TLS) ? { - servername: convertStringValues(process.env.REDIS_CLUSTER_SERVICE_HOST), + servername: convertStringValues(process.env.REDIS_CLUSTER_SERVICE_HOST)?.split(',')[0]?.trim(), } : undefined, }; - const { host } = redisClusterConfig; - const ports = redisClusterConfig.ports ? JSON.parse(redisClusterConfig.ports) : []; + const hosts = parseRedisClusterHosts(redisClusterConfig.host); + const ports = parseRedisClusterPorts(redisClusterConfig.ports, redisClusterConfig.port); + const { username } = redisClusterConfig; const { password } = redisClusterConfig; const connectTimeout = redisClusterConfig.connectTimeout ? Number(redisClusterConfig.connectTimeout) @@ -66,13 +128,13 @@ export const getRedisClusterProviderConfig = (): IRedisClusterProviderConfig => const keepAlive = redisClusterConfig.keepAlive ? Number(redisClusterConfig.keepAlive) : DEFAULT_KEEP_ALIVE; const keyPrefix = redisClusterConfig.keyPrefix ?? DEFAULT_KEY_PREFIX; const ttl = redisClusterConfig.ttl ? Number(redisClusterConfig.ttl) : DEFAULT_TTL_SECONDS; - - const instances: ClusterNode[] = ports.map((port: number): ClusterNode => ({ host, port })); + const instances = buildRedisClusterInstances(hosts, ports); return { - host, + host: redisClusterConfig.host, ports, instances, + username, password, connectTimeout, family, @@ -84,23 +146,42 @@ export const getRedisClusterProviderConfig = (): IRedisClusterProviderConfig => }; export const getRedisCluster = (enableAutoPipelining?: boolean): Cluster | undefined => { - const { instances, password, tls } = getRedisClusterProviderConfig(); + const { instances, password, username, tls, connectTimeout } = getRedisClusterProviderConfig(); const skipVersionCheck = process.env.REDIS_SKIP_VERSION_CHECK === 'true'; - const redisOptions = { + const redisOptions: any = { + maxRetriesPerRequest: null, ...(tls && { tls }), - ...(password && { password }), + connectTimeout, skipVersionCheck, }; + if (username && password) { + redisOptions.username = username; + redisOptions.password = password; + Logger.log('Configuring Redis Cluster with ACL authentication'); + } else if (password) { + redisOptions.password = password; + Logger.log('Configuring Redis Cluster with password-only authentication'); + } else if (username) { + throw new Error('Redis Cluster misconfiguration: username provided without password'); + } + const options: ClusterOptions = { dnsLookup: (address, callback) => callback(null, address), enableAutoPipelining: enableAutoPipelining ?? false, enableOfflineQueue: false, enableReadyCheck: true, redisOptions, - scaleReads: 'slave', + clusterRetryStrategy: (times: number) => { + return Math.max(Math.min(Math.exp(times), 20000), 1000); + }, + /* + * Queue Lua scripts and writes must hit masters. Replica reads break BullMQ + * (CROSSSLOT / READONLY) even when the same cluster is used as a cache fallback. + */ + scaleReads: 'master', /* * Disabled in Prod as affects performance */ @@ -125,7 +206,7 @@ export const validateRedisClusterProviderConfig = (): boolean => { const validPorts = config.ports && config.ports.length > 0 && config.ports.every((port: number) => Number.isInteger(port)); - return !!config.host && !!validPorts; + return !!config.host && !!validPorts && (config.instances?.length ?? 0) > 0; }; export const isClientReady = (status: string): boolean => status === CLIENT_READY; diff --git a/libs/application-generic/src/services/in-memory-provider/providers/redis-master-slave-provider.ts b/libs/application-generic/src/services/in-memory-provider/providers/redis-master-slave-provider.ts index 7ad9ab90517..f5ef6d6ab4c 100644 --- a/libs/application-generic/src/services/in-memory-provider/providers/redis-master-slave-provider.ts +++ b/libs/application-generic/src/services/in-memory-provider/providers/redis-master-slave-provider.ts @@ -2,7 +2,7 @@ import { Logger } from '@nestjs/common'; import Redis, { Cluster, ClusterNode, ClusterOptions, NodeRole } from 'ioredis'; import { ConnectionOptions } from 'tls'; -import { convertStringValues } from './variable-mappers'; +import { convertStringValues, isTlsFlagEnabled } from './variable-mappers'; export { Cluster, ClusterOptions }; @@ -57,7 +57,7 @@ export const getRedisMasterSlaveProviderConfig = (): IRedisMasterSlaveProviderCo keepAlive: convertStringValues(process.env.REDIS_CLUSTER_KEEP_ALIVE), family: convertStringValues(process.env.REDIS_CLUSTER_FAMILY), keyPrefix: convertStringValues(process.env.REDIS_CLUSTER_KEY_PREFIX), - tls: process.env.REDIS_CLUSTER_TLS + tls: isTlsFlagEnabled(process.env.REDIS_CLUSTER_TLS) ? { servername: convertStringValues(process.env.REDIS_MASTER_HOST), } diff --git a/libs/application-generic/src/services/in-memory-provider/providers/variable-mappers.ts b/libs/application-generic/src/services/in-memory-provider/providers/variable-mappers.ts index 89fbe29743e..89cead512f9 100644 --- a/libs/application-generic/src/services/in-memory-provider/providers/variable-mappers.ts +++ b/libs/application-generic/src/services/in-memory-provider/providers/variable-mappers.ts @@ -12,3 +12,20 @@ export const convertStringValues = (value: string | undefined): string | undefin return value; }; + +const DISABLED_FLAG_VALUES = new Set(['false', '0', 'no', 'off']); + +/** + * TLS env vars are boolean-ish flags whose value is never read, so a truthiness + * check would turn `REDIS_CLUSTER_TLS=false` into a TLS handshake against a + * plaintext server. Anything but an explicit negative enables TLS. + */ +export const isTlsFlagEnabled = (value: string | undefined): boolean => { + const normalized = convertStringValues(value)?.trim().toLowerCase(); + + if (!normalized) { + return false; + } + + return !DISABLED_FLAG_VALUES.has(normalized); +}; diff --git a/libs/application-generic/src/services/in-memory-provider/workflow-in-memory-provider.service.spec.ts b/libs/application-generic/src/services/in-memory-provider/workflow-in-memory-provider.service.spec.ts new file mode 100644 index 00000000000..727366a8000 --- /dev/null +++ b/libs/application-generic/src/services/in-memory-provider/workflow-in-memory-provider.service.spec.ts @@ -0,0 +1,85 @@ +import { InMemoryProviderEnum } from './types'; +import { selectWorkflowInMemoryProvider, WorkflowInMemoryProviderService } from './workflow-in-memory-provider.service'; + +describe('selectWorkflowInMemoryProvider', () => { + const originalEnv = { ...process.env }; + + afterEach(() => { + process.env = { ...originalEnv }; + }); + + const setRedisClusterEnv = () => { + process.env.REDIS_CLUSTER_SERVICE_HOST = 'redis.internal'; + process.env.REDIS_CLUSTER_SERVICE_PORTS = '[6379]'; + }; + + it('uses MemoryDB for Novu Cloud', () => { + process.env.IS_SELF_HOSTED = 'false'; + process.env.NOVU_ENTERPRISE = 'true'; + + expect(selectWorkflowInMemoryProvider()).toEqual(InMemoryProviderEnum.MEMORY_DB); + }); + + it('uses single-node Redis for community self-hosted even when cluster env is set', () => { + process.env.IS_SELF_HOSTED = 'true'; + process.env.NOVU_ENTERPRISE = 'false'; + process.env.IS_IN_MEMORY_CLUSTER_MODE_ENABLED = 'true'; + setRedisClusterEnv(); + + expect(selectWorkflowInMemoryProvider()).toEqual(InMemoryProviderEnum.REDIS); + }); + + it('uses single-node Redis for enterprise self-hosted by default', () => { + process.env.IS_SELF_HOSTED = 'true'; + process.env.NOVU_ENTERPRISE = 'true'; + process.env.IS_IN_MEMORY_CLUSTER_MODE_ENABLED = 'false'; + setRedisClusterEnv(); + delete process.env.MEMORY_DB_CLUSTER_SERVICE_HOST; + delete process.env.MEMORY_DB_CLUSTER_SERVICE_PORT; + + expect(selectWorkflowInMemoryProvider()).toEqual(InMemoryProviderEnum.REDIS); + }); + + it('uses Redis Cluster for enterprise self-hosted when cluster mode is enabled', () => { + process.env.IS_SELF_HOSTED = 'true'; + process.env.NOVU_ENTERPRISE = 'true'; + process.env.IS_IN_MEMORY_CLUSTER_MODE_ENABLED = 'true'; + setRedisClusterEnv(); + delete process.env.MEMORY_DB_CLUSTER_SERVICE_HOST; + delete process.env.MEMORY_DB_CLUSTER_SERVICE_PORT; + + expect(selectWorkflowInMemoryProvider()).toEqual(InMemoryProviderEnum.REDIS_CLUSTER); + }); + + it('prefers MemoryDB over Redis Cluster for enterprise self-hosted', () => { + process.env.IS_SELF_HOSTED = 'true'; + process.env.NOVU_ENTERPRISE = 'true'; + process.env.IS_IN_MEMORY_CLUSTER_MODE_ENABLED = 'true'; + setRedisClusterEnv(); + process.env.MEMORY_DB_CLUSTER_SERVICE_HOST = 'memorydb.internal'; + process.env.MEMORY_DB_CLUSTER_SERVICE_PORT = '6379'; + + expect(selectWorkflowInMemoryProvider()).toEqual(InMemoryProviderEnum.MEMORY_DB); + }); + + /** + * Cluster mode routes construction through the cluster path regardless of the + * selected provider, so an incomplete cluster config must fail startup rather + * than quietly send queues to a single-node Redis the operator did not pick. + */ + it('fails startup instead of downgrading to standalone Redis when cluster endpoints are missing', () => { + process.env.IS_SELF_HOSTED = 'true'; + process.env.NOVU_ENTERPRISE = 'true'; + process.env.IS_IN_MEMORY_CLUSTER_MODE_ENABLED = 'true'; + delete process.env.MEMORY_DB_CLUSTER_SERVICE_HOST; + delete process.env.MEMORY_DB_CLUSTER_SERVICE_PORT; + delete process.env.REDIS_CLUSTER_SERVICE_HOST; + delete process.env.REDIS_CLUSTER_SERVICE_PORT; + delete process.env.REDIS_CLUSTER_SERVICE_PORTS; + + expect(selectWorkflowInMemoryProvider()).toEqual(InMemoryProviderEnum.REDIS_CLUSTER); + expect(() => new WorkflowInMemoryProviderService()).toThrow( + 'Provider RedisCluster is not properly configured in the environment variables' + ); + }); +}); diff --git a/libs/application-generic/src/services/in-memory-provider/workflow-in-memory-provider.service.ts b/libs/application-generic/src/services/in-memory-provider/workflow-in-memory-provider.service.ts index 869e1f4756b..30778160c8a 100644 --- a/libs/application-generic/src/services/in-memory-provider/workflow-in-memory-provider.service.ts +++ b/libs/application-generic/src/services/in-memory-provider/workflow-in-memory-provider.service.ts @@ -6,46 +6,55 @@ import { isClusterModeEnabled } from './utils'; const LOG_CONTEXT = 'WorkflowInMemoryProviderService'; +const isSelfHosted = (): boolean => process.env.IS_SELF_HOSTED === 'true'; +const isEnterprise = (): boolean => process.env.NOVU_ENTERPRISE === 'true'; +const isMemoryDbConfigured = (): boolean => + !!(process.env.MEMORY_DB_CLUSTER_SERVICE_HOST && process.env.MEMORY_DB_CLUSTER_SERVICE_PORT); + +/** + * Rules for the provider selection: + * - Community self-hosted always uses a single-node Redis instance for BullMQ. + * - Self-hosted enterprise defaults to single-node Redis. Opt into MemoryDB when + * MEMORY_DB_CLUSTER_SERVICE_HOST/PORT are set, or into OSS Redis Cluster by + * enabling cluster mode. MemoryDB wins if both are configured. + * - Novu Cloud uses MemoryDB, falling back to Redis Cluster when MemoryDB is + * not configured (see /in-memory-provider/providers/index.ts). + * + * Selection is intent-based, never validated here: cluster mode already routes + * construction through the cluster path, so silently returning REDIS on an + * incomplete cluster config would move queues to a different backend rather + * than fix anything. Endpoint validation belongs to the provider mapping, which + * fails startup with the offending provider named. + */ +export const selectWorkflowInMemoryProvider = (): InMemoryProviderEnum => { + if (isSelfHosted()) { + if (isEnterprise() && isMemoryDbConfigured()) { + return InMemoryProviderEnum.MEMORY_DB; + } + + if (isEnterprise() && isClusterModeEnabled()) { + return InMemoryProviderEnum.REDIS_CLUSTER; + } + + return InMemoryProviderEnum.REDIS; + } + + return InMemoryProviderEnum.MEMORY_DB; +}; + export class WorkflowInMemoryProviderService { public inMemoryProviderService: InMemoryProviderService; public isCluster: boolean; constructor() { - const provider = this.selectProvider(); + const provider = selectWorkflowInMemoryProvider(); this.isCluster = this.isClusterMode(); this.inMemoryProviderService = new InMemoryProviderService(provider, this.isCluster, false); } - /** - * Rules for the provider selection: - * - For ALL self hosted users (enterprise and non-enterprise) we use a single - * node Redis instance for BullMQ queues by default. This is simpler and more - * reliable for queue operations which are write-heavy and sequential. - * - For self hosted enterprise users we allow opting into MemoryDB when it is - * explicitly configured via environment variables. - * - For Novu cloud we use MemoryDB. We fallback to a Redis Cluster configuration - * if MemoryDB not configured properly. That's happening in the provider - * mapping in the /in-memory-provider/providers/index.ts - */ - private selectProvider(): InMemoryProviderEnum { - if (process.env.IS_SELF_HOSTED === 'true') { - if ( - process.env.NOVU_ENTERPRISE === 'true' && - process.env.MEMORY_DB_CLUSTER_SERVICE_HOST && - process.env.MEMORY_DB_CLUSTER_SERVICE_PORT - ) { - return InMemoryProviderEnum.MEMORY_DB; - } - - return InMemoryProviderEnum.REDIS; - } - - return InMemoryProviderEnum.MEMORY_DB; - } - private descriptiveLogMessage(message) { - return `[Provider: ${this.selectProvider()}] ${message}`; + return `[Provider: ${selectWorkflowInMemoryProvider()}] ${message}`; } private isClusterMode(): boolean {