@lenne.tech/nest-server 11.32.3 → 11.33.0

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 (262) 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/bin/migrate.js +13 -3
  7. package/dist/core/common/helpers/content-disposition.helper.d.ts +1 -0
  8. package/dist/core/common/helpers/content-disposition.helper.js +14 -0
  9. package/dist/core/common/helpers/content-disposition.helper.js.map +1 -0
  10. package/dist/core/common/helpers/file.helper.d.ts +21 -2
  11. package/dist/core/common/helpers/file.helper.js +69 -13
  12. package/dist/core/common/helpers/file.helper.js.map +1 -1
  13. package/dist/core/common/helpers/graceful-shutdown.helper.d.ts +2 -0
  14. package/dist/core/common/helpers/graceful-shutdown.helper.js +55 -0
  15. package/dist/core/common/helpers/graceful-shutdown.helper.js.map +1 -0
  16. package/dist/core/common/helpers/project-name.helper.d.ts +2 -0
  17. package/dist/core/common/helpers/project-name.helper.js +38 -0
  18. package/dist/core/common/helpers/project-name.helper.js.map +1 -0
  19. package/dist/core/common/interfaces/cron-job-config.interface.d.ts +1 -0
  20. package/dist/core/common/interfaces/server-options.interface.d.ts +39 -0
  21. package/dist/core/common/services/core-cron-jobs.initializer.d.ts +10 -0
  22. package/dist/core/common/services/core-cron-jobs.initializer.js +46 -0
  23. package/dist/core/common/services/core-cron-jobs.initializer.js.map +1 -0
  24. package/dist/core/common/services/core-cron-jobs.registry.d.ts +8 -0
  25. package/dist/core/common/services/core-cron-jobs.registry.js +12 -0
  26. package/dist/core/common/services/core-cron-jobs.registry.js.map +1 -0
  27. package/dist/core/common/services/core-cron-jobs.service.d.ts +37 -7
  28. package/dist/core/common/services/core-cron-jobs.service.js +278 -27
  29. package/dist/core/common/services/core-cron-jobs.service.js.map +1 -1
  30. package/dist/core/common/services/core-redis-pubsub.d.ts +20 -0
  31. package/dist/core/common/services/core-redis-pubsub.js +112 -0
  32. package/dist/core/common/services/core-redis-pubsub.js.map +1 -0
  33. package/dist/core/common/services/core-redis.service.d.ts +25 -0
  34. package/dist/core/common/services/core-redis.service.js +154 -0
  35. package/dist/core/common/services/core-redis.service.js.map +1 -0
  36. package/dist/core/common/services/core-s3.service.d.ts +51 -0
  37. package/dist/core/common/services/core-s3.service.js +261 -0
  38. package/dist/core/common/services/core-s3.service.js.map +1 -0
  39. package/dist/core/common/services/core-trust-proxy.initializer.d.ts +16 -0
  40. package/dist/core/common/services/core-trust-proxy.initializer.js +77 -0
  41. package/dist/core/common/services/core-trust-proxy.initializer.js.map +1 -0
  42. package/dist/core/common/services/rate-limit-store.d.ts +61 -0
  43. package/dist/core/common/services/rate-limit-store.js +235 -0
  44. package/dist/core/common/services/rate-limit-store.js.map +1 -0
  45. package/dist/core/modules/ai/core-ai-mcp.controller.d.ts +19 -3
  46. package/dist/core/modules/ai/core-ai-mcp.controller.js +148 -18
  47. package/dist/core/modules/ai/core-ai-mcp.controller.js.map +1 -1
  48. package/dist/core/modules/ai/services/core-ai.service.d.ts +7 -2
  49. package/dist/core/modules/ai/services/core-ai.service.js +24 -20
  50. package/dist/core/modules/ai/services/core-ai.service.js.map +1 -1
  51. package/dist/core/modules/auth/core-auth.module.js +4 -1
  52. package/dist/core/modules/auth/core-auth.module.js.map +1 -1
  53. package/dist/core/modules/auth/guards/legacy-auth-rate-limit.guard.d.ts +1 -1
  54. package/dist/core/modules/auth/guards/legacy-auth-rate-limit.guard.js +3 -14
  55. package/dist/core/modules/auth/guards/legacy-auth-rate-limit.guard.js.map +1 -1
  56. package/dist/core/modules/auth/services/legacy-auth-rate-limiter.service.d.ts +12 -10
  57. package/dist/core/modules/auth/services/legacy-auth-rate-limiter.service.js +30 -59
  58. package/dist/core/modules/auth/services/legacy-auth-rate-limiter.service.js.map +1 -1
  59. package/dist/core/modules/better-auth/core-better-auth-email-verification.service.d.ts +8 -2
  60. package/dist/core/modules/better-auth/core-better-auth-email-verification.service.js +118 -45
  61. package/dist/core/modules/better-auth/core-better-auth-email-verification.service.js.map +1 -1
  62. package/dist/core/modules/better-auth/core-better-auth-rate-limit.middleware.d.ts +1 -1
  63. package/dist/core/modules/better-auth/core-better-auth-rate-limit.middleware.js +2 -11
  64. package/dist/core/modules/better-auth/core-better-auth-rate-limit.middleware.js.map +1 -1
  65. package/dist/core/modules/better-auth/core-better-auth-rate-limiter.service.d.ts +12 -10
  66. package/dist/core/modules/better-auth/core-better-auth-rate-limiter.service.js +30 -85
  67. package/dist/core/modules/better-auth/core-better-auth-rate-limiter.service.js.map +1 -1
  68. package/dist/core/modules/better-auth/core-better-auth-user.mapper.js +0 -1
  69. package/dist/core/modules/better-auth/core-better-auth-user.mapper.js.map +1 -1
  70. package/dist/core/modules/file/core-file.controller.d.ts +15 -3
  71. package/dist/core/modules/file/core-file.controller.js +90 -20
  72. package/dist/core/modules/file/core-file.controller.js.map +1 -1
  73. package/dist/core/modules/file/core-file.resolver.d.ts +4 -4
  74. package/dist/core/modules/file/core-file.resolver.js +23 -16
  75. package/dist/core/modules/file/core-file.resolver.js.map +1 -1
  76. package/dist/core/modules/file/core-file.service.d.ts +41 -7
  77. package/dist/core/modules/file/core-file.service.js +227 -7
  78. package/dist/core/modules/file/core-file.service.js.map +1 -1
  79. package/dist/core/modules/file/file-metadata.helper.d.ts +16 -0
  80. package/dist/core/modules/file/file-metadata.helper.js +33 -0
  81. package/dist/core/modules/file/file-metadata.helper.js.map +1 -0
  82. package/dist/core/modules/file/file-roles.helper.d.ts +4 -0
  83. package/dist/core/modules/file/file-roles.helper.js +53 -0
  84. package/dist/core/modules/file/file-roles.helper.js.map +1 -0
  85. package/dist/core/modules/file/file-storage.helper.d.ts +13 -0
  86. package/dist/core/modules/file/file-storage.helper.js +65 -0
  87. package/dist/core/modules/file/file-storage.helper.js.map +1 -0
  88. package/dist/core/modules/file/filesystem-file.helper.d.ts +22 -0
  89. package/dist/core/modules/file/filesystem-file.helper.js +77 -0
  90. package/dist/core/modules/file/filesystem-file.helper.js.map +1 -0
  91. package/dist/core/modules/file/interfaces/file-service-options.interface.d.ts +1 -0
  92. package/dist/core/modules/file/interfaces/file-upload.interface.d.ts +7 -1
  93. package/dist/core/modules/file/s3-file.helper.d.ts +37 -0
  94. package/dist/core/modules/file/s3-file.helper.js +81 -0
  95. package/dist/core/modules/file/s3-file.helper.js.map +1 -0
  96. package/dist/core/modules/hub/core-hub.controller.d.ts +8 -8
  97. package/dist/core/modules/hub/core-hub.controller.js +24 -22
  98. package/dist/core/modules/hub/core-hub.controller.js.map +1 -1
  99. package/dist/core/modules/hub/helpers/hub-mask.helper.js +2 -2
  100. package/dist/core/modules/hub/helpers/hub-mask.helper.js.map +1 -1
  101. package/dist/core/modules/hub/hub-buffer.d.ts +23 -0
  102. package/dist/core/modules/hub/hub-buffer.js +101 -0
  103. package/dist/core/modules/hub/hub-buffer.js.map +1 -0
  104. package/dist/core/modules/hub/services/core-hub-mailbox.service.d.ts +7 -5
  105. package/dist/core/modules/hub/services/core-hub-mailbox.service.js +14 -10
  106. package/dist/core/modules/hub/services/core-hub-mailbox.service.js.map +1 -1
  107. package/dist/core/modules/hub/services/hub-log-buffer.service.d.ts +5 -2
  108. package/dist/core/modules/hub/services/hub-log-buffer.service.js +17 -8
  109. package/dist/core/modules/hub/services/hub-log-buffer.service.js.map +1 -1
  110. package/dist/core/modules/hub/services/hub-query-profiler.service.d.ts +4 -2
  111. package/dist/core/modules/hub/services/hub-query-profiler.service.js +13 -7
  112. package/dist/core/modules/hub/services/hub-query-profiler.service.js.map +1 -1
  113. package/dist/core/modules/hub/services/hub-trace-buffer.service.d.ts +4 -2
  114. package/dist/core/modules/hub/services/hub-trace-buffer.service.js +13 -8
  115. package/dist/core/modules/hub/services/hub-trace-buffer.service.js.map +1 -1
  116. package/dist/core/modules/migrate/cli/migrate-cli.d.ts +3 -1
  117. package/dist/core/modules/migrate/cli/migrate-cli.js +29 -4
  118. package/dist/core/modules/migrate/cli/migrate-cli.js.map +1 -1
  119. package/dist/core/modules/migrate/helpers/migration.helper.d.ts +3 -6
  120. package/dist/core/modules/migrate/helpers/migration.helper.js +55 -8
  121. package/dist/core/modules/migrate/helpers/migration.helper.js.map +1 -1
  122. package/dist/core/modules/migrate/migration-runner.d.ts +1 -0
  123. package/dist/core/modules/migrate/migration-runner.js +4 -0
  124. package/dist/core/modules/migrate/migration-runner.js.map +1 -1
  125. package/dist/core/modules/migrate/mongo-state-store.d.ts +1 -0
  126. package/dist/core/modules/migrate/mongo-state-store.js +98 -9
  127. package/dist/core/modules/migrate/mongo-state-store.js.map +1 -1
  128. package/dist/core/modules/system-setup/core-system-setup.service.d.ts +2 -0
  129. package/dist/core/modules/system-setup/core-system-setup.service.js +31 -1
  130. package/dist/core/modules/system-setup/core-system-setup.service.js.map +1 -1
  131. package/dist/core/modules/tenant/core-tenant.guard.d.ts +17 -3
  132. package/dist/core/modules/tenant/core-tenant.guard.js +92 -15
  133. package/dist/core/modules/tenant/core-tenant.guard.js.map +1 -1
  134. package/dist/core/modules/tenant/core-tenant.module.js +3 -4
  135. package/dist/core/modules/tenant/core-tenant.module.js.map +1 -1
  136. package/dist/core/modules/tus/core-tus.controller.d.ts +2 -0
  137. package/dist/core/modules/tus/core-tus.controller.js +29 -3
  138. package/dist/core/modules/tus/core-tus.controller.js.map +1 -1
  139. package/dist/core/modules/tus/core-tus.service.d.ts +24 -1
  140. package/dist/core/modules/tus/core-tus.service.js +182 -28
  141. package/dist/core/modules/tus/core-tus.service.js.map +1 -1
  142. package/dist/core/modules/tus/interfaces/tus-config.interface.js +3 -0
  143. package/dist/core/modules/tus/interfaces/tus-config.interface.js.map +1 -1
  144. package/dist/core/modules/tus/tus-redis-locker.d.ts +25 -0
  145. package/dist/core/modules/tus/tus-redis-locker.js +87 -0
  146. package/dist/core/modules/tus/tus-redis-locker.js.map +1 -0
  147. package/dist/core/modules/tus/tus.module.d.ts +1 -0
  148. package/dist/core/modules/tus/tus.module.js +30 -4
  149. package/dist/core/modules/tus/tus.module.js.map +1 -1
  150. package/dist/core.module.js +18 -1
  151. package/dist/core.module.js.map +1 -1
  152. package/dist/index.d.ts +10 -0
  153. package/dist/index.js +10 -0
  154. package/dist/index.js.map +1 -1
  155. package/dist/main.js +2 -1
  156. package/dist/main.js.map +1 -1
  157. package/dist/server/modules/file/file.controller.js.map +1 -1
  158. package/dist/server/modules/file/file.resolver.js +1 -12
  159. package/dist/server/modules/file/file.resolver.js.map +1 -1
  160. package/dist/server/modules/file/file.service.d.ts +5 -1
  161. package/dist/server/modules/file/file.service.js +12 -3
  162. package/dist/server/modules/file/file.service.js.map +1 -1
  163. package/dist/server/modules/user/avatar.controller.d.ts +5 -1
  164. package/dist/server/modules/user/avatar.controller.js +26 -10
  165. package/dist/server/modules/user/avatar.controller.js.map +1 -1
  166. package/dist/server/modules/user/user.model.js +3 -2
  167. package/dist/server/modules/user/user.model.js.map +1 -1
  168. package/dist/server/modules/user/user.module.js +6 -2
  169. package/dist/server/modules/user/user.module.js.map +1 -1
  170. package/dist/server/modules/user/user.service.d.ts +1 -1
  171. package/dist/server/modules/user/user.service.js +6 -13
  172. package/dist/server/modules/user/user.service.js.map +1 -1
  173. package/dist/tsconfig.build.tsbuildinfo +1 -1
  174. package/docs/REQUEST-LIFECYCLE.md +57 -8
  175. package/docs/security-overrides.md +30 -5
  176. package/migration-guides/11.32.3-to-11.32.4.md +335 -0
  177. package/migration-guides/11.32.x-to-11.33.x.md +1551 -0
  178. package/package.json +74 -42
  179. package/src/core/common/helpers/content-disposition.helper.ts +90 -0
  180. package/src/core/common/helpers/file.helper.ts +201 -20
  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 +108 -0
  204. package/src/core/modules/file/README.md +324 -41
  205. package/src/core/modules/file/core-file.controller.ts +267 -19
  206. package/src/core/modules/file/core-file.resolver.ts +46 -12
  207. package/src/core/modules/file/core-file.service.ts +482 -16
  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 +68 -6
  224. package/src/core/modules/migrate/cli/migrate-cli.ts +69 -6
  225. package/src/core/modules/migrate/helpers/migration.helper.ts +144 -10
  226. package/src/core/modules/migrate/migration-runner.ts +10 -1
  227. package/src/core/modules/migrate/mongo-state-store.ts +179 -11
  228. package/src/core/modules/system-setup/README.md +7 -2
  229. package/src/core/modules/system-setup/core-system-setup.service.ts +95 -1
  230. package/src/core/modules/tenant/README.md +21 -1
  231. package/src/core/modules/tenant/core-tenant.guard.ts +135 -14
  232. package/src/core/modules/tenant/core-tenant.module.ts +8 -4
  233. package/src/core/modules/tus/INTEGRATION-CHECKLIST.md +93 -21
  234. package/src/core/modules/tus/README.md +35 -2
  235. package/src/core/modules/tus/core-tus.controller.ts +64 -12
  236. package/src/core/modules/tus/core-tus.service.ts +358 -34
  237. package/src/core/modules/tus/interfaces/tus-config.interface.ts +9 -0
  238. package/src/core/modules/tus/tus-redis-locker.ts +139 -0
  239. package/src/core/modules/tus/tus.module.ts +78 -6
  240. package/src/core.module.ts +26 -1
  241. package/src/index.ts +10 -0
  242. package/src/main.ts +7 -1
  243. package/src/server/modules/file/file.controller.ts +7 -3
  244. package/src/server/modules/file/file.resolver.ts +5 -17
  245. package/src/server/modules/file/file.service.ts +42 -3
  246. package/src/server/modules/user/avatar.controller.ts +39 -9
  247. package/src/server/modules/user/user.model.ts +9 -3
  248. package/src/server/modules/user/user.module.ts +12 -3
  249. package/src/server/modules/user/user.service.ts +20 -17
  250. package/src/test/README.md +7 -2
  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/hub/core-hub.module.spec.ts +0 -108
  255. package/src/core/modules/hub/helpers/hub-command-shape.helper.spec.ts +0 -48
  256. package/src/core/modules/hub/helpers/hub-mask.helper.spec.ts +0 -67
  257. package/src/core/modules/hub/helpers/hub-mermaid.helper.spec.ts +0 -61
  258. package/src/core/modules/hub/helpers/hub-shell.helper.spec.ts +0 -106
  259. package/src/core/modules/hub/hub-config.helper.spec.ts +0 -108
  260. package/src/core/modules/hub/hub-ring-buffer.spec.ts +0 -95
  261. package/src/core/modules/hub/services/core-hub-mailbox.service.spec.ts +0 -116
  262. package/src/core/modules/hub/services/hub-trace-buffer.service.spec.ts +0 -112
@@ -1,4 +1,6 @@
1
+ import { randomUUID } from 'crypto';
1
2
  import { Db, MongoClient } from 'mongodb';
3
+ import { hostname } from 'os';
2
4
  import { promisify } from 'util';
3
5
 
4
6
  /**
@@ -181,14 +183,20 @@ export async function synchronizedMigration(
181
183
  throw new Error('`lockCollectionName` in MongoStateStore is not set');
182
184
  }
183
185
 
186
+ let heartbeat: undefined | { stop: () => void };
187
+ let lockToken: string | undefined;
184
188
  try {
185
- await acquireLock(stateStore.mongodbHost, lockCollectionName);
189
+ lockToken = await acquireLock(stateStore.mongodbHost, lockCollectionName);
190
+ heartbeat = startLockHeartbeat(stateStore.mongodbHost, lockCollectionName, lockToken);
186
191
 
187
192
  // Load migration set using async method
188
193
  const set = await stateStore.loadAsync();
189
194
  await callback(set);
190
195
  } finally {
191
- await releaseLock(stateStore.mongodbHost, lockCollectionName);
196
+ heartbeat?.stop();
197
+ if (lockToken) {
198
+ await releaseLock(stateStore.mongodbHost, lockCollectionName, lockToken);
199
+ }
192
200
  }
193
201
  }
194
202
 
@@ -217,14 +225,119 @@ export async function synchronizedUp(opts: MigrationOptions): Promise<void> {
217
225
  });
218
226
  }
219
227
 
228
+ /**
229
+ * Runs `fn` while holding the migration lock of the given state store
230
+ *
231
+ * When the store has no `lockCollectionName`, `fn` runs unsynchronized — a single
232
+ * replica keeps behaving exactly as before. Stores built by `createMigrationStore()`
233
+ * carry the default lock collection, so concurrent `migrate up` runs from several
234
+ * replicas serialize here: the second replica waits, then re-reads the migration
235
+ * state and finds nothing pending.
236
+ *
237
+ * @param stateStore - State store holding the connection URI and lock collection name
238
+ * @param fn - Work to execute under the lock
239
+ * @returns Promise with the result of `fn`
240
+ */
241
+ export async function withMigrationLock<T>(stateStore: MongoStateStore, fn: () => Promise<T>): Promise<T> {
242
+ const lockCollectionName = stateStore.lockCollectionName;
243
+
244
+ if (!lockCollectionName) {
245
+ return fn();
246
+ }
247
+
248
+ const token = await acquireLock(stateStore.mongodbHost, lockCollectionName);
249
+ const heartbeat = startLockHeartbeat(stateStore.mongodbHost, lockCollectionName, token);
250
+ try {
251
+ return await fn();
252
+ } finally {
253
+ heartbeat.stop();
254
+ await releaseLock(stateStore.mongodbHost, lockCollectionName, token);
255
+ }
256
+ }
257
+
258
+ /**
259
+ * How long a lock may go without a heartbeat before another replica may break it.
260
+ *
261
+ * Only a lock whose holder stopped refreshing it is ever broken, so this is not a cap
262
+ * on migration runtime — a migration running for hours keeps its lock as long as the
263
+ * process lives.
264
+ */
265
+ const LOCK_STALE_AFTER_MS = 60_000;
266
+
267
+ /** Heartbeat interval — comfortably below {@link LOCK_STALE_AFTER_MS} */
268
+ const LOCK_HEARTBEAT_INTERVAL_MS = 15_000;
269
+
270
+ /** How long a replica waits for a held lock before giving up with a diagnosable error */
271
+ const LOCK_WAIT_TIMEOUT_MS = 15 * 60_000;
272
+
273
+ /**
274
+ * Keep refreshing `acquiredAt` while the migration runs.
275
+ *
276
+ * This is what makes lock-breaking safe: a lock is only ever taken over when its holder
277
+ * stopped refreshing it, which for a live process cannot happen. Without the heartbeat,
278
+ * breaking a stale lock and a long-running migration would be indistinguishable.
279
+ */
280
+ function startLockHeartbeat(url: string, lockCollectionName: string, token: string): { stop: () => void } {
281
+ // ONE connection for the whole heartbeat, rather than `dbRequest()`'s connect-and-close per
282
+ // call: that opened and tore down a MongoClient every 15 seconds for the entire migration run —
283
+ // a full handshake each time, against the database at its busiest moment. The client is created
284
+ // on the first tick (so a heartbeat that never fires costs nothing) and closed by stop().
285
+ let client: MongoClient | undefined;
286
+ let stopped = false;
287
+
288
+ const beat = async (): Promise<void> => {
289
+ if (stopped) {
290
+ return;
291
+ }
292
+ if (!client) {
293
+ const connected = await MongoClient.connect(url);
294
+ if (stopped) {
295
+ // stop() ran while we were connecting — nothing would ever close this one.
296
+ await connected.close();
297
+ return;
298
+ }
299
+ client = connected;
300
+ }
301
+ // Scoped: once our lock has been broken and re-taken, our heartbeat must not keep the NEW
302
+ // holder's lock alive on our behalf.
303
+ await client
304
+ .db()
305
+ .collection(lockCollectionName)
306
+ .updateOne({ lock: 'lock', owner: token }, { $set: { acquiredAt: new Date() } });
307
+ };
308
+
309
+ const timer = setInterval(() => {
310
+ beat().catch((error) => {
311
+ // A missed heartbeat is not fatal on its own — the next one may succeed, and only
312
+ // a sustained gap makes the lock breakable. Drop the connection though: it may itself be
313
+ // what failed, and a held-open broken client would fail every remaining tick.
314
+ console.warn(`Migration lock heartbeat failed: ${error instanceof Error ? error.message : 'Unknown error'}`);
315
+ const broken = client;
316
+ client = undefined;
317
+ void broken?.close().catch(() => undefined);
318
+ });
319
+ }, LOCK_HEARTBEAT_INTERVAL_MS);
320
+ timer.unref?.();
321
+
322
+ return {
323
+ stop: () => {
324
+ stopped = true;
325
+ clearInterval(timer);
326
+ const open = client;
327
+ client = undefined;
328
+ void open?.close().catch(() => undefined);
329
+ },
330
+ };
331
+ }
332
+
220
333
  /**
221
334
  * Acquires a lock in MongoDB to ensure only one migration runs at a time
222
335
  *
223
336
  * @param url - MongoDB connection URI
224
337
  * @param lockCollectionName - Name of the collection to use for locking
225
338
  */
226
- async function acquireLock(url: string, lockCollectionName: string): Promise<void> {
227
- await dbRequest(url, async (db) => {
339
+ async function acquireLock(url: string, lockCollectionName: string): Promise<string> {
340
+ return dbRequest(url, async (db) => {
228
341
  const collection = db.collection(lockCollectionName);
229
342
 
230
343
  // Create unique index for atomicity
@@ -233,15 +346,58 @@ async function acquireLock(url: string, lockCollectionName: string): Promise<voi
233
346
  // https://stackoverflow.com/questions/33346175/mongodb-upsert-operation-seems-not-atomic-which-throws-duplicatekeyexception/34784533
234
347
  await collection.createIndex({ lock: 1 }, { unique: true });
235
348
 
349
+ const token = lockOwnerId();
350
+ const deadline = Date.now() + LOCK_WAIT_TIMEOUT_MS;
236
351
  let showMessage = true;
237
352
 
238
353
  for (;;) {
239
- // Use updateOne with upsert for atomic lock acquisition (same as original package)
240
- const result = await collection.updateOne({ lock: 'lock' }, { $set: { lock: 'lock' } }, { upsert: true });
241
- const lockAcquired = result.upsertedCount > 0;
354
+ // Use updateOne with upsert for atomic lock acquisition (same as original package).
355
+ // `$setOnInsert` stamps the acquisition time only for the winner, so a loser's
356
+ // update never refreshes the holder's staleness clock.
357
+ const result = await collection.updateOne(
358
+ { lock: 'lock' },
359
+ { $set: { lock: 'lock' }, $setOnInsert: { acquiredAt: new Date(), owner: token } },
360
+ { upsert: true },
361
+ );
362
+
363
+ if (result.upsertedCount > 0) {
364
+ return token;
365
+ }
366
+
367
+ // The holder may be gone: migrations run on every container boot, so a replica
368
+ // SIGKILLed mid-migration (OOM, node drain, failed deploy) would otherwise leave a
369
+ // lock nobody holds — and every future boot of every replica would wait on it
370
+ // forever. Only a lock that stopped heart-beating is broken; see LOCK_STALE_AFTER_MS.
371
+ const holder = await collection.findOne({ lock: 'lock' });
372
+ if (holder) {
373
+ const acquiredAt = holder.acquiredAt instanceof Date ? holder.acquiredAt : undefined;
374
+ if (!acquiredAt) {
375
+ // Written by a version that did not stamp the lock. Start its clock now rather
376
+ // than breaking it immediately — the holder may well be alive.
377
+ await collection.updateOne(
378
+ { _id: holder._id, acquiredAt: { $exists: false } },
379
+ { $set: { acquiredAt: new Date() } },
380
+ );
381
+ } else if (Date.now() - acquiredAt.getTime() > LOCK_STALE_AFTER_MS) {
382
+ console.warn(
383
+ `Breaking stale migration lock in "${lockCollectionName}" (last heartbeat ${acquiredAt.toISOString()}, ` +
384
+ `owner ${holder.owner ?? 'unknown'}) — its holder is gone.`,
385
+ );
386
+ // Matching on acquiredAt makes the break safe under concurrency: if another
387
+ // waiter already broke and re-acquired the lock, the timestamp differs and this
388
+ // deletes nothing.
389
+ await collection.deleteOne({ _id: holder._id, acquiredAt });
390
+ continue;
391
+ }
392
+ }
242
393
 
243
- if (lockAcquired) {
244
- break;
394
+ if (Date.now() > deadline) {
395
+ throw new Error(
396
+ `Timed out after ${Math.round(LOCK_WAIT_TIMEOUT_MS / 60_000)} minutes waiting for the migration lock in ` +
397
+ `collection "${lockCollectionName}". Another replica is still migrating, or the lock is held by a process ` +
398
+ `that is alive but stuck. Inspect it with: db.getCollection("${lockCollectionName}").find({}) — and remove ` +
399
+ `the document only once you are sure no migration is running.`,
400
+ );
245
401
  }
246
402
 
247
403
  if (showMessage) {
@@ -254,6 +410,15 @@ async function acquireLock(url: string, lockCollectionName: string): Promise<voi
254
410
  });
255
411
  }
256
412
 
413
+ /**
414
+ * Identifies the process holding the lock, so a stale-lock warning names something actionable
415
+ */
416
+ function lockOwnerId(): string {
417
+ // Random suffix, not just host+pid: a restarted process can reuse a pid, and the token is what
418
+ // decides whose lock a release may delete.
419
+ return `${hostname()}:${process.pid}:${randomUUID()}`;
420
+ }
421
+
257
422
  /**
258
423
  * Executes database operations with automatic connection management
259
424
  *
@@ -278,6 +443,9 @@ async function dbRequest<T>(url: string, callback: (db: Db) => Promise<T> | T):
278
443
  * @param url - MongoDB connection URI
279
444
  * @param lockCollectionName - Name of the collection used for locking
280
445
  */
281
- async function releaseLock(url: string, lockCollectionName: string): Promise<void> {
282
- await dbRequest(url, (db) => db.collection(lockCollectionName).deleteOne({ lock: 'lock' }));
446
+ async function releaseLock(url: string, lockCollectionName: string, token: string): Promise<void> {
447
+ // Scoped to OUR token. An unscoped delete removes whatever lock exists — so after a missed
448
+ // heartbeat let another replica break and re-take the lock, the original holder's `finally`
449
+ // would delete the NEW holder's lock and let a third replica start a concurrent migration.
450
+ await dbRequest(url, (db) => db.collection(lockCollectionName).deleteOne({ lock: 'lock', owner: token }));
283
451
  }
@@ -164,7 +164,11 @@ NEST_SERVER_CONFIG='{ "systemSetup": { "initialAdmin": { "email": "admin@example
164
164
  - The admin is created automatically during application bootstrap (`OnApplicationBootstrap`)
165
165
  - Same zero-user guard applies: only works when no users exist
166
166
  - If users already exist, auto-creation is silently skipped (no error)
167
- - Race conditions between multiple instances are handled gracefully
167
+ - Race conditions between multiple instances are handled gracefully: the zero-user check alone is
168
+ check-then-act, so N replicas booting at the same time would each pass it. Before creating, an
169
+ instance claims the setup by upserting the marker `{ _id: 'initial-admin' }` into the
170
+ `system-setup-locks` collection — an atomic operation only one instance wins. The others log a
171
+ debug message and skip. If creation fails, the marker is removed again so a later boot can retry.
168
172
 
169
173
  ### Security Best Practices
170
174
 
@@ -179,7 +183,8 @@ NEST_SERVER_CONFIG='{ "systemSetup": { "initialAdmin": { "email": "admin@example
179
183
 
180
184
  1. **Zero-user guard** - Init only works when `countDocuments({}) === 0`
181
185
  2. **Enabled by default** - Safe because endpoints are permanently locked once any user exists
182
- 3. **Race condition protection** - MongoDB unique email index prevents duplicates
186
+ 3. **Race condition protection** - MongoDB unique email index prevents duplicates; auto-creation on
187
+ bootstrap is additionally claimed atomically via the `system-setup-locks` marker
183
188
  4. **Permanent lock** - Once any user exists, init returns 403
184
189
  5. **BetterAuth required** - Returns 403 if BetterAuth is not enabled
185
190
 
@@ -8,6 +8,25 @@ import { CoreBetterAuthUserMapper } from '../better-auth/core-better-auth-user.m
8
8
  import { CoreBetterAuthService } from '../better-auth/core-better-auth.service';
9
9
  import { ErrorCode } from '../error-code/error-codes';
10
10
 
11
+ /**
12
+ * Collection holding the bootstrap claim markers.
13
+ *
14
+ * Native collection access is intentional here: no Mongoose schema exists for it
15
+ * (see docs/native-driver-security.md).
16
+ */
17
+ const SETUP_LOCK_COLLECTION = 'system-setup-locks';
18
+
19
+ /**
20
+ * How long an initial-admin claim may stand before another replica takes it over.
21
+ *
22
+ * Long enough that a slow but live creation is never stolen, short enough that a crashed
23
+ * claimer does not lock the deployment out for good.
24
+ */
25
+ const INITIAL_ADMIN_CLAIM_STALE_AFTER_MS = 5 * 60_000;
26
+
27
+ /** `_id` of the marker claiming the initial-admin creation */
28
+ const INITIAL_ADMIN_LOCK_ID = 'initial-admin';
29
+
11
30
  /**
12
31
  * Input for creating the initial admin user
13
32
  */
@@ -101,6 +120,10 @@ export class CoreSystemSetupService implements OnApplicationBootstrap {
101
120
  return;
102
121
  }
103
122
 
123
+ // The atomic claim lives in createInitialAdmin() so that BOTH entry points — this
124
+ // one and the anonymous POST /system-setup/init — serialize on the same marker.
125
+ // Claiming here as well would take the claim twice on this path and leave the
126
+ // public path unguarded.
104
127
  try {
105
128
  const result = await this.createInitialAdmin({
106
129
  email: initialAdmin.email,
@@ -109,8 +132,11 @@ export class CoreSystemSetupService implements OnApplicationBootstrap {
109
132
  });
110
133
  this.logger.log(`Auto-created initial admin on startup: ${result.email}`);
111
134
  } catch (error) {
135
+ // The claim is released by createInitialAdmin() itself, which is the only place
136
+ // that takes it — so a replica that crashed mid-creation cannot block setup on
137
+ // every future boot, and the marker has exactly one owner.
112
138
  if (error instanceof ForbiddenException) {
113
- this.logger.log('Initial admin auto-creation skipped (users already exist)');
139
+ this.logger.log('Initial admin auto-creation skipped (users already exist or claimed elsewhere)');
114
140
  } else {
115
141
  this.logger.warn(
116
142
  `Initial admin auto-creation failed: ${error instanceof Error ? error.message : 'Unknown error'}`,
@@ -119,6 +145,58 @@ export class CoreSystemSetupService implements OnApplicationBootstrap {
119
145
  }
120
146
  }
121
147
 
148
+ /**
149
+ * Try to claim the initial-admin creation for this instance
150
+ *
151
+ * The upsert on a fixed `_id` is atomic, so exactly one of N concurrently booting
152
+ * replicas sees no previous document and wins the claim.
153
+ *
154
+ * @returns true when this instance may create the initial admin
155
+ */
156
+ protected async claimInitialAdminSetup(): Promise<boolean> {
157
+ try {
158
+ const collection = this.connection.collection(SETUP_LOCK_COLLECTION);
159
+
160
+ // The marker is a CLAIM, not a permanent record: a replica SIGKILLed between claiming and
161
+ // creating (OOM, node drain, a failed first rollout — all ordinary) never reaches the
162
+ // release in the catch below. Without an expiry that leaves a deployment with zero users
163
+ // and no way in, recoverable only by deleting a document from an undocumented collection.
164
+ // A stale claim is therefore taken over rather than obeyed.
165
+ const staleBefore = new Date(Date.now() - INITIAL_ADMIN_CLAIM_STALE_AFTER_MS);
166
+ await collection.deleteOne({ _id: INITIAL_ADMIN_LOCK_ID as any, claimedAt: { $lt: staleBefore } });
167
+
168
+ const previous = await collection.findOneAndUpdate(
169
+ { _id: INITIAL_ADMIN_LOCK_ID as any },
170
+ { $setOnInsert: { claimedAt: new Date() } },
171
+ { returnDocument: 'before', upsert: true },
172
+ );
173
+
174
+ // No previous document → this instance inserted the marker and owns the setup
175
+ return !previous;
176
+ } catch (error) {
177
+ // Two replicas upserting the same `_id` at the very same moment: one insert wins,
178
+ // the other gets a duplicate key error — which means the claim is taken.
179
+ if (error instanceof Error && (error.message?.includes('duplicate key') || error.message?.includes('E11000'))) {
180
+ return false;
181
+ }
182
+ throw error;
183
+ }
184
+ }
185
+
186
+ /**
187
+ * Remove the initial-admin claim marker so a later boot can retry the setup
188
+ */
189
+ protected async releaseInitialAdminSetupClaim(): Promise<void> {
190
+ try {
191
+ await this.connection.collection(SETUP_LOCK_COLLECTION).deleteOne({ _id: INITIAL_ADMIN_LOCK_ID as any });
192
+ } catch (error) {
193
+ // Never mask the failure that triggered the release
194
+ this.logger.warn(
195
+ `Failed to release initial admin setup claim: ${error instanceof Error ? error.message : 'Unknown error'}`,
196
+ );
197
+ }
198
+ }
199
+
122
200
  /**
123
201
  * Check if the system needs initial setup (zero users)
124
202
  */
@@ -153,6 +231,17 @@ export class CoreSystemSetupService implements OnApplicationBootstrap {
153
231
  throw new ForbiddenException(ErrorCode.SYSTEM_SETUP_BETTERAUTH_REQUIRED);
154
232
  }
155
233
 
234
+ // The count check above is check-then-act, and this method is reachable ANONYMOUSLY
235
+ // via POST /system-setup/init. Two concurrent callers with DIFFERENT emails both pass
236
+ // it and both get an admin — the E11000 handler below only catches the same-email
237
+ // case. The claim is the same marker the boot path uses, so the HTTP path and the
238
+ // auto-bootstrap also serialize against each other instead of racing: an attacker
239
+ // racing a fresh deployment can no longer obtain an admin account ALONGSIDE the
240
+ // configured one.
241
+ if (!(await this.claimInitialAdminSetup())) {
242
+ throw new ForbiddenException(ErrorCode.SYSTEM_SETUP_NOT_AVAILABLE);
243
+ }
244
+
156
245
  try {
157
246
  // Access BetterAuth internal context (same pattern as core-better-auth-api.middleware.ts)
158
247
  const context = await authInstance.$context;
@@ -208,6 +297,11 @@ export class CoreSystemSetupService implements OnApplicationBootstrap {
208
297
  success: true,
209
298
  };
210
299
  } catch (error) {
300
+ // Release the claim we took above: a caller that failed mid-creation must not
301
+ // leave a deployment with zero users and no way in. The marker is additionally
302
+ // stale-expiring, so this is belt AND braces for the crash-before-release case.
303
+ await this.releaseInitialAdminSetupClaim();
304
+
211
305
  // Handle duplicate email (race condition via MongoDB unique index)
212
306
  if (error instanceof Error && (error.message?.includes('duplicate key') || error.message?.includes('E11000'))) {
213
307
  throw new ForbiddenException(ErrorCode.SYSTEM_SETUP_NOT_AVAILABLE);
@@ -238,7 +238,7 @@ multiTenancy: {
238
238
  - **Config-change detection:** Cache is flushed when `multiTenancy` config changes (e.g., `roleHierarchy` update).
239
239
  - **Bounded:** Max 500 entries with FIFO eviction. Memory overhead: ~100-250 KB.
240
240
 
241
- **Important:** The cache is process-local. In horizontally scaled deployments (multiple instances), membership changes on one instance are not reflected on other instances until the TTL expires. Set `cacheTtlMs: 0` for security-sensitive deployments.
241
+ **Important:** The cache storage itself is always process-local — every replica keeps its own map. What changed in 11.33.0 is how _invalidations_ travel: with `redis` configured they are broadcast, without it they are not (see below). A membership change that happens **outside** `invalidateUser()` / `invalidateAll()` a direct DB write, an admin tool, a migration — is still invisible to every replica until `cacheTtlMs` expires. Set `cacheTtlMs: 0` for security-sensitive deployments.
242
242
 
243
243
  ### Manual Cache Invalidation
244
244
 
@@ -256,6 +256,26 @@ export class TenantService extends CoreTenantService {
256
256
 
257
257
  Use `invalidateAll()` to flush the entire cache (e.g., after bulk operations).
258
258
 
259
+ Both are **instance** methods on the singleton `CoreTenantGuard` — inject it and call
260
+ `this.tenantGuard?.invalidateUser(userId)`. There is no static equivalent.
261
+
262
+ #### Cross-replica invalidation (since 11.33.0)
263
+
264
+ `invalidateUser()` / `invalidateAll()` are no longer a purely local clear:
265
+
266
+ | Setup | What happens |
267
+ | ---------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
268
+ | **`redis` configured** | The local caches are cleared **and** the invalidation is published to `<keyPrefix>:tenant-cache:invalidate`. Every replica subscribed to that channel clears its own caches, so a membership change takes effect fleet-wide within one round trip. |
269
+ | **No `redis`** | Only the calling process clears. Other replicas keep serving the stale membership until `cacheTtlMs` expires — the pre-11.33.0 behaviour. |
270
+
271
+ Mechanism, for anyone extending the guard (`core-tenant.guard.ts`):
272
+
273
+ - The channel is namespaced with the framework's Redis key prefix, so two applications sharing one Redis instance do not clear each other's caches. Two **stages** of the same application do, unless you set `redis.keyPrefix` or `redis.db`.
274
+ - The message is `{ scope: 'user', userId }` or `{ scope: 'all' }`, JSON-encoded.
275
+ - Subscription uses the shared subscriber connection from `CoreRedisService.getSubscriber()`; the listener is held in a field and detached on destroy, because that connection is shared.
276
+ - **A received broadcast clears locally and does NOT re-publish** (`applyInvalidation()` → `clearUser()` / `clearAll()`, the no-broadcast variants). Re-publishing would bounce the message around the cluster forever.
277
+ - **Publishing is fire-and-forget and failures are debug-level only.** A missed broadcast is not an error: it merely leaves the other replicas' caches stale until their TTL — exactly the no-Redis behaviour. Nothing about a request path depends on the broadcast succeeding.
278
+
259
279
  ### SkipTenantCheck
260
280
 
261
281
  For high-frequency endpoints that don't access tenant-scoped data, use `@SkipTenantCheck()` to avoid the membership lookup entirely.
@@ -4,7 +4,9 @@ import {
4
4
  ForbiddenException,
5
5
  Injectable,
6
6
  Logger,
7
+ OnApplicationBootstrap,
7
8
  OnModuleDestroy,
9
+ Optional,
8
10
  UnauthorizedException,
9
11
  } from '@nestjs/common';
10
12
  import { Reflector } from '@nestjs/core';
@@ -14,6 +16,7 @@ import { Model } from 'mongoose';
14
16
 
15
17
  import { RoleEnum } from '../../common/enums/role.enum';
16
18
  import { ConfigService } from '../../common/services/config.service';
19
+ import { CoreRedisService } from '../../common/services/core-redis.service';
17
20
  import { ErrorCode } from '../error-code/error-codes';
18
21
  import { CoreTenantMemberModel } from './core-tenant-member.model';
19
22
  import { SKIP_TENANT_CHECK_KEY } from './core-tenant.decorators';
@@ -42,6 +45,14 @@ interface CachedTenantIds {
42
45
  ids: string[];
43
46
  }
44
47
 
48
+ /**
49
+ * Cache invalidation message broadcast between replicas via Redis pub/sub
50
+ */
51
+ interface TenantCacheInvalidation {
52
+ scope: 'all' | 'user';
53
+ userId?: string;
54
+ }
55
+
45
56
  /**
46
57
  * Global guard for multi-tenancy with defense-in-depth security.
47
58
  *
@@ -103,7 +114,7 @@ interface CachedTenantIds {
103
114
  * - No user + checkable roles → 401 (authentication required)
104
115
  */
105
116
  @Injectable()
106
- export class CoreTenantGuard implements CanActivate, OnModuleDestroy {
117
+ export class CoreTenantGuard implements CanActivate, OnApplicationBootstrap, OnModuleDestroy {
107
118
  private readonly logger = new Logger(CoreTenantGuard.name);
108
119
 
109
120
  /**
@@ -111,6 +122,12 @@ export class CoreTenantGuard implements CanActivate, OnModuleDestroy {
111
122
  * Key: `${userId}:${tenantId}`, Value: cached membership result with expiry.
112
123
  * Eliminates repeated DB queries for the same user+tenant combination.
113
124
  */
125
+ /** Channel this guard subscribed to, so it can unsubscribe again */
126
+ private invalidationChannel?: string;
127
+
128
+ /** The shared-subscriber listener, kept so it can be detached on destroy */
129
+ private invalidationListener?: (channel: string, message: string) => void;
130
+
114
131
  private readonly membershipCache = new Map<string, CachedMembership>();
115
132
 
116
133
  /**
@@ -131,6 +148,7 @@ export class CoreTenantGuard implements CanActivate, OnModuleDestroy {
131
148
  constructor(
132
149
  private readonly reflector: Reflector,
133
150
  @InjectModel(TENANT_MEMBER_MODEL_TOKEN) private readonly memberModel: Model<CoreTenantMemberModel>,
151
+ @Optional() protected readonly redisService?: CoreRedisService,
134
152
  ) {
135
153
  // Clean up expired cache entries every 60 seconds
136
154
  this.cleanupInterval = setInterval(() => this.evictExpired(), 60_000);
@@ -139,7 +157,50 @@ export class CoreTenantGuard implements CanActivate, OnModuleDestroy {
139
157
  }
140
158
  }
141
159
 
160
+ /**
161
+ * Subscribe to the cross-replica cache invalidation channel.
162
+ *
163
+ * Runs after ALL onModuleInit hooks so that CoreRedisService is connected.
164
+ * Without Redis this is a no-op and the caches stay process-local.
165
+ */
166
+ async onApplicationBootstrap(): Promise<void> {
167
+ if (!this.redisService?.enabled) {
168
+ return;
169
+ }
170
+ const channel = this.redisService.key('tenant-cache', 'invalidate');
171
+ try {
172
+ const subscriber = this.redisService.getSubscriber();
173
+ // Held in a field, not an anonymous closure: the subscriber connection is SHARED between
174
+ // features, so a listener that is never detached keeps dispatching into a destroyed
175
+ // guard's caches for the life of the process — and a second app in the same process
176
+ // inherits the previous one's listener alongside its own.
177
+ this.invalidationChannel = channel;
178
+ this.invalidationListener = (incomingChannel: string, message: string) => {
179
+ if (incomingChannel === channel) {
180
+ this.applyInvalidation(message);
181
+ }
182
+ };
183
+ subscriber.on('message', this.invalidationListener);
184
+ await subscriber.subscribe(channel);
185
+ } catch (error) {
186
+ this.logger.debug(`Tenant cache invalidation subscribe failed: ${(error as Error).message}`);
187
+ }
188
+ }
189
+
142
190
  onModuleDestroy(): void {
191
+ if (this.invalidationListener) {
192
+ try {
193
+ const subscriber = this.redisService?.getSubscriber();
194
+ subscriber?.off('message', this.invalidationListener);
195
+ if (this.invalidationChannel) {
196
+ void subscriber?.unsubscribe(this.invalidationChannel).catch(() => undefined);
197
+ }
198
+ } catch {
199
+ // Redis already gone — nothing to detach from
200
+ }
201
+ this.invalidationListener = undefined;
202
+ }
203
+
143
204
  if (this.cleanupInterval) {
144
205
  clearInterval(this.cleanupInterval);
145
206
  this.cleanupInterval = null;
@@ -152,31 +213,28 @@ export class CoreTenantGuard implements CanActivate, OnModuleDestroy {
152
213
  * Invalidate all cache entries for a specific user.
153
214
  * Call this when memberships change (add/remove/update).
154
215
  *
216
+ * When Redis is enabled, the invalidation is additionally broadcast to all other
217
+ * replicas — otherwise their process-local caches would stay stale for up to cacheTtlMs.
218
+ *
155
219
  * Note: userId must not contain ':' characters (used as cache key delimiter).
156
220
  * MongoDB ObjectIds and standard UUID formats are safe.
157
221
  *
158
222
  * @param userId - The user ID whose cache entries should be invalidated
159
223
  */
160
224
  invalidateUser(userId: string): void {
161
- for (const key of this.membershipCache.keys()) {
162
- if (key.startsWith(`${userId}:`)) {
163
- this.membershipCache.delete(key);
164
- }
165
- }
166
- for (const key of this.tenantIdsCache.keys()) {
167
- if (key === userId || key.startsWith(`${userId}:`)) {
168
- this.tenantIdsCache.delete(key);
169
- }
170
- }
225
+ this.clearUser(userId);
226
+ this.publishInvalidation({ scope: 'user', userId });
171
227
  }
172
228
 
173
229
  /**
174
230
  * Clear all cache entries.
175
231
  * Useful when configuration changes (e.g., roleHierarchy) or for testing.
232
+ *
233
+ * When Redis is enabled, the invalidation is additionally broadcast to all other replicas.
176
234
  */
177
235
  invalidateAll(): void {
178
- this.membershipCache.clear();
179
- this.tenantIdsCache.clear();
236
+ this.clearAll();
237
+ this.publishInvalidation({ scope: 'all' });
180
238
  }
181
239
 
182
240
  async canActivate(context: ExecutionContext): Promise<boolean> {
@@ -192,7 +250,8 @@ export class CoreTenantGuard implements CanActivate, OnModuleDestroy {
192
250
  const isTestEnv =
193
251
  process.env.VITEST === 'true' || process.env.NODE_ENV === 'test' || process.env.NODE_ENV === 'e2e';
194
252
  this.cacheTtlMs = config.cacheTtlMs ?? (isTestEnv ? 0 : 30_000);
195
- this.invalidateAll();
253
+ // Local-only flush: every replica detects the config change itself, no broadcast needed
254
+ this.clearAll();
196
255
  }
197
256
 
198
257
  const request = this.getRequest(context);
@@ -494,6 +553,68 @@ export class CoreTenantGuard implements CanActivate, OnModuleDestroy {
494
553
  // Cache helpers
495
554
  // ===================================================================================================================
496
555
 
556
+ /**
557
+ * Apply an invalidation message received from another replica.
558
+ * Clears locally only — re-publishing would bounce the message around the cluster.
559
+ */
560
+ protected applyInvalidation(message: string): void {
561
+ let parsed: TenantCacheInvalidation;
562
+ try {
563
+ parsed = JSON.parse(message);
564
+ } catch {
565
+ this.logger.debug(`Ignoring malformed tenant cache invalidation message: ${message}`);
566
+ return;
567
+ }
568
+ if (parsed?.scope === 'all') {
569
+ this.clearAll();
570
+ } else if (parsed?.scope === 'user' && parsed.userId) {
571
+ this.clearUser(parsed.userId);
572
+ }
573
+ }
574
+
575
+ /**
576
+ * Clear both caches locally (no broadcast)
577
+ */
578
+ protected clearAll(): void {
579
+ this.membershipCache.clear();
580
+ this.tenantIdsCache.clear();
581
+ }
582
+
583
+ /**
584
+ * Remove all entries of a user from both caches locally (no broadcast)
585
+ */
586
+ protected clearUser(userId: string): void {
587
+ for (const key of this.membershipCache.keys()) {
588
+ if (key.startsWith(`${userId}:`)) {
589
+ this.membershipCache.delete(key);
590
+ }
591
+ }
592
+ for (const key of this.tenantIdsCache.keys()) {
593
+ if (key === userId || key.startsWith(`${userId}:`)) {
594
+ this.tenantIdsCache.delete(key);
595
+ }
596
+ }
597
+ }
598
+
599
+ /**
600
+ * Broadcast an invalidation to the other replicas (fire-and-forget).
601
+ * No-op without Redis; failures are logged at debug level only — a missed
602
+ * broadcast merely leaves the other replicas' caches stale until their TTL.
603
+ */
604
+ protected publishInvalidation(message: TenantCacheInvalidation): void {
605
+ if (!this.redisService?.enabled) {
606
+ return;
607
+ }
608
+ try {
609
+ void this.redisService
610
+ .getClient()
611
+ .publish(this.redisService.key('tenant-cache', 'invalidate'), JSON.stringify(message))
612
+ .catch((error: Error) => this.logger.debug(`Tenant cache invalidation publish failed: ${error.message}`));
613
+ } catch (error) {
614
+ this.logger.debug(`Tenant cache invalidation publish failed: ${(error as Error).message}`);
615
+ }
616
+ }
617
+
497
618
  /**
498
619
  * Look up a membership with process-level TTL cache.
499
620
  * Avoids repeated DB queries when the same user accesses the same tenant repeatedly.