@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
@@ -8,6 +8,25 @@ import { CoreBetterAuthUserMapper } from '../better-auth/core-better-auth-user.m
8
8
  import { CoreBetterAuthService } from '../better-auth/core-better-auth.service';
9
9
  import { ErrorCode } from '../error-code/error-codes';
10
10
 
11
+ /**
12
+ * Collection holding the bootstrap claim markers.
13
+ *
14
+ * Native collection access is intentional here: no Mongoose schema exists for it
15
+ * (see docs/native-driver-security.md).
16
+ */
17
+ const SETUP_LOCK_COLLECTION = 'system-setup-locks';
18
+
19
+ /**
20
+ * How long an initial-admin claim may stand before another replica takes it over.
21
+ *
22
+ * Long enough that a slow but live creation is never stolen, short enough that a crashed
23
+ * claimer does not lock the deployment out for good.
24
+ */
25
+ const INITIAL_ADMIN_CLAIM_STALE_AFTER_MS = 5 * 60_000;
26
+
27
+ /** `_id` of the marker claiming the initial-admin creation */
28
+ const INITIAL_ADMIN_LOCK_ID = 'initial-admin';
29
+
11
30
  /**
12
31
  * Input for creating the initial admin user
13
32
  */
@@ -101,6 +120,10 @@ export class CoreSystemSetupService implements OnApplicationBootstrap {
101
120
  return;
102
121
  }
103
122
 
