@lenne.tech/nest-server 11.32.4 → 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 (260) 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/core/common/helpers/content-disposition.helper.d.ts +1 -0
  7. package/dist/core/common/helpers/content-disposition.helper.js +14 -0
  8. package/dist/core/common/helpers/content-disposition.helper.js.map +1 -0
  9. package/dist/core/common/helpers/file.helper.d.ts +7 -0
  10. package/dist/core/common/helpers/file.helper.js +21 -4
  11. package/dist/core/common/helpers/file.helper.js.map +1 -1
  12. package/dist/core/common/helpers/graceful-shutdown.helper.d.ts +2 -0
  13. package/dist/core/common/helpers/graceful-shutdown.helper.js +55 -0
  14. package/dist/core/common/helpers/graceful-shutdown.helper.js.map +1 -0
  15. package/dist/core/common/helpers/project-name.helper.d.ts +2 -0
  16. package/dist/core/common/helpers/project-name.helper.js +38 -0
  17. package/dist/core/common/helpers/project-name.helper.js.map +1 -0
  18. package/dist/core/common/interfaces/cron-job-config.interface.d.ts +1 -0
  19. package/dist/core/common/interfaces/server-options.interface.d.ts +39 -0
  20. package/dist/core/common/services/core-cron-jobs.initializer.d.ts +10 -0
  21. package/dist/core/common/services/core-cron-jobs.initializer.js +46 -0
  22. package/dist/core/common/services/core-cron-jobs.initializer.js.map +1 -0
  23. package/dist/core/common/services/core-cron-jobs.registry.d.ts +8 -0
  24. package/dist/core/common/services/core-cron-jobs.registry.js +12 -0
  25. package/dist/core/common/services/core-cron-jobs.registry.js.map +1 -0
  26. package/dist/core/common/services/core-cron-jobs.service.d.ts +37 -7
  27. package/dist/core/common/services/core-cron-jobs.service.js +278 -27
  28. package/dist/core/common/services/core-cron-jobs.service.js.map +1 -1
  29. package/dist/core/common/services/core-redis-pubsub.d.ts +20 -0
  30. package/dist/core/common/services/core-redis-pubsub.js +112 -0
  31. package/dist/core/common/services/core-redis-pubsub.js.map +1 -0
  32. package/dist/core/common/services/core-redis.service.d.ts +25 -0
  33. package/dist/core/common/services/core-redis.service.js +154 -0
  34. package/dist/core/common/services/core-redis.service.js.map +1 -0
  35. package/dist/core/common/services/core-s3.service.d.ts +51 -0
  36. package/dist/core/common/services/core-s3.service.js +261 -0
  37. package/dist/core/common/services/core-s3.service.js.map +1 -0
  38. package/dist/core/common/services/core-trust-proxy.initializer.d.ts +16 -0
  39. package/dist/core/common/services/core-trust-proxy.initializer.js +77 -0
  40. package/dist/core/common/services/core-trust-proxy.initializer.js.map +1 -0
  41. package/dist/core/common/services/rate-limit-store.d.ts +61 -0
  42. package/dist/core/common/services/rate-limit-store.js +235 -0
  43. package/dist/core/common/services/rate-limit-store.js.map +1 -0
  44. package/dist/core/modules/ai/core-ai-mcp.controller.d.ts +19 -3
  45. package/dist/core/modules/ai/core-ai-mcp.controller.js +148 -18
  46. package/dist/core/modules/ai/core-ai-mcp.controller.js.map +1 -1
  47. package/dist/core/modules/ai/services/core-ai.service.d.ts +7 -2
  48. package/dist/core/modules/ai/services/core-ai.service.js +24 -20
  49. package/dist/core/modules/ai/services/core-ai.service.js.map +1 -1
  50. package/dist/core/modules/auth/core-auth.module.js +4 -1
  51. package/dist/core/modules/auth/core-auth.module.js.map +1 -1
  52. package/dist/core/modules/auth/guards/legacy-auth-rate-limit.guard.d.ts +1 -1
  53. package/dist/core/modules/auth/guards/legacy-auth-rate-limit.guard.js +3 -14
  54. package/dist/core/modules/auth/guards/legacy-auth-rate-limit.guard.js.map +1 -1
  55. package/dist/core/modules/auth/services/legacy-auth-rate-limiter.service.d.ts +12 -10
  56. package/dist/core/modules/auth/services/legacy-auth-rate-limiter.service.js +30 -59
  57. package/dist/core/modules/auth/services/legacy-auth-rate-limiter.service.js.map +1 -1
  58. package/dist/core/modules/better-auth/core-better-auth-email-verification.service.d.ts +8 -2
  59. package/dist/core/modules/better-auth/core-better-auth-email-verification.service.js +118 -45
  60. package/dist/core/modules/better-auth/core-better-auth-email-verification.service.js.map +1 -1
  61. package/dist/core/modules/better-auth/core-better-auth-rate-limit.middleware.d.ts +1 -1
  62. package/dist/core/modules/better-auth/core-better-auth-rate-limit.middleware.js +2 -11
  63. package/dist/core/modules/better-auth/core-better-auth-rate-limit.middleware.js.map +1 -1
  64. package/dist/core/modules/better-auth/core-better-auth-rate-limiter.service.d.ts +12 -10
  65. package/dist/core/modules/better-auth/core-better-auth-rate-limiter.service.js +30 -85
  66. package/dist/core/modules/better-auth/core-better-auth-rate-limiter.service.js.map +1 -1
  67. package/dist/core/modules/better-auth/core-better-auth-user.mapper.js +0 -1
  68. package/dist/core/modules/better-auth/core-better-auth-user.mapper.js.map +1 -1
  69. package/dist/core/modules/file/core-file.controller.d.ts +11 -2
  70. package/dist/core/modules/file/core-file.controller.js +52 -15
  71. package/dist/core/modules/file/core-file.controller.js.map +1 -1
  72. package/dist/core/modules/file/core-file.resolver.d.ts +4 -4
  73. package/dist/core/modules/file/core-file.resolver.js +23 -16
  74. package/dist/core/modules/file/core-file.resolver.js.map +1 -1
  75. package/dist/core/modules/file/core-file.service.d.ts +41 -7
  76. package/dist/core/modules/file/core-file.service.js +227 -7
  77. package/dist/core/modules/file/core-file.service.js.map +1 -1
  78. package/dist/core/modules/file/file-metadata.helper.d.ts +16 -0
  79. package/dist/core/modules/file/file-metadata.helper.js +33 -0
  80. package/dist/core/modules/file/file-metadata.helper.js.map +1 -0
  81. package/dist/core/modules/file/file-roles.helper.d.ts +4 -0
  82. package/dist/core/modules/file/file-roles.helper.js +53 -0
  83. package/dist/core/modules/file/file-roles.helper.js.map +1 -0
  84. package/dist/core/modules/file/file-storage.helper.d.ts +13 -0
  85. package/dist/core/modules/file/file-storage.helper.js +65 -0
  86. package/dist/core/modules/file/file-storage.helper.js.map +1 -0
  87. package/dist/core/modules/file/filesystem-file.helper.d.ts +22 -0
  88. package/dist/core/modules/file/filesystem-file.helper.js +77 -0
  89. package/dist/core/modules/file/filesystem-file.helper.js.map +1 -0
  90. package/dist/core/modules/file/interfaces/file-service-options.interface.d.ts +1 -0
  91. package/dist/core/modules/file/interfaces/file-upload.interface.d.ts +7 -1
  92. package/dist/core/modules/file/s3-file.helper.d.ts +37 -0
  93. package/dist/core/modules/file/s3-file.helper.js +81 -0
  94. package/dist/core/modules/file/s3-file.helper.js.map +1 -0
  95. package/dist/core/modules/hub/core-hub.controller.d.ts +8 -8
  96. package/dist/core/modules/hub/core-hub.controller.js +24 -22
  97. package/dist/core/modules/hub/core-hub.controller.js.map +1 -1
  98. package/dist/core/modules/hub/helpers/hub-mask.helper.js +2 -2
  99. package/dist/core/modules/hub/helpers/hub-mask.helper.js.map +1 -1
  100. package/dist/core/modules/hub/hub-buffer.d.ts +23 -0
  101. package/dist/core/modules/hub/hub-buffer.js +101 -0
  102. package/dist/core/modules/hub/hub-buffer.js.map +1 -0
  103. package/dist/core/modules/hub/services/core-hub-mailbox.service.d.ts +7 -5
  104. package/dist/core/modules/hub/services/core-hub-mailbox.service.js +14 -10
  105. package/dist/core/modules/hub/services/core-hub-mailbox.service.js.map +1 -1
  106. package/dist/core/modules/hub/services/hub-log-buffer.service.d.ts +5 -2
  107. package/dist/core/modules/hub/services/hub-log-buffer.service.js +17 -8
  108. package/dist/core/modules/hub/services/hub-log-buffer.service.js.map +1 -1
  109. package/dist/core/modules/hub/services/hub-query-profiler.service.d.ts +4 -2
  110. package/dist/core/modules/hub/services/hub-query-profiler.service.js +13 -7
  111. package/dist/core/modules/hub/services/hub-query-profiler.service.js.map +1 -1
  112. package/dist/core/modules/hub/services/hub-trace-buffer.service.d.ts +4 -2
  113. package/dist/core/modules/hub/services/hub-trace-buffer.service.js +13 -8
  114. package/dist/core/modules/hub/services/hub-trace-buffer.service.js.map +1 -1
  115. package/dist/core/modules/migrate/helpers/migration.helper.d.ts +2 -6
  116. package/dist/core/modules/migrate/helpers/migration.helper.js +4 -4
  117. package/dist/core/modules/migrate/helpers/migration.helper.js.map +1 -1
  118. package/dist/core/modules/migrate/migration-runner.d.ts +1 -0
  119. package/dist/core/modules/migrate/migration-runner.js +4 -0
  120. package/dist/core/modules/migrate/migration-runner.js.map +1 -1
  121. package/dist/core/modules/migrate/mongo-state-store.d.ts +1 -0
  122. package/dist/core/modules/migrate/mongo-state-store.js +98 -9
  123. package/dist/core/modules/migrate/mongo-state-store.js.map +1 -1
  124. package/dist/core/modules/system-setup/core-system-setup.service.d.ts +2 -0
  125. package/dist/core/modules/system-setup/core-system-setup.service.js +31 -1
  126. package/dist/core/modules/system-setup/core-system-setup.service.js.map +1 -1
  127. package/dist/core/modules/tenant/core-tenant.guard.d.ts +17 -3
  128. package/dist/core/modules/tenant/core-tenant.guard.js +92 -15
  129. package/dist/core/modules/tenant/core-tenant.guard.js.map +1 -1
  130. package/dist/core/modules/tenant/core-tenant.module.js +3 -4
  131. package/dist/core/modules/tenant/core-tenant.module.js.map +1 -1
  132. package/dist/core/modules/tus/core-tus.controller.d.ts +2 -0
  133. package/dist/core/modules/tus/core-tus.controller.js +29 -3
  134. package/dist/core/modules/tus/core-tus.controller.js.map +1 -1
  135. package/dist/core/modules/tus/core-tus.service.d.ts +24 -1
  136. package/dist/core/modules/tus/core-tus.service.js +182 -28
  137. package/dist/core/modules/tus/core-tus.service.js.map +1 -1
  138. package/dist/core/modules/tus/interfaces/tus-config.interface.js +3 -0
  139. package/dist/core/modules/tus/interfaces/tus-config.interface.js.map +1 -1
  140. package/dist/core/modules/tus/tus-redis-locker.d.ts +25 -0
  141. package/dist/core/modules/tus/tus-redis-locker.js +87 -0
  142. package/dist/core/modules/tus/tus-redis-locker.js.map +1 -0
  143. package/dist/core/modules/tus/tus.module.d.ts +1 -0
  144. package/dist/core/modules/tus/tus.module.js +30 -4
  145. package/dist/core/modules/tus/tus.module.js.map +1 -1
  146. package/dist/core.module.js +18 -1
  147. package/dist/core.module.js.map +1 -1
  148. package/dist/index.d.ts +10 -0
  149. package/dist/index.js +10 -0
  150. package/dist/index.js.map +1 -1
  151. package/dist/main.js +2 -1
  152. package/dist/main.js.map +1 -1
  153. package/dist/server/modules/file/file.controller.js.map +1 -1
  154. package/dist/server/modules/file/file.resolver.js +1 -12
  155. package/dist/server/modules/file/file.resolver.js.map +1 -1
  156. package/dist/server/modules/file/file.service.d.ts +5 -1
  157. package/dist/server/modules/file/file.service.js +12 -3
  158. package/dist/server/modules/file/file.service.js.map +1 -1
  159. package/dist/server/modules/user/avatar.controller.d.ts +5 -1
  160. package/dist/server/modules/user/avatar.controller.js +26 -10
  161. package/dist/server/modules/user/avatar.controller.js.map +1 -1
  162. package/dist/server/modules/user/user.model.js +3 -2
  163. package/dist/server/modules/user/user.model.js.map +1 -1
  164. package/dist/server/modules/user/user.module.js +6 -2
  165. package/dist/server/modules/user/user.module.js.map +1 -1
  166. package/dist/server/modules/user/user.service.d.ts +1 -1
  167. package/dist/server/modules/user/user.service.js +6 -13
  168. package/dist/server/modules/user/user.service.js.map +1 -1
  169. package/dist/tsconfig.build.tsbuildinfo +1 -1
  170. package/docs/REQUEST-LIFECYCLE.md +57 -8
  171. package/docs/security-overrides.md +21 -3
  172. package/migration-guides/11.32.3-to-11.32.4.md +15 -3
  173. package/migration-guides/11.32.x-to-11.33.x.md +1551 -0
  174. package/package.json +74 -42
  175. package/src/core/common/helpers/content-disposition.helper.ts +90 -0
  176. package/src/core/common/helpers/file.helper.ts +53 -10
  177. package/src/core/common/helpers/graceful-shutdown.helper.ts +116 -0
  178. package/src/core/common/helpers/project-name.helper.ts +71 -0
  179. package/src/core/common/interfaces/cron-job-config.interface.ts +16 -0
  180. package/src/core/common/interfaces/server-options.interface.ts +439 -2
  181. package/src/core/common/services/core-cron-jobs.initializer.ts +39 -0
  182. package/src/core/common/services/core-cron-jobs.registry.ts +45 -0
  183. package/src/core/common/services/core-cron-jobs.service.ts +589 -47
  184. package/src/core/common/services/core-redis-pubsub.ts +183 -0
  185. package/src/core/common/services/core-redis.service.ts +249 -0
  186. package/src/core/common/services/core-s3.service.ts +465 -0
  187. package/src/core/common/services/core-trust-proxy.initializer.ts +112 -0
  188. package/src/core/common/services/rate-limit-store.ts +492 -0
  189. package/src/core/modules/ai/README.md +25 -0
  190. package/src/core/modules/ai/core-ai-mcp.controller.ts +253 -22
  191. package/src/core/modules/ai/services/core-ai.service.ts +33 -24
  192. package/src/core/modules/auth/core-auth.module.ts +7 -1
  193. package/src/core/modules/auth/guards/legacy-auth-rate-limit.guard.ts +12 -21
  194. package/src/core/modules/auth/services/legacy-auth-rate-limiter.service.ts +49 -93
  195. package/src/core/modules/better-auth/core-better-auth-email-verification.service.ts +202 -66
  196. package/src/core/modules/better-auth/core-better-auth-rate-limit.middleware.ts +11 -18
  197. package/src/core/modules/better-auth/core-better-auth-rate-limiter.service.ts +51 -132
  198. package/src/core/modules/better-auth/core-better-auth-user.mapper.ts +7 -1
  199. package/src/core/modules/file/INTEGRATION-CHECKLIST.md +108 -0
  200. package/src/core/modules/file/README.md +277 -53
  201. package/src/core/modules/file/core-file.controller.ts +168 -12
  202. package/src/core/modules/file/core-file.resolver.ts +46 -12
  203. package/src/core/modules/file/core-file.service.ts +482 -16
  204. package/src/core/modules/file/file-metadata.helper.ts +142 -0
  205. package/src/core/modules/file/file-roles.helper.ts +110 -0
  206. package/src/core/modules/file/file-storage.helper.ts +163 -0
  207. package/src/core/modules/file/filesystem-file.helper.ts +184 -0
  208. package/src/core/modules/file/interfaces/file-service-options.interface.ts +14 -0
  209. package/src/core/modules/file/interfaces/file-upload.interface.ts +17 -1
  210. package/src/core/modules/file/s3-file.helper.ts +209 -0
  211. package/src/core/modules/hub/README.md +48 -0
  212. package/src/core/modules/hub/core-hub.controller.ts +18 -16
  213. package/src/core/modules/hub/helpers/hub-mask.helper.ts +21 -4
  214. package/src/core/modules/hub/hub-buffer.ts +198 -0
  215. package/src/core/modules/hub/services/core-hub-mailbox.service.ts +22 -12
  216. package/src/core/modules/hub/services/hub-log-buffer.service.ts +24 -8
  217. package/src/core/modules/hub/services/hub-query-profiler.service.ts +9 -6
  218. package/src/core/modules/hub/services/hub-trace-buffer.service.ts +14 -9
  219. package/src/core/modules/migrate/README.md +33 -6
  220. package/src/core/modules/migrate/helpers/migration.helper.ts +13 -6
  221. package/src/core/modules/migrate/migration-runner.ts +10 -1
  222. package/src/core/modules/migrate/mongo-state-store.ts +179 -11
  223. package/src/core/modules/system-setup/README.md +7 -2
  224. package/src/core/modules/system-setup/core-system-setup.service.ts +95 -1
  225. package/src/core/modules/tenant/README.md +21 -1
  226. package/src/core/modules/tenant/core-tenant.guard.ts +135 -14
  227. package/src/core/modules/tenant/core-tenant.module.ts +8 -4
  228. package/src/core/modules/tus/INTEGRATION-CHECKLIST.md +93 -21
  229. package/src/core/modules/tus/README.md +35 -2
  230. package/src/core/modules/tus/core-tus.controller.ts +64 -12
  231. package/src/core/modules/tus/core-tus.service.ts +358 -34
  232. package/src/core/modules/tus/interfaces/tus-config.interface.ts +9 -0
  233. package/src/core/modules/tus/tus-redis-locker.ts +139 -0
  234. package/src/core/modules/tus/tus.module.ts +78 -6
  235. package/src/core.module.ts +26 -1
  236. package/src/index.ts +10 -0
  237. package/src/main.ts +7 -1
  238. package/src/server/modules/file/file.controller.ts +7 -3
  239. package/src/server/modules/file/file.resolver.ts +5 -17
  240. package/src/server/modules/file/file.service.ts +42 -3
  241. package/src/server/modules/user/avatar.controller.ts +39 -9
  242. package/src/server/modules/user/user.model.ts +9 -3
  243. package/src/server/modules/user/user.module.ts +12 -3
  244. package/src/server/modules/user/user.service.ts +20 -17
  245. package/src/test/README.md +7 -2
  246. package/src/core/common/helpers/file.helper.spec.ts +0 -145
  247. package/src/core/common/helpers/logging.helper.spec.ts +0 -61
  248. package/src/core/common/helpers/process-diagnostics.helper.spec.ts +0 -310
  249. package/src/core/common/services/brevo.service.spec.ts +0 -266
  250. package/src/core/modules/file/core-file.controller.spec.ts +0 -164
  251. package/src/core/modules/hub/core-hub.module.spec.ts +0 -108
  252. package/src/core/modules/hub/helpers/hub-command-shape.helper.spec.ts +0 -48
  253. package/src/core/modules/hub/helpers/hub-mask.helper.spec.ts +0 -67
  254. package/src/core/modules/hub/helpers/hub-mermaid.helper.spec.ts +0 -61
  255. package/src/core/modules/hub/helpers/hub-shell.helper.spec.ts +0 -106
  256. package/src/core/modules/hub/hub-config.helper.spec.ts +0 -108
  257. package/src/core/modules/hub/hub-ring-buffer.spec.ts +0 -95
  258. package/src/core/modules/hub/services/core-hub-mailbox.service.spec.ts +0 -116
  259. package/src/core/modules/hub/services/hub-trace-buffer.service.spec.ts +0 -112
  260. package/src/core/modules/migrate/helpers/migration.helper.spec.ts +0 -85
@@ -0,0 +1,1551 @@
1
+ # Migration Guide: 11.32.x → 11.33.x
2
+
3
+ ## Overview
4
+
5
+ | Category | Details |
6
+ |----------|---------|
7
+ | **Breaking Changes** | **File downloads, the file GraphQL members and TUS uploads are no longer public (§A, §B) — this one affects every project, not just multi-replica ones.** `CoreBetterAuthRateLimiter` and `LegacyAuthRateLimiter`: `check()` / `reset()` / `clear()` are **async** now (§1). Hub collector reads are async (§2). `getFileStream()` / `getFileStreamByName()` are typed `Readable` instead of `GridFSBucketReadStream` (§3) |
8
+ | **New Features** | `file.downloadRoles` / `uploadRoles` / `deleteRoles` and `tus.roles`; per-file authorization via `checkRights()` with real metadata support (§A). Optional central `redis` (rate limits, cron dedup, GraphQL subscriptions, tenant-cache invalidation, MCP session registry, Hub collectors); optional `s3`; three file storage drivers via `file.storage` (`'filesystem'` is new) with a derived default and fail-fast enforcement; `tus.s3Staging`; `shutdownDelayMs`; `trustProxy` (§11c — **configure it if you rate-limit behind a reverse proxy**); per-job cron `distributed`; `multerFileToUpload()` for REST uploads into central storage |
9
+ | **Behaviour Changes** | The migration lock is **on by default** for `migrate up` (§4). Initial-admin auto-creation takes an atomic claim (§5). The `src/server` reference implementation stores avatars and GraphQL uploads in the central file storage instead of pod-local disk (§10) |
10
+ | **Bugfixes** | Migration lock gained heartbeat + stale-break + bounded wait; `runOnInit` cron dedup no longer keyed on each replica's boot clock and no longer crashes the process on a failed startup run; BullMQ worker no longer swallows a tick delivered during bootstrap; rate limiters degrade instead of 500-ing on a Redis outage; Hub buffer mirror failures no longer recurse through the Logger they buffer; the email-verification cooldown timer can no longer expire a later cooldown early; a presigned S3 download no longer serves the file under a different name than the streamed one (§14) |
11
+ | **Under the hood (§14)** | Two new MongoDB collections (`s3-files`, `filesystem-files` — include them in backups), new public exports, a bucket-less `s3` block is now ignored, NestJS 11.1.28 → 11.1.29, plus two dependency MAJORS: `js-sha256` 0.12.0 → 1.0.0 (**digests unchanged — no password-hash break**) and `graphql-query-complexity` 1.1.1 → 2.0.0 |
12
+ | **Dependency Housekeeping** | The framework's own `pnpm audit` went from 20 findings to 0. Those are `pnpm-workspace.yaml` overrides, which **do not reach npm consumers** — run your own audit (§13) |
13
+ | **Migration Effort** | **Start with §A and §B — they are the only changes that can break a single-replica project, and they break it silently** (no crash, just 401/403 on `/files/**` and `/tus`). Then **§11a / §11b if your config already contains a `redis` or an `s3` key** — the framework reads both now, and an existing `s3` key makes the boot FAIL until you act. Everything else is opt-in: read §1 if you call or override the rate limiters, §2 if you override Hub services, §4 before your next multi-replica deploy, §10 if you copied the avatar upload from `src/server`, **§11c if you rate-limit behind a reverse proxy** (a boot warning will tell you), §13 for what the security overrides do and do not do for you |
14
+
15
+ Most of this release exists to make one deployment shape possible: **more than one replica**. Nothing there is required to keep running on one.
16
+
17
+ **Two changes are different and apply to every project: §A and §B.** The file endpoints and TUS were
18
+ public by default and are not any more. Read those two first.
19
+
20
+ ---
21
+
22
+ ## Quick Migration
23
+
24
+ ```bash
25
+ pnpm update @lenne.tech/nest-server@11.33.0
26
+ pnpm run build
27
+ pnpm test
28
+ ```
29
+
30
+ No configuration change is required for the multi-replica features, and no new package needs
31
+ installing unless you opt into one (see §12). **§A and §B are the exception** — if your project
32
+ serves files to non-admins or accepts anonymous uploads, they need action.
33
+
34
+ **Vendor-mode projects, before you start:** if your vendored baseline is older than 11.32.0, check
35
+ your `@getbrevo/brevo` pin. The core's `common/services/brevo.service.ts` was rewritten for the
36
+ Brevo SDK **v6** (`BrevoClient`, `transactionalEmails.sendTransacEmail()`); none of that API exists
37
+ in v3/v5, so syncing the file onto an older pin leaves a core that neither type-checks nor runs.
38
+ Bump to `6.0.2` in the same change. This bites only vendor mode — npm consumers get the version
39
+ through the package's own dependency. Found by syncing a real project pinned at `3.0.4`.
40
+
41
+ **Vendor-mode projects:** this is a large sync. All **14** newly added code files under `src/core/`:
42
+
43
+ ```
44
+ common/helpers/graceful-shutdown.helper.ts
45
+ common/helpers/project-name.helper.ts
46
+ common/services/core-cron-jobs.initializer.ts
47
+ common/services/core-cron-jobs.registry.ts
48
+ common/services/core-redis-pubsub.ts
49
+ common/services/core-redis.service.ts
50
+ common/services/core-s3.service.ts
51
+ common/services/rate-limit-store.ts
52
+ modules/file/file-roles.helper.ts
53
+ modules/file/file-storage.helper.ts
54
+ modules/file/filesystem-file.helper.ts
55
+ modules/file/s3-file.helper.ts
56
+ modules/hub/hub-buffer.ts
57
+ modules/tus/tus-redis-locker.ts
58
+ ```
59
+
60
+ Plus one new documentation file, `modules/file/INTEGRATION-CHECKLIST.md`, which carries no code and
61
+ can be taken separately.
62
+
63
+ All **33** modified code files under `src/core/` (plus the two top-level files `src/index.ts` and
64
+ `src/core.module.ts`):
65
+
66
+ ```
67
+ common/helpers/file.helper.ts
68
+ common/interfaces/cron-job-config.interface.ts
69
+ common/interfaces/server-options.interface.ts
70
+ common/services/core-cron-jobs.service.ts
71
+ modules/ai/core-ai-mcp.controller.ts
72
+ modules/ai/services/core-ai.service.ts
73
+ modules/auth/core-auth.module.ts
74
+ modules/auth/guards/legacy-auth-rate-limit.guard.ts
75
+ modules/auth/services/legacy-auth-rate-limiter.service.ts
76
+ modules/better-auth/core-better-auth-email-verification.service.ts
77
+ modules/better-auth/core-better-auth-rate-limit.middleware.ts
78
+ modules/better-auth/core-better-auth-rate-limiter.service.ts
79
+ modules/better-auth/core-better-auth-user.mapper.ts
80
+ modules/file/core-file.controller.ts
81
+ modules/file/core-file.resolver.ts
82
+ modules/file/core-file.service.ts
83
+ modules/file/interfaces/file-service-options.interface.ts
84
+ modules/file/interfaces/file-upload.interface.ts
85
+ modules/hub/core-hub.controller.ts
86
+ modules/hub/services/core-hub-mailbox.service.ts
87
+ modules/hub/services/hub-log-buffer.service.ts
88
+ modules/hub/services/hub-query-profiler.service.ts
89
+ modules/hub/services/hub-trace-buffer.service.ts
90
+ modules/migrate/helpers/migration.helper.ts
91
+ modules/migrate/migration-runner.ts
92
+ modules/migrate/mongo-state-store.ts
93
+ modules/system-setup/core-system-setup.service.ts
94
+ modules/tenant/core-tenant.guard.ts
95
+ modules/tenant/core-tenant.module.ts
96
+ modules/tus/core-tus.controller.ts
97
+ modules/tus/core-tus.service.ts
98
+ modules/tus/interfaces/tus-config.interface.ts
99
+ modules/tus/tus.module.ts
100
+ ```
101
+
102
+ Six `README.md` files also changed (`modules/ai`, `modules/file`, `modules/hub`, `modules/migrate`,
103
+ `modules/system-setup`, `modules/tus`). Those are documentation-only and safe to take separately.
104
+
105
+ **Also removed from `src/core/`:** every co-located `*.spec.ts` (they moved to `tests/unit/`, since
106
+ `src/` is what ships in the tarball and what vendor mode copies verbatim). A sync that only *adds*
107
+ and *updates* leaves those orphans behind in your tree, where they reference helpers that no longer
108
+ sit next to them. Delete any `src/core/**/*.spec.ts` your baseline still has.
109
+
110
+ **This sync has an atomic file-set hazard**, and the four files most easily missed are exactly the
111
+ ones that break the build:
112
+
113
+ | Missing file | Breaks |
114
+ |--------------|--------|
115
+ | `common/helpers/project-name.helper.ts` | `common/services/core-redis.service.ts` (default `keyPrefix`) |
116
+ | `modules/file/file-storage.helper.ts` | `modules/file/core-file.service.ts`, `modules/tus/core-tus.service.ts` |
117
+ | `modules/file/filesystem-file.helper.ts` | `modules/file/core-file.service.ts`, `modules/tus/core-tus.service.ts` |
118
+ | `modules/file/file-roles.helper.ts` | `src/core.module.ts` |
119
+
120
+ The same applies to the services: adopting
121
+ `modules/auth/services/legacy-auth-rate-limiter.service.ts` without
122
+ `common/services/rate-limit-store.ts` and `common/services/core-redis.service.ts` does not compile.
123
+ Take the whole code set or none of it.
124
+
125
+ ---
126
+
127
+ ## A. Breaking: file downloads, uploads and the GraphQL members are role-gated
128
+
129
+ Six members moved from `@Roles(RoleEnum.S_EVERYONE)` to a configurable role list, defaulting to
130
+ `[RoleEnum.ADMIN]`:
131
+
132
+ | Member | Kind | Now gated by |
133
+ |--------|------|--------------|
134
+ | `GET /files/id/:id` | REST | `file.downloadRoles` |
135
+ | `GET /files/:filename` | REST | `file.downloadRoles` |
136
+ | `getFileInfo` | GraphQL query | `file.downloadRoles` |
137
+ | `uploadFile` | GraphQL mutation | `file.uploadRoles` |
138
+ | `uploadFiles` | GraphQL mutation | `file.uploadRoles` |
139
+ | `deleteFile` | GraphQL mutation | `file.deleteRoles` |
140
+
141
+ `S_EVERYONE` was not merely permissive. The roles guard treats it as an unconditional `return true`
142
+ **before authenticating at all**, and because handler and class roles are UNIONed rather than
143
+ overridden, the handler-level `S_EVERYONE` silently defeated the class-level `@Roles(ADMIN)` both
144
+ classes already carried. `deleteFile` was reachable anonymously.
145
+
146
+ **Symptom if you skip this:** no crash, no error — `401`/`403` on `/files/**`, showing up as broken
147
+ images and empty avatars.
148
+
149
+ ### A.1 — Check your own controller FIRST
150
+
151
+ If your `FileController` re-declares the roles, your downloads were never affected by the old
152
+ default and are still public today:
153
+
154
+ ```typescript
155
+ // If you find THIS, the config below will not reach these routes:
156
+ @Get('id/:id')
157
+ @Roles(RoleEnum.S_EVERYONE)
158
+ override async getFileById(@Param('id') id: string, @Res() res: Response): Promise<Response> {
159
+ return super.getFileById(id, res);
160
+ }
161
+ ```
162
+
163
+ Role metadata lives on the **function object**, so an override carries its own and opts out of
164
+ `file.downloadRoles` permanently. Delete such overrides and configure the roles instead.
165
+
166
+ Two further consequences:
167
+
168
+ - An override that omits `@Get(...)` **unregisters the route** — Nest finds no `PATH_METADATA` on the
169
+ subclass function and never maps it. 404 for everyone, no error, no warning.
170
+ - A class-level `@Roles()` on your subclass **cannot** relax an inherited member, because the
171
+ inherited function carries its own handler-level roles and the two are unioned.
172
+
173
+ `nest-server-starter` shipped exactly this override — added only to attach Swagger annotations — and
174
+ is corrected in the same release.
175
+
176
+ ### A.2 — Configure
177
+
178
+ ```typescript
179
+ // src/config.env.ts
180
+ file: {
181
+ downloadRoles: [RoleEnum.ADMIN], // default
182
+ uploadRoles: [RoleEnum.ADMIN], // default
183
+ deleteRoles: [RoleEnum.ADMIN], // default
184
+ },
185
+ ```
186
+
187
+ Plain role strings, so project-specific roles work: `downloadRoles: ['company-admin', 'editor']`.
188
+
189
+ - **ADMIN always keeps access** (the class-level `@Roles(ADMIN)` is unioned in). These knobs cannot
190
+ exclude admins.
191
+ - **`[]` is rejected** with a warning and the default applies — an all-empty role set reads to the
192
+ guards as "no roles required" and would open the route.
193
+ - Roles resolve against `user.roles`, never `membership.role`: both classes now carry
194
+ `@SkipTenantCheck()`. GridFS and the S3 metadata collection are reached outside Mongoose, so
195
+ `mongooseTenantPlugin` never scopes them — a role name alone cannot express a per-tenant rule.
196
+
197
+ ### A.3 — The better answer: authorize per file
198
+
199
+ The real requirement is usually "which files", not "which roles". Write an owner at upload time and
200
+ decide per file:
201
+
202
+ ```typescript
203
+ await this.fileService.createFile(file, { metadata: { ownerId: currentUser.id } });
204
+
205
+ export class FileService extends CoreFileService {
206
+ protected override async checkRights(
207
+ input: any,
208
+ options?: FileServiceOptions & { checkInputType: FileInputCheckType },
209
+ ): Promise<boolean> {
210
+ if (options?.checkInputType !== 'id' || options.force) {
211
+ return true;
212
+ }
213
+ if (options.currentUser?.hasRole([RoleEnum.ADMIN])) {
214
+ return true;
215
+ }
216
+ const raw = await this.getRawFileInfo(input);
217
+ return !!raw && String(raw.metadata?.ownerId) === String(options.currentUser?.id);
218
+ }
219
+ }
220
+ ```
221
+
222
+ Three new pieces make this possible, and all three were missing before: `createFile()` now forwards
223
+ `serviceOptions.metadata` to storage; `getRawFileInfo()` / `getRawFileInfoByName()` read the raw
224
+ document (the public `getFileInfo()` runs `prepareOutput` → `check()` and strips the very field the
225
+ decision rests on); and the core controller and resolver now pass `currentUser` into every service
226
+ call. `getRawFileInfo()` checks S3 metadata first, then the **filesystem** store, then GridFS — all
227
+ three stores, in the same order as `getFileInfo()`, so a rule sees the file the download would
228
+ actually serve (§7). Omitting a store from your own version of this lookup is not a cosmetic
229
+ difference: the rule then decides on a document that is not the one being served.
230
+
231
+ With that in place, `downloadRoles: [RoleEnum.S_USER]` means "signed-in users may fetch **their
232
+ own** files" rather than "everything in the bucket".
233
+
234
+ A refused `checkRights()` answers **404**, deliberately the same as an unknown id, so the endpoint
235
+ cannot be used to probe which files exist.
236
+
237
+ ### A.4 — Browser `<img>` / `<a download>`, and presigned URLs
238
+
239
+ Markup-driven requests cannot send an `Authorization` header, and a CORS preflight sends no
240
+ credentials. Anything stricter than `S_EVERYONE` therefore only works from markup when the session
241
+ travels as a **cookie** on a same-site request. If your frontend is Bearer-only, gated file URLs
242
+ will not render in markup at all — fetch them programmatically and build an object URL, or expose a
243
+ separate public route for exactly the files meant to be public.
244
+
245
+ If you enable presigned S3 downloads (§7), note that the issued URL is a **bearer capability**:
246
+ anyone holding it can fetch the object until it expires, with no session. Authorization happens once,
247
+ when the URL is issued. Keep the expiry short, and do not enable it for files whose audience is
248
+ narrower than "anyone who was ever given the link".
249
+
250
+ ### A.5 — Also new: `Cache-Control: private, no-store` on downloads
251
+
252
+ An authorization-gated response with no cache directive may be stored by a shared cache under
253
+ RFC 9111 — a reverse proxy or CDN with a blanket `/files/*` rule could hand an authorized response to
254
+ the next, unauthorized requester. **Review any CDN rule you have on `/files/*`.** Override
255
+ `setFileHeaders()` on your controller if you serve genuinely public, cacheable assets.
256
+
257
+ ### A.6 — Fix your tests
258
+
259
+ ```typescript
260
+ const res = await testHelper.download(`/files/id/${id}`, { token: adminToken });
261
+ expect(res.statusCode).toEqual(200);
262
+
263
+ expect((await testHelper.download(`/files/id/${id}`)).statusCode).toEqual(401); // anonymous
264
+ expect((await testHelper.download(`/files/id/${id}`, { token: userToken })).statusCode)
265
+ .toEqual(403); // no right
266
+ ```
267
+
268
+ Assert the exact code, not `[401, 403]` — SPA auth layers branch on 401 to trigger logout, so
269
+ conflating them hides a real regression.
270
+
271
+ ---
272
+
273
+ ## B. Breaking: TUS uploads require a session
274
+
275
+ `tus.roles` now defaults to `[RoleEnum.S_USER]`; it was `S_EVERYONE`.
276
+
277
+ A TUS upload writes into the **same file store** the download routes guard — GridFS or S3, whichever
278
+ `file.storage` selects — and the termination extension (on by default) can delete from it. Anonymous
279
+ writes into a store only privileged callers may read is the wrong way round.
280
+
281
+ ```typescript
282
+ tus: { roles: [RoleEnum.S_USER] }, // default
283
+ tus: { roles: [RoleEnum.S_EVERYONE] }, // opt back in to anonymous uploads
284
+ tus: { roles: ['editor', 'contributor'] }, // project-specific roles work too
285
+ ```
286
+
287
+ **If you accept attachments on a public form, you must set `S_EVERYONE` explicitly** — a public
288
+ support or contact form with file upload is the common case, and it breaks at the very first
289
+ request otherwise.
290
+
291
+ `roles: []` is rejected with a warning rather than honoured, for the same reason as in §A.2.
292
+
293
+ `OPTIONS` stays public regardless: it is the CORS preflight, which browsers send without
294
+ credentials, and it returns only server capabilities. Gating it would make every browser upload fail
295
+ before it starts.
296
+
297
+ **Watch the pairing with §A.** `tus.roles` and `file.downloadRoles` are separate, so with both at
298
+ their defaults a signed-in user may upload but cannot read the result back. Either widen
299
+ `downloadRoles`, or — better — write an owner into the metadata at upload time and authorize per
300
+ file (§A.3). TUS uploads already carry `metadata.tusUploadId` and the original TUS metadata, so
301
+ there is a natural place for it.
302
+
303
+ ---
304
+
305
+ ## 1. Breaking: the rate limiters are async
306
+
307
+ **What changed:** `check()`, `reset()` and `clear()` on `CoreBetterAuthRateLimiter` and
308
+ `LegacyAuthRateLimiter` return promises now. Their counters moved behind a `RateLimitStore`
309
+ abstraction so the same code can count in a process-local `Map` (unchanged behaviour) or in Redis
310
+ (shared across replicas).
311
+
312
+ This is the reason this release is a MINOR rather than a PATCH.
313
+
314
+ **Who is affected:** only projects that call these methods directly or override the classes. The
315
+ framework's own callers — `LegacyAuthRateLimitGuard.canActivate()` and
316
+ `CoreBetterAuthRateLimitMiddleware.use()` — were made async in the same commit, so an untouched
317
+ project needs no change.
318
+
319
+ **Before:**
320
+
321
+ ```typescript
322
+ const result = this.rateLimiter.check(ip, '/iam/sign-in');
323
+ if (!result.allowed) {
324
+ throw new HttpException(this.rateLimiter.getMessage(), HttpStatus.TOO_MANY_REQUESTS);
325
+ }
326
+
327
+ // e.g. in a test teardown
328
+ rateLimiter.clear();
329
+ rateLimiter.reset('127.0.0.1');
330
+ ```
331
+
332
+ **After:**
333
+
334
+ ```typescript
335
+ const result = await this.rateLimiter.check(ip, '/iam/sign-in');
336
+ if (!result.allowed) {
337
+ throw new HttpException(this.rateLimiter.getMessage(), HttpStatus.TOO_MANY_REQUESTS);
338
+ }
339
+
340
+ await rateLimiter.clear();
341
+ await rateLimiter.reset('127.0.0.1');
342
+ ```
343
+
344
+ If you **override** one of these methods, the override must return a promise as well, and a guard or
345
+ middleware that called it must be made `async`:
346
+
347
+ ```typescript
348
+ // Before
349
+ export class MyRateLimitGuard implements CanActivate {
350
+ canActivate(context: ExecutionContext): boolean {
351
+ const result = this.rateLimiter.check(ip, endpoint);
352
+ // ...
353
+ }
354
+ }
355
+
356
+ // After
357
+ export class MyRateLimitGuard implements CanActivate {
358
+ async canActivate(context: ExecutionContext): Promise<boolean> {
359
+ const result = await this.rateLimiter.check(ip, endpoint);
360
+ // ...
361
+ }
362
+ }
363
+ ```
364
+
365
+ `getMessage()`, `isEnabled()`, `configure()` and `getStats()` are unchanged and still synchronous.
366
+
367
+ **One nuance in `getStats()`:** `activeEntries` is `-1` when the Redis store is in use — Redis does
368
+ not know the count cheaply, and reporting `0` would read as "no one is rate limited". Without Redis
369
+ it is the map size, exactly as before.
370
+
371
+ **The AI module's per-user limit** moved to the same store, but
372
+ `CoreAiService.checkRateLimit()` was already `async` before this release — a project that overrode
373
+ it needs no change. The new `protected getRateLimitStore()` is the extension point if you want to
374
+ substitute a store.
375
+
376
+ **On a Redis outage the limiters do not fail requests.** `RedisRateLimitStore` degrades to a
377
+ process-local in-memory counter and logs once, then logs again on recovery. Both alternatives are
378
+ worse: letting the error escape turns every sign-in and password-reset into a 500 for the duration
379
+ of a blip, and treating the error as "allowed" silently removes brute-force protection. Degrading
380
+ keeps a real bound — the per-replica one that applied before Redis existed.
381
+
382
+ ---
383
+
384
+ ## 2. Breaking: Hub collector reads are async
385
+
386
+ **What changed:** the Hub's diagnostic buffers gained an optional Redis mirror, so reading them is
387
+ now an I/O operation:
388
+
389
+ | Class | Method | Now returns |
390
+ |-------|--------|-------------|
391
+ | `HubLogBufferService` | `getData(since?)` | `Promise<HubLogsData>` |
392
+ | `HubTraceBufferService` | `getData(since?)` | `Promise<HubTracesData>` |
393
+ | `HubQueryProfilerService` | `getData()` | `Promise<HubQueriesData>` |
394
+ | `CoreHubMailboxService` | `getMailbox(since?)` | `Promise<HubMailboxData>` |
395
+ | `CoreHubMailboxService` | `getMailHtml(seq)` | `Promise<string \| undefined>` |
396
+ | `CoreHubController` | `logsJson` / `tracesJson` / `queriesJson` / `mailboxJson` / `mailboxHtml` / `diagnosticsJson` | `Promise<void>` |
397
+ | `CoreHubController` | `protected collectorBuffers()` | `Promise<Record<…>>` |
398
+
399
+ **Who is affected:** only projects that pass a `hub: { controller, service, htmlService, … }` override
400
+ to `CoreModule.forRoot()` and override one of these methods. Everyone else sees no change — the Hub
401
+ keeps working identically, and without Redis every buffer is still the same process-local ring buffer.
402
+
403
+ **Action:** `await` the call in your override and widen its return type to a promise.
404
+
405
+ **`captureAndGetSeq()` caveat:** it still returns the **process-local** seq synchronously. When the
406
+ mailbox is Redis-backed, the seq that the panels and `getMailHtml()` use comes from the shared
407
+ counter and will differ. Read it from `getMailbox()` instead of assuming the returned value.
408
+
409
+ ---
410
+
411
+ ## 3. Breaking (type level only): file stream return types widened
412
+
413
+ `CoreFileService.getFileStream()` and `getFileStreamByName()` are declared `Promise<Readable>`
414
+ instead of `Promise<mongo.GridFSBucketReadStream>`, because an S3-stored file returns an SDK stream
415
+ rather than a GridFS one. At runtime nothing changes for a GridFS-backed project — the same object
416
+ comes back.
417
+
418
+ **Action:** only if you annotated a variable with the concrete type:
419
+
420
+ ```typescript
421
+ // Before
422
+ const stream: mongo.GridFSBucketReadStream = await this.fileService.getFileStream(id);
423
+
424
+ // After
425
+ import { Readable } from 'stream';
426
+ const stream: Readable = await this.fileService.getFileStream(id);
427
+ ```
428
+
429
+ `createFile()` / `createFiles()` accept the new, narrower `FileUploadSource` interface instead of
430
+ `FileUpload`. That is a **widening** of what they accept: `FileUpload` now extends
431
+ `FileUploadSource`, so every existing caller still type-checks. It exists so a multer REST upload
432
+ (which has no graphql-upload `capacitor`) can use the same service method — see §10.
433
+
434
+ `CoreFileService`'s protected constructor gained an optional third parameter
435
+ (`options?: CoreFileServiceOptions`). Existing `super(connection)` and `super(connection, 'fs')`
436
+ calls compile unchanged; you only pass it to enable S3 (§7).
437
+
438
+ ---
439
+
440
+ ## 4. The migration lock is active by default
441
+
442
+ **What changed:** `createMigrationStore()` now defaults its third parameter, `lockCollectionName`,
443
+ to `'migrations_lock'`, and `MigrationRunner.up()` — what the `migrate up` CLI command runs —
444
+ acquires that lock around the whole run.
445
+
446
+ **Why it matters, in deployment terms:** the container entrypoint runs migrations on **every** boot.
447
+ Without a lock, N replicas starting together each read the same empty state, each decide the same
448
+ migration is pending, and each apply it. A migration that inserts seed data inserts it N times; one
449
+ that is not idempotent corrupts on the second run.
450
+
451
+ With the lock, the replicas that lose the race wait, then re-read the migration state **inside** the
452
+ lock and find nothing pending. Reading inside the lock is the part that makes it correct — a waiter
453
+ that re-used a state snapshot taken before the wait would apply the migrations a second time.
454
+
455
+ **With a single replica nothing changes.** The lock is acquired and released uncontended, costing one
456
+ insert and one delete per boot.
457
+
458
+ **What the lock does when a holder dies.** Migrations run on every boot, so a lock nobody holds is not
459
+ a rare event — a container SIGKILLed mid-migration (OOM, node drain, failed deploy) leaves one behind,
460
+ and without recovery every future boot of every replica would wait on it forever. Three mechanisms
461
+ prevent that:
462
+
463
+ | Mechanism | Value | Meaning |
464
+ |-----------|-------|---------|
465
+ | Heartbeat | every **15 s** | The holder refreshes `acquiredAt` while it works |
466
+ | Stale threshold | **60 s** without a heartbeat | Another replica may break and take over the lock |
467
+ | Wait timeout | **15 minutes** | A waiter gives up with a diagnosable error rather than hanging forever |
468
+
469
+ The heartbeat is what makes breaking safe: a lock is only ever broken when its holder **stopped
470
+ refreshing it**, which a live process cannot do. A migration that legitimately runs for hours keeps
471
+ its lock the whole time — the 60 s threshold is not a cap on migration runtime. The break itself
472
+ matches on the exact `acquiredAt` it observed, so if another waiter already broke and re-acquired the
473
+ lock, the delete matches nothing.
474
+
475
+ **If the wait ever times out**, the error names the collection so you can inspect it:
476
+
477
+ ```
478
+ Timed out after 15 minutes waiting for the migration lock in collection "migrations_lock".
479
+ Another replica is still migrating, or the lock is held by a process that is alive but stuck.
480
+ Inspect it with: db.getCollection("migrations_lock").find({}) — and remove the document only
481
+ once you are sure no migration is running.
482
+ ```
483
+
484
+ Manual recovery:
485
+
486
+ ```javascript
487
+ // 1. Look at the holder. `owner` is "<hostname>:<pid>" and `acquiredAt` is the last heartbeat.
488
+ db.getCollection('migrations_lock').find({})
489
+
490
+ // 2. Confirm no migration is actually running (check the pod/container named by `owner`).
491
+ // 3. Only then:
492
+ db.getCollection('migrations_lock').deleteOne({ lock: 'lock' })
493
+ ```
494
+
495
+ **Opting out** — a project that deliberately wants unsynchronised migrations passes an empty string:
496
+
497
+ ```javascript
498
+ // migrations/state-store.js
499
+ module.exports = createMigrationStore(uri, 'migrations', ''); // no locking
500
+ ```
501
+
502
+ A `MongoStateStore` constructed **directly** is unaffected: it still locks only when you pass
503
+ `lockCollectionName` yourself. The default lives in `createMigrationStore()`. The legacy
504
+ `synchronizedMigration()` / `synchronizedUp()` helpers use the same lock and still require the name
505
+ to be set explicitly (they throw otherwise).
506
+
507
+ ---
508
+
509
+ ## 5. Initial-admin auto-creation takes an atomic claim
510
+
511
+ **What changed:** `systemSetup.initialAdmin` auto-creation guarded itself with a zero-user check.
512
+ That is check-then-act: N replicas booting together each see zero users and each proceed. Before
513
+ creating, an instance now claims the setup by upserting `{ _id: 'initial-admin' }` into a
514
+ `system-setup-locks` collection — an atomic operation exactly one instance wins. The others log a
515
+ debug line and skip. If creation then fails, the marker is removed so a later boot can retry.
516
+
517
+ **Action: none.** The unique email index already prevented duplicate *users*; this removes the noisy
518
+ failures and the ambiguity about which replica actually did the setup. A new collection
519
+ `system-setup-locks` appears in the database with a single small document.
520
+
521
+ ---
522
+
523
+ ## 6. New: optional central Redis
524
+
525
+ ```typescript
526
+ // config.env.ts
527
+ {
528
+ redis: true, // localhost:6379, db 0, keyPrefix = your package name
529
+ // or
530
+ redis: { host: 'redis', port: 6379, keyPrefix: 'my-app' },
531
+ // or
532
+ redis: { url: 'redis://user:pass@redis:6379/0' },
533
+ // pre-configured but off:
534
+ redis: { enabled: false, host: 'redis' },
535
+ }
536
+ ```
537
+
538
+ Full shape: `url` **or** `host`/`port`/`username`/`password`/`db`, plus `keyPrefix`, `enabled` and
539
+ `options` (passed through to `ioredis`, e.g. `maxRetriesPerRequest`).
540
+
541
+ Two of these matter more than they look:
542
+
543
+ - **`db`** — picks the numbered Redis database. Sharing one Redis instance between environments
544
+ without separating them is how a staging deploy flushes production's rate-limit counters.
545
+ - **`keyPrefix`** — namespaces every key this application writes. Two deployments on one Redis with
546
+ the same prefix invalidate each other's tenant caches and share each other's cron leases. It
547
+ defaults to the project slug, so distinct projects are already separated; two STAGES of the same
548
+ project are not, unless you set it (or `db`).
549
+
550
+ **ioredis 5 and 6 both work.** The peer range deliberately spans both majors, so a project
551
+ already using ioredis 5 for its own code does not have to jump. Note that **6 defaults to the RESP3
552
+ wire protocol** where 5 used RESP2; every framework path (rate-limit counters, cron leases, tus
553
+ locks, pub/sub, Hub buffers) is exercised against 6 in CI. If something in your infrastructure
554
+ cannot speak RESP3 — an older proxy, for instance — pin the protocol through the pass-through
555
+ options:
556
+
557
+ ```typescript
558
+ redis: { host: 'redis', options: { protocol: 2 } },
559
+ ```
560
+
561
+ Follows "presence implies enabled": **no `redis` key means no Redis**, and every feature below keeps
562
+ its process-local behaviour. Requires the optional peer dependency `ioredis` (§12); when `redis` is
563
+ configured and the package is missing, the boot **fails fast** with an actionable error rather than
564
+ crashing later on first use.
565
+
566
+ What switches on when Redis is enabled — all of it automatically, with no further config:
567
+
568
+ | Feature | Without Redis | With Redis |
569
+ |---------|---------------|------------|
570
+ | Auth + AI rate limits | Counter per replica, so the effective limit is `max × replicas` | Shared counters, `max` enforced exactly |
571
+ | Cron jobs | Every replica runs every tick | Deduplicated — see §9 |
572
+ | GraphQL subscriptions | Delivered only to clients connected to the publishing replica | Delivered cluster-wide (`CoreRedisPubSub` replaces the in-memory `PubSub`) |
573
+ | Tenant membership cache | `invalidateUser()` clears only the local cache; other replicas stay stale until `cacheTtlMs` | Invalidation is broadcast to all replicas |
574
+ | Hub collectors | Each pod shows only its own logs/traces/queries/mails | Merged cluster-wide view, "clear" clears everywhere |
575
+ | MCP sessions | Wrong-replica request answers `404 Unknown or expired MCP session` | Answers `409` naming both instances — sticky sessions are still **required**, the failure just stops looking like an expiry bug |
576
+
577
+ **GraphQL subscription payloads must be JSON-serialisable** once Redis is in play — they go over the
578
+ wire as JSON. Class instances, `Date` objects, `Map`/`Set` and `undefined` values do not survive the
579
+ round trip. An in-process `PubSub` never had this constraint, so a payload that worked on one replica
580
+ can lose fields on a cluster. Publish plain objects and ISO strings.
581
+
582
+ `keyPrefix` (default: your `package.json` name, slugified — `@acme/api` → `acme-api`) is applied by
583
+ the framework per key, not via ioredis's own
584
+ `keyPrefix` option — that would conflict with BullMQ's prefix handling. Set it when several
585
+ applications share one Redis instance.
586
+
587
+ `CoreRedisService` is exported and provided by `CoreModule`, so you can inject it for your own keys:
588
+
589
+ ```typescript
590
+ constructor(@Optional() private readonly redis?: CoreRedisService) {}
591
+
592
+ async cacheSomething(): Promise<void> {
593
+ if (!this.redis?.enabled) {
594
+ return; // keep a non-Redis fallback — the framework does the same everywhere
595
+ }
596
+ await this.redis.getClient().set(this.redis.key('my-feature', 'thing'), '1', 'EX', 60);
597
+ }
598
+ ```
599
+
600
+ Use `getSubscriber()` for pub/sub (a subscribing client cannot run other commands) and
601
+ `createClient(label)` for a dedicated connection. All connections are tracked and quit on shutdown.
602
+
603
+ ---
604
+
605
+ ## 7. New: three file storage drivers (S3, GridFS, filesystem)
606
+
607
+ `file.storage` picks where the bytes live: `'s3'`, `'gridfs'` or `'filesystem'`. All three offer the
608
+ same operations through `CoreFileService`.
609
+
610
+ **Left unset it is derived**, most capable first: `'s3'` when `s3.bucket` is configured, else
611
+ `'gridfs'` when a database is, else `'filesystem'`. **An existing project keeps GridFS** — it has no
612
+ `s3` block, so the cascade lands on step 2, exactly where it was before.
613
+
614
+ **Set it and it is enforced.** If the chosen store is unavailable the boot FAILS:
615
+
616
+ ```
617
+ file.storage is set to 's3', but that storage is not available.
618
+ Configure `s3` (bucket, credentials/endpoint) and install `@aws-sdk/client-s3`, …
619
+ ```
620
+
621
+ This replaces a silent fallback to GridFS. The old behaviour kept the app running while files landed
622
+ in a store the operator did not believe they were in — and afterwards nobody can tell which file went
623
+ where. A derived driver is enforced the same way: `s3.bucket` in the config makes S3 the default, but
624
+ the bytes go nowhere unless your `FileService` forwards the services to `super()` (see below).
625
+
626
+ **Metadata always lives in the database**, whichever driver holds the bytes — filename, content type,
627
+ length and the custom `metadata` a per-file rule reads have to stay queryable. `'filesystem'` moves
628
+ the bytes off the database, not the bookkeeping.
629
+
630
+ **`'filesystem'` is pod-local.** Not shared between replicas, lost on restart unless `storageDir` is a
631
+ mounted volume. Right for a single instance or a real volume; wrong for a scaled service.
632
+
633
+ **Switching is forward-only.** Reads consult every store, so files written under a previous driver
634
+ stay readable — no migration step, no cut-over. The driver in use is logged at boot:
635
+ `[CoreFileStorage] File storage: s3 (defaulted — s3.bucket is configured)`.
636
+
637
+ ```typescript
638
+ // config.env.ts
639
+ {
640
+ file: {
641
+ storage: 's3', // omit to derive it
642
+ storageDir: 'uploads/files', // only for 'filesystem'
643
+ },
644
+ s3: {
645
+ bucket: 'my-app-files',
646
+ region: 'eu-central-1',
647
+ // self-hosted (MinIO, RustFS):
648
+ endpoint: 'http://minio:9000',
649
+ forcePathStyle: true,
650
+ accessKeyId: '…', // omit to use the AWS default credential chain
651
+ secretAccessKey: '…',
652
+ presignedDownloads: { expiresInSeconds: 300 }, // or `true` for the 300 s default
653
+ stagingBucket: 'my-app-tus', // defaults to `bucket`
654
+ autoCreateBucket: false, // default: false
655
+ },
656
+ }
657
+ ```
658
+
659
+ **`autoCreateBucket`** creates a missing bucket at boot instead of only reporting it. Off by default
660
+ for a reason: managed S3 credentials usually carry no `CreateBucket` permission, and silently
661
+ creating a bucket because of a typo in the name is worse than a clear error. Turn it on for a
662
+ self-hosted MinIO/RustFS, where provisioning the bucket by hand is the only alternative.
663
+
664
+ **`enabled: false`** pre-configures S3 without activating it — the same "presence implies enabled"
665
+ shape the rest of the config uses.
666
+
667
+ **A block without `bucket` is ignored entirely** (with a warning), since a bucket is the one thing
668
+ S3 cannot default — see §14.
669
+
670
+ Requires `@aws-sdk/client-s3`, plus `@aws-sdk/s3-request-presigner` when `presignedDownloads` is on
671
+ (§12). Both are lazy-imported; a missing package fails the boot with a named error.
672
+
673
+ **`s3` now also decides the DEFAULT.** Previously `s3` alone only made `CoreS3Service` available
674
+ (TUS staging, your own code) and files moved to S3 only when `file.storage: 's3'` was set as well.
675
+ Now a configured `s3.bucket` makes S3 the derived default. If you want S3 available for staging while
676
+ files stay in GridFS, say so explicitly:
677
+
678
+ ```typescript
679
+ file: { storage: 'gridfs' },
680
+ s3: { bucket: 'my-app-tus' },
681
+ ```
682
+
683
+ `file.storage` sits alongside the role knobs from §A — one object configures the file module: where
684
+ the bytes live and who may reach them. The two are orthogonal, and a per-file rule in
685
+ `checkRights()` works under either driver (`getRawFileInfo()` checks S3 metadata first, then GridFS).
686
+
687
+ **Existing GridFS files stay readable.** Reads check the S3 metadata collection first and fall back
688
+ to GridFS, so a switch to `'s3'` is forward-only: new files land in S3, old files keep being served
689
+ from GridFS. There is no migration step and no cut-over moment. `findFileInfo()` returns the union
690
+ of every store, paged once over the merged result.
691
+
692
+ File metadata lives in a new collection `s3-files` (filename, contentType, length, uploadDate,
693
+ custom `metadata`) — and `filesystem-files` for the filesystem driver, see §14 —
694
+ S3 has no queryable metadata store, so this is the equivalent of GridFS's `fs.files`. The object key
695
+ in the bucket is the string form of `_id`.
696
+
697
+ **`presignedDownloads` changes what `GET /files/id/:id` answers**: a `302` redirect to a
698
+ time-limited S3 URL instead of a streamed body, which takes the bytes off the API. It is
699
+ deliberately fail-soft — a missing presigner package, an S3 outage, or a project `FileService`
700
+ predating `getDownloadUrl()` all fall through to the streaming path, which runs the same rights
701
+ check. A client that follows redirects (every browser, every HTTP library by default) needs no
702
+ change; a client with `redirect: 'manual'` does.
703
+
704
+ **To enable S3 in your project's `FileService`**, pass the two services through:
705
+
706
+ ```typescript
707
+ @Injectable()
708
+ export class FileService extends CoreFileService {
709
+ constructor(
710
+ @InjectConnection() protected override readonly connection: Connection,
711
+ protected readonly configService: ConfigService,
712
+ @Optional() protected readonly s3Service?: CoreS3Service,
713
+ ) {
714
+ super(connection, 'fs', { configService, s3Service });
715
+ }
716
+ }
717
+ ```
718
+
719
+ **Do not read the third argument as optional once `s3` is configured.** `CoreFileService`'s
720
+ constructor runs `assertFileStorageAvailable()`, so if the resolved driver is `'s3'` — whether you
721
+ set `file.storage: 's3'` or merely configured `s3.bucket` and let it be derived — a `FileService`
722
+ that does not forward `s3Service` to `super()` **fails the boot**:
723
+
724
+ ```
725
+ File storage 's3' was selected automatically (s3.bucket is configured) but is not available.
726
+ ```
727
+
728
+ That is the intended behaviour: the alternative would be a silent fallback to GridFS, files landing
729
+ in a store the operator does not believe they are in, and no way afterwards to tell which file went
730
+ where. Either forward the services as shown above, or pin `file: { storage: 'gridfs' }` (§11b).
731
+
732
+ `super(connection)` / `super(connection, 'fs')` on a project **without** any `s3` config is
733
+ unaffected — the driver derives to `'gridfs'`, which is available, and nothing changes.
734
+
735
+ **If you call `CoreS3Service.putObject()` directly, pass the length for large files.** The signature
736
+ is `putObject(key, body, contentType?, contentLength?)`. The AWS SDK cannot sign a stream whose
737
+ length it does not know — it fails on `x-amz-decoded-content-length` — so when `body` is a stream and
738
+ `contentLength` is omitted, the service reads the whole stream into memory to measure it. For a
739
+ multi-gigabyte upload that is the difference between a constant-memory transfer and an OOM:
740
+
741
+ ```typescript
742
+ // Streams the file without buffering it
743
+ await this.s3Service.putObject(key, createReadStream(path), 'video/mp4', statSync(path).size);
744
+
745
+ // Reads the entire stream into a Buffer first — fine for small payloads, not for large ones
746
+ await this.s3Service.putObject(key, createReadStream(path), 'video/mp4');
747
+ ```
748
+
749
+ A `Buffer` or `string` body needs nothing: the length is taken from the value itself.
750
+
751
+ ---
752
+
753
+ ## 8. New: `tus.s3Staging`
754
+
755
+ When `s3` is configured, in-progress TUS uploads are staged in the S3 staging bucket
756
+ (`@tus/s3-store`) instead of on local disk — **default `true`**, so configuring `s3` is enough. Set
757
+ `tus: { s3Staging: false }` to force local disk.
758
+
759
+ Why it matters: a chunk written to `uploads/tus` lives on one pod's filesystem. Without sticky
760
+ sessions the next chunk of the same upload lands elsewhere and the upload cannot be resumed; a
761
+ restart discards it either way. With S3 staging, resumable uploads survive replica restarts and need
762
+ no sticky sessions.
763
+
764
+ Requires `@tus/s3-store` (§12). Missing package → a warning and a fall back to local disk, not a
765
+ boot failure.
766
+
767
+ **Give the staging bucket a lifecycle rule that expires incomplete multipart uploads.** The
768
+ local-disk store is swept by the framework's own expiration cleanup; the S3 store is not — that
769
+ cleanup is skipped in S3 mode, because S3 is the right place to express the policy. Aborted uploads
770
+ otherwise accumulate parts nothing removes.
771
+
772
+ ---
773
+
774
+ ## 9. New: cron deduplication across replicas
775
+
776
+ A new per-job option, `distributed`:
777
+
778
+ ```typescript
779
+ const cronJobs = {
780
+ cleanUpTempFiles: { cronTime: CronExpression.EVERY_HOUR },
781
+ refreshLocalCache: { cronTime: CronExpression.EVERY_MINUTE, distributed: false },
782
+ sendDigest: { cronTime: CronExpression.EVERY_DAY_AT_6AM, distributed: true },
783
+ };
784
+ ```
785
+
786
+ **Default: `true` when `redis` is configured, `false` otherwise.** That default is the point — a
787
+ single-replica project that upgrades does not silently gain a `cron-locks` collection, a lease write
788
+ per tick, and a new way for a tick to be skipped. A **Redis-less multi-replica fleet opts in per
789
+ job** with `distributed: true`, which uses a MongoDB lease.
790
+
791
+ How a deduplicated job runs:
792
+
793
+ | Setup | Mechanism |
794
+ |-------|-----------|
795
+ | Redis + `bullmq` installed, `cronTime` is a **string** and no `utcOffset` | The job becomes a BullMQ job scheduler — exactly one worker in the cluster picks up each tick |
796
+ | Redis, but `cronTime` is a `Date` or `utcOffset` is set | Local timer per replica; each tick first takes a Redis `SET NX` lease |
797
+ | Redis configured, `bullmq` **not** installed | Warning, then local timer + Redis lease |
798
+ | No Redis, `distributed: true` | Local timer + a `cron-locks` document in MongoDB (TTL-indexed) |
799
+ | No Redis, `distributed` unset | No lease at all — byte-identical to 11.32.x |
800
+
801
+ **Leases fail open.** An unreachable lease store runs the tick on every replica rather than stopping
802
+ it everywhere. A lease exists to prevent a *duplicate* run; treating an outage as "someone else won"
803
+ would turn a Redis blip into a silent, fleet-wide outage of every scheduled job — the worse failure.
804
+
805
+ **`runOnInit` deduplicates over a 5-minute window, and you should know what that means.** A
806
+ scheduled tick has an instant every replica computes identically, so its lease key comes from the
807
+ schedule. A *startup* has no such instant — replicas boot milliseconds to minutes apart, so a key
808
+ built from each replica's own clock would be different everywhere and deduplicate nothing. Since
809
+ `runOnInit` defaults to `true`, that would leave the most common path undeduplicated. The init lease
810
+ therefore uses one **fixed key per job** with a 5-minute TTL:
811
+
812
+ - Replicas that boot within 5 minutes of each other run the startup tick **once between them**.
813
+ - A replica that restarts *inside* that window — a crash-loop, a rolling deploy, a rescheduled pod —
814
+ **skips its startup tick**. If a job's `runOnInit` work must happen on every process start (warming
815
+ a process-local cache, for instance), set `distributed: false` on it.
816
+ - A replica joining later (autoscaling, a much later restart) runs it again, which is the intended
817
+ behaviour for a genuinely new instance.
818
+
819
+ **No constructor change is needed.** `CoreCronJobs` is abstract and instantiated by your project, so
820
+ the framework cannot inject into it. `CoreModule` populates a registry
821
+ (`setCronJobsInfrastructure()`) that the base class reads lazily, so existing
822
+ `super(schedulerRegistry, cronJobs, { log })` calls gain deduplication untouched. You *may* pass
823
+ `connection` / `redisService` explicitly in the options object; values passed there win.
824
+
825
+ With neither source available the service warns once ("Cron job deduplication is inactive …") and
826
+ behaves as before.
827
+
828
+ ---
829
+
830
+ ## 10. Reference implementation: uploads go to central storage
831
+
832
+ `src/server/` is the framework's own test implementation, but consumer projects are generated from
833
+ the same shapes. **If you copied the avatar upload or the GraphQL multi-upload example, you have
834
+ pod-local disk writes that break with more than one replica** — the file lands on whichever pod
835
+ served the request, is unreadable from every other pod, and is gone after a restart.
836
+
837
+ ### 10.1 `UserService.setAvatar()` changed signature
838
+
839
+ It takes the **stored file id** and returns the **previous** avatar id, so the caller can delete the
840
+ orphan. It no longer touches the filesystem.
841
+
842
+ **Why the storing moved to the controller:** `UserService` is instantiated by `CoreAuthModule`,
843
+ which knows nothing about the project's `FileModule`. It cannot depend on `FileService`. The
844
+ controller can.
845
+
846
+ **Before:**
847
+
848
+ ```typescript
849
+ // user.service.ts
850
+ async setAvatar(file: Express.Multer.File, user: User): Promise<string> {
851
+ const dbUser = await this.mainDbModel.findOne({ id: user.id }).exec();
852
+ if (!dbUser) {
853
+ throw new UnauthorizedException();
854
+ }
855
+ if (!file) {
856
+ throw new UnprocessableEntityException('Missing avatar file');
857
+ }
858
+ if (user.avatar) {
859
+ fs.unlink(`${this.configService.configFastButReadOnly.staticAssets.path}/avatars/${user.avatar}`, …);
860
+ }
861
+ dbUser.avatar = file.filename;
862
+ await dbUser.save();
863
+ return file.filename;
864
+ }
865
+
866
+ // avatar.controller.ts
867
+ @UseInterceptors(FileInterceptor('file', multerOptionsForImageUpload({
868
+ destination: `${envConfig.staticAssets.path}/avatars`,
869
+ })))
870
+ uploadFile(@UploadedFile() file: Express.Multer.File, @CurrentUser() user: User): Promise<string> {
871
+ return this.usersService.setAvatar(file, user);
872
+ }
873
+ ```
874
+
875
+ **After:**
876
+
877
+ ```typescript
878
+ // user.service.ts
879
+ /** @returns the PREVIOUS avatar id, so the caller can delete the orphaned file */
880
+ async setAvatar(avatarId: string, user: User): Promise<string> {
881
+ const dbUser = await this.mainDbModel.findOne({ id: user.id }).exec();
882
+ if (!dbUser) {
883
+ throw new UnauthorizedException();
884
+ }
885
+ if (!avatarId) {
886
+ throw new UnprocessableEntityException('Missing avatar file');
887
+ }
888
+ const previousAvatar = dbUser.avatar;
889
+ dbUser.avatar = avatarId;
890
+ await dbUser.save();
891
+ return previousAvatar;
892
+ }
893
+
894
+ // avatar.controller.ts — `memory: true`, NOT a disk destination
895
+ @UseInterceptors(FileInterceptor('file', multerOptionsForImageUpload({ memory: true })))
896
+ async uploadFile(@UploadedFile() file: Express.Multer.File, @CurrentUser() user: User): Promise<string> {
897
+ const stored = await this.fileService.createFile(multerFileToUpload(file));
898
+ const previousAvatar = await this.usersService.setAvatar(stored.id, user);
899
+
900
+ // A failure here must not fail the upload: the new avatar is already stored and
901
+ // referenced, so an orphan is a cleanup concern, not a request error.
902
+ if (previousAvatar) {
903
+ try {
904
+ await this.fileService.deleteFile(previousAvatar);
905
+ } catch (error) {
906
+ this.logger.warn(`Could not remove previous avatar ${previousAvatar}: …`);
907
+ }
908
+ }
909
+
910
+ return stored.id;
911
+ }
912
+ ```
913
+
914
+ `AvatarController` now injects `FileService` in addition to `UserService`. In the reference
915
+ implementation that made `UserModule` and `FileModule` reference each other, resolved with
916
+ `forwardRef(() => FileModule)` on the `UserModule` side (`FileModule` already had one on the other).
917
+
918
+ **Frontend impact — this changes what `user.avatar` contains.** It used to be a filename served from
919
+ the static-assets directory; it is now a **file id** served via `GET /files/id/:id`. Existing avatar
920
+ values in the database still point at old static filenames. Either migrate them (upload the files
921
+ into the store and rewrite the field) or have the frontend fall back to the old URL shape for values
922
+ that are not valid ObjectIds.
923
+
924
+ ### 10.2 GraphQL multi-upload
925
+
926
+ **Before:**
927
+
928
+ ```typescript
929
+ for (const file of files) {
930
+ const { createReadStream, filename } = await file;
931
+ await fs.promises.mkdir('./uploads', { recursive: true });
932
+ createReadStream().pipe(createWriteStream(`./uploads/${filename}`));
933
+ }
934
+ ```
935
+
936
+ `./uploads` is relative to the process working directory — one pod's disk.
937
+
938
+ **After:**
939
+
940
+ ```typescript
941
+ await this.fileService.createFiles(files);
942
+ ```
943
+
944
+ ### 10.3 `multerFileToUpload()`
945
+
946
+ The new helper adapts a multer upload to what `CoreFileService` consumes, so a REST/multer endpoint
947
+ writes to the same central storage as the GraphQL path:
948
+
949
+ ```typescript
950
+ import { multerFileToUpload, multerOptionsForImageUpload } from '@lenne.tech/nest-server';
951
+
952
+ @UseInterceptors(FileInterceptor('file', multerOptionsForImageUpload({ memory: true })))
953
+ async upload(@UploadedFile() file: Express.Multer.File) {
954
+ return this.fileService.createFile(multerFileToUpload(file));
955
+ }
956
+ ```
957
+
958
+ It **requires `memory: true`** on the multer options — a disk-stored file has no `buffer`, and the
959
+ helper throws with that exact instruction rather than silently storing an empty file. The returned
960
+ `createReadStream` is callable more than once, matching graphql-upload's contract.
961
+
962
+ ### 10.4 `PUB_SUB` provider
963
+
964
+ Both `CoreAuthModule` and the reference `UserModule` build `PUB_SUB` from a factory now: a
965
+ `CoreRedisPubSub` when Redis is enabled, the in-memory `PubSub` otherwise. If your project provides
966
+ its own `PUB_SUB` with `useValue: new PubSub()`, subscriptions stay single-replica. Mirror the
967
+ factory to fix that:
968
+
969
+ ```typescript
970
+ {
971
+ provide: 'PUB_SUB',
972
+ useFactory: (redisService?: CoreRedisService) =>
973
+ redisService?.enabled ? new CoreRedisPubSub(redisService) : new PubSub(),
974
+ inject: [{ optional: true, token: CoreRedisService }],
975
+ }
976
+ ```
977
+
978
+ ---
979
+
980
+ ## 11. New: `shutdownDelayMs`
981
+
982
+ ```typescript
983
+ { shutdownDelayMs: 5000 } // default: 0 (no delay, previous behaviour)
984
+ ```
985
+
986
+ On a rolling deploy the orchestrator sends SIGTERM and deregisters the instance from the load
987
+ balancer at the same time — but deregistration is not instant, so requests keep arriving for a
988
+ moment after the signal. `installGracefulShutdown()` waits in the SIGTERM/SIGINT handler itself,
989
+ *before* `close()` is entered, so those in-flight requests land on a fully healthy instance.
990
+
991
+ A NestJS lifecycle hook cannot do this. `close()` runs `onModuleDestroy` →
992
+ `beforeApplicationShutdown` → dispose (close the HTTP server) → `onApplicationShutdown`, so a delay
993
+ in `beforeApplicationShutdown` waits *after* every module is torn down while the socket still
994
+ accepts — worse than not waiting at all.
995
+
996
+ **Wire it in `main.ts` as a REPLACEMENT for `server.enableShutdownHooks()`:**
997
+
998
+ ```typescript
999
+ import { installGracefulShutdown } from '@lenne.tech/nest-server';
1000
+
1001
+ installGracefulShutdown(server); // instead of server.enableShutdownHooks()
1002
+ ```
1003
+
1004
+ Do **not** keep `enableShutdownHooks()` alongside it. Nest would register its own listener for the
1005
+ same signals and close the app immediately, in parallel with the wait — the delay then silently
1006
+ never happens. With `shutdownDelayMs` unset or zero, `installGracefulShutdown()` *is*
1007
+ `enableShutdownHooks()`, so this line is correct either way.
1008
+
1009
+ **Keep the value well below your orchestrator's grace period, and leave room for the drain that
1010
+ follows it.** Docker/Compose `stop_grace_period` defaults to 10s, Kubernetes
1011
+ `terminationGracePeriodSeconds` to 30s, and `installProcessDiagnostics()` force-exits after 30s.
1012
+ Exceed any of them and the process is SIGKILLed mid-wait, running no shutdown hook at all. Values
1013
+ above 10s log a warning; above 60s the delay is capped. Inert — no delay, no log — when unset or
1014
+ zero.
1015
+
1016
+ ---
1017
+
1018
+ ## 11a. Check whether you ALREADY have a `redis` key
1019
+
1020
+ `redis` did not exist as a server option before 11.33.0, so the framework ignored it — which means a
1021
+ project may already carry one in its server options for its **own** client, and never think about it
1022
+ again. From 11.33.0 the framework reads it, and because activation follows "presence implies
1023
+ enabled", the mere presence of that key switches on every distributed path at once: rate limits,
1024
+ cron deduplication, the email-verification cooldown, tenant-cache invalidation, Hub buffer
1025
+ mirroring, MCP session ownership and — if you use GraphQL — subscriptions over Redis pub/sub.
1026
+
1027
+ Nothing about that is wrong, but you get it without asking for it. Three consequences are worth
1028
+ knowing before you upgrade:
1029
+
1030
+ - **Limits get stricter, not looser.** A rate limit of `max` was effectively `max × replicas`
1031
+ before; now it is `max`. That is the bug being fixed, but a deployment tuned around the old
1032
+ behaviour will throttle earlier.
1033
+ - **Subscription payloads must be JSON-serializable** once pub/sub goes through Redis. `Date` is
1034
+ revived on parse; class instances, `Map`, `Set` and `undefined` are not.
1035
+ - **The limit is only as exact as the client IP — configure `trustProxy` (new in 11.33.0).**
1036
+ See §11c: behind a reverse proxy this is now the difference between one bucket per client and
1037
+ one bucket for your entire user base.
1038
+
1039
+ The boot log now states this explicitly — `CoreRedisService` logs the endpoint, the key prefix and
1040
+ the list of subsystems that switched, once, at startup. If you do not want any of it yet, set
1041
+ `redis: { enabled: false, ... }`: the config stays in place and the framework stays out.
1042
+
1043
+ This was found by upgrading a real project that had carried a `redis` block in all five of its
1044
+ environments for its own BullMQ queues.
1045
+
1046
+ ---
1047
+
1048
+ ## 11b. Check whether you ALREADY have an `s3` key — this one FAILS THE BOOT
1049
+
1050
+ Same shape as §11a, but with a louder failure mode. `s3` did not exist as a server option before
1051
+ 11.33.0 either, so a project may already carry one for its **own** SDK client — a report export, a
1052
+ backup job, a media pipeline. From 11.33.0 the framework reads it, and `s3.bucket` alone now makes
1053
+ S3 the **derived** default for `file.storage` (§7).
1054
+
1055
+ The consequence is not subtle. `CoreFileService`'s constructor asserts that the resolved driver is
1056
+ actually usable, so a project whose `FileService` still calls `super(connection, 'fs')` — which is
1057
+ every project that has not read §7 — stops at boot:
1058
+
1059
+ ```
1060
+ File storage 's3' was selected automatically (s3.bucket is configured) but is not available.
1061
+ ```
1062
+
1063
+ You are looking at a startup crash, not at misplaced files. That is deliberate: before 11.33.0 the
1064
+ same situation silently fell back to GridFS, and a silent fallback is unrecoverable — nobody can
1065
+ tell afterwards which file went to which store.
1066
+
1067
+ **Three ways out, in order of how likely they are to be what you want:**
1068
+
1069
+ ```typescript
1070
+ // 1. Your `s3` block is NOT meant for file storage (own client, TUS staging only).
1071
+ // Pin the driver and nothing changes:
1072
+ file: { storage: 'gridfs' },
1073
+
1074
+ // 2. Your `s3` block IS meant for file storage — adopt it properly (§7):
1075
+ // forward { configService, s3Service } to super() in your FileService,
1076
+ // and install @aws-sdk/client-s3 (§12).
1077
+
1078
+ // 3. You want the config parked, inert, for later:
1079
+ s3: { enabled: false, bucket: 'my-app-files', /* … */ },
1080
+ ```
1081
+
1082
+ Two more things worth knowing before the upgrade:
1083
+
1084
+ - **`tus.s3Staging` defaults to `true` whenever `s3` is usable** (§8). A pre-existing `s3` block
1085
+ therefore also moves in-progress TUS uploads off local disk into `s3.stagingBucket` (which
1086
+ defaults to `bucket`). That is the better behaviour for more than one replica, but it puts
1087
+ objects into a bucket you may have provisioned for something else — set `stagingBucket`, or
1088
+ `tus: { s3Staging: false }`.
1089
+ - **A block without `bucket` is now ignored** with a warning (§14), where it used to make
1090
+ `CoreS3Service.enabled` true. If you kept a bucket-less `s3` block around, TUS staging switched
1091
+ itself on against a bucket that did not exist; that failure is gone, and so is the block's effect.
1092
+
1093
+ `s3` is checked at boot, so unlike §11a there is no version of this you can discover in production
1094
+ three days later.
1095
+
1096
+ ---
1097
+
1098
+ ## 11c. New: `trustProxy` — required if you run behind a reverse proxy AND rate-limit
1099
+
1100
+ **Applies to:** every deployment where something terminates TLS in front of the app — Caddy, nginx,
1101
+ Traefik, a Kubernetes ingress, a cloud load balancer — **and** `auth.rateLimit` or
1102
+ `betterAuth.rateLimit` is enabled. That is the normal production shape.
1103
+
1104
+ ### What changed
1105
+
1106
+ Both rate limiters key their counter on the client IP. Up to 11.32.x they took the first entry of
1107
+ `X-Forwarded-For` themselves and trusted it unconditionally, which meant any client could pick its
1108
+ own bucket by sending a fresh value per request — the counter never reached the limit and the
1109
+ brute-force protection was effectively off.
1110
+
1111
+ 11.33.0 reads `request.ip` instead, which is the correct source: Express derives it from the
1112
+ forwarded chain **only as far as the app's `trust proxy` setting allows**, and reports the socket
1113
+ peer otherwise. That closes the bypass — but `trust proxy` defaults to `false`, and until 11.33.0
1114
+ this framework had no way to set it. Left unset behind a proxy, every request now resolves to the
1115
+ **proxy's** address, so:
1116
+
1117
+ > All your clients share ONE rate-limit bucket. With `auth.rateLimit: { max: 10, windowSeconds: 60 }`
1118
+ > the 11th sign-in attempt per minute from *anybody* returns 429 to *everybody*.
1119
+
1120
+ And because §6 makes the counter Redis-backed, that limit is enforced exactly fleet-wide rather than
1121
+ once per replica — so the collapse is total, not diluted by your replica count.
1122
+
1123
+ ### What to configure
1124
+
1125
+ ```typescript
1126
+ // config.env.ts — one reverse proxy in front of the app (the usual case)
1127
+ {
1128
+ trustProxy: 1,
1129
+ }
1130
+ ```
1131
+
1132
+ `trustProxy` is passed verbatim to Express's `app.set('trust proxy', …)` by `CoreModule`, so you do
1133
+ **not** touch `main.ts`. Accepted values:
1134
+
1135
+ | Value | Meaning |
1136
+ |-------|---------|
1137
+ | `false` | Trust nothing, use the socket address (Express default). Also the explicit "nothing proxies me" answer — it silences the boot warning below |
1138
+ | `1`, `2`, … | Trust exactly N hops closest to the app. One reverse proxy is `1`; a proxy behind a CDN is `2` |
1139
+ | `'loopback'` / `'linklocal'` / `'uniquelocal'` | Trust those address ranges |
1140
+ | `'10.0.0.0/8'`, `['loopback', '10.0.0.0/8']` | Trust specific addresses or subnets |
1141
+ | `true` | Trust the LEFTMOST entry — i.e. whatever the client sent. **Never on a public deployment**; it hands every caller its own bucket and restores the old bypass |
1142
+
1143
+ **Count the hops.** Trusting more than actually sit in front of the app is the bypass in the other
1144
+ direction: a client prepends its own entry and the app believes it.
1145
+
1146
+ A predicate function is deliberately not accepted, although Express supports one — the value has to
1147
+ survive `NEST_SERVER_CONFIG` / `NSC__*` (JSON) and the ConfigService deep clone. If you need one,
1148
+ leave `trustProxy` unset and call `app.set('trust proxy', fn)` in your own `main.ts`: an unset value
1149
+ is never applied, so nothing overwrites it. Conversely, a value that IS configured is applied during
1150
+ module init — which runs inside `app.init()`/`listen()`, i.e. **after** `main.ts` — so it takes
1151
+ precedence over a hand-written `app.set()`.
1152
+
1153
+ ### If you do not configure it
1154
+
1155
+ Nothing fails at boot, but you get a warning naming the consequence:
1156
+
1157
+ ```
1158
+ Rate limiting is enabled (auth.rateLimit) but Express "trust proxy" is not set. Behind a reverse
1159
+ proxy (Caddy, nginx, a Kubernetes ingress) request.ip is then the PROXY address for every request,
1160
+ so every client behind a reverse proxy shares one bucket and the limit throttles all of them at once.
1161
+ ```
1162
+
1163
+ The warning exists because this misconfiguration is otherwise silent: nothing in the request path
1164
+ can tell that every caller resolved to the same address, so the first symptom is users locked out of
1165
+ sign-in with no matching attack anywhere in the logs.
1166
+
1167
+ It is not emitted when `trustProxy` is set (including to `false`), when you set `trust proxy`
1168
+ yourself in `main.ts`, or when no IP-keyed limiter is enabled. `ai.rateLimit` keys on the user id and
1169
+ is unaffected either way.
1170
+
1171
+ ### Still worth doing at the proxy
1172
+
1173
+ Configuring `trustProxy` correctly makes header spoofing ineffective, but **overwriting**
1174
+ `X-Forwarded-For` at your edge rather than appending to it remains good practice — it removes the
1175
+ question of hop counting entirely.
1176
+
1177
+ ---
1178
+
1179
+ ## 12. Optional peer dependencies
1180
+
1181
+ Five new **optional** peers. `pnpm install` does not pull any of them; install only what you use.
1182
+
1183
+ | Package | Peer range | Needed for | Missing → |
1184
+ |---------|-----------|-----------|-----------|
1185
+ | `ioredis` | `>=5.0.0 <7` | any `redis` feature | **Boot fails** with a named error |
1186
+ | `bullmq` | `>=5.16.0 <7` | BullMQ cron schedulers | Warning; cron falls back to per-tick leases |
1187
+ | `@aws-sdk/client-s3` | `>=3.1045.0 <4` | any `s3` feature | **Boot fails** with a named error |
1188
+ | `@aws-sdk/s3-request-presigner` | `>=3.1045.0 <4` | `s3.presignedDownloads` | Throws on first presign; the controller catches it and streams instead |
1189
+ | `@tus/s3-store` | `>=2.0.5 <3` | `tus.s3Staging` | Warning; TUS stages on local disk |
1190
+
1191
+ **If you already use BullMQ, you do NOT have to upgrade it.** The floor is `5.16.0` because that is
1192
+ the release that introduced the Job Scheduler API (`upsertJobScheduler` / `removeJobScheduler` /
1193
+ `getJobSchedulers`) — verified against 5.15.0 (absent) and 5.16.0 (present). Everything the
1194
+ framework calls exists throughout 5.x, so a project on BullMQ 5 keeps its own queue code untouched.
1195
+ This matters because the projects most likely to want distributed cron are exactly the ones that
1196
+ already run BullMQ, and forcing them to 6.x breaks *their* code: `job.discard()`,
1197
+ `repeat` in `JobsOptions`, `getRepeatableJobs()`, `removeRepeatableByKey()` and `queue.client` were
1198
+ all removed or renamed in 6.0.
1199
+
1200
+ ```bash
1201
+ pnpm add ioredis bullmq # distributed rate limits + cron
1202
+ pnpm add @aws-sdk/client-s3 @aws-sdk/s3-request-presigner # S3 file storage
1203
+ pnpm add @tus/s3-store # resumable uploads staged in S3
1204
+ ```
1205
+
1206
+ Per `.claude/rules/package-management.md`, pin exact versions in your own `package.json` — the
1207
+ ranges declared here are peer ranges, not an invitation to float.
1208
+
1209
+ ---
1210
+
1211
+ ## 13. Dependency housekeeping — and what it does *not* do for you
1212
+
1213
+ The framework's `pnpm-workspace.yaml` overrides were raised to newly published patch levels
1214
+ (`brace-expansion` 1.1.18 / 5.0.9, `js-yaml` 4.3.1, `fast-uri` 3.1.5) and five were added
1215
+ (`ip-address`, `nanoid`, `postcss`, `undici`, `hono`). `pnpm audit` inside this repository went from
1216
+ 20 findings to 0.
1217
+
1218
+ **That number is about this repository, not about yours.** pnpm `overrides:` apply only to the
1219
+ **root** project of an install, so nothing in the framework's `pnpm-workspace.yaml` travels with the
1220
+ published npm package. Your project resolves its own tree with *your* overrides — by default, none.
1221
+ A green audit here says nothing about a consumer tree. See
1222
+ [`docs/security-overrides.md`](../docs/security-overrides.md).
1223
+
1224
+ **Action required: run your own `pnpm audit`.** It is the only thing that tells you about your tree.
1225
+
1226
+ **What you still have to replicate is unchanged by this release: the same three entries.** They are
1227
+ the ones whose parent packages **exact-pin** a vulnerable version, or ship no fix inside the declared
1228
+ major, so no amount of updating resolves them forward:
1229
+
1230
+ | Override | Why it cannot resolve forward |
1231
+ |----------|-------------------------------|
1232
+ | `ws@>=8.0.0 <8.21.0` → `8.21.3` | `@nestjs/graphql` exact-pins `ws@8.20.1` |
1233
+ | `js-yaml@>=5.0.0 <5.2.2` → `5.2.2` | `@nestjs/swagger` exact-pins `js-yaml@5.2.1` |
1234
+ | `@hono/node-server@<2.0.10` → `2.0.11` | `@modelcontextprotocol/sdk` declares `^1.19.9` with no 1.x fix line |
1235
+
1236
+ **The `ws` target changed in this release** (`8.21.1` → `8.21.3`) and is not a cosmetic bump. It has
1237
+ to equal the version you carry as a **direct** `ws` dependency: under `nodeLinker: hoisted` a lower
1238
+ override target leaves a second, older `ws` copy in the tree beside the direct one. If you copied
1239
+ the old block, raise the target and re-resolve.
1240
+
1241
+ `docs/security-overrides.md` carries the full rationale and the copy-pasteable block.
1242
+
1243
+ **None of the five new entries joins that list.** Every one of them sits behind a caret range
1244
+ (`@modelcontextprotocol/sdk` declares `hono@^4.11.4`, `@hono/node-server` peers `hono@^4`,
1245
+ `express-rate-limit@^8.2.1` pulls `ip-address`, and `nanoid`/`postcss`/`undici` are dev-only
1246
+ transitives), so a **fresh** resolve in your project lands on a patched version by itself. The
1247
+ caveat is the word *fresh*: an existing `pnpm-lock.yaml` keeps whatever it already pinned until you
1248
+ re-resolve. Update the lockfile and re-run `pnpm audit` rather than assuming the caret did the work.
1249
+
1250
+ ---
1251
+
1252
+ ## Compatibility Notes
1253
+
1254
+ | Pattern | Status | Notes |
1255
+ |---------|--------|-------|
1256
+ | No `redis` / `s3` / `shutdownDelayMs` config | **Unchanged** | Every new service is inert; rate limits, cron, subscriptions, files and Hub behave exactly as in 11.32.x |
1257
+ | Single replica | **Unchanged**, plus one uncontended lock acquire/release per `migrate up` | |
1258
+ | `CoreCronJobs` subclass with `super(schedulerRegistry, cronJobs, { log })` | **Compatible** | Deduplication arrives via the registry, no constructor change |
1259
+ | Project `FileService extends CoreFileService` with `super(connection)`, **no `s3` config** | **Compatible** | Driver derives to GridFS; forward the options object to enable S3 (§7) |
1260
+ | Project `FileService extends CoreFileService` with `super(connection)`, **`s3.bucket` already configured** | **Boot fails — action required** | S3 becomes the derived driver and the constructor asserts it. Forward `{ configService, s3Service }`, or pin `file: { storage: 'gridfs' }` — §11b |
1261
+ | An existing `s3` key in the server options (own client, backups, media) | **Action required** | The framework reads it now and it changes `file.storage` and `tus.s3Staging` — §11b |
1262
+ | Project `TusService` / `TusModule` usage | **Compatible** | `TusModule` injects the new dependencies itself |
1263
+ | Direct calls to `rateLimiter.check/reset/clear` | **Action required** | Await them (§1) |
1264
+ | Rate limiting enabled **behind a reverse proxy** | **Action required** | Set `trustProxy` (hop count) or every client shares one bucket — §11c. A boot warning names it |
1265
+ | Rate limiting enabled with **nothing in front of the app** | **Unchanged**, plus a boot warning | Set `trustProxy: false` to state it explicitly and silence the warning |
1266
+ | Project overriding `CoreFileService.getFileInfo()` | **Action required for `GET /files/id/:id`** | That route now resolves through `resolveFile()`; override it too — §14 |
1267
+ | Project calling `findFileInfo()` **without** a `sort`, with `file.storage` other than `gridfs` | **Ordering changed** | The merged multi-store page defaults to `uploadDate` descending — §14 |
1268
+ | Overridden Hub collector `getData()` / mailbox reads | **Action required** | Await and widen the return type (§2) |
1269
+ | `const s: GridFSBucketReadStream = await getFileStream(...)` | **Action required** | Type is `Readable` now (§3) |
1270
+ | Custom `PUB_SUB` with `useValue: new PubSub()` | **Works, but stays single-replica** | Mirror the factory (§10.4) |
1271
+ | Copied avatar upload from `src/server` | **Action required for >1 replica** | §10.1 — including the frontend impact |
1272
+ | `createMigrationStore(uri, 'migrations', 'my_lock')` | **Unchanged** | An explicit lock name still wins |
1273
+ | `MongoStateStore` constructed directly | **Unchanged** | Locks only when you pass `lockCollectionName` |
1274
+ | GraphQL subscription payloads with `Date` / class instances | **Breaks only once Redis is enabled** | Publish JSON-serialisable payloads (§6) |
1275
+ | `/ai/mcp` behind a load balancer | **Still requires sticky sessions** | Redis only improves the error message, it does not make sessions portable |
1276
+ | `/hub` behind a load balancer | **Without Redis: route it to one replica** | Each pod otherwise shows only its own slice |
1277
+
1278
+ ---
1279
+
1280
+ ## 14. What else changed under the hood
1281
+
1282
+ Nothing here needs action, but it is the kind of thing an operator or a reviewer wants to know
1283
+ before it shows up unannounced.
1284
+
1285
+ ### New MongoDB collections
1286
+
1287
+ | Collection | Written when | Holds |
1288
+ |------------|--------------|-------|
1289
+ | `s3-files` | `file.storage: 's3'` | filename, contentType, length, uploadDate, custom `metadata` |
1290
+ | `filesystem-files` | `file.storage: 'filesystem'` | the same fields |
1291
+
1292
+ Metadata always lives in the database, whichever driver holds the bytes — it has to stay queryable
1293
+ for `findFileInfo()` and for the per-file rules `checkRights()` evaluates (§A.3). Include them in
1294
+ backups: with `'filesystem'` or `'s3'`, losing this collection orphans the bytes, because nothing
1295
+ else records what a file is called.
1296
+
1297
+ GridFS keeps using `fs.files` / `fs.chunks` unchanged.
1298
+
1299
+ ### New public exports
1300
+
1301
+ ```typescript
1302
+ import {
1303
+ // Storage driver selection
1304
+ resolveFileStorage, assertFileStorageAvailable, FileStorageDriver, FILE_STORAGE_DRIVERS,
1305
+ // Filesystem driver
1306
+ FilesystemFileHelper, FilesystemFileInfo, FILESYSTEM_FILES_COLLECTION, DEFAULT_FILESYSTEM_DIR,
1307
+ // Config-driven role application
1308
+ applyFileRoles, FILE_ROLE_DEFAULTS,
1309
+ // Content-Disposition rendering, shared by the streamed and presigned download paths
1310
+ buildContentDisposition,
1311
+ // Infrastructure (see §6, §7)
1312
+ CoreRedisService, CoreS3Service, CoreRedisPubSub,
1313
+ RateLimitStore, installGracefulShutdown,
1314
+ // Cron infrastructure registry (see §9) — a registry OBJECT, not a class
1315
+ CronJobsInfrastructure, getCronJobsInfrastructure, setCronJobsInfrastructure,
1316
+ } from '@lenne.tech/nest-server';
1317
+ ```
1318
+
1319
+ You need none of them for a normal upgrade. They are exported because a project overriding
1320
+ `CoreFileService` or writing its own storage-aware code otherwise has to reach into deep paths.
1321
+
1322
+ The cron registry is deliberately **not** a class. `core-cron-jobs.registry.ts` is an import-free
1323
+ leaf holding module-level state (`CronJobsInfrastructure` is the shape, `get…`/`set…` the accessors),
1324
+ because a class there would put a runtime import edge back on a cycle — see
1325
+ `.claude/rules/architecture.md` → "DI Token Placement (SWC-Safe)". `CoreModule` calls
1326
+ `setCronJobsInfrastructure()`; you only need it yourself if you register your own Mongoose connection
1327
+ or `CoreRedisService` for cron leases.
1328
+
1329
+ ### Behaviour change: `GET /files/id/:id` resolves through `resolveFile()`, not `getFileInfo()`
1330
+
1331
+ The download route used to call the public `getFileInfo()` and then `getFileStream()`, so the id was
1332
+ resolved twice — once for the metadata, once for the bytes, with the two lookups free to disagree
1333
+ about which store the file lives in. It now makes a single `resolveFile()` call that returns the
1334
+ metadata **and** the storage location together. `resolveFile()` runs the very same `checkRights()`
1335
+ that `getFileInfo()` did, so authorization is unchanged.
1336
+
1337
+ **What this costs a project that OVERRODE `getFileInfo()`** — for a tenant filter, a custom store, or
1338
+ to strip extra fields. The override is still honoured on `getFileInfoByName()`, on the GraphQL
1339
+ resolver and on `deleteFile()`, but **no longer on that route**. If the override carried behaviour
1340
+ you rely on for downloads, override `resolveFile()` as well:
1341
+
1342
+ ```typescript
1343
+ export class FileService extends CoreFileService {
1344
+ override async resolveFile(id, serviceOptions?) {
1345
+ const resolved = await super.resolveFile(id, serviceOptions);
1346
+ // …the same narrowing your getFileInfo() override applies
1347
+ return resolved;
1348
+ }
1349
+ }
1350
+ ```
1351
+
1352
+ Authorization rules do **not** belong here — put those in `checkRights()` (§A.3), which both paths
1353
+ call.
1354
+
1355
+ ### Behaviour change: presigned S3 downloads keep the file's real name
1356
+
1357
+ `s3.presignedDownloads` sets `response-content-disposition` on the signed URL, and S3 echoes it back
1358
+ as the response header. That value was rendered separately from the streamed branch's, and rendered
1359
+ wrongly: the filename was percent-encoded inside an RFC 6266 quoted-string — which nothing
1360
+ percent-decodes, so `Jahresbericht 2024.pdf` was saved as `Jahresbericht%202024.pdf` — and there was
1361
+ no `filename*` parameter at all, so a non-ASCII name was lost outright.
1362
+
1363
+ Both paths now render the value with the same exported helper (`buildContentDisposition`), so a file
1364
+ keeps its name whichever branch serves it. **If you enabled `presignedDownloads`, downloaded
1365
+ filenames change** — to the correct ones. Nothing to configure.
1366
+
1367
+ ### Behaviour change: `findFileInfo()` default ordering depends on `file.storage`
1368
+
1369
+ With `file.storage: 'gridfs'` (or a derived GridFS driver) nothing was ever written to the other
1370
+ stores, so the query stays a single database query and keeps MongoDB's natural order — unchanged.
1371
+
1372
+ With `'s3'` or `'filesystem'`, rows come from up to three collections and have to be merged before
1373
+ they can be paged; concatenating them would interleave by store and page the wrong rows. The merge
1374
+ therefore re-sorts, and **when the filter args carry no `sort`, it falls back to `uploadDate`
1375
+ descending** (newest first).
1376
+
1377
+ So the same `findFileInfo()` call returns a different default order depending on the configured
1378
+ driver. An explicit `sort` in the filter args is honoured on both paths and removes the difference —
1379
+ pass one if your UI depends on the ordering.
1380
+
1381
+ ### Behaviour change: an `s3` block without a `bucket` is now ignored
1382
+
1383
+ `CoreS3Service.enabled` used to be true for any `s3` block. A bucket is the one thing S3 cannot
1384
+ default — region, endpoint and credentials all have fallbacks — so a bucket-less block enabled the
1385
+ service while nothing could be written to it. `tus.s3Staging` (default `true`) keys off exactly that
1386
+ flag, so staging switched on against a bucket that did not exist and failed at the first upload.
1387
+
1388
+ Now such a block is ignored with a warning, and `CoreS3Service.enabled` agrees with the storage
1389
+ resolver about what "usable" means.
1390
+
1391
+ ### Dependency bumps
1392
+
1393
+ `@nestjs/common`, `@nestjs/core` and `@nestjs/platform-express` move 11.1.28 → 11.1.29 (core import
1394
+ lookup, middleware regex, SSE, multer field-nesting errors). The release also fixes the Redis pub/sub
1395
+ client type in `@nestjs/microservices` — not used here: this framework talks to Redis through
1396
+ `ioredis` directly, because the microservices transport is a message bus and cannot express the
1397
+ atomic counters, leases and Lua scripts the rate limiter, cron dedup and tus locker need.
1398
+
1399
+ **Two `dependencies` also cross a MAJOR**, and because they are ordinary dependencies of this
1400
+ package they get installed into your project whether you asked for them or not. Neither needs
1401
+ action; both are listed here because a major in a lockfile diff deserves an answer rather than a
1402
+ shrug.
1403
+
1404
+ | Package | Move | What it means for you |
1405
+ |---------|------|-----------------------|
1406
+ | `js-sha256` | 0.12.0 → **1.0.0** | **No hash change.** Verified against the standard vector: `sha256('abc')` is `ba7816bf…f20015ad` on 1.0.0, byte-identical to 0.12.0. The 1.0.0 release is a packaging/API-surface milestone, not an algorithm change — a hash function whose output changed would not be SHA-256 any more. |
1407
+ | `graphql-query-complexity` | 1.1.1 → **2.0.0** | API-compatible at this framework's only call site. |
1408
+
1409
+ **Why the `js-sha256` answer matters:** the framework pre-hashes passwords with SHA-256 before
1410
+ BCrypt/scrypt when `sha256: true` is configured (`mongoose-password.plugin.ts`,
1411
+ `service.helper.ts`, `core-auth.service.ts`). A changed digest would have invalidated **every stored
1412
+ password hash** on upgrade — silently, with the symptom "all users can suddenly no longer sign in".
1413
+ It does not: the digests are identical, existing hashes keep verifying, and no password migration
1414
+ is needed.
1415
+
1416
+ **`graphql-query-complexity` 2.0.0**: `src/core/common/plugins/complexity.plugin.ts` uses
1417
+ `getComplexity`, `simpleEstimator` and `fieldExtensionsEstimator`, all still exported with unchanged
1418
+ signatures in 2.x — nothing in this framework had to be touched.
1419
+
1420
+ v2 adds a second, node-count-based DoS bound alongside the existing complexity bound:
1421
+ `maxQueryNodes`, defaulting to **10 000** evaluated selection-set nodes. Exceeding it aborts the
1422
+ traversal with `GraphQLError: Query exceeds the maximum allowed number of nodes.` — so a document
1423
+ that is cheap per field but enormous in shape is now rejected before its complexity score finishes
1424
+ being computed, where v1 would have walked all of it. The framework does not pass the option, so it
1425
+ inherits the default; `graphQl.maxComplexity` is unaffected and still applies on top.
1426
+
1427
+ One caveat about coverage rather than about the library: `src/core.module.ts:298` deliberately does
1428
+ **not** register `ComplexityPlugin` under Vitest (a dual-GraphQL-loading issue), so this repository's
1429
+ own test suite does not exercise the v2 code path at all. If you rely on complexity limits, verify
1430
+ them against your own schema after the upgrade — in particular any legitimate query that is large in
1431
+ node count rather than in cost.
1432
+
1433
+ ---
1434
+
1435
+ ## Troubleshooting
1436
+
1437
+ | Symptom | Cause | Fix |
1438
+ |---------|-------|-----|
1439
+ | `Redis is configured (ServerOptions.redis) but the optional peer dependency "ioredis" is not installed` | Boot-time fail-fast | `pnpm add ioredis`, or remove the `redis` config |
1440
+ | `S3 is configured (ServerOptions.s3) but the optional peer dependency "@aws-sdk/client-s3" is not installed` | Boot-time fail-fast | `pnpm add @aws-sdk/client-s3` |
1441
+ | `file.storage is set to 's3', but that storage is not available` | Boot-time fail-fast — replaces the old silent fallback to GridFS | Forward `{ configService, s3Service }` to `super()` in your `FileService` and install `@aws-sdk/client-s3` — or pin `file: { storage: 'gridfs' }` — §7 |
1442
+ | `File storage 's3' was selected automatically (s3.bucket is configured) but is not available` | Same, but the driver was DERIVED: an `s3` block makes S3 the default — the usual cause is an `s3` key the project already had for its OWN client | If S3 is only meant for tus staging or your own code, say so: `file: { storage: 'gridfs' }` — §11b |
1443
+ | `Query exceeds the maximum allowed number of nodes.` on a GraphQL request | `graphql-query-complexity` 2.0.0 adds a node-count bound (default 10 000) next to the complexity bound | §14 — split the query, or the document is genuinely pathological |
1444
+ | `Ignoring the \`s3\` configuration: no \`bucket\` is set` warning, and S3 stays off | A bucket is the one thing S3 cannot default | Set `s3.bucket`. Without it `tus.s3Staging` would have staged against a bucket that does not exist |
1445
+ | Files land in GridFS although `s3` is configured | Your `FileService` does not forward the services to `super()` | §7 — the boot error above now catches this instead of failing silently |
1446
+ | Uploaded files disappear after a container restart | `file.storage: 'filesystem'` on ephemeral storage | Mount a volume at `file.storageDir`, or use `'s3'` / `'gridfs'` — §7 |
1447
+ | `TS2345: Argument of type 'Promise<RateLimitResult>' is not assignable…` | A rate-limiter call is not awaited | §1 |
1448
+ | A guard/middleware that calls `check()` compiles but never rate-limits | It returns a truthy promise, not a boolean | Make it `async` and `await` the call — §1 |
1449
+ | `Timed out … waiting for the migration lock in collection "migrations_lock"` | Another replica is migrating, or a stuck process holds the lock | §4 — inspect `owner` / `acquiredAt`, delete only when sure |
1450
+ | `Breaking stale migration lock …` warning on boot | A previous run was killed and stopped heart-beating | Expected recovery, no action |
1451
+ | Migrations now take noticeably longer to start on a rolling deploy | Replicas serialise on the lock | Intended — §4 |
1452
+ | A cron job stopped running on every replica after adding `redis` | It is deduplicated now | Set `distributed: false` if it must run everywhere |
1453
+ | A `runOnInit` job skips its startup tick after a restart | Restarted inside the 5-minute init window | §9 — `distributed: false` if the startup work is per-process |
1454
+ | `Redis is configured but the optional peer dependency "bullmq" is not installed` | Warning only | Install `bullmq`, or ignore — leases still deduplicate |
1455
+ | `Cron job deduplication is inactive: neither a Redis service nor a Mongoose connection…` | `distributed: true` without any lease backend | Configure `redis`, or pass `connection` in the options object |
1456
+ | `Redis rate limiting unavailable … Falling back to per-replica in-memory limits` | Redis outage | Requests keep working with per-replica limits; a recovery line follows — §1 |
1457
+ | Uploads now return an ObjectId where a filename was expected | Avatars are file ids now | §10.1 — including the frontend fallback |
1458
+ | `multerFileToUpload() needs an in-memory upload` | Multer wrote the file to disk | Pass `memory: true` to `multerOptionsForImageUpload()` |
1459
+ | Downloads answer `302` instead of the file body | `s3.presignedDownloads` is enabled | Intended; follow the redirect, or disable presigned downloads — §7 |
1460
+ | Heap blows up on a large `putObject()` of a stream | No `contentLength`, so the stream is buffered to measure it | Pass the fourth argument — §7 |
1461
+ | `pnpm audit` still reports findings in your project after updating | Framework overrides do not travel with the npm package | §13 — replicate the three load-bearing entries and re-resolve your lockfile |
1462
+ | Subscription payload arrives with missing/mangled fields after enabling Redis | Payload is not JSON-serialisable | §6 |
1463
+ | Hub panels look empty or lossy across pods | Collectors are process-local without Redis | Configure `redis`, or route `/hub` to one replica |
1464
+ | TUS staging bucket fills with abandoned parts | No lifecycle rule on the staging bucket | §8 |
1465
+
1466
+ ---
1467
+
1468
+ ## Verification
1469
+
1470
+ ```bash
1471
+ pnpm test
1472
+ ```
1473
+
1474
+ New coverage:
1475
+
1476
+ - `tests/unit/redis-rate-limiters.spec.ts` — the store abstraction, exact limits across "replicas", the outage fallback, and glob escaping in caller-controlled key parts
1477
+ - `tests/unit/core-cron-jobs-dedup.spec.ts` — the three dedup modes, the `runOnInit` fixed-key lease, fail-open on an unreachable lease store, the BullMQ worker start order
1478
+ - `tests/unit/migration-store-lock.spec.ts` and `tests/migrate/mongo-state-store.e2e-spec.ts` — heartbeat, stale-break and bounded wait
1479
+ - `tests/unit/performance-caches.spec.ts` — tenant-cache invalidation broadcast and the no-Redis path
1480
+ - `tests/unit/hub-buffer.spec.ts` — the Redis mirror, sequence-number continuity, fallback on a read failure
1481
+ - `tests/redis-infra.e2e-spec.ts` / `tests/s3-infra.e2e-spec.ts` — round trips against a real Redis and a real S3-compatible store
1482
+ - `tests/redis-consumers.e2e-spec.ts` — the framework's own Redis consumers seen from two replicas
1483
+ - `tests/file-storage-s3.e2e-spec.ts` — `CoreFileService` against a real S3 store
1484
+ - `tests/graceful-shutdown.e2e-spec.ts` — `installGracefulShutdown()` with real connections to close
1485
+ - `tests/redis-s3-bootstrap.e2e-spec.ts` — boots the assembled `CoreModule` with both configured, covering the wiring itself (an unresolvable provider or a lifecycle hook that throws on a real connection passes every other spec and fails on a consumer's first `nest start`)
1486
+ - `tests/multi-replica.e2e-spec.ts` — the acceptance test: two independent service instances sharing one Redis, asserting that a scheduled tick and a startup tick each run exactly once, that one rate limit is enforced instead of one per replica, and that a severed Redis still yields a decision instead of an error
1487
+
1488
+ Those **seven** infrastructure suites need real containers. They do **not** skip when nothing is
1489
+ reachable — a silently skipped infrastructure test is how an untested driver ships — and most of
1490
+ them name the exact `docker run` line in the failure:
1491
+
1492
+ ```bash
1493
+ docker run -d --name nest-server-2985-redis -p 6380:6379 redis:7.4-alpine
1494
+ docker run -d --name nest-server-2985-rustfs -p 9102:9000 \
1495
+ -e RUSTFS_ROOT_USER=rustfs -e RUSTFS_ROOT_PASSWORD=rustfs-secret \
1496
+ -e RUSTFS_VOLUMES=/data rustfs/rustfs:latest server /data
1497
+ ```
1498
+
1499
+ Port 6380 rather than 6379 is deliberate: on `lt dev` machines 6379 is occupied by an
1500
+ auth-protected Redis. Override with `REDIS_HOST` / `REDIS_PORT` and `S3_ENDPOINT` /
1501
+ `S3_ACCESS_KEY` / `S3_SECRET_KEY`. CI starts both containers in `build.yml` and `publish.yml`.
1502
+
1503
+ ---
1504
+
1505
+ ## Module Documentation
1506
+
1507
+ ### Migrate
1508
+
1509
+ - **README:** [src/core/modules/migrate/README.md](../src/core/modules/migrate/README.md) — "Locking Mechanism" and `createMigrationStore()`
1510
+ - **Key Files:** `mongo-state-store.ts` (lock, heartbeat, stale-break), `migration-runner.ts` (`up()` under the lock), `helpers/migration.helper.ts` (`createMigrationStore()` default)
1511
+
1512
+ ### File
1513
+
1514
+ - **README:** [src/core/modules/file/README.md](../src/core/modules/file/README.md) — new "Access control" section (roles vs. `checkRights()`)
1515
+ - **Integration Checklist:** [src/core/modules/file/INTEGRATION-CHECKLIST.md](../src/core/modules/file/INTEGRATION-CHECKLIST.md) — new in this release
1516
+ - **Reference Implementation:** `src/server/modules/file/`
1517
+ - **Key Files:** `core-file.service.ts` (storage driver selection, GridFS read fallback, `getRawFileInfo()`), `s3-file.helper.ts` (`s3-files` metadata collection), `core-file.controller.ts` (presigned redirect, fail-soft, `setFileHeaders()`), `file-roles.helper.ts` (config-driven role application)
1518
+
1519
+ ### TUS
1520
+
1521
+ - **README:** [src/core/modules/tus/README.md](../src/core/modules/tus/README.md) — `tus.roles`, OPTIONS handling
1522
+ - **Key Files:** `core-tus.controller.ts` (separate public `@Options()` handlers), `tus.module.ts` (`applyRoles()`), `interfaces/tus-config.interface.ts` (`roles` default, `s3Staging`), `core-tus.service.ts` (S3 staging store), `tus-redis-locker.ts` (cross-replica upload lock)
1523
+
1524
+ ### Hub
1525
+
1526
+ - **README:** [src/core/modules/hub/README.md](../src/core/modules/hub/README.md) — new "Multi-replica" section
1527
+ - **Key Files:** `hub-buffer.ts` (optional Redis mirror), `core-hub.controller.ts` (async panel handlers)
1528
+
1529
+ ### AI
1530
+
1531
+ - **README:** [src/core/modules/ai/README.md](../src/core/modules/ai/README.md) — new "Multi-replica / sticky sessions" section
1532
+ - **Integration Checklist:** [src/core/modules/ai/INTEGRATION-CHECKLIST.md](../src/core/modules/ai/INTEGRATION-CHECKLIST.md)
1533
+
1534
+ ### System Setup
1535
+
1536
+ - **README:** [src/core/modules/system-setup/README.md](../src/core/modules/system-setup/README.md) — bootstrap claim
1537
+ - **Key File:** `core-system-setup.service.ts` (`system-setup-locks`)
1538
+
1539
+ ### Tenant (Multi-Tenancy)
1540
+
1541
+ - **README:** [src/core/modules/tenant/README.md](../src/core/modules/tenant/README.md)
1542
+ - **Key File:** `core-tenant.guard.ts` (cross-replica cache invalidation)
1543
+
1544
+ ---
1545
+
1546
+ ## References
1547
+
1548
+ - [`.claude/rules/configurable-features.md`](../.claude/rules/configurable-features.md) — the "presence implies enabled" pattern every new option follows
1549
+ - [`docs/native-driver-security.md`](../docs/native-driver-security.md) — why `cron-locks`, `system-setup-locks` and `s3-files` use the native driver
1550
+ - [`.claude/rules/package-management.md`](../.claude/rules/package-management.md) — fixed versions for the optional peers you install
1551
+ - [nest-server-starter](https://github.com/lenneTech/nest-server-starter) (reference implementation)