@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
@@ -1,10 +1,12 @@
1
- import { Controller, Delete, Get, Logger, Post, Req, Res } from '@nestjs/common';
1
+ import { Controller, Delete, Get, Logger, OnModuleDestroy, Optional, Post, Req, Res } from '@nestjs/common';
2
2
  import { ApiExcludeController } from '@nestjs/swagger';
3
3
  import { Request, Response } from 'express';
4
+ import { hostname } from 'node:os';
4
5
 
5
6
  import { Roles } from '../../common/decorators/roles.decorator';
6
7
  import { RoleEnum } from '../../common/enums/role.enum';
7
8
  import { ConfigService } from '../../common/services/config.service';
9
+ import { CoreRedisService } from '../../common/services/core-redis.service';
8
10
  import { CoreBetterAuthModule } from '../better-auth/core-better-auth.module';
9
11
  import { ErrorCode } from '../error-code/error-codes';
10
12
  import { CoreAiMcpOAuthService } from './services/core-ai-mcp-oauth.service';
@@ -26,22 +28,65 @@ import { CoreAiMcpService } from './services/core-ai-mcp.service';
26
28
  *
27
29
  * When `ai.mcp.oauth` is enabled, the handler additionally accepts OAuth 2.1 access
28
30
  * tokens (see `mountAiMcpOAuth`); otherwise it authenticates via Bearer/session token.
31
+ *
32
+ * ## Multi-replica
33
+ *
34
+ * A Streamable-HTTP transport is a live object holding the open response stream — it cannot
35
+ * be serialized, so the session map is inherently process-local and `/ai/mcp` REQUIRES sticky
36
+ * sessions behind a load balancer. When Redis is configured, session ids are additionally
37
+ * registered in a shared registry (replica + owning user), which turns a mis-routed request
38
+ * into an explicit 409 naming the owning replica instead of a misleading "unknown session" 404.
39
+ * Without Redis the behavior is unchanged.
40
+ *
41
+ * The registry entry records the OWNING USER, and only that user is ever told about the 409.
42
+ * Otherwise the cross-replica path would undo the local one: an authenticated caller probing
43
+ * ids would learn both that a session id is valid and the internal hostname/PID holding it,
44
+ * where the same probe against the local map deliberately answers 404.
29
45
  */
30
46
  @ApiExcludeController()
31
47
  @Controller('ai/mcp')
32
48
  @Roles(RoleEnum.S_EVERYONE)
33
- export class CoreAiMcpController {
49
+ export class CoreAiMcpController implements OnModuleDestroy {
34
50
  protected readonly logger = new Logger(CoreAiMcpController.name);
35
51
 
36
- /** Active transports keyed by MCP session id. */
37
- private readonly transports = new Map<string, { lastUsed: number; transport: any }>();
52
+ /**
53
+ * Active transports keyed by MCP session id.
54
+ *
55
+ * `ownerId` is load-bearing, not bookkeeping. The transport is built once via
56
+ * `createServer(user)` and stays bound to THAT user's identity and role-filtered tools for its
57
+ * whole life, while the lookup below is driven by a client-supplied `mcp-session-id` header.
58
+ * Authenticating the caller only proves they are *some* user, so without comparing the owner,
59
+ * anyone holding another user's session id drives that user's server — the id travels in a
60
+ * response header, through proxies and client logs.
61
+ */
62
+ private readonly transports = new Map<string, { lastUsed: number; ownerId: string; transport: any }>();
38
63
 
39
- /** Cap on concurrent MCP sessions (oldest evicted on overflow). */
64
+ /**
65
+ * Cap on concurrent MCP sessions held by ONE user.
66
+ *
67
+ * The per-user cap is the load-bearing one. A single global cap alone made one authenticated
68
+ * user able to evict everybody else's sessions — each eviction closing a live SSE stream —
69
+ * simply by opening sessions in a loop, because the victim was always the globally oldest.
70
+ */
71
+ private readonly maxSessionsPerUser = 25;
72
+
73
+ /** Cap on concurrent MCP sessions across all users (bounded memory). */
40
74
  private readonly maxSessions = 500;
41
75
 
76
+ /** Owner id written into the shared registry — identifies THIS replica. */
77
+ protected readonly instanceId = `${hostname()}:${process.pid}`;
78
+
79
+ /**
80
+ * TTL of a shared registry entry. The local map is bounded by `maxSessions`, not by time,
81
+ * so this TTL exists only to let a crashed replica's entries expire from Redis. It is
82
+ * refreshed on every request of a session.
83
+ */
84
+ protected readonly sessionTtlSeconds = 3600;
85
+
42
86
  constructor(
43
87
  private readonly mcpService: CoreAiMcpService,
44
88
  private readonly oauthService: CoreAiMcpOAuthService,
89
+ @Optional() protected readonly redisService?: CoreRedisService,
45
90
  ) {}
46
91
 
47
92
  @Post()
@@ -55,6 +100,23 @@ export class CoreAiMcpController {
55
100
  const sessionId = req.headers['mcp-session-id'] as string | undefined;
56
101
  let entry = sessionId ? this.transports.get(sessionId) : undefined;
57
102
 
103
+ // A session belongs to the user it was created for. Treat someone else's id as unknown
104
+ // rather than as an authorization error — confirming it exists would turn the endpoint into
105
+ // an oracle for valid session ids.
106
+ if (entry && entry.ownerId !== user.id) {
107
+ entry = undefined;
108
+ res.status(404).json({ error: 'Unknown or expired MCP session' });
109
+ return;
110
+ }
111
+
112
+ if (!entry && sessionId) {
113
+ const owner = await this.foreignSessionOwner(sessionId, user.id);
114
+ if (owner) {
115
+ this.foreignSession(res, owner);
116
+ return;
117
+ }
118
+ }
119
+
58
120
  if (!entry) {
59
121
  let StreamableHTTPServerTransport: any;
60
122
  try {
@@ -79,18 +141,23 @@ export class CoreAiMcpController {
79
141
  transport.onclose = () => {
80
142
  if (transport.sessionId) {
81
143
  this.transports.delete(transport.sessionId);
144
+ this.releaseSession(transport.sessionId);
82
145
  }
83
146
  };
84
- entry = { lastUsed: Date.now(), transport };
147
+ entry = { lastUsed: Date.now(), ownerId: user.id, transport };
85
148
  }
86
149
 
87
150
  entry.lastUsed = Date.now();
88
151
  await entry.transport.handleRequest(req, res, req.body);
89
152
 
90
153
  // The sessionId is assigned during handleRequest (initialize); register after.
91
- if (entry.transport.sessionId && !this.transports.has(entry.transport.sessionId)) {
92
- this.evictIfNeeded();
93
- this.transports.set(entry.transport.sessionId, entry);
154
+ if (entry.transport.sessionId) {
155
+ if (!this.transports.has(entry.transport.sessionId)) {
156
+ this.evictIfNeeded(user.id);
157
+ this.transports.set(entry.transport.sessionId, entry);
158
+ }
159
+ // Also refreshes the TTL for an already-registered session.
160
+ this.registerSession(entry.transport.sessionId, user.id);
94
161
  }
95
162
  }
96
163
 
@@ -161,15 +228,141 @@ export class CoreAiMcpController {
161
228
  return;
162
229
  }
163
230
  const sessionId = req.headers['mcp-session-id'] as string | undefined;
164
- const entry = sessionId ? this.transports.get(sessionId) : undefined;
231
+ const held = sessionId ? this.transports.get(sessionId) : undefined;
232
+ // Same ownership rule as the POST path: another user's session is not ours to serve.
233
+ const entry = held && held.ownerId === user.id ? held : undefined;
165
234
  if (!entry) {
235
+ const owner = sessionId ? await this.foreignSessionOwner(sessionId, user.id) : undefined;
236
+ if (owner) {
237
+ this.foreignSession(res, owner);
238
+ return;
239
+ }
166
240
  res.status(404).json({ error: 'Unknown or expired MCP session' });
167
241
  return;
168
242
  }
169
243
  entry.lastUsed = Date.now();
244
+ this.registerSession(sessionId as string, user.id);
170
245
  await entry.transport.handleRequest(req, res, (req as any).body);
171
246
  }
172
247
 
248
+ /**
249
+ * Replica holding a session that this process does not hold — but ONLY when the session belongs
250
+ * to `userId`. Undefined when Redis is disabled, the session is unknown, it belongs to this
251
+ * instance, or it belongs to somebody else.
252
+ *
253
+ * The owner comparison is what keeps this path in step with the local one. Answering 409 for any
254
+ * session id an authenticated caller can name would confirm the id exists AND disclose the
255
+ * internal `<hostname>:<pid>` holding it — an oracle the local map deliberately refuses by
256
+ * returning 404. Only the session's own user benefits from the 409 anyway: it tells THEIR client
257
+ * to retry against the right replica.
258
+ *
259
+ * Never throws: a registry outage must degrade to the plain "unknown session" path, not to a 500.
260
+ */
261
+ protected async foreignSessionOwner(sessionId: string, userId: string): Promise<string | undefined> {
262
+ if (!this.redisService?.enabled) {
263
+ return undefined;
264
+ }
265
+ try {
266
+ const raw = await this.redisService.getClient().get(this.sessionKey(sessionId));
267
+ const entry = this.parseSessionEntry(raw);
268
+ if (!entry || entry.userId !== userId || entry.instanceId === this.instanceId) {
269
+ return undefined;
270
+ }
271
+ return entry.instanceId;
272
+ } catch (err) {
273
+ this.logger.debug(`MCP session registry lookup failed: ${(err as Error).message}`);
274
+ return undefined;
275
+ }
276
+ }
277
+
278
+ /**
279
+ * Parse a shared registry value, or undefined when it is absent or not readable.
280
+ *
281
+ * An entry written by an older version carries the bare instance id and no owner. It is treated
282
+ * as unreadable on purpose: without an owner we cannot tell whether the caller may learn about
283
+ * it, and "fall through to 404" is the safe answer during a rolling upgrade.
284
+ */
285
+ protected parseSessionEntry(raw: null | string): undefined | { instanceId: string; userId: string } {
286
+ if (!raw) {
287
+ return undefined;
288
+ }
289
+ try {
290
+ const parsed = JSON.parse(raw);
291
+ return parsed?.instanceId && parsed?.userId ? parsed : undefined;
292
+ } catch {
293
+ return undefined;
294
+ }
295
+ }
296
+
297
+ /** Register the session (or refresh its TTL) in the shared registry. Fire-and-forget. */
298
+ protected registerSession(sessionId: string, userId: string): void {
299
+ if (!this.redisService?.enabled) {
300
+ return;
301
+ }
302
+ this.redisService
303
+ .getClient()
304
+ .set(
305
+ this.sessionKey(sessionId),
306
+ JSON.stringify({ instanceId: this.instanceId, userId }),
307
+ 'EX',
308
+ this.sessionTtlSeconds,
309
+ )
310
+ .catch((err: Error) => this.logger.debug(`MCP session registry write failed: ${err.message}`));
311
+ }
312
+
313
+ /** Drop the session from the shared registry (close/evict). Fire-and-forget. */
314
+ protected releaseSession(sessionId: string): Promise<void> {
315
+ if (!this.redisService?.enabled) {
316
+ return Promise.resolve();
317
+ }
318
+ return this.redisService
319
+ .getClient()
320
+ .del(this.sessionKey(sessionId))
321
+ .then(() => undefined)
322
+ .catch((err: Error) => this.logger.debug(`MCP session registry delete failed: ${err.message}`));
323
+ }
324
+
325
+ /**
326
+ * Close every live transport and drop this instance's sessions from the shared registry.
327
+ *
328
+ * Each transport holds an OPEN response stream, and an open stream keeps the HTTP server from
329
+ * closing — so without this, `app.close()` waits on clients that will never disconnect on their
330
+ * own. Releasing the registry keys matters too: a restarted replica's sessions would otherwise
331
+ * keep answering 409 "owned by another replica" — naming a replica that no longer exists —
332
+ * until their TTL expires an hour later.
333
+ */
334
+ async onModuleDestroy(): Promise<void> {
335
+ const sessions = [...this.transports.keys()];
336
+ for (const [, entry] of this.transports) {
337
+ try {
338
+ await entry.transport?.close?.();
339
+ } catch (error) {
340
+ this.logger.debug(`MCP transport close failed: ${error instanceof Error ? error.message : 'unknown'}`);
341
+ }
342
+ }
343
+ this.transports.clear();
344
+ await Promise.allSettled(sessions.map((sessionId) => this.releaseSession(sessionId)));
345
+ }
346
+
347
+ protected sessionKey(sessionId: string): string {
348
+ return this.redisService!.key('ai-mcp-session', sessionId);
349
+ }
350
+
351
+ /**
352
+ * 409 Conflict when the session exists — on another replica. The live transport cannot be
353
+ * moved, so the only fix is routing: name the owner and state the sticky-session requirement
354
+ * instead of returning a 404 that reads like "your session expired".
355
+ */
356
+ protected foreignSession(res: Response, owner: string): void {
357
+ res.status(409).json({
358
+ error:
359
+ `MCP session belongs to another server instance (${owner}); this instance is ${this.instanceId}. ` +
360
+ 'The MCP transport is held in process memory and cannot be shared, so /ai/mcp requires sticky ' +
361
+ 'sessions — route every request of one MCP session to the same replica.',
362
+ statusCode: 409,
363
+ });
364
+ }
365
+
173
366
  /**
174
367
  * 503 Service Unavailable when `@modelcontextprotocol/sdk` cannot be loaded.
175
368
  * The SDK is lazy-imported because not every consumer needs MCP — when
@@ -212,28 +405,66 @@ export class CoreAiMcpController {
212
405
  }
213
406
 
214
407
  /**
215
- * Evict the oldest session when the cap is exceeded (bounded memory).
408
+ * Make room for a new session of `ownerId`, evicting WITHIN the offending user's own set.
409
+ *
410
+ * Eviction is not free — it closes a live SSE stream — so who pays for it matters. The naive
411
+ * "drop the globally oldest" rule let one authenticated user open sessions in a loop and
412
+ * disconnect every other user in turn. Here a user first evicts their own oldest session at
413
+ * their personal cap, and when the global cap is reached the victim is the oldest session of
414
+ * whoever holds the MOST sessions, i.e. the account actually responsible for the pressure.
216
415
  */
217
- private evictIfNeeded(): void {
416
+ private evictIfNeeded(ownerId: string): void {
417
+ const perUser = new Map<string, string[]>();
418
+ for (const [key, value] of this.transports) {
419
+ const owned = perUser.get(value.ownerId);
420
+ if (owned) {
421
+ owned.push(key);
422
+ } else {
423
+ perUser.set(value.ownerId, [key]);
424
+ }
425
+ }
426
+
427
+ if ((perUser.get(ownerId)?.length ?? 0) >= this.maxSessionsPerUser) {
428
+ this.evictOldestOf(perUser.get(ownerId));
429
+ return;
430
+ }
431
+
218
432
  if (this.transports.size < this.maxSessions) {
219
433
  return;
220
434
  }
435
+
436
+ let largest: string[] | undefined;
437
+ for (const owned of perUser.values()) {
438
+ if (!largest || owned.length > largest.length) {
439
+ largest = owned;
440
+ }
441
+ }
442
+ this.evictOldestOf(largest);
443
+ }
444
+
445
+ /**
446
+ * Close and forget the least recently used session among the given ids
447
+ */
448
+ private evictOldestOf(sessionIds: string[] | undefined): void {
221
449
  let oldestKey: string | undefined;
222
450
  let oldest = Infinity;
223
- for (const [key, value] of this.transports) {
224
- if (value.lastUsed < oldest) {
451
+ for (const key of sessionIds ?? []) {
452
+ const value = this.transports.get(key);
453
+ if (value && value.lastUsed < oldest) {
225
454
  oldest = value.lastUsed;
226
455
  oldestKey = key;
227
456
  }
228
457
  }
229
- if (oldestKey) {
230
- const evicted = this.transports.get(oldestKey);
231
- this.transports.delete(oldestKey);
232
- try {
233
- evicted?.transport.close?.();
234
- } catch {
235
- // ignore close errors during eviction
236
- }
458
+ if (!oldestKey) {
459
+ return;
460
+ }
461
+ const evicted = this.transports.get(oldestKey);
462
+ this.transports.delete(oldestKey);
463
+ this.releaseSession(oldestKey);
464
+ try {
465
+ evicted?.transport.close?.();
466
+ } catch {
467
+ // ignore close errors during eviction
237
468
  }
238
469
  }
239
470
  }
@@ -2,6 +2,8 @@ import { HttpException, HttpStatus, Injectable, Logger, Optional } from '@nestjs
2
2
 
3
3
  import { ServiceOptions } from '../../../common/interfaces/service-options.interface';
4
4
  import { ConfigService } from '../../../common/services/config.service';
5
+ import { CoreRedisService } from '../../../common/services/core-redis.service';
6
+ import { InMemoryRateLimitStore, RateLimitStore, RedisRateLimitStore } from '../../../common/services/rate-limit-store';
5
7
  import { RequestContext } from '../../../common/services/request-context.service';
6
8
  import { ErrorCode } from '../../error-code';
7
9
  import { AiToolAuthorization, AiToolContext, AiToolResult, IAiTool } from '../interfaces/ai-tool.interface';
@@ -92,8 +94,8 @@ export interface AiRunContext {
92
94
  export class CoreAiService {
93
95
  protected readonly logger = new Logger(CoreAiService.name);
94
96
 
95
- /** In-memory rate-limit buckets keyed by user id. */
96
- private readonly rateBuckets = new Map<string, { count: number; resetAt: number }>();
97
+ /** Rate-limit counters keyed by user id (Redis-backed when configured). */
98
+ protected rateLimitStore?: RateLimitStore;
97
99
 
98
100
  constructor(
99
101
  protected readonly connectionService: CoreAiConnectionService,
@@ -110,6 +112,7 @@ export class CoreAiService {
110
112
  @Optional() protected readonly toolPolicyService?: CoreAiToolPolicyService,
111
113
  @Optional() protected readonly modeService?: CoreAiModeService,
112
114
  @Optional() protected readonly placeholderRegistry?: CoreAiPlaceholderRegistry,
115
+ @Optional() protected readonly coreRedisService?: CoreRedisService,
113
116
  ) {}
114
117
 
115
118
  /**
@@ -901,8 +904,9 @@ export class CoreAiService {
901
904
  }
902
905
 
903
906
  /**
904
- * Simple in-memory sliding-window rate limit. Enabled when `ai.rateLimit` is
905
- * present (presence implies enabled). Override for distributed limiting.
907
+ * Fixed-window per-user rate limit. Enabled when `ai.rateLimit` is present
908
+ * (presence implies enabled). Counters are shared across replicas when
909
+ * `ServerOptions.redis` is configured, process-local otherwise.
906
910
  */
907
911
  protected async checkRateLimit(userId?: string): Promise<void> {
908
912
  const cfg = ConfigService.get<{ enabled?: boolean; max?: number; windowSeconds?: number }>('ai.rateLimit');
@@ -910,31 +914,36 @@ export class CoreAiService {
910
914
  return;
911
915
  }
912
916
  const max = cfg.max ?? 20;
913
- const windowMs = (cfg.windowSeconds ?? 60) * 1000;
914
- const key = userId || 'anonymous';
915
- const now = Date.now();
916
-
917
- let bucket = this.rateBuckets.get(key);
918
- if (!bucket || bucket.resetAt <= now) {
919
- bucket = { count: 0, resetAt: now + windowMs };
920
- this.rateBuckets.set(key, bucket);
921
- }
922
- bucket.count++;
923
-
924
- // Evict expired buckets when the map grows large (bounded memory).
925
- if (this.rateBuckets.size > 5000) {
926
- for (const [k, b] of this.rateBuckets) {
927
- if (b.resetAt <= now) {
928
- this.rateBuckets.delete(k);
929
- }
930
- }
931
- }
917
+ const { count } = await this.getRateLimitStore().hit(userId || 'anonymous', cfg.windowSeconds ?? 60);
932
918
 
933
- if (bucket.count > max) {
919
+ if (count > max) {
934
920
  throw new HttpException(ErrorCode.AI_RATE_LIMITED, HttpStatus.TOO_MANY_REQUESTS);
935
921
  }
936
922
  }
937
923
 
924
+ /**
925
+ * Lazily select the rate-limit store: Redis when configured and enabled,
926
+ * process-local otherwise.
927
+ */
928
+ protected getRateLimitStore(): RateLimitStore {
929
+ if (!this.rateLimitStore) {
930
+ this.rateLimitStore = this.coreRedisService?.enabled
931
+ ? new RedisRateLimitStore(this.coreRedisService, 'ai')
932
+ : new InMemoryRateLimitStore(5000);
933
+ }
934
+ return this.rateLimitStore;
935
+ }
936
+
937
+ /**
938
+ * Stop the rate-limit store's cleanup interval (for graceful shutdown).
939
+ * `RedisRateLimitStore` owns an in-memory fallback store, so it needs releasing too.
940
+ */
941
+ onModuleDestroy(): void {
942
+ if (this.rateLimitStore instanceof InMemoryRateLimitStore || this.rateLimitStore instanceof RedisRateLimitStore) {
943
+ this.rateLimitStore.destroy();
944
+ }
945
+ }
946
+
938
947
  /**
939
948
  * Execute a single tool call with the user's permissions.
940
949
  */
@@ -4,6 +4,8 @@ import { JwtModule, JwtModuleOptions } from '@nestjs/jwt';
4
4
  import { PassportModule } from '@nestjs/passport';
5
5
  import { PubSub } from 'graphql-subscriptions';
6
6
 
7
+ import { CoreRedisPubSub } from '../../common/services/core-redis-pubsub';
8
+ import { CoreRedisService } from '../../common/services/core-redis.service';
7
9
  import { AuthGuardStrategy } from './auth-guard-strategy.enum';
8
10
  import { LegacyAuthRateLimitGuard } from './guards/legacy-auth-rate-limit.guard';
9
11
  import { RolesGuardRegistry } from './guards/roles-guard-registry';
@@ -67,8 +69,12 @@ export class CoreAuthModule {
67
69
  useClass: UserService,
68
70
  },
69
71
  {
72
+ // Redis-backed when Redis is enabled (subscriptions reach all replicas),
73
+ // process-local in-memory PubSub otherwise
70
74
  provide: 'PUB_SUB',
71
- useValue: new PubSub(),
75
+ useFactory: (redisService?: CoreRedisService) =>
76
+ redisService?.enabled ? new CoreRedisPubSub(redisService) : new PubSub(),
77
+ inject: [{ optional: true, token: CoreRedisService }],
72
78
  },
73
79
  {
74
80
  provide: CoreAuthService,
@@ -26,14 +26,14 @@ import { LegacyAuthRateLimiter } from '../services/legacy-auth-rate-limiter.serv
26
26
  export class LegacyAuthRateLimitGuard implements CanActivate {
27
27
  constructor(private readonly rateLimiter: LegacyAuthRateLimiter) {}
28
28
 
29
- canActivate(context: ExecutionContext): boolean {
29
+ async canActivate(context: ExecutionContext): Promise<boolean> {
30
30
  // If rate limiting is disabled, always allow
31
31
  if (!this.rateLimiter.isEnabled()) {
32
32
  return true;
33
33
  }
34
34
 
35
35
  const { endpoint, ip } = this.extractRequestInfo(context);
36
- const result = this.rateLimiter.check(ip, endpoint);
36
+ const result = await this.rateLimiter.check(ip, endpoint);
37
37
 
38
38
  if (!result.allowed) {
39
39
  throw new HttpException(
@@ -84,26 +84,17 @@ export class LegacyAuthRateLimitGuard implements CanActivate {
84
84
  }
85
85
 
86
86
  /**
87
- * Get client IP from request, handling proxies
87
+ * Get the client IP that the rate limit counter is keyed on.
88
+ *
89
+ * `request.ip` — NOT `x-forwarded-for` directly. Express derives `req.ip` from the forwarded
90
+ * chain only as far as the app's `trust proxy` setting allows, and falls back to the socket
91
+ * address otherwise. Reading the header ourselves skips that check entirely, so any client
92
+ * simply picks its own bucket by sending a fresh value per request: the counter never reaches
93
+ * the limit, and the brute-force protection this guard exists for is off. A deployment behind
94
+ * a proxy must therefore configure `trust proxy` — that is the one place where "which hop do I
95
+ * believe" belongs.
88
96
  */
89
97
  private getClientIp(request: any): string {
90
- if (!request) {
91
- return 'unknown';
92
- }
93
-
94
- // Check common proxy headers
95
- const forwardedFor = request.headers?.['x-forwarded-for'];
96
- if (forwardedFor) {
97
- // Take the first IP in the chain (original client)
98
- return forwardedFor.split(',')[0].trim();
99
- }
100
-
101
- const realIp = request.headers?.['x-real-ip'];
102
- if (realIp) {
103
- return realIp;
104
- }
105
-
106
- // Fall back to direct connection IP
107
- return request.ip || request.connection?.remoteAddress || 'unknown';
98
+ return request?.ip || request?.socket?.remoteAddress || 'unknown';
108
99
  }
109
100
  }