123
+ // The atomic claim lives in createInitialAdmin() so that BOTH entry points — this
124
+ // one and the anonymous POST /system-setup/init — serialize on the same marker.
125
+ // Claiming here as well would take the claim twice on this path and leave the
126
+ // public path unguarded.
104
127
  try {
105
128
  const result = await this.createInitialAdmin({
106
129
  email: initialAdmin.email,
@@ -109,8 +132,11 @@ export class CoreSystemSetupService implements OnApplicationBootstrap {
109
132
  });
110
133
  this.logger.log(`Auto-created initial admin on startup: ${result.email}`);
111
134
  } catch (error) {
135
+ // The claim is released by createInitialAdmin() itself, which is the only place
136
+ // that takes it — so a replica that crashed mid-creation cannot block setup on
137
+ // every future boot, and the marker has exactly one owner.
112
138
  if (error instanceof ForbiddenException) {
113
- this.logger.log('Initial admin auto-creation skipped (users already exist)');
139
+ this.logger.log('Initial admin auto-creation skipped (users already exist or claimed elsewhere)');
114
140
  } else {
115
141
  this.logger.warn(
116
142
  `Initial admin auto-creation failed: ${error instanceof Error ? error.message : 'Unknown error'}`,
@@ -119,6 +145,58 @@ export class CoreSystemSetupService implements OnApplicationBootstrap {
119
145
  }
120
146
  }
121
147
 
148
+ /**
149
+ * Try to claim the initial-admin creation for this instance
150
+ *
151
+ * The upsert on a fixed `_id` is atomic, so exactly one of N concurrently booting
152
+ * replicas sees no previous document and wins the claim.
153
+ *
154
+ * @returns true when this instance may create the initial admin
155
+ */
156
+ protected async claimInitialAdminSetup(): Promise<boolean> {
157
+ try {
158
+ const collection = this.connection.collection(SETUP_LOCK_COLLECTION);
159
+
160
+ // The marker is a CLAIM, not a permanent record: a replica SIGKILLed between claiming and
161
+ // creating (OOM, node drain, a failed first rollout — all ordinary) never reaches the
162
+ // release in the catch below. Without an expiry that leaves a deployment with zero users
163
+ // and no way in, recoverable only by deleting a document from an undocumented collection.
164
+ // A stale claim is therefore taken over rather than obeyed.
165
+ const staleBefore = new Date(Date.now() - INITIAL_ADMIN_CLAIM_STALE_AFTER_MS);
166
+ await collection.deleteOne({ _id: INITIAL_ADMIN_LOCK_ID as any, claimedAt: { $lt: staleBefore } });
167
+
168
+ const previous = await collection.findOneAndUpdate(
169
+ { _id: INITIAL_ADMIN_LOCK_ID as any },
170
+ { $setOnInsert: { claimedAt: new Date() } },
171
+ { returnDocument: 'before', upsert: true },
172
+ );
173
+
174
+ // No previous document → this instance inserted the marker and owns the setup
175
+ return !previous;
176
+ } catch (error) {
177
+ // Two replicas upserting the same `_id` at the very same moment: one insert wins,
178
+ // the other gets a duplicate key error — which means the claim is taken.
179
+ if (error instanceof Error && (error.message?.includes('duplicate key') || error.message?.includes('E11000'))) {
180
+ return false;
181
+ }
182
+ throw error;
183
+ }
184
+ }
185
+
186
+ /**
187
+ * Remove the initial-admin claim marker so a later boot can retry the setup
188
+ */
189
+ protected async releaseInitialAdminSetupClaim(): Promise<void> {
190
+ try {
191
+ await this.connection.collection(SETUP_LOCK_COLLECTION).deleteOne({ _id: INITIAL_ADMIN_LOCK_ID as any });
192
+ } catch (error) {
193
+ // Never mask the failure that triggered the release
194
+ this.logger.warn(
195
+ `Failed to release initial admin setup claim: ${error instanceof Error ? error.message : 'Unknown error'}`,
196
+ );
197
+ }
198
+ }
199
+
122
200
  /**
123
201
  * Check if the system needs initial setup (zero users)
124
202
  */
@@ -153,6 +231,17 @@ export class CoreSystemSetupService implements OnApplicationBootstrap {
153
231
  throw new ForbiddenException(ErrorCode.SYSTEM_SETUP_BETTERAUTH_REQUIRED);
154
232
  }
155
233
 
234
+ // The count check above is check-then-act, and this method is reachable ANONYMOUSLY
235
+ // via POST /system-setup/init. Two concurrent callers with DIFFERENT emails both pass
236
+ // it and both get an admin — the E11000 handler below only catches the same-email
237
+ // case. The claim is the same marker the boot path uses, so the HTTP path and the
238
+ // auto-bootstrap also serialize against each other instead of racing: an attacker
239
+ // racing a fresh deployment can no longer obtain an admin account ALONGSIDE the
240
+ // configured one.
241
+ if (!(await this.claimInitialAdminSetup())) {
242
+ throw new ForbiddenException(ErrorCode.SYSTEM_SETUP_NOT_AVAILABLE);
243
+ }
244
+
156
245
  try {
157
246
  // Access BetterAuth internal context (same pattern as core-better-auth-api.middleware.ts)
158
247
  const context = await authInstance.$context;
@@ -208,6 +297,11 @@ export class CoreSystemSetupService implements OnApplicationBootstrap {
208
297
  success: true,
209
298
  };
210
299
  } catch (error) {
300
+ // Release the claim we took above: a caller that failed mid-creation must not
301
+ // leave a deployment with zero users and no way in. The marker is additionally
302
+ // stale-expiring, so this is belt AND braces for the crash-before-release case.
303
+ await this.releaseInitialAdminSetupClaim();
304
+
211
305
  // Handle duplicate email (race condition via MongoDB unique index)
212
306
  if (error instanceof Error && (error.message?.includes('duplicate key') || error.message?.includes('E11000'))) {
213
307
  throw new ForbiddenException(ErrorCode.SYSTEM_SETUP_NOT_AVAILABLE);
@@ -238,7 +238,7 @@ multiTenancy: {
238
238
  - **Config-change detection:** Cache is flushed when `multiTenancy` config changes (e.g., `roleHierarchy` update).
239
239
  - **Bounded:** Max 500 entries with FIFO eviction. Memory overhead: ~100-250 KB.
240
240
 
241
- **Important:** The cache is process-local. In horizontally scaled deployments (multiple instances), membership changes on one instance are not reflected on other instances until the TTL expires. Set `cacheTtlMs: 0` for security-sensitive deployments.
241
+ **Important:** The cache storage itself is always process-local — every replica keeps its own map. What changed in 11.33.0 is how _invalidations_ travel: with `redis` configured they are broadcast, without it they are not (see below). A membership change that happens **outside** `invalidateUser()` / `invalidateAll()` a direct DB write, an admin tool, a migration — is still invisible to every replica until `cacheTtlMs` expires. Set `cacheTtlMs: 0` for security-sensitive deployments.
242
242
 
243
243
  ### Manual Cache Invalidation
244
244
 
@@ -256,6 +256,26 @@ export class TenantService extends CoreTenantService {
256
256
 
257
257
  Use `invalidateAll()` to flush the entire cache (e.g., after bulk operations).
258
258
 
259
+ Both are **instance** methods on the singleton `CoreTenantGuard` — inject it and call
260
+ `this.tenantGuard?.invalidateUser(userId)`. There is no static equivalent.
261
+
262
+ #### Cross-replica invalidation (since 11.33.0)
263
+
264
+ `invalidateUser()` / `invalidateAll()` are no longer a purely local clear:
265
+
266
+ | Setup | What happens |
267
+ | ---------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
268
+ | **`redis` configured** | The local caches are cleared **and** the invalidation is published to `<keyPrefix>:tenant-cache:invalidate`. Every replica subscribed to that channel clears its own caches, so a membership change takes effect fleet-wide within one round trip. |
269
+ | **No `redis`** | Only the calling process clears. Other replicas keep serving the stale membership until `cacheTtlMs` expires — the pre-11.33.0 behaviour. |
270
+
271
+ Mechanism, for anyone extending the guard (`core-tenant.guard.ts`):
272
+
273
+ - The channel is namespaced with the framework's Redis key prefix, so two applications sharing one Redis instance do not clear each other's caches. Two **stages** of the same application do, unless you set `redis.keyPrefix` or `redis.db`.
274
+ - The message is `{ scope: 'user', userId }` or `{ scope: 'all' }`, JSON-encoded.
275
+ - Subscription uses the shared subscriber connection from `CoreRedisService.getSubscriber()`; the listener is held in a field and detached on destroy, because that connection is shared.
276
+ - **A received broadcast clears locally and does NOT re-publish** (`applyInvalidation()` → `clearUser()` / `clearAll()`, the no-broadcast variants). Re-publishing would bounce the message around the cluster forever.
277
+ - **Publishing is fire-and-forget and failures are debug-level only.** A missed broadcast is not an error: it merely leaves the other replicas' caches stale until their TTL — exactly the no-Redis behaviour. Nothing about a request path depends on the broadcast succeeding.
278
+
259
279
  ### SkipTenantCheck
260
280
 
261
281
  For high-frequency endpoints that don't access tenant-scoped data, use `@SkipTenantCheck()` to avoid the membership lookup entirely.
@@ -4,7 +4,9 @@ import {
4
4
  ForbiddenException,
5
5
  Injectable,
6
6
  Logger,
7
+ OnApplicationBootstrap,
7
8
  OnModuleDestroy,
9
+ Optional,
8
10
  UnauthorizedException,
9
11
  } from '@nestjs/common';
10
12
  import { Reflector } from '@nestjs/core';
@@ -14,6 +16,7 @@ import { Model } from 'mongoose';
14
16
 
15
17
  import { RoleEnum } from '../../common/enums/role.enum';
16
18
  import { ConfigService } from '../../common/services/config.service';
19
+ import { CoreRedisService } from '../../common/services/core-redis.service';
17
20
  import { ErrorCode } from '../error-code/error-codes';
18
21
  import { CoreTenantMemberModel } from './core-tenant-member.model';
19
22
  import { SKIP_TENANT_CHECK_KEY } from './core-tenant.decorators';
@@ -42,6 +45,14 @@ interface CachedTenantIds {
42
45
  ids: string[];
43
46
  }
44
47
 
48
+ /**
49
+ * Cache invalidation message broadcast between replicas via Redis pub/sub
50
+ */
51
+ interface TenantCacheInvalidation {
52
+ scope: 'all' | 'user';
53
+ userId?: string;
54
+ }
55
+
45
56
  /**
46
57
  * Global guard for multi-tenancy with defense-in-depth security.
47
58
  *
@@ -103,7 +114,7 @@ interface CachedTenantIds {
103
114
  * - No user + checkable roles → 401 (authentication required)
104
115
  */
105
116
  @Injectable()
106
- export class CoreTenantGuard implements CanActivate, OnModuleDestroy {
117
+ export class CoreTenantGuard implements CanActivate, OnApplicationBootstrap, OnModuleDestroy {
107
118
  private readonly logger = new Logger(CoreTenantGuard.name);
108
119
 
109
120
  /**
@@ -111,6 +122,12 @@ export class CoreTenantGuard implements CanActivate, OnModuleDestroy {
111
122
  * Key: `${userId}:${tenantId}`, Value: cached membership result with expiry.
112
123
  * Eliminates repeated DB queries for the same user+tenant combination.
113
124
  */
125
+ /** Channel this guard subscribed to, so it can unsubscribe again */
126
+ private invalidationChannel?: string;
127
+
128
+ /** The shared-subscriber listener, kept so it can be detached on destroy */
129
+ private invalidationListener?: (channel: string, message: string) => void;
130
+
114
131
  private readonly membershipCache = new Map<string, CachedMembership>();
115
132
 
116
133
  /**
@@ -131,6 +148,7 @@ export class CoreTenantGuard implements CanActivate, OnModuleDestroy {
131
148
  constructor(
132
149
  private readonly reflector: Reflector,
133
150
  @InjectModel(TENANT_MEMBER_MODEL_TOKEN) private readonly memberModel: Model<CoreTenantMemberModel>,
151
+ @Optional() protected readonly redisService?: CoreRedisService,
134
152
  ) {
135
153
  // Clean up expired cache entries every 60 seconds
136
154
  this.cleanupInterval = setInterval(() => this.evictExpired(), 60_000);
@@ -139,7 +157,50 @@ export class CoreTenantGuard implements CanActivate, OnModuleDestroy {
139
157
  }
140
158
  }
141
159
 
160
+ /**
161
+ * Subscribe to the cross-replica cache invalidation channel.
162
+ *
163
+ * Runs after ALL onModuleInit hooks so that CoreRedisService is connected.
164
+ * Without Redis this is a no-op and the caches stay process-local.
165
+ */
166
+ async onApplicationBootstrap(): Promise<void> {
167
+ if (!this.redisService?.enabled) {
168
+ return;
169
+ }
170
+ const channel = this.redisService.key('tenant-cache', 'invalidate');
171
+ try {
172
+ const subscriber = this.redisService.getSubscriber();
173
+ // Held in a field, not an anonymous closure: the subscriber connection is SHARED between
174
+ // features, so a listener that is never detached keeps dispatching into a destroyed
175
+ // guard's caches for the life of the process — and a second app in the same process
176
+ // inherits the previous one's listener alongside its own.
177
+ this.invalidationChannel = channel;
178
+ this.invalidationListener = (incomingChannel: string, message: string) => {
179
+ if (incomingChannel === channel) {
180
+ this.applyInvalidation(message);
181
+ }
182
+ };
183
+ subscriber.on('message', this.invalidationListener);
184
+ await subscriber.subscribe(channel);
185
+ } catch (error) {
186
+ this.logger.debug(`Tenant cache invalidation subscribe failed: ${(error as Error).message}`);
187
+ }
188
+ }
189
+
142
190
  onModuleDestroy(): void {
191
+ if (this.invalidationListener) {
192
+ try {
193
+ const subscriber = this.redisService?.getSubscriber();
194
+ subscriber?.off('message', this.invalidationListener);
195
+ if (this.invalidationChannel) {
196
+ void subscriber?.unsubscribe(this.invalidationChannel).catch(() => undefined);
197
+ }
198
+ } catch {
199
+ // Redis already gone — nothing to detach from
200
+ }
201
+ this.invalidationListener = undefined;
202
+ }
203
+
143
204
  if (this.cleanupInterval) {
144
205
  clearInterval(this.cleanupInterval);
145
206
  this.cleanupInterval = null;
@@ -152,31 +213,28 @@ export class CoreTenantGuard implements CanActivate, OnModuleDestroy {
152
213
  * Invalidate all cache entries for a specific user.
153
214
  * Call this when memberships change (add/remove/update).
154
215
  *
216
+ * When Redis is enabled, the invalidation is additionally broadcast to all other
217
+ * replicas — otherwise their process-local caches would stay stale for up to cacheTtlMs.
218
+ *
155
219
  * Note: userId must not contain ':' characters (used as cache key delimiter).
156
220
  * MongoDB ObjectIds and standard UUID formats are safe.
157
221
  *
158
222
  * @param userId - The user ID whose cache entries should be invalidated
159
223
  */
160
224
  invalidateUser(userId: string): void {
161
- for (const key of this.membershipCache.keys()) {
162
- if (key.startsWith(`${userId}:`)) {
163
- this.membershipCache.delete(key);
164
- }
165
- }
166
- for (const key of this.tenantIdsCache.keys()) {
167
- if (key === userId || key.startsWith(`${userId}:`)) {
168
- this.tenantIdsCache.delete(key);
169
- }
170
- }
225
+ this.clearUser(userId);
226
+ this.publishInvalidation({ scope: 'user', userId });
171
227
  }
172
228
 
173
229
  /**
174
230
  * Clear all cache entries.
175
231
  * Useful when configuration changes (e.g., roleHierarchy) or for testing.
232
+ *
233
+ * When Redis is enabled, the invalidation is additionally broadcast to all other replicas.
176
234
  */
177
235
  invalidateAll(): void {
178
- this.membershipCache.clear();
179
- this.tenantIdsCache.clear();
236
+ this.clearAll();
237
+ this.publishInvalidation({ scope: 'all' });
180
238
  }
181
239
 
182
240
  async canActivate(context: ExecutionContext): Promise<boolean> {
@@ -192,7 +250,8 @@ export class CoreTenantGuard implements CanActivate, OnModuleDestroy {
192
250
  const isTestEnv =
193
251
  process.env.VITEST === 'true' || process.env.NODE_ENV === 'test' || process.env.NODE_ENV === 'e2e';
194
252
  this.cacheTtlMs = config.cacheTtlMs ?? (isTestEnv ? 0 : 30_000);
195
- this.invalidateAll();
253
+ // Local-only flush: every replica detects the config change itself, no broadcast needed
254
+ this.clearAll();
196
255
  }
197
256
 
198
257
  const request = this.getRequest(context);
@@ -494,6 +553,68 @@ export class CoreTenantGuard implements CanActivate, OnModuleDestroy {
494
553
  // Cache helpers
495
554
  // ===================================================================================================================
496
555
 
556
+ /**
557
+ * Apply an invalidation message received from another replica.
558
+ * Clears locally only — re-publishing would bounce the message around the cluster.
559
+ */
560
+ protected applyInvalidation(message: string): void {
561
+ let parsed: TenantCacheInvalidation;
562
+ try {
563
+ parsed = JSON.parse(message);
564
+ } catch {
565
+ this.logger.debug(`Ignoring malformed tenant cache invalidation message: ${message}`);
566
+ return;
567
+ }
568
+ if (parsed?.scope === 'all') {
569
+ this.clearAll();
570
+ } else if (parsed?.scope === 'user' && parsed.userId) {
571
+ this.clearUser(parsed.userId);
572
+ }
573
+ }
574
+
575
+ /**
576
+ * Clear both caches locally (no broadcast)
577
+ */
578
+ protected clearAll(): void {
579
+ this.membershipCache.clear();
580
+ this.tenantIdsCache.clear();
581
+ }
582
+
583
+ /**
584
+ * Remove all entries of a user from both caches locally (no broadcast)
585
+ */
586
+ protected clearUser(userId: string): void {
587
+ for (const key of this.membershipCache.keys()) {
588
+ if (key.startsWith(`${userId}:`)) {
589
+ this.membershipCache.delete(key);
590
+ }
591
+ }
592
+ for (const key of this.tenantIdsCache.keys()) {
593
+ if (key === userId || key.startsWith(`${userId}:`)) {
594
+ this.tenantIdsCache.delete(key);
595
+ }
596
+ }
597
+ }
598
+
599
+ /**
600
+ * Broadcast an invalidation to the other replicas (fire-and-forget).
601
+ * No-op without Redis; failures are logged at debug level only — a missed
602
+ * broadcast merely leaves the other replicas' caches stale until their TTL.
603
+ */
604
+ protected publishInvalidation(message: TenantCacheInvalidation): void {
605
+ if (!this.redisService?.enabled) {
606
+ return;
607
+ }
608
+ try {
609
+ void this.redisService
610
+ .getClient()
611
+ .publish(this.redisService.key('tenant-cache', 'invalidate'), JSON.stringify(message))
612
+ .catch((error: Error) => this.logger.debug(`Tenant cache invalidation publish failed: ${error.message}`));
613
+ } catch (error) {
614
+ this.logger.debug(`Tenant cache invalidation publish failed: ${(error as Error).message}`);
615
+ }
616
+ }
617
+
497
618
  /**
498
619
  * Look up a membership with process-level TTL cache.
499
620
  * Avoids repeated DB queries when the same user accesses the same tenant repeatedly.
@@ -75,10 +75,14 @@ export class CoreTenantModule {
75
75
  provide: CoreTenantService,
76
76
  useClass: Service,
77
77
  },
78
- {
79
- provide: APP_GUARD,
80
- useClass: Guard,
81
- },
78
+ // Registered under its OWN token first, then aliased to APP_GUARD — the pattern
79
+ // CoreAuthModule uses for RolesGuard. A bare `{ provide: APP_GUARD, useClass: Guard }`
80
+ // makes Nest mint an internal token for the instance, so `@Optional() CoreTenantGuard`
81
+ // in CoreTenantService resolves to undefined and every invalidateUser()/invalidateAll()
82
+ // — including the cross-replica Redis broadcast — silently did nothing.
83
+ Guard,
84
+ ...(Guard === CoreTenantGuard ? [] : [{ provide: CoreTenantGuard, useExisting: Guard }]),
85
+ { provide: APP_GUARD, useExisting: Guard },
82
86
  ];
83
87
 
84
88
  // When a custom model name is used, alias the default injection token to the custom model.
@@ -2,18 +2,61 @@
2
2
 
3
3
  **For customizing TUS uploads in projects using `@lenne.tech/nest-server`.**
4
4
 
5
- > **Note:** TUS is **enabled by default** with no configuration needed. This checklist is only for projects that need to customize behavior (e.g., require authentication).
5
+ > **Note:** TUS is **enabled by default** with no configuration needed. This checklist is only for projects that need to customize behavior (e.g., widen or narrow who may upload).
6
+
7
+ > **Since 11.33.0 uploads require a signed-in user.** `tus.roles` defaults to `[RoleEnum.S_USER]`; it used to be `S_EVERYONE`. If your project accepts attachments on a public form, you must opt back in explicitly — see Step 0.
6
8
 
7
9
  ---
8
10
 
9
11
  ## Do You Need This Checklist?
10
12
 
11
- | Scenario | Checklist Needed? |
12
- | -------------------------------------------- | ---------------------------------------------------- |
13
- | Use TUS with defaults (everyone can upload) | No - works automatically |
14
- | Require authentication for uploads | Yes - Step 1 |
15
- | Custom upload handling (notifications, etc.) | Yes - Step 2 |
16
- | Disable TUS completely | No - just use `TusModule.forRoot({ config: false })` |
13
+ | Scenario | Checklist Needed? |
14
+ | -------------------------------------------------- | ---------------------------------------------------- |
15
+ | Use TUS with defaults (signed-in users may upload) | No - works automatically |
16
+ | **Allow anonymous uploads** (public form) | **Yes - Step 0** (the default no longer allows this) |
17
+ | Restrict uploads to specific roles | Yes - Step 0 |
18
+ | Custom upload handling (notifications, etc.) | Yes - Step 2 |
19
+ | Hard-code a policy that config must NOT change | Yes - Step 1 |
20
+ | Disable TUS completely | No - just use `TusModule.forRoot({ config: false })` |
21
+
22
+ ---
23
+
24
+ ## Step 0: Configure `tus.roles` (Who May Upload)
25
+
26
+ This is the normal way to set TUS permissions — no controller subclass needed.
27
+
28
+ ```typescript
29
+ // src/config.env.ts
30
+ tus: {
31
+ roles: [RoleEnum.S_USER], // default since 11.33.0
32
+ // roles: [RoleEnum.S_EVERYONE], // opt back in to anonymous uploads (public form)
33
+ // roles: ['editor', 'contributor'], // project-specific roles work too
34
+ },
35
+ ```
36
+
37
+ Plain role **strings**, exactly like a hand-written `@Roles()` call, so project roles work alongside
38
+ the `RoleEnum` system roles.
39
+
40
+ **Three properties worth knowing:**
41
+
42
+ 1. **`roles: []` is rejected**, with a warning, and the default applies. An all-empty role set reads
43
+ to the guards as "no roles required" and returns `true` — honouring it literally would _open_ the
44
+ endpoints, the exact opposite of what someone writing `[]` intends. Same for a non-array or an
45
+ array holding a non-string.
46
+
47
+ 2. **`OPTIONS` stays public regardless.** `handleTusOptions` / `handleTusOptionsWithId` keep their
48
+ own `@Roles(RoleEnum.S_EVERYONE)`, because that is the CORS preflight — browsers send it
49
+ **without credentials**, and it returns server capabilities only. Gating it would make every
50
+ browser upload fail before the first byte.
51
+
52
+ 3. **Pair it with the file roles.** A TUS upload lands in the same store `file.downloadRoles`
53
+ guards (default `[RoleEnum.ADMIN]`), so with both at their defaults a signed-in user may upload
54
+ but cannot read the result back. Either widen `file.downloadRoles`, or write an owner into the
55
+ metadata at upload time and authorize per file in `CoreFileService.checkRights()`. TUS uploads
56
+ already carry `metadata.tusUploadId` plus the original TUS metadata.
57
+
58
+ **Verify:** `curl -X OPTIONS <baseUrl>/tus` still answers `204` with the TUS capability headers,
59
+ while `POST /tus` without a token answers `401`.
17
60
 
18
61
  ---
19
62
 
@@ -30,22 +73,42 @@ https://github.com/lenneTech/nest-server/tree/develop/src/server
30
73
 
31
74
  ---
32
75
 
33
- ## Step 1: Custom Controller (Require Authentication)
76
+ ## Step 1: Custom Controller (Custom Upload Logic)
77
+
78
+ > Only needed for custom handler behaviour. For "who may upload", use Step 0 — it needs no subclass.
34
79
 
35
80
  **Create:** `src/server/modules/tus/tus.controller.ts`
36
81
 
37
82
  ```typescript
38
83
  import { Controller } from '@nestjs/common';
39
- import { CoreTusController, Roles, RoleEnum } from '@lenne.tech/nest-server';
84
+ import { CoreTusController } from '@lenne.tech/nest-server';
40
85
 
41
86
  @Controller('tus')
42
- @Roles(RoleEnum.S_USER) // Require authenticated user
43
87
  export class TusController extends CoreTusController {
44
- // All methods inherit the S_USER requirement
45
- // Override methods here for custom logic
88
+ // Inherits the handlers; `tus.roles` from config applies to them.
89
+ // Override methods here for custom logic.
46
90
  }
47
91
  ```
48
92
 
93
+ **How `tus.roles` reaches a custom controller — and why you should not fight it with `@Roles()`:**
94
+
95
+ `TusModule.forRoot()` calls `applyRoles()` on the class it actually registers, writing the configured
96
+ roles onto that class **and** onto its `handleTus` / `handleTusWithId`, resolved through the
97
+ prototype chain. `forRoot()` runs after the controller's decorators have been evaluated, so it wins:
98
+
99
+ | You write on the subclass | Effective at runtime |
100
+ | ---------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------- |
101
+ | Nothing | `tus.roles` (config) |
102
+ | A class-level `@Roles(...)` | `tus.roles` — overwritten by `applyRoles()` |
103
+ | A re-declared `handleTus` with its own `@Roles(...)` | `tus.roles` — overwritten too, because `applyRoles()` writes onto whichever function `prototype.handleTus` resolves to |
104
+
105
+ So `tus.roles` is the single source of truth for TUS permissions. If you need a policy that
106
+ configuration cannot change, either give your handler a **different method name** (`applyRoles()`
107
+ only targets `handleTus` / `handleTusWithId`), or register the controller outside `TusModule`.
108
+
109
+ > This differs from the file module, where `applyFileRoles()` targets the **base** class methods by
110
+ > name — there an override genuinely does opt out.
111
+
49
112
  **Update ServerModule:**
50
113
 
51
114
  ```typescript
@@ -104,6 +167,10 @@ export class TusService extends CoreTusService {
104
167
  path: '/tus',
105
168
  maxSize: 50 * 1024 * 1024 * 1024, // 50 GB
106
169
  expiration: { expiresIn: '24h' },
170
+ roles: [RoleEnum.S_USER], // since 11.33.0 (was S_EVERYONE)
171
+ // s3Staging: true when `s3` is configured — stages in-progress uploads in
172
+ // s3.stagingBucket instead of on pod-local disk, so resumable uploads survive
173
+ // replica restarts and need no sticky sessions. Set false to force local disk.
107
174
  }
108
175
  ```
109
176
 
@@ -132,20 +199,25 @@ TusModule.forRoot({ config: false });
132
199
 
133
200
  - [ ] `npm run build` succeeds
134
201
  - [ ] `npm test` passes
135
- - [ ] `OPTIONS /tus` returns TUS capabilities
136
- - [ ] Upload via tus-js-client works
137
- - [ ] File appears in GridFS after upload completion
138
- - [ ] (If customized) Authentication is required for uploads
202
+ - [ ] `OPTIONS /tus` returns TUS capabilities **without a token** (the CORS preflight must stay public)
203
+ - [ ] `POST /tus` **without** a token answers `401` (or `204` if you deliberately set `roles: [S_EVERYONE]`)
204
+ - [ ] Upload via tus-js-client works for a user holding a role from `tus.roles`
205
+ - [ ] File appears in the configured file store after upload completion (GridFS by default; S3 or filesystem per `file.storage`)
206
+ - [ ] The uploaded file can also be **downloaded** by its intended audience — check `file.downloadRoles` too
139
207
 
140
208
  ---
141
209
 
142
210
  ## Common Mistakes
143
211
 
144
- | Mistake | Symptom | Fix |
145
- | ------------------------------------ | ------------------------------ | ------------------------------------------------- |
146
- | Forgot to register custom controller | Default S_EVERYONE permissions | Add `controller: TusController` to forRoot() |
147
- | Custom controller missing @Roles | No authentication required | Add `@Roles(RoleEnum.S_USER)` to controller class |
148
- | Using wrong endpoint path | 404 on upload | Ensure client uses same path as config |
212
+ | Mistake | Symptom | Fix |
213
+ | -------------------------------------------------- | --------------------------------------------------------- | ----------------------------------------------------------------------------------------------- |
214
+ | Assuming uploads are still public (pre-11.33.0) | `401` on the very first `POST /tus` from a public form | `tus: { roles: [RoleEnum.S_EVERYONE] }` Step 0 |
215
+ | `tus: { roles: [] }` meaning "nobody" | Warning in the log, default `[S_USER]` applied instead | An empty role set would OPEN the routes; name the roles explicitly — Step 0 |
216
+ | Gating `OPTIONS` in a custom controller | Every browser upload fails before the first byte | Leave `handleTusOptions*` on `@Roles(RoleEnum.S_EVERYONE)` the preflight sends no credentials |
217
+ | Expecting a subclass `@Roles()` to win over config | Roles look ignored | `applyRoles()` overwrites them — Step 1 table |
218
+ | Forgot to register custom controller | Custom handler logic never runs (core controller is used) | Add `controller: TusController` to `forRoot()` |
219
+ | Upload succeeds but the file cannot be downloaded | `403` on `GET /files/id/:id` | `file.downloadRoles` defaults to `[ADMIN]` — widen it, or authorize per file in `checkRights()` |
220
+ | Using wrong endpoint path | 404 on upload | Ensure client uses same path as config |
149
221
 
150
222
  ---
151
223
 
@@ -262,7 +262,31 @@ upload.start(); // Resumes from where it left off
262
262
 
263
263
  ### Require Authentication
264
264
 
265
- By default, TUS allows everyone (`S_EVERYONE`) to upload. To require authentication, create a custom controller:
265
+ Since 11.33.0 TUS requires a signed-in caller by default (`tus.roles`, default `[S_USER]`). The
266
+ previous default was `S_EVERYONE`, which let anonymous callers write into — and, with the
267
+ termination extension, delete from — the same GridFS bucket the download routes guard.
268
+
269
+ Set it in `config.env.ts`:
270
+
271
+ ```typescript
272
+ tus: {
273
+ roles: [RoleEnum.S_USER];
274
+ } // default
275
+ tus: {
276
+ roles: [RoleEnum.S_EVERYONE];
277
+ } // opt back in to anonymous uploads
278
+ tus: {
279
+ roles: ['editor', 'contributor'];
280
+ } // project-specific roles work too
281
+ ```
282
+
283
+ `roles: []` is rejected with a warning rather than honoured — an all-empty role set reads to the
284
+ guards as "no roles required" and would open the endpoints instead of closing them.
285
+
286
+ `OPTIONS` stays public regardless: it is the CORS preflight, which browsers send without
287
+ credentials, and it discloses only server capabilities.
288
+
289
+ Alternatively, create a custom controller:
266
290
 
267
291
  ```typescript
268
292
  // src/server/modules/tus/tus.controller.ts
@@ -332,7 +356,7 @@ GET /files/:filename
332
356
 
333
357
  # Via GraphQL
334
358
  query {
335
- file(id: "...") {
359
+ getFileInfo(filename: "...") {
336
360
  id
337
361
  filename
338
362
  contentType
@@ -343,6 +367,15 @@ query {
343
367
 
344
368
  **Recommendation:** Use the ID-based endpoint (`/files/id/:id`) for TUS uploads as filenames may not be unique.
345
369
 
370
+ > **These download routes are gated.** They require `file.downloadRoles`, which defaults to
371
+ > `[RoleEnum.ADMIN]` — while uploading here only requires `tus.roles` (default `S_USER`). So out of
372
+ > the box a signed-in user can upload but cannot read their own file back.
373
+ >
374
+ > Do not fix that by widening `downloadRoles` to `S_USER`: that would let every signed-in user read
375
+ > _every_ file in the shared bucket. Write an owner into the file metadata at upload time and
376
+ > authorize per file in `CoreFileService.checkRights()` — see the File module's README, section
377
+ > "Access control".
378
+
346
379
  ### File Metadata
347
380
 
348
381
  The following metadata is stored with each GridFS file: