@lenne.tech/nest-server 11.32.4 → 11.33.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (264) hide show
  1. package/.claude/rules/architecture.md +16 -0
  2. package/.claude/rules/configurable-features.md +11 -3
  3. package/.claude/rules/testing.md +101 -1
  4. package/CLAUDE.md +5 -0
  5. package/FRAMEWORK-API.md +58 -3
  6. package/dist/config.env.js +6 -0
  7. package/dist/config.env.js.map +1 -1
  8. package/dist/core/common/helpers/content-disposition.helper.d.ts +1 -0
  9. package/dist/core/common/helpers/content-disposition.helper.js +14 -0
  10. package/dist/core/common/helpers/content-disposition.helper.js.map +1 -0
  11. package/dist/core/common/helpers/file.helper.d.ts +7 -0
  12. package/dist/core/common/helpers/file.helper.js +21 -4
  13. package/dist/core/common/helpers/file.helper.js.map +1 -1
  14. package/dist/core/common/helpers/graceful-shutdown.helper.d.ts +2 -0
  15. package/dist/core/common/helpers/graceful-shutdown.helper.js +55 -0
  16. package/dist/core/common/helpers/graceful-shutdown.helper.js.map +1 -0
  17. package/dist/core/common/helpers/project-name.helper.d.ts +2 -0
  18. package/dist/core/common/helpers/project-name.helper.js +38 -0
  19. package/dist/core/common/helpers/project-name.helper.js.map +1 -0
  20. package/dist/core/common/interfaces/cron-job-config.interface.d.ts +1 -0
  21. package/dist/core/common/interfaces/server-options.interface.d.ts +39 -0
  22. package/dist/core/common/services/core-cron-jobs.initializer.d.ts +10 -0
  23. package/dist/core/common/services/core-cron-jobs.initializer.js +46 -0
  24. package/dist/core/common/services/core-cron-jobs.initializer.js.map +1 -0
  25. package/dist/core/common/services/core-cron-jobs.registry.d.ts +8 -0
  26. package/dist/core/common/services/core-cron-jobs.registry.js +12 -0
  27. package/dist/core/common/services/core-cron-jobs.registry.js.map +1 -0
  28. package/dist/core/common/services/core-cron-jobs.service.d.ts +37 -7
  29. package/dist/core/common/services/core-cron-jobs.service.js +278 -27
  30. package/dist/core/common/services/core-cron-jobs.service.js.map +1 -1
  31. package/dist/core/common/services/core-redis-pubsub.d.ts +20 -0
  32. package/dist/core/common/services/core-redis-pubsub.js +112 -0
  33. package/dist/core/common/services/core-redis-pubsub.js.map +1 -0
  34. package/dist/core/common/services/core-redis.service.d.ts +25 -0
  35. package/dist/core/common/services/core-redis.service.js +154 -0
  36. package/dist/core/common/services/core-redis.service.js.map +1 -0
  37. package/dist/core/common/services/core-s3.service.d.ts +51 -0
  38. package/dist/core/common/services/core-s3.service.js +261 -0
  39. package/dist/core/common/services/core-s3.service.js.map +1 -0
  40. package/dist/core/common/services/core-trust-proxy.initializer.d.ts +16 -0
  41. package/dist/core/common/services/core-trust-proxy.initializer.js +77 -0
  42. package/dist/core/common/services/core-trust-proxy.initializer.js.map +1 -0
  43. package/dist/core/common/services/rate-limit-store.d.ts +61 -0
  44. package/dist/core/common/services/rate-limit-store.js +235 -0
  45. package/dist/core/common/services/rate-limit-store.js.map +1 -0
  46. package/dist/core/modules/ai/core-ai-mcp.controller.d.ts +19 -3
  47. package/dist/core/modules/ai/core-ai-mcp.controller.js +148 -18
  48. package/dist/core/modules/ai/core-ai-mcp.controller.js.map +1 -1
  49. package/dist/core/modules/ai/services/core-ai.service.d.ts +7 -2
  50. package/dist/core/modules/ai/services/core-ai.service.js +24 -20
  51. package/dist/core/modules/ai/services/core-ai.service.js.map +1 -1
  52. package/dist/core/modules/auth/core-auth.module.js +4 -1
  53. package/dist/core/modules/auth/core-auth.module.js.map +1 -1
  54. package/dist/core/modules/auth/guards/legacy-auth-rate-limit.guard.d.ts +1 -1
  55. package/dist/core/modules/auth/guards/legacy-auth-rate-limit.guard.js +3 -14
  56. package/dist/core/modules/auth/guards/legacy-auth-rate-limit.guard.js.map +1 -1
  57. package/dist/core/modules/auth/services/legacy-auth-rate-limiter.service.d.ts +12 -10
  58. package/dist/core/modules/auth/services/legacy-auth-rate-limiter.service.js +30 -59
  59. package/dist/core/modules/auth/services/legacy-auth-rate-limiter.service.js.map +1 -1
  60. package/dist/core/modules/better-auth/core-better-auth-email-verification.service.d.ts +8 -2
  61. package/dist/core/modules/better-auth/core-better-auth-email-verification.service.js +118 -45
  62. package/dist/core/modules/better-auth/core-better-auth-email-verification.service.js.map +1 -1
  63. package/dist/core/modules/better-auth/core-better-auth-rate-limit.middleware.d.ts +1 -1
  64. package/dist/core/modules/better-auth/core-better-auth-rate-limit.middleware.js +2 -11
  65. package/dist/core/modules/better-auth/core-better-auth-rate-limit.middleware.js.map +1 -1
  66. package/dist/core/modules/better-auth/core-better-auth-rate-limiter.service.d.ts +12 -10
  67. package/dist/core/modules/better-auth/core-better-auth-rate-limiter.service.js +30 -85
  68. package/dist/core/modules/better-auth/core-better-auth-rate-limiter.service.js.map +1 -1
  69. package/dist/core/modules/better-auth/core-better-auth-user.mapper.js +0 -1
  70. package/dist/core/modules/better-auth/core-better-auth-user.mapper.js.map +1 -1
  71. package/dist/core/modules/file/core-file.controller.d.ts +11 -2
  72. package/dist/core/modules/file/core-file.controller.js +52 -15
  73. package/dist/core/modules/file/core-file.controller.js.map +1 -1
  74. package/dist/core/modules/file/core-file.resolver.d.ts +4 -4
  75. package/dist/core/modules/file/core-file.resolver.js +23 -16
  76. package/dist/core/modules/file/core-file.resolver.js.map +1 -1
  77. package/dist/core/modules/file/core-file.service.d.ts +41 -7
  78. package/dist/core/modules/file/core-file.service.js +228 -8
  79. package/dist/core/modules/file/core-file.service.js.map +1 -1
  80. package/dist/core/modules/file/file-metadata.helper.d.ts +16 -0
  81. package/dist/core/modules/file/file-metadata.helper.js +33 -0
  82. package/dist/core/modules/file/file-metadata.helper.js.map +1 -0
  83. package/dist/core/modules/file/file-roles.helper.d.ts +4 -0
  84. package/dist/core/modules/file/file-roles.helper.js +53 -0
  85. package/dist/core/modules/file/file-roles.helper.js.map +1 -0
  86. package/dist/core/modules/file/file-storage.helper.d.ts +13 -0
  87. package/dist/core/modules/file/file-storage.helper.js +65 -0
  88. package/dist/core/modules/file/file-storage.helper.js.map +1 -0
  89. package/dist/core/modules/file/filesystem-file.helper.d.ts +22 -0
  90. package/dist/core/modules/file/filesystem-file.helper.js +77 -0
  91. package/dist/core/modules/file/filesystem-file.helper.js.map +1 -0
  92. package/dist/core/modules/file/interfaces/file-service-options.interface.d.ts +1 -0
  93. package/dist/core/modules/file/interfaces/file-upload.interface.d.ts +7 -1
  94. package/dist/core/modules/file/s3-file.helper.d.ts +37 -0
  95. package/dist/core/modules/file/s3-file.helper.js +81 -0
  96. package/dist/core/modules/file/s3-file.helper.js.map +1 -0
  97. package/dist/core/modules/hub/core-hub.controller.d.ts +8 -8
  98. package/dist/core/modules/hub/core-hub.controller.js +24 -22
  99. package/dist/core/modules/hub/core-hub.controller.js.map +1 -1
  100. package/dist/core/modules/hub/helpers/hub-mask.helper.js +2 -2
  101. package/dist/core/modules/hub/helpers/hub-mask.helper.js.map +1 -1
  102. package/dist/core/modules/hub/hub-buffer.d.ts +23 -0
  103. package/dist/core/modules/hub/hub-buffer.js +101 -0
  104. package/dist/core/modules/hub/hub-buffer.js.map +1 -0
  105. package/dist/core/modules/hub/services/core-hub-mailbox.service.d.ts +7 -5
  106. package/dist/core/modules/hub/services/core-hub-mailbox.service.js +14 -10
  107. package/dist/core/modules/hub/services/core-hub-mailbox.service.js.map +1 -1
  108. package/dist/core/modules/hub/services/hub-log-buffer.service.d.ts +5 -2
  109. package/dist/core/modules/hub/services/hub-log-buffer.service.js +17 -8
  110. package/dist/core/modules/hub/services/hub-log-buffer.service.js.map +1 -1
  111. package/dist/core/modules/hub/services/hub-query-profiler.service.d.ts +4 -2
  112. package/dist/core/modules/hub/services/hub-query-profiler.service.js +13 -7
  113. package/dist/core/modules/hub/services/hub-query-profiler.service.js.map +1 -1
  114. package/dist/core/modules/hub/services/hub-trace-buffer.service.d.ts +4 -2
  115. package/dist/core/modules/hub/services/hub-trace-buffer.service.js +13 -8
  116. package/dist/core/modules/hub/services/hub-trace-buffer.service.js.map +1 -1
  117. package/dist/core/modules/migrate/helpers/migration.helper.d.ts +2 -6
  118. package/dist/core/modules/migrate/helpers/migration.helper.js +4 -4
  119. package/dist/core/modules/migrate/helpers/migration.helper.js.map +1 -1
  120. package/dist/core/modules/migrate/migration-runner.d.ts +1 -0
  121. package/dist/core/modules/migrate/migration-runner.js +4 -0
  122. package/dist/core/modules/migrate/migration-runner.js.map +1 -1
  123. package/dist/core/modules/migrate/mongo-state-store.d.ts +1 -0
  124. package/dist/core/modules/migrate/mongo-state-store.js +98 -9
  125. package/dist/core/modules/migrate/mongo-state-store.js.map +1 -1
  126. package/dist/core/modules/system-setup/core-system-setup.service.d.ts +2 -0
  127. package/dist/core/modules/system-setup/core-system-setup.service.js +31 -1
  128. package/dist/core/modules/system-setup/core-system-setup.service.js.map +1 -1
  129. package/dist/core/modules/tenant/core-tenant.guard.d.ts +17 -3
  130. package/dist/core/modules/tenant/core-tenant.guard.js +92 -15
  131. package/dist/core/modules/tenant/core-tenant.guard.js.map +1 -1
  132. package/dist/core/modules/tenant/core-tenant.module.js +3 -4
  133. package/dist/core/modules/tenant/core-tenant.module.js.map +1 -1
  134. package/dist/core/modules/tus/core-tus.controller.d.ts +2 -0
  135. package/dist/core/modules/tus/core-tus.controller.js +29 -3
  136. package/dist/core/modules/tus/core-tus.controller.js.map +1 -1
  137. package/dist/core/modules/tus/core-tus.service.d.ts +24 -1
  138. package/dist/core/modules/tus/core-tus.service.js +182 -28
  139. package/dist/core/modules/tus/core-tus.service.js.map +1 -1
  140. package/dist/core/modules/tus/interfaces/tus-config.interface.js +3 -0
  141. package/dist/core/modules/tus/interfaces/tus-config.interface.js.map +1 -1
  142. package/dist/core/modules/tus/tus-redis-locker.d.ts +25 -0
  143. package/dist/core/modules/tus/tus-redis-locker.js +87 -0
  144. package/dist/core/modules/tus/tus-redis-locker.js.map +1 -0
  145. package/dist/core/modules/tus/tus.module.d.ts +1 -0
  146. package/dist/core/modules/tus/tus.module.js +30 -4
  147. package/dist/core/modules/tus/tus.module.js.map +1 -1
  148. package/dist/core.module.js +18 -1
  149. package/dist/core.module.js.map +1 -1
  150. package/dist/index.d.ts +10 -0
  151. package/dist/index.js +10 -0
  152. package/dist/index.js.map +1 -1
  153. package/dist/main.js +2 -1
  154. package/dist/main.js.map +1 -1
  155. package/dist/server/modules/file/file.controller.js +2 -2
  156. package/dist/server/modules/file/file.controller.js.map +1 -1
  157. package/dist/server/modules/file/file.resolver.js +3 -14
  158. package/dist/server/modules/file/file.resolver.js.map +1 -1
  159. package/dist/server/modules/file/file.service.d.ts +10 -2
  160. package/dist/server/modules/file/file.service.js +23 -3
  161. package/dist/server/modules/file/file.service.js.map +1 -1
  162. package/dist/server/modules/user/avatar.controller.d.ts +5 -1
  163. package/dist/server/modules/user/avatar.controller.js +26 -10
  164. package/dist/server/modules/user/avatar.controller.js.map +1 -1
  165. package/dist/server/modules/user/user.model.js +3 -2
  166. package/dist/server/modules/user/user.model.js.map +1 -1
  167. package/dist/server/modules/user/user.module.js +6 -2
  168. package/dist/server/modules/user/user.module.js.map +1 -1
  169. package/dist/server/modules/user/user.service.d.ts +1 -1
  170. package/dist/server/modules/user/user.service.js +6 -13
  171. package/dist/server/modules/user/user.service.js.map +1 -1
  172. package/dist/tsconfig.build.tsbuildinfo +1 -1
  173. package/docs/REQUEST-LIFECYCLE.md +57 -8
  174. package/docs/security-overrides.md +21 -3
  175. package/migration-guides/11.32.3-to-11.32.4.md +15 -3
  176. package/migration-guides/11.32.x-to-11.33.x.md +1584 -0
  177. package/package.json +74 -42
  178. package/src/config.env.ts +45 -0
  179. package/src/core/common/helpers/content-disposition.helper.ts +90 -0
  180. package/src/core/common/helpers/file.helper.ts +53 -10
  181. package/src/core/common/helpers/graceful-shutdown.helper.ts +116 -0
  182. package/src/core/common/helpers/project-name.helper.ts +71 -0
  183. package/src/core/common/interfaces/cron-job-config.interface.ts +16 -0
  184. package/src/core/common/interfaces/server-options.interface.ts +439 -2
  185. package/src/core/common/services/core-cron-jobs.initializer.ts +39 -0
  186. package/src/core/common/services/core-cron-jobs.registry.ts +45 -0
  187. package/src/core/common/services/core-cron-jobs.service.ts +589 -47
  188. package/src/core/common/services/core-redis-pubsub.ts +183 -0
  189. package/src/core/common/services/core-redis.service.ts +249 -0
  190. package/src/core/common/services/core-s3.service.ts +465 -0
  191. package/src/core/common/services/core-trust-proxy.initializer.ts +112 -0
  192. package/src/core/common/services/rate-limit-store.ts +492 -0
  193. package/src/core/modules/ai/README.md +25 -0
  194. package/src/core/modules/ai/core-ai-mcp.controller.ts +253 -22
  195. package/src/core/modules/ai/services/core-ai.service.ts +33 -24
  196. package/src/core/modules/auth/core-auth.module.ts +7 -1
  197. package/src/core/modules/auth/guards/legacy-auth-rate-limit.guard.ts +12 -21
  198. package/src/core/modules/auth/services/legacy-auth-rate-limiter.service.ts +49 -93
  199. package/src/core/modules/better-auth/core-better-auth-email-verification.service.ts +202 -66
  200. package/src/core/modules/better-auth/core-better-auth-rate-limit.middleware.ts +11 -18
  201. package/src/core/modules/better-auth/core-better-auth-rate-limiter.service.ts +51 -132
  202. package/src/core/modules/better-auth/core-better-auth-user.mapper.ts +7 -1
  203. package/src/core/modules/file/INTEGRATION-CHECKLIST.md +120 -0
  204. package/src/core/modules/file/README.md +308 -53
  205. package/src/core/modules/file/core-file.controller.ts +168 -12
  206. package/src/core/modules/file/core-file.resolver.ts +46 -12
  207. package/src/core/modules/file/core-file.service.ts +517 -17
  208. package/src/core/modules/file/file-metadata.helper.ts +142 -0
  209. package/src/core/modules/file/file-roles.helper.ts +110 -0
  210. package/src/core/modules/file/file-storage.helper.ts +163 -0
  211. package/src/core/modules/file/filesystem-file.helper.ts +184 -0
  212. package/src/core/modules/file/interfaces/file-service-options.interface.ts +14 -0
  213. package/src/core/modules/file/interfaces/file-upload.interface.ts +17 -1
  214. package/src/core/modules/file/s3-file.helper.ts +209 -0
  215. package/src/core/modules/hub/README.md +48 -0
  216. package/src/core/modules/hub/core-hub.controller.ts +18 -16
  217. package/src/core/modules/hub/helpers/hub-mask.helper.ts +21 -4
  218. package/src/core/modules/hub/hub-buffer.ts +198 -0
  219. package/src/core/modules/hub/services/core-hub-mailbox.service.ts +22 -12
  220. package/src/core/modules/hub/services/hub-log-buffer.service.ts +24 -8
  221. package/src/core/modules/hub/services/hub-query-profiler.service.ts +9 -6
  222. package/src/core/modules/hub/services/hub-trace-buffer.service.ts +14 -9
  223. package/src/core/modules/migrate/README.md +33 -6
  224. package/src/core/modules/migrate/helpers/migration.helper.ts +13 -6
  225. package/src/core/modules/migrate/migration-runner.ts +10 -1
  226. package/src/core/modules/migrate/mongo-state-store.ts +179 -11
  227. package/src/core/modules/system-setup/README.md +7 -2
  228. package/src/core/modules/system-setup/core-system-setup.service.ts +95 -1
  229. package/src/core/modules/tenant/README.md +21 -1
  230. package/src/core/modules/tenant/core-tenant.guard.ts +135 -14
  231. package/src/core/modules/tenant/core-tenant.module.ts +8 -4
  232. package/src/core/modules/tus/INTEGRATION-CHECKLIST.md +93 -21
  233. package/src/core/modules/tus/README.md +35 -2
  234. package/src/core/modules/tus/core-tus.controller.ts +64 -12
  235. package/src/core/modules/tus/core-tus.service.ts +358 -34
  236. package/src/core/modules/tus/interfaces/tus-config.interface.ts +9 -0
  237. package/src/core/modules/tus/tus-redis-locker.ts +139 -0
  238. package/src/core/modules/tus/tus.module.ts +78 -6
  239. package/src/core.module.ts +26 -1
  240. package/src/index.ts +10 -0
  241. package/src/main.ts +7 -1
  242. package/src/server/modules/file/file.controller.ts +14 -5
  243. package/src/server/modules/file/file.resolver.ts +11 -19
  244. package/src/server/modules/file/file.service.ts +89 -4
  245. package/src/server/modules/user/avatar.controller.ts +46 -9
  246. package/src/server/modules/user/user.model.ts +9 -3
  247. package/src/server/modules/user/user.module.ts +12 -3
  248. package/src/server/modules/user/user.service.ts +20 -17
  249. package/src/test/README.md +7 -2
  250. package/src/core/common/helpers/file.helper.spec.ts +0 -145
  251. package/src/core/common/helpers/logging.helper.spec.ts +0 -61
  252. package/src/core/common/helpers/process-diagnostics.helper.spec.ts +0 -310
  253. package/src/core/common/services/brevo.service.spec.ts +0 -266
  254. package/src/core/modules/file/core-file.controller.spec.ts +0 -164
  255. package/src/core/modules/hub/core-hub.module.spec.ts +0 -108
  256. package/src/core/modules/hub/helpers/hub-command-shape.helper.spec.ts +0 -48
  257. package/src/core/modules/hub/helpers/hub-mask.helper.spec.ts +0 -67
  258. package/src/core/modules/hub/helpers/hub-mermaid.helper.spec.ts +0 -61
  259. package/src/core/modules/hub/helpers/hub-shell.helper.spec.ts +0 -106
  260. package/src/core/modules/hub/hub-config.helper.spec.ts +0 -108
  261. package/src/core/modules/hub/hub-ring-buffer.spec.ts +0 -95
  262. package/src/core/modules/hub/services/core-hub-mailbox.service.spec.ts +0 -116
  263. package/src/core/modules/hub/services/hub-trace-buffer.service.spec.ts +0 -112
  264. package/src/core/modules/migrate/helpers/migration.helper.spec.ts +0 -85
@@ -1,15 +1,15 @@
1
- import { Injectable, Logger, OnModuleInit } from '@nestjs/common';
1
+ import { Injectable, Logger, OnModuleInit, Optional } from '@nestjs/common';
2
2
 
3
3
  import { IAuthRateLimit } from '../../../common/interfaces/server-options.interface';
4
4
  import { ConfigService } from '../../../common/services/config.service';
5
-
6
- /**
7
- * Rate limit entry for tracking requests
8
- */
9
- interface RateLimitEntry {
10
- count: number;
11
- resetTime: number;
12
- }
5
+ import { CoreRedisService } from '../../../common/services/core-redis.service';
6
+ import {
7
+ InMemoryRateLimitStore,
8
+ rateLimitKey,
9
+ rateLimitKeyPrefix,
10
+ RateLimitStore,
11
+ RedisRateLimitStore,
12
+ } from '../../../common/services/rate-limit-store';
13
13
 
14
14
  /**
15
15
  * Result of a rate limit check
@@ -41,10 +41,11 @@ const DEFAULT_CONFIG: Required<IAuthRateLimit> = {
41
41
  };
42
42
 
43
43
  /**
44
- * In-memory rate limiter for Legacy Auth endpoints
44
+ * Rate limiter for Legacy Auth endpoints
45
45
  *
46
46
  * This service provides rate limiting to protect against brute-force attacks
47
- * on authentication endpoints. It uses an in-memory store with automatic cleanup.
47
+ * on authentication endpoints. Counters live in a {@link RateLimitStore}: shared
48
+ * via Redis when `ServerOptions.redis` is configured, process-local otherwise.
48
49
  *
49
50
  * Features:
50
51
  * - Configurable request limits and time windows
@@ -68,15 +69,11 @@ const DEFAULT_CONFIG: Required<IAuthRateLimit> = {
68
69
  */
69
70
  @Injectable()
70
71
  export class LegacyAuthRateLimiter implements OnModuleInit {
71
- private readonly logger = new Logger(LegacyAuthRateLimiter.name);
72
- private readonly store = new Map<string, RateLimitEntry>();
73
- private config: Required<IAuthRateLimit> = DEFAULT_CONFIG;
74
- private cleanupInterval: NodeJS.Timeout | null = null;
72
+ protected readonly logger = new Logger(LegacyAuthRateLimiter.name);
73
+ protected config: Required<IAuthRateLimit> = DEFAULT_CONFIG;
74
+ protected store?: RateLimitStore;
75
75
 
76
- constructor() {
77
- // Start cleanup interval (every 5 minutes)
78
- this.startCleanup();
79
- }
76
+ constructor(@Optional() protected readonly coreRedisService?: CoreRedisService) {}
80
77
 
81
78
  /**
82
79
  * Auto-configure from ConfigService on module initialization
@@ -127,7 +124,7 @@ export class LegacyAuthRateLimiter implements OnModuleInit {
127
124
  * @param endpoint - Endpoint name (e.g., 'signIn', 'signUp')
128
125
  * @returns Rate limit check result
129
126
  */
130
- check(ip: string, endpoint: string): RateLimitResult {
127
+ async check(ip: string, endpoint: string): Promise<RateLimitResult> {
131
128
  // If rate limiting is disabled, always allow
132
129
  if (!this.config.enabled) {
133
130
  return {
@@ -140,43 +137,21 @@ export class LegacyAuthRateLimiter implements OnModuleInit {
140
137
  }
141
138
 
142
139
  const limit = this.config.max;
143
- const key = `${ip}:${endpoint}`;
144
- const now = Date.now();
145
-
146
- // Get or create entry
147
- let entry = this.store.get(key);
148
-
149
- if (!entry || now >= entry.resetTime) {
150
- // Create new entry or reset expired one
151
- entry = {
152
- count: 1,
153
- resetTime: now + this.config.windowSeconds * 1000,
154
- };
155
- this.store.set(key, entry);
156
-
157
- return {
158
- allowed: true,
159
- current: 1,
160
- limit,
161
- remaining: limit - 1,
162
- resetIn: this.config.windowSeconds,
163
- };
164
- }
165
-
166
- // Increment count
167
- entry.count++;
140
+ // rateLimitKey(), not string concatenation: an IP that itself contains the separator (every
141
+ // IPv6 address does) would otherwise straddle the ip/endpoint boundary and share a counter
142
+ // with a different caller.
143
+ const { count, resetIn } = await this.getStore().hit(rateLimitKey(ip, endpoint), this.config.windowSeconds);
168
144
 
169
- const resetIn = Math.ceil((entry.resetTime - now) / 1000);
170
- const allowed = entry.count <= limit;
171
- const remaining = Math.max(0, limit - entry.count);
145
+ const allowed = count <= limit;
146
+ const remaining = Math.max(0, limit - count);
172
147
 
173
148
  if (!allowed) {
174
- this.logger.warn(`Rate limit exceeded for IP ${this.maskIp(ip)} on ${endpoint}: ${entry.count}/${limit}`);
149
+ this.logger.warn(`Rate limit exceeded for IP ${this.maskIp(ip)} on ${endpoint}: ${count}/${limit}`);
175
150
  }
176
151
 
177
152
  return {
178
153
  allowed,
179
- current: entry.count,
154
+ current: count,
180
155
  limit,
181
156
  remaining,
182
157
  resetIn,
@@ -202,39 +177,50 @@ export class LegacyAuthRateLimiter implements OnModuleInit {
202
177
  *
203
178
  * @param ip - Client IP address
204
179
  */
205
- reset(ip: string): void {
206
- for (const key of this.store.keys()) {
207
- if (key.startsWith(`${ip}:`)) {
208
- this.store.delete(key);
209
- }
210
- }
180
+ async reset(ip: string): Promise<void> {
181
+ await this.getStore().resetByPrefix(rateLimitKeyPrefix(ip));
211
182
  }
212
183
 
213
184
  /**
214
185
  * Clear all rate limit entries (useful for testing)
215
186
  */
216
- clear(): void {
217
- this.store.clear();
187
+ async clear(): Promise<void> {
188
+ await this.getStore().clear();
218
189
  }
219
190
 
220
191
  /**
221
192
  * Get statistics about the rate limiter
193
+ *
194
+ * `activeEntries` is `-1` when a Redis store is in use — the entry count is
195
+ * not cheaply known there.
222
196
  */
223
197
  getStats(): { activeEntries: number; enabled: boolean } {
224
198
  return {
225
- activeEntries: this.store.size,
199
+ activeEntries: this.store?.size() ?? 0,
226
200
  enabled: this.config.enabled,
227
201
  };
228
202
  }
229
203
 
230
204
  /**
231
- * Stop the cleanup interval (for graceful shutdown)
205
+ * Stop the in-memory cleanup interval (for graceful shutdown)
232
206
  */
233
207
  onModuleDestroy(): void {
234
- if (this.cleanupInterval) {
235
- clearInterval(this.cleanupInterval);
236
- this.cleanupInterval = null;
208
+ // Not `instanceof InMemoryRateLimitStore`: a RedisRateLimitStore owns an in-memory fallback
209
+ // whose cleanup interval would otherwise outlive the app.
210
+ this.store?.destroy?.();
211
+ }
212
+
213
+ /**
214
+ * Lazily select the counter store: Redis when configured and enabled,
215
+ * process-local otherwise.
216
+ */
217
+ protected getStore(): RateLimitStore {
218
+ if (!this.store) {
219
+ this.store = this.coreRedisService?.enabled
220
+ ? new RedisRateLimitStore(this.coreRedisService, 'legacy-auth')
221
+ : new InMemoryRateLimitStore();
237
222
  }
223
+ return this.store;
238
224
  }
239
225
 
240
226
  /**
@@ -250,34 +236,4 @@ export class LegacyAuthRateLimiter implements OnModuleInit {
250
236
  const parts = ip.split(':');
251
237
  return `${parts[0]}:****`;
252
238
  }
253
-
254
- /**
255
- * Start periodic cleanup of expired entries
256
- */
257
- private startCleanup(): void {
258
- // Clean up every 5 minutes
259
- this.cleanupInterval = setInterval(
260
- () => {
261
- const now = Date.now();
262
- let cleaned = 0;
263
-
264
- for (const [key, entry] of this.store.entries()) {
265
- if (now >= entry.resetTime) {
266
- this.store.delete(key);
267
- cleaned++;
268
- }
269
- }
270
-
271
- if (cleaned > 0) {
272
- this.logger.debug(`Cleaned up ${cleaned} expired rate limit entries`);
273
- }
274
- },
275
- 5 * 60 * 1000,
276
- );
277
-
278
- // Prevent the interval from keeping the process alive
279
- if (this.cleanupInterval.unref) {
280
- this.cleanupInterval.unref();
281
- }
282
- }
283
239
  }
@@ -1,4 +1,5 @@
1
1
  import { Inject, Injectable, Logger, Optional } from '@nestjs/common';
2
+ import { randomUUID } from 'node:crypto';
2
3
  import ejs = require('ejs');
3
4
  import * as fs from 'fs';
4
5
  import * as path from 'path';
@@ -7,6 +8,7 @@ import { maskEmail } from '../../common/helpers/logging.helper';
7
8
  import { IBetterAuthEmailVerificationConfig } from '../../common/interfaces/server-options.interface';
8
9
  import { BrevoService } from '../../common/services/brevo.service';
9
10
  import { ConfigService } from '../../common/services/config.service';
11
+ import { CoreRedisService } from '../../common/services/core-redis.service';
10
12
  import { EmailService } from '../../common/services/email.service';
11
13
  import { TemplateService } from '../../common/services/template.service';
12
14
  import { formatProjectName } from './better-auth.config';
@@ -75,7 +77,6 @@ export interface SendVerificationEmailOptions {
75
77
  * @example
76
78
  * ```typescript
77
79
  * // Override to customize email sending
78
- * @Injectable()
79
80
  * export class MyEmailVerificationService extends CoreBetterAuthEmailVerificationService {
80
81
  * override async sendVerificationEmail(options: SendVerificationEmailOptions): Promise<void> {
81
82
  * // Custom logic before
@@ -87,6 +88,12 @@ export interface SendVerificationEmailOptions {
87
88
  *
88
89
  * @since 11.13.0
89
90
  */
91
+ /**
92
+ * Sentinel returned when the slot was taken on the process-local path, where there is no Redis key
93
+ * to compare against. Distinguishes "acquired locally" from "acquired in Redis with this token".
94
+ */
95
+ const LOCAL_SLOT_TOKEN = 'local';
96
+
90
97
  @Injectable()
91
98
  export class CoreBetterAuthEmailVerificationService {
92
99
  protected readonly logger = new Logger(CoreBetterAuthEmailVerificationService.name);
@@ -96,7 +103,12 @@ export class CoreBetterAuthEmailVerificationService {
96
103
  * In-memory tracking of last send time per email address for cooldown enforcement.
97
104
  * Key: email address (lowercase), Value: timestamp (ms) of last send
98
105
  */
99
- private readonly lastSendTimes = new Map<string, number>();
106
+ protected readonly lastSendTimes = new Map<string, number>();
107
+
108
+ /**
109
+ * Pending cleanup timer per email, so a released slot can never expire a LATER cooldown
110
+ */
111
+ protected readonly sendTimers = new Map<string, NodeJS.Timeout>();
100
112
 
101
113
  /**
102
114
  * Token for optional BrevoService injection.
@@ -113,6 +125,7 @@ export class CoreBetterAuthEmailVerificationService {
113
125
  @Optional()
114
126
  @Inject(CoreBetterAuthEmailVerificationService.BREVO_SERVICE_TOKEN)
115
127
  protected readonly brevoService?: BrevoService | null,
128
+ @Optional() protected readonly coreRedisService?: CoreRedisService,
116
129
  ) {
117
130
  this.configure();
118
131
  }
@@ -157,94 +170,209 @@ export class CoreBetterAuthEmailVerificationService {
157
170
  const { token, user } = options;
158
171
  let { url } = options;
159
172
 
160
- // Check resend cooldown per email address
161
- if (this.isInCooldown(user.email)) {
173
+ // Reserve the cooldown slot per email address (atomic across replicas with Redis).
174
+ // `slotToken`, not `token` — `token` is already the verification token from the options.
175
+ const slotToken = await this.acquireSendSlot(user.email);
176
+ if (!slotToken) {
162
177
  this.logger.debug(`Resend cooldown active for ${this.maskEmail(user.email)}, skipping email send`);
163
178
  return;
164
179
  }
165
180
 
166
- // Override URL if callbackURL is configured (frontend-based verification)
167
- if (this.config.callbackURL) {
168
- url = this.buildFrontendVerificationUrl(token);
169
- }
181
+ // Only a SUCCESSFUL send may burn the cooldown — the slot is released again below otherwise
182
+ let sent = false;
170
183
 
171
- // Log verification URL in non-production environments for debugging and test capture
172
- // Uses console.log directly to ensure reliable capture in test environments (Vitest)
173
- // NestJS Logger may buffer output which makes interception unreliable in tests
174
- if (process.env.NODE_ENV !== 'production') {
175
- // oxlint-disable-next-line no-console
176
- console.log(`[EMAIL VERIFICATION] User: ${user.email}, URL: ${url}`);
177
- }
184
+ try {
185
+ // Override URL if callbackURL is configured (frontend-based verification)
186
+ if (this.config.callbackURL) {
187
+ url = this.buildFrontendVerificationUrl(token);
188
+ }
189
+
190
+ // Log verification URL in non-production environments for debugging and test capture
191
+ // Uses console.log directly to ensure reliable capture in test environments (Vitest)
192
+ // NestJS Logger may buffer output which makes interception unreliable in tests
193
+ if (process.env.NODE_ENV !== 'production') {
194
+ // oxlint-disable-next-line no-console
195
+ console.log(`[EMAIL VERIFICATION] User: ${user.email}, URL: ${url}`);
196
+ }
197
+
198
+ // Brevo template path: send via Brevo transactional API if configured
199
+ if (this.config.brevoTemplateId && this.brevoService) {
200
+ try {
201
+ const appName = this.getAppName();
202
+ const result = await this.brevoService.sendMail(user.email, this.config.brevoTemplateId, {
203
+ appName,
204
+ expiresIn: this.formatExpiresIn(this.config.expiresIn),
205
+ link: url,
206
+ name: user.name || user.email.split('@')[0],
207
+ });
208
+
209
+ // `sendMail()` swallows SDK errors and resolves to `null` (unless `brevo.throwOnError` is
210
+ // set), so "did not throw" is NOT "was delivered". Recording a send here on a null would
211
+ // mark the address as mailed, log success, and skip the SMTP fallback below — leaving the
212
+ // user with no verification email at all on a Brevo outage or a revoked key.
213
+ if (result === null) {
214
+ this.logger.error(
215
+ `Brevo verification send failed for ${this.maskEmail(user.email)} — falling back to SMTP`,
216
+ );
217
+ // Deliberately no `return`: fall through to the EmailService path.
218
+ } else {
219
+ sent = true;
220
+ this.logger.debug(`Verification email sent via Brevo to ${this.maskEmail(user.email)}`);
221
+ return;
222
+ }
223
+ } catch (error) {
224
+ this.logger.error(
225
+ `Failed to send verification email via Brevo to ${this.maskEmail(user.email)}: ${error instanceof Error ? error.message : 'Unknown error'}`,
226
+ );
227
+ throw error;
228
+ }
229
+ }
230
+
231
+ if (!this.emailService) {
232
+ this.logger.warn('EmailService not available, cannot send verification email');
233
+ return;
234
+ }
178
235
 
179
- // Brevo template path: send via Brevo transactional API if configured
180
- if (this.config.brevoTemplateId && this.brevoService) {
181
236
  try {
237
+ const resolved = await this.resolveTemplatePath(this.config.template, this.config.locale);
182
238
  const appName = this.getAppName();
183
- const result = await this.brevoService.sendMail(user.email, this.config.brevoTemplateId, {
239
+
240
+ const templateData = {
184
241
  appName,
185
242
  expiresIn: this.formatExpiresIn(this.config.expiresIn),
186
243
  link: url,
187
244
  name: user.name || user.email.split('@')[0],
188
- });
189
-
190
- // `sendMail()` swallows SDK errors and resolves to `null` (unless `brevo.throwOnError` is
191
- // set), so "did not throw" is NOT "was delivered". Recording a send here on a null would
192
- // mark the address as mailed, log success, and skip the SMTP fallback below — leaving the
193
- // user with no verification email at all on a Brevo outage or a revoked key.
194
- if (result === null) {
195
- this.logger.error(`Brevo verification send failed for ${this.maskEmail(user.email)} falling back to SMTP`);
196
- // Deliberately no `return`: fall through to the EmailService path.
245
+ };
246
+
247
+ if (resolved.isAbsolute) {
248
+ // Fallback template from nest-server: render directly via EJS
249
+ const templateContent = fs.readFileSync(`${resolved.path}.ejs`, 'utf-8');
250
+ const html = ejs.render(templateContent, templateData);
251
+
252
+ await this.emailService.sendMail(user.email, this.getEmailSubject(appName), { html });
197
253
  } else {
198
- this.trackSend(user.email);
199
- this.logger.debug(`Verification email sent via Brevo to ${this.maskEmail(user.email)}`);
200
- return;
254
+ // Project template: use TemplateService (relative path)
255
+ await this.emailService.sendMail(user.email, this.getEmailSubject(appName), {
256
+ htmlTemplate: resolved.path,
257
+ templateData,
258
+ });
201
259
  }
260
+
261
+ sent = true;
262
+ this.logger.debug(`Verification email sent to ${this.maskEmail(user.email)}`);
202
263
  } catch (error) {
203
264
  this.logger.error(
204
- `Failed to send verification email via Brevo to ${this.maskEmail(user.email)}: ${error instanceof Error ? error.message : 'Unknown error'}`,
265
+ `Failed to send verification email to ${this.maskEmail(user.email)}: ${error instanceof Error ? error.message : 'Unknown error'}`,
205
266
  );
206
267
  throw error;
207
268
  }
269
+ } finally {
270
+ if (!sent) {
271
+ await this.releaseSendSlot(user.email, slotToken);
272
+ }
208
273
  }
274
+ }
209
275
 
210
- if (!this.emailService) {
211
- this.logger.warn('EmailService not available, cannot send verification email');
212
- return;
276
+ /**
277
+ * Reserve the resend-cooldown slot for an email address.
278
+ *
279
+ * With Redis configured this is a single atomic `SET NX PX`, so two replicas
280
+ * handling concurrent resend requests cannot both pass the cooldown. Without
281
+ * Redis it falls back to the process-local {@link isInCooldown}/{@link trackSend}.
282
+ *
283
+ * @returns a truthy slot token when the caller may send, `null` while the cooldown is active.
284
+ * The token is what {@link releaseSendSlot} compares against before deleting the key, so a
285
+ * release can only ever clear the slot IT acquired. A subclass overriding this and returning a
286
+ * plain `true` still works — release then falls back to the previous unconditional delete.
287
+ */
288
+ protected async acquireSendSlot(email: string): Promise<null | string> {
289
+ const cooldown = this.config.resendCooldownSeconds;
290
+ if (cooldown <= 0) {
291
+ return LOCAL_SLOT_TOKEN;
213
292
  }
214
293
 
215
- try {
216
- const resolved = await this.resolveTemplatePath(this.config.template, this.config.locale);
217
- const appName = this.getAppName();
218
-
219
- const templateData = {
220
- appName,
221
- expiresIn: this.formatExpiresIn(this.config.expiresIn),
222
- link: url,
223
- name: user.name || user.email.split('@')[0],
224
- };
225
-
226
- if (resolved.isAbsolute) {
227
- // Fallback template from nest-server: render directly via EJS
228
- const templateContent = fs.readFileSync(`${resolved.path}.ejs`, 'utf-8');
229
- const html = ejs.render(templateContent, templateData);
230
-
231
- await this.emailService.sendMail(user.email, this.getEmailSubject(appName), { html });
232
- } else {
233
- // Project template: use TemplateService (relative path)
234
- await this.emailService.sendMail(user.email, this.getEmailSubject(appName), {
235
- htmlTemplate: resolved.path,
236
- templateData,
237
- });
294
+ if (this.coreRedisService?.enabled) {
295
+ try {
296
+ const token = randomUUID();
297
+ const result = await this.coreRedisService
298
+ .getClient()
299
+ .set(this.cooldownKey(email), token, 'PX', cooldown * 1000, 'NX');
300
+ return result === 'OK' ? token : null;
301
+ } catch (error) {
302
+ // Degrade to the process-local cooldown rather than failing closed. An unhandled error
303
+ // here reads as "cooldown active" and would stop verification mail entirely for the
304
+ // duration of a Redis blip — locking new users out of their accounts. The local counter
305
+ // is the pre-Redis behavior: still a real anti-flood bound, just per replica.
306
+ this.logger.warn(
307
+ `Redis cooldown unavailable, falling back to the per-replica counter: ${
308
+ error instanceof Error ? error.message : 'Unknown error'
309
+ }`,
310
+ );
238
311
  }
312
+ }
313
+
314
+ if (this.isInCooldown(email)) {
315
+ return null;
316
+ }
317
+ this.trackSend(email);
318
+ return LOCAL_SLOT_TOKEN;
319
+ }
239
320
 
240
- this.trackSend(user.email);
241
- this.logger.debug(`Verification email sent to ${this.maskEmail(user.email)}`);
242
- } catch (error) {
243
- this.logger.error(
244
- `Failed to send verification email to ${this.maskEmail(user.email)}: ${error instanceof Error ? error.message : 'Unknown error'}`,
245
- );
246
- throw error;
321
+ /**
322
+ * Release a slot reserved by {@link acquireSendSlot} — used when no email was
323
+ * actually sent, so a failed send does not burn the cooldown.
324
+ */
325
+ protected async releaseSendSlot(email: string, token?: null | string): Promise<void> {
326
+ if (this.config.resendCooldownSeconds <= 0) {
327
+ return;
328
+ }
329
+
330
+ if (this.coreRedisService?.enabled) {
331
+ // Releasing runs in a `finally` after a FAILED send. Letting a Redis error escape here
332
+ // would replace the real send failure with a connection error in the caller's log — and
333
+ // the key expires on its own TTL anyway, so the worst case is one cooldown served out.
334
+ // Compare-and-delete, never a bare DEL. Releasing runs in a `finally` after a FAILED send,
335
+ // and a send can fail SLOWER than the cooldown (an SMTP socket timeout outlives a 60s
336
+ // window comfortably). By then our key has expired and a later request holds a new one — an
337
+ // unconditional delete would clear THAT request's cooldown, and repeating the trick floods
338
+ // a chosen address with valid verification links. Same pattern as the migration lock and
339
+ // the TUS locker.
340
+ if (typeof token === 'string' && token !== LOCAL_SLOT_TOKEN) {
341
+ const script = `if redis.call('GET', KEYS[1]) == ARGV[1] then return redis.call('DEL', KEYS[1]) end return 0`;
342
+ await this.coreRedisService
343
+ .getClient()
344
+ .eval(script, 1, this.cooldownKey(email), token)
345
+ .catch(() => undefined);
346
+ } else if (token === undefined) {
347
+ // No token supplied (a subclass acquired the slot the old way) — preserve the previous
348
+ // behavior rather than silently skipping the release.
349
+ await this.coreRedisService
350
+ .getClient()
351
+ .del(this.cooldownKey(email))
352
+ .catch(() => undefined);
353
+ }
354
+ // Deliberately NOT `return`: when acquire fell back to the local counter because Redis
355
+ // errored, the Redis key was never written and only the local entry exists. Branching on
356
+ // `enabled` alone would leave it in place, so the failed send would burn the cooldown —
357
+ // the exact semantics the acquire/release split exists to preserve. Clearing both is
358
+ // harmless: the local map is empty whenever Redis actually served the acquire.
247
359
  }
360
+
361
+ // Clear the pending cleanup timer too. Left running, it would fire `cooldown` seconds
362
+ // after the FAILED send and delete whatever entry exists then — which is the entry of a
363
+ // later, successful send. The cooldown would end early and the anti-flood control it
364
+ // exists to be would quietly weaken.
365
+ const key = email.toLowerCase();
366
+ clearTimeout(this.sendTimers.get(key));
367
+ this.sendTimers.delete(key);
368
+ this.lastSendTimes.delete(key);
369
+ }
370
+
371
+ /**
372
+ * Redis key for the resend cooldown of an email address
373
+ */
374
+ protected cooldownKey(email: string): string {
375
+ return this.coreRedisService!.key('email-verification-cooldown', email.toLowerCase());
248
376
  }
249
377
 
250
378
  /**
@@ -451,10 +579,18 @@ export class CoreBetterAuthEmailVerificationService {
451
579
 
452
580
  this.lastSendTimes.set(key, Date.now());
453
581
 
454
- // Schedule cleanup to prevent memory leak
582
+ // Schedule cleanup to prevent memory leak. Any timer left over from an earlier,
583
+ // RELEASED send is cancelled first — it would otherwise delete THIS entry when it
584
+ // fires and end the new cooldown early.
455
585
  const cooldown = this.config.resendCooldownSeconds;
456
586
  if (cooldown > 0) {
457
- setTimeout(() => this.lastSendTimes.delete(key), cooldown * 1000);
587
+ clearTimeout(this.sendTimers.get(key));
588
+ const timer = setTimeout(() => {
589
+ this.lastSendTimes.delete(key);
590
+ this.sendTimers.delete(key);
591
+ }, cooldown * 1000);
592
+ timer.unref?.();
593
+ this.sendTimers.set(key, timer);
458
594
  }
459
595
  }
460
596
 
@@ -36,7 +36,7 @@ export class CoreBetterAuthRateLimitMiddleware implements NestMiddleware {
36
36
  private readonly betterAuthService: CoreBetterAuthService,
37
37
  ) {}
38
38
 
39
- use(req: Request, res: Response, next: NextFunction) {
39
+ async use(req: Request, res: Response, next: NextFunction) {
40
40
  // Skip if Better-Auth is not enabled
41
41
  if (!this.betterAuthService.isEnabled()) {
42
42
  return next();
@@ -55,7 +55,7 @@ export class CoreBetterAuthRateLimitMiddleware implements NestMiddleware {
55
55
  const path = req.path.startsWith(basePath) ? req.path.substring(basePath.length) : req.path;
56
56
 
57
57
  // Check rate limit
58
- const result = this.rateLimiter.check(ip, path);
58
+ const result = await this.rateLimiter.check(ip, path);
59
59
 
60
60
  // Add rate limit headers
61
61
  this.addRateLimitHeaders(res, result);
@@ -77,24 +77,17 @@ export class CoreBetterAuthRateLimitMiddleware implements NestMiddleware {
77
77
  }
78
78
 
79
79
  /**
80
- * Extract client IP address from request
81
- * Handles proxied requests via X-Forwarded-For header
80
+ * Get the client IP that the rate limit counter is keyed on.
81
+ *
82
+ * `req.ip` — NOT `x-forwarded-for` directly. Express derives `req.ip` from the forwarded chain
83
+ * only as far as the app's `trust proxy` setting allows, and falls back to the socket address
84
+ * otherwise. Reading the header ourselves skips that check entirely, so any client simply picks
85
+ * its own bucket by sending a fresh value per request: the counter never reaches the limit, and
86
+ * the brute-force protection on the auth endpoints is off. A deployment behind a proxy must
87
+ * therefore configure `trust proxy` — that is the one place where "which hop do I believe"
88
+ * belongs.
82
89
  */
83
90
  private getClientIp(req: Request): string {
84
- // Check X-Forwarded-For header (for proxied requests)
85
- const forwardedFor = req.headers['x-forwarded-for'];
86
- if (forwardedFor) {
87
- const ips = Array.isArray(forwardedFor) ? forwardedFor[0] : forwardedFor.split(',')[0];
88
- return ips.trim();
89
- }
90
-
91
- // Check X-Real-IP header (nginx)
92
- const realIp = req.headers['x-real-ip'];
93
- if (realIp) {
94
- return Array.isArray(realIp) ? realIp[0] : realIp;
95
- }
96
-
97
- // Fall back to connection remote address
98
91
  return req.ip || req.socket?.remoteAddress || 'unknown';
99
92
  }
100
93