@lenne.tech/nest-server 11.32.4 → 11.33.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (264) hide show
  1. package/.claude/rules/architecture.md +16 -0
  2. package/.claude/rules/configurable-features.md +11 -3
  3. package/.claude/rules/testing.md +101 -1
  4. package/CLAUDE.md +5 -0
  5. package/FRAMEWORK-API.md +58 -3
  6. package/dist/config.env.js +6 -0
  7. package/dist/config.env.js.map +1 -1
  8. package/dist/core/common/helpers/content-disposition.helper.d.ts +1 -0
  9. package/dist/core/common/helpers/content-disposition.helper.js +14 -0
  10. package/dist/core/common/helpers/content-disposition.helper.js.map +1 -0
  11. package/dist/core/common/helpers/file.helper.d.ts +7 -0
  12. package/dist/core/common/helpers/file.helper.js +21 -4
  13. package/dist/core/common/helpers/file.helper.js.map +1 -1
  14. package/dist/core/common/helpers/graceful-shutdown.helper.d.ts +2 -0
  15. package/dist/core/common/helpers/graceful-shutdown.helper.js +55 -0
  16. package/dist/core/common/helpers/graceful-shutdown.helper.js.map +1 -0
  17. package/dist/core/common/helpers/project-name.helper.d.ts +2 -0
  18. package/dist/core/common/helpers/project-name.helper.js +38 -0
  19. package/dist/core/common/helpers/project-name.helper.js.map +1 -0
  20. package/dist/core/common/interfaces/cron-job-config.interface.d.ts +1 -0
  21. package/dist/core/common/interfaces/server-options.interface.d.ts +39 -0
  22. package/dist/core/common/services/core-cron-jobs.initializer.d.ts +10 -0
  23. package/dist/core/common/services/core-cron-jobs.initializer.js +46 -0
  24. package/dist/core/common/services/core-cron-jobs.initializer.js.map +1 -0
  25. package/dist/core/common/services/core-cron-jobs.registry.d.ts +8 -0
  26. package/dist/core/common/services/core-cron-jobs.registry.js +12 -0
  27. package/dist/core/common/services/core-cron-jobs.registry.js.map +1 -0
  28. package/dist/core/common/services/core-cron-jobs.service.d.ts +37 -7
  29. package/dist/core/common/services/core-cron-jobs.service.js +278 -27
  30. package/dist/core/common/services/core-cron-jobs.service.js.map +1 -1
  31. package/dist/core/common/services/core-redis-pubsub.d.ts +20 -0
  32. package/dist/core/common/services/core-redis-pubsub.js +112 -0
  33. package/dist/core/common/services/core-redis-pubsub.js.map +1 -0
  34. package/dist/core/common/services/core-redis.service.d.ts +25 -0
  35. package/dist/core/common/services/core-redis.service.js +154 -0
  36. package/dist/core/common/services/core-redis.service.js.map +1 -0
  37. package/dist/core/common/services/core-s3.service.d.ts +51 -0
  38. package/dist/core/common/services/core-s3.service.js +261 -0
  39. package/dist/core/common/services/core-s3.service.js.map +1 -0
  40. package/dist/core/common/services/core-trust-proxy.initializer.d.ts +16 -0
  41. package/dist/core/common/services/core-trust-proxy.initializer.js +77 -0
  42. package/dist/core/common/services/core-trust-proxy.initializer.js.map +1 -0
  43. package/dist/core/common/services/rate-limit-store.d.ts +61 -0
  44. package/dist/core/common/services/rate-limit-store.js +235 -0
  45. package/dist/core/common/services/rate-limit-store.js.map +1 -0
  46. package/dist/core/modules/ai/core-ai-mcp.controller.d.ts +19 -3
  47. package/dist/core/modules/ai/core-ai-mcp.controller.js +148 -18
  48. package/dist/core/modules/ai/core-ai-mcp.controller.js.map +1 -1
  49. package/dist/core/modules/ai/services/core-ai.service.d.ts +7 -2
  50. package/dist/core/modules/ai/services/core-ai.service.js +24 -20
  51. package/dist/core/modules/ai/services/core-ai.service.js.map +1 -1
  52. package/dist/core/modules/auth/core-auth.module.js +4 -1
  53. package/dist/core/modules/auth/core-auth.module.js.map +1 -1
  54. package/dist/core/modules/auth/guards/legacy-auth-rate-limit.guard.d.ts +1 -1
  55. package/dist/core/modules/auth/guards/legacy-auth-rate-limit.guard.js +3 -14
  56. package/dist/core/modules/auth/guards/legacy-auth-rate-limit.guard.js.map +1 -1
  57. package/dist/core/modules/auth/services/legacy-auth-rate-limiter.service.d.ts +12 -10
  58. package/dist/core/modules/auth/services/legacy-auth-rate-limiter.service.js +30 -59
  59. package/dist/core/modules/auth/services/legacy-auth-rate-limiter.service.js.map +1 -1
  60. package/dist/core/modules/better-auth/core-better-auth-email-verification.service.d.ts +8 -2
  61. package/dist/core/modules/better-auth/core-better-auth-email-verification.service.js +118 -45
  62. package/dist/core/modules/better-auth/core-better-auth-email-verification.service.js.map +1 -1
  63. package/dist/core/modules/better-auth/core-better-auth-rate-limit.middleware.d.ts +1 -1
  64. package/dist/core/modules/better-auth/core-better-auth-rate-limit.middleware.js +2 -11
  65. package/dist/core/modules/better-auth/core-better-auth-rate-limit.middleware.js.map +1 -1
  66. package/dist/core/modules/better-auth/core-better-auth-rate-limiter.service.d.ts +12 -10
  67. package/dist/core/modules/better-auth/core-better-auth-rate-limiter.service.js +30 -85
  68. package/dist/core/modules/better-auth/core-better-auth-rate-limiter.service.js.map +1 -1
  69. package/dist/core/modules/better-auth/core-better-auth-user.mapper.js +0 -1
  70. package/dist/core/modules/better-auth/core-better-auth-user.mapper.js.map +1 -1
  71. package/dist/core/modules/file/core-file.controller.d.ts +11 -2
  72. package/dist/core/modules/file/core-file.controller.js +52 -15
  73. package/dist/core/modules/file/core-file.controller.js.map +1 -1
  74. package/dist/core/modules/file/core-file.resolver.d.ts +4 -4
  75. package/dist/core/modules/file/core-file.resolver.js +23 -16
  76. package/dist/core/modules/file/core-file.resolver.js.map +1 -1
  77. package/dist/core/modules/file/core-file.service.d.ts +41 -7
  78. package/dist/core/modules/file/core-file.service.js +228 -8
  79. package/dist/core/modules/file/core-file.service.js.map +1 -1
  80. package/dist/core/modules/file/file-metadata.helper.d.ts +16 -0
  81. package/dist/core/modules/file/file-metadata.helper.js +33 -0
  82. package/dist/core/modules/file/file-metadata.helper.js.map +1 -0
  83. package/dist/core/modules/file/file-roles.helper.d.ts +4 -0
  84. package/dist/core/modules/file/file-roles.helper.js +53 -0
  85. package/dist/core/modules/file/file-roles.helper.js.map +1 -0
  86. package/dist/core/modules/file/file-storage.helper.d.ts +13 -0
  87. package/dist/core/modules/file/file-storage.helper.js +65 -0
  88. package/dist/core/modules/file/file-storage.helper.js.map +1 -0
  89. package/dist/core/modules/file/filesystem-file.helper.d.ts +22 -0
  90. package/dist/core/modules/file/filesystem-file.helper.js +77 -0
  91. package/dist/core/modules/file/filesystem-file.helper.js.map +1 -0
  92. package/dist/core/modules/file/interfaces/file-service-options.interface.d.ts +1 -0
  93. package/dist/core/modules/file/interfaces/file-upload.interface.d.ts +7 -1
  94. package/dist/core/modules/file/s3-file.helper.d.ts +37 -0
  95. package/dist/core/modules/file/s3-file.helper.js +81 -0
  96. package/dist/core/modules/file/s3-file.helper.js.map +1 -0
  97. package/dist/core/modules/hub/core-hub.controller.d.ts +8 -8
  98. package/dist/core/modules/hub/core-hub.controller.js +24 -22
  99. package/dist/core/modules/hub/core-hub.controller.js.map +1 -1
  100. package/dist/core/modules/hub/helpers/hub-mask.helper.js +2 -2
  101. package/dist/core/modules/hub/helpers/hub-mask.helper.js.map +1 -1
  102. package/dist/core/modules/hub/hub-buffer.d.ts +23 -0
  103. package/dist/core/modules/hub/hub-buffer.js +101 -0
  104. package/dist/core/modules/hub/hub-buffer.js.map +1 -0
  105. package/dist/core/modules/hub/services/core-hub-mailbox.service.d.ts +7 -5
  106. package/dist/core/modules/hub/services/core-hub-mailbox.service.js +14 -10
  107. package/dist/core/modules/hub/services/core-hub-mailbox.service.js.map +1 -1
  108. package/dist/core/modules/hub/services/hub-log-buffer.service.d.ts +5 -2
  109. package/dist/core/modules/hub/services/hub-log-buffer.service.js +17 -8
  110. package/dist/core/modules/hub/services/hub-log-buffer.service.js.map +1 -1
  111. package/dist/core/modules/hub/services/hub-query-profiler.service.d.ts +4 -2
  112. package/dist/core/modules/hub/services/hub-query-profiler.service.js +13 -7
  113. package/dist/core/modules/hub/services/hub-query-profiler.service.js.map +1 -1
  114. package/dist/core/modules/hub/services/hub-trace-buffer.service.d.ts +4 -2
  115. package/dist/core/modules/hub/services/hub-trace-buffer.service.js +13 -8
  116. package/dist/core/modules/hub/services/hub-trace-buffer.service.js.map +1 -1
  117. package/dist/core/modules/migrate/helpers/migration.helper.d.ts +2 -6
  118. package/dist/core/modules/migrate/helpers/migration.helper.js +4 -4
  119. package/dist/core/modules/migrate/helpers/migration.helper.js.map +1 -1
  120. package/dist/core/modules/migrate/migration-runner.d.ts +1 -0
  121. package/dist/core/modules/migrate/migration-runner.js +4 -0
  122. package/dist/core/modules/migrate/migration-runner.js.map +1 -1
  123. package/dist/core/modules/migrate/mongo-state-store.d.ts +1 -0
  124. package/dist/core/modules/migrate/mongo-state-store.js +98 -9
  125. package/dist/core/modules/migrate/mongo-state-store.js.map +1 -1
  126. package/dist/core/modules/system-setup/core-system-setup.service.d.ts +2 -0
  127. package/dist/core/modules/system-setup/core-system-setup.service.js +31 -1
  128. package/dist/core/modules/system-setup/core-system-setup.service.js.map +1 -1
  129. package/dist/core/modules/tenant/core-tenant.guard.d.ts +17 -3
  130. package/dist/core/modules/tenant/core-tenant.guard.js +92 -15
  131. package/dist/core/modules/tenant/core-tenant.guard.js.map +1 -1
  132. package/dist/core/modules/tenant/core-tenant.module.js +3 -4
  133. package/dist/core/modules/tenant/core-tenant.module.js.map +1 -1
  134. package/dist/core/modules/tus/core-tus.controller.d.ts +2 -0
  135. package/dist/core/modules/tus/core-tus.controller.js +29 -3
  136. package/dist/core/modules/tus/core-tus.controller.js.map +1 -1
  137. package/dist/core/modules/tus/core-tus.service.d.ts +24 -1
  138. package/dist/core/modules/tus/core-tus.service.js +182 -28
  139. package/dist/core/modules/tus/core-tus.service.js.map +1 -1
  140. package/dist/core/modules/tus/interfaces/tus-config.interface.js +3 -0
  141. package/dist/core/modules/tus/interfaces/tus-config.interface.js.map +1 -1
  142. package/dist/core/modules/tus/tus-redis-locker.d.ts +25 -0
  143. package/dist/core/modules/tus/tus-redis-locker.js +87 -0
  144. package/dist/core/modules/tus/tus-redis-locker.js.map +1 -0
  145. package/dist/core/modules/tus/tus.module.d.ts +1 -0
  146. package/dist/core/modules/tus/tus.module.js +30 -4
  147. package/dist/core/modules/tus/tus.module.js.map +1 -1
  148. package/dist/core.module.js +18 -1
  149. package/dist/core.module.js.map +1 -1
  150. package/dist/index.d.ts +10 -0
  151. package/dist/index.js +10 -0
  152. package/dist/index.js.map +1 -1
  153. package/dist/main.js +2 -1
  154. package/dist/main.js.map +1 -1
  155. package/dist/server/modules/file/file.controller.js +2 -2
  156. package/dist/server/modules/file/file.controller.js.map +1 -1
  157. package/dist/server/modules/file/file.resolver.js +3 -14
  158. package/dist/server/modules/file/file.resolver.js.map +1 -1
  159. package/dist/server/modules/file/file.service.d.ts +10 -2
  160. package/dist/server/modules/file/file.service.js +23 -3
  161. package/dist/server/modules/file/file.service.js.map +1 -1
  162. package/dist/server/modules/user/avatar.controller.d.ts +5 -1
  163. package/dist/server/modules/user/avatar.controller.js +26 -10
  164. package/dist/server/modules/user/avatar.controller.js.map +1 -1
  165. package/dist/server/modules/user/user.model.js +3 -2
  166. package/dist/server/modules/user/user.model.js.map +1 -1
  167. package/dist/server/modules/user/user.module.js +6 -2
  168. package/dist/server/modules/user/user.module.js.map +1 -1
  169. package/dist/server/modules/user/user.service.d.ts +1 -1
  170. package/dist/server/modules/user/user.service.js +6 -13
  171. package/dist/server/modules/user/user.service.js.map +1 -1
  172. package/dist/tsconfig.build.tsbuildinfo +1 -1
  173. package/docs/REQUEST-LIFECYCLE.md +57 -8
  174. package/docs/security-overrides.md +21 -3
  175. package/migration-guides/11.32.3-to-11.32.4.md +15 -3
  176. package/migration-guides/11.32.x-to-11.33.x.md +1584 -0
  177. package/package.json +74 -42
  178. package/src/config.env.ts +45 -0
  179. package/src/core/common/helpers/content-disposition.helper.ts +90 -0
  180. package/src/core/common/helpers/file.helper.ts +53 -10
  181. package/src/core/common/helpers/graceful-shutdown.helper.ts +116 -0
  182. package/src/core/common/helpers/project-name.helper.ts +71 -0
  183. package/src/core/common/interfaces/cron-job-config.interface.ts +16 -0
  184. package/src/core/common/interfaces/server-options.interface.ts +439 -2
  185. package/src/core/common/services/core-cron-jobs.initializer.ts +39 -0
  186. package/src/core/common/services/core-cron-jobs.registry.ts +45 -0
  187. package/src/core/common/services/core-cron-jobs.service.ts +589 -47
  188. package/src/core/common/services/core-redis-pubsub.ts +183 -0
  189. package/src/core/common/services/core-redis.service.ts +249 -0
  190. package/src/core/common/services/core-s3.service.ts +465 -0
  191. package/src/core/common/services/core-trust-proxy.initializer.ts +112 -0
  192. package/src/core/common/services/rate-limit-store.ts +492 -0
  193. package/src/core/modules/ai/README.md +25 -0
  194. package/src/core/modules/ai/core-ai-mcp.controller.ts +253 -22
  195. package/src/core/modules/ai/services/core-ai.service.ts +33 -24
  196. package/src/core/modules/auth/core-auth.module.ts +7 -1
  197. package/src/core/modules/auth/guards/legacy-auth-rate-limit.guard.ts +12 -21
  198. package/src/core/modules/auth/services/legacy-auth-rate-limiter.service.ts +49 -93
  199. package/src/core/modules/better-auth/core-better-auth-email-verification.service.ts +202 -66
  200. package/src/core/modules/better-auth/core-better-auth-rate-limit.middleware.ts +11 -18
  201. package/src/core/modules/better-auth/core-better-auth-rate-limiter.service.ts +51 -132
  202. package/src/core/modules/better-auth/core-better-auth-user.mapper.ts +7 -1
  203. package/src/core/modules/file/INTEGRATION-CHECKLIST.md +120 -0
  204. package/src/core/modules/file/README.md +308 -53
  205. package/src/core/modules/file/core-file.controller.ts +168 -12
  206. package/src/core/modules/file/core-file.resolver.ts +46 -12
  207. package/src/core/modules/file/core-file.service.ts +517 -17
  208. package/src/core/modules/file/file-metadata.helper.ts +142 -0
  209. package/src/core/modules/file/file-roles.helper.ts +110 -0
  210. package/src/core/modules/file/file-storage.helper.ts +163 -0
  211. package/src/core/modules/file/filesystem-file.helper.ts +184 -0
  212. package/src/core/modules/file/interfaces/file-service-options.interface.ts +14 -0
  213. package/src/core/modules/file/interfaces/file-upload.interface.ts +17 -1
  214. package/src/core/modules/file/s3-file.helper.ts +209 -0
  215. package/src/core/modules/hub/README.md +48 -0
  216. package/src/core/modules/hub/core-hub.controller.ts +18 -16
  217. package/src/core/modules/hub/helpers/hub-mask.helper.ts +21 -4
  218. package/src/core/modules/hub/hub-buffer.ts +198 -0
  219. package/src/core/modules/hub/services/core-hub-mailbox.service.ts +22 -12
  220. package/src/core/modules/hub/services/hub-log-buffer.service.ts +24 -8
  221. package/src/core/modules/hub/services/hub-query-profiler.service.ts +9 -6
  222. package/src/core/modules/hub/services/hub-trace-buffer.service.ts +14 -9
  223. package/src/core/modules/migrate/README.md +33 -6
  224. package/src/core/modules/migrate/helpers/migration.helper.ts +13 -6
  225. package/src/core/modules/migrate/migration-runner.ts +10 -1
  226. package/src/core/modules/migrate/mongo-state-store.ts +179 -11
  227. package/src/core/modules/system-setup/README.md +7 -2
  228. package/src/core/modules/system-setup/core-system-setup.service.ts +95 -1
  229. package/src/core/modules/tenant/README.md +21 -1
  230. package/src/core/modules/tenant/core-tenant.guard.ts +135 -14
  231. package/src/core/modules/tenant/core-tenant.module.ts +8 -4
  232. package/src/core/modules/tus/INTEGRATION-CHECKLIST.md +93 -21
  233. package/src/core/modules/tus/README.md +35 -2
  234. package/src/core/modules/tus/core-tus.controller.ts +64 -12
  235. package/src/core/modules/tus/core-tus.service.ts +358 -34
  236. package/src/core/modules/tus/interfaces/tus-config.interface.ts +9 -0
  237. package/src/core/modules/tus/tus-redis-locker.ts +139 -0
  238. package/src/core/modules/tus/tus.module.ts +78 -6
  239. package/src/core.module.ts +26 -1
  240. package/src/index.ts +10 -0
  241. package/src/main.ts +7 -1
  242. package/src/server/modules/file/file.controller.ts +14 -5
  243. package/src/server/modules/file/file.resolver.ts +11 -19
  244. package/src/server/modules/file/file.service.ts +89 -4
  245. package/src/server/modules/user/avatar.controller.ts +46 -9
  246. package/src/server/modules/user/user.model.ts +9 -3
  247. package/src/server/modules/user/user.module.ts +12 -3
  248. package/src/server/modules/user/user.service.ts +20 -17
  249. package/src/test/README.md +7 -2
  250. package/src/core/common/helpers/file.helper.spec.ts +0 -145
  251. package/src/core/common/helpers/logging.helper.spec.ts +0 -61
  252. package/src/core/common/helpers/process-diagnostics.helper.spec.ts +0 -310
  253. package/src/core/common/services/brevo.service.spec.ts +0 -266
  254. package/src/core/modules/file/core-file.controller.spec.ts +0 -164
  255. package/src/core/modules/hub/core-hub.module.spec.ts +0 -108
  256. package/src/core/modules/hub/helpers/hub-command-shape.helper.spec.ts +0 -48
  257. package/src/core/modules/hub/helpers/hub-mask.helper.spec.ts +0 -67
  258. package/src/core/modules/hub/helpers/hub-mermaid.helper.spec.ts +0 -61
  259. package/src/core/modules/hub/helpers/hub-shell.helper.spec.ts +0 -106
  260. package/src/core/modules/hub/hub-config.helper.spec.ts +0 -108
  261. package/src/core/modules/hub/hub-ring-buffer.spec.ts +0 -95
  262. package/src/core/modules/hub/services/core-hub-mailbox.service.spec.ts +0 -116
  263. package/src/core/modules/hub/services/hub-trace-buffer.service.spec.ts +0 -112
  264. package/src/core/modules/migrate/helpers/migration.helper.spec.ts +0 -85
@@ -0,0 +1,209 @@
1
+ import { Types } from 'mongoose';
2
+ import { Readable } from 'stream';
3
+
4
+ import { CoreS3Service } from '../../common/services/core-s3.service';
5
+ import {
6
+ ensureFilenameIndex,
7
+ FileCollection,
8
+ FileMetadataInfo,
9
+ findMetadata,
10
+ findMetadataById,
11
+ findMetadataByName,
12
+ } from './file-metadata.helper';
13
+
14
+ /**
15
+ * Name of the MongoDB collection holding the metadata of S3-stored files.
16
+ *
17
+ * S3 itself has no queryable metadata store, so file info (filename, contentType,
18
+ * length, uploadDate) is kept in this collection — the equivalent of GridFS's
19
+ * `fs.files`. The object key in the bucket is the string form of `_id`.
20
+ */
21
+ export const S3_FILES_COLLECTION = 's3-files';
22
+
23
+ /**
24
+ * Metadata of a file stored in S3.
25
+ *
26
+ * Alias of the shared {@link FileMetadataInfo}, kept as a named export because it is
27
+ * part of the published API. It used to be a separate, structurally identical
28
+ * declaration alongside `FilesystemFileInfo` — which is how the two lookup surfaces
29
+ * drifted apart. One declaration now, so they cannot.
30
+ */
31
+ export type S3FileInfo = FileMetadataInfo;
32
+
33
+ /**
34
+ * Read a stream completely into a buffer
35
+ */
36
+ export async function streamToBuffer(stream: Readable): Promise<Buffer> {
37
+ const chunks: Buffer[] = [];
38
+ for await (const chunk of stream) {
39
+ chunks.push(Buffer.from(chunk));
40
+ }
41
+ return Buffer.concat(chunks);
42
+ }
43
+
44
+ /**
45
+ * Helper for files stored in S3 with their metadata in MongoDB.
46
+ *
47
+ * Counterpart of GridFSHelper: same operations, S3 as the byte store and
48
+ * `s3-files` as the metadata store.
49
+ */
50
+ export class S3FileHelper {
51
+ /**
52
+ * Store a file in S3 and record its metadata.
53
+ *
54
+ * The object is verified via HEAD before the metadata document is written, so
55
+ * a file info is never returned for bytes that did not arrive (same guarantee
56
+ * GridFSHelper gives by reading the file document back after the upload).
57
+ */
58
+ static async writeFile(
59
+ s3Service: CoreS3Service,
60
+ collection: FileCollection,
61
+ options: {
62
+ body?: Readable;
63
+ buffer?: Buffer;
64
+ contentLength?: number;
65
+ contentType?: string;
66
+ filename: string;
67
+ metadata?: Record<string, any>;
68
+ },
69
+ ): Promise<S3FileInfo> {
70
+ const _id = new Types.ObjectId();
71
+ const key = _id.toHexString();
72
+
73
+ // A stream with a known length streams straight through. Reading it into a Buffer first —
74
+ // which every caller used to do — materialises the WHOLE file in one process: a 4 GB
75
+ // resumable upload (well inside the 50 GB default cap) then either throws
76
+ // "Array buffer allocation failed" or gets the container OOM-killed at 100% progress,
77
+ // taking every other in-flight request with it.
78
+ const body = options.body ?? options.buffer;
79
+ if (!body) {
80
+ throw new Error('S3FileHelper.writeFile needs either a body stream or a buffer');
81
+ }
82
+ await s3Service.putObject(key, body, options.contentType, options.contentLength ?? options.buffer?.length);
83
+
84
+ return S3FileHelper.recordFile(s3Service, collection, _id, {
85
+ contentType: options.contentType,
86
+ filename: options.filename,
87
+ length: options.contentLength ?? options.buffer?.length,
88
+ metadata: options.metadata,
89
+ });
90
+ }
91
+
92
+ /**
93
+ * Adopt an object that ALREADY lives in the same S3 endpoint into file storage.
94
+ *
95
+ * The bytes are moved by S3 itself (`CopyObject`) rather than pulled into this process and
96
+ * pushed back out — see {@link CoreS3Service.copyObject}. Used for the TUS S3→S3 hand-off,
97
+ * where source and destination are two buckets of the same store.
98
+ *
99
+ * The metadata document is written by the same {@link S3FileHelper.recordFile} the streaming
100
+ * path uses, so the two cannot drift into producing differently-shaped file infos.
101
+ */
102
+ static async copyFile(
103
+ s3Service: CoreS3Service,
104
+ collection: FileCollection,
105
+ options: {
106
+ contentLength?: number;
107
+ contentType?: string;
108
+ filename: string;
109
+ metadata?: Record<string, any>;
110
+ sourceBucket?: string;
111
+ sourceKey: string;
112
+ },
113
+ ): Promise<S3FileInfo> {
114
+ const _id = new Types.ObjectId();
115
+ await s3Service.copyObject(options.sourceKey, _id.toHexString(), options.sourceBucket, options.contentType);
116
+
117
+ return S3FileHelper.recordFile(s3Service, collection, _id, {
118
+ contentType: options.contentType,
119
+ filename: options.filename,
120
+ length: options.contentLength,
121
+ metadata: options.metadata,
122
+ });
123
+ }
124
+
125
+ /**
126
+ * Verify the object arrived and record its metadata.
127
+ *
128
+ * The HEAD before the insert is what keeps a file info from ever describing bytes that are not
129
+ * there (the guarantee GridFSHelper gets by reading the file document back after the upload).
130
+ */
131
+ protected static async recordFile(
132
+ s3Service: CoreS3Service,
133
+ collection: FileCollection,
134
+ _id: Types.ObjectId,
135
+ options: { contentType?: string; filename: string; length?: number; metadata?: Record<string, any> },
136
+ ): Promise<S3FileInfo> {
137
+ if (!(await s3Service.objectExists(_id.toHexString()))) {
138
+ throw new Error('File uploaded but not found in S3');
139
+ }
140
+
141
+ // On the WRITE path, not the read path: createIndex creates the collection, so
142
+ // ensuring it when reading gave a GridFS-only deployment an empty `s3-files`.
143
+ // Here the collection is about to exist anyway. Never throws.
144
+ await ensureFilenameIndex(collection);
145
+
146
+ const fileInfo: S3FileInfo = {
147
+ _id,
148
+ contentType: options.contentType,
149
+ filename: options.filename,
150
+ length: options.length,
151
+ metadata: options.metadata,
152
+ // Records WHERE the bytes went, so a reader never has to probe all three
153
+ // stores to find out. Legacy documents lack it and are handled by probing.
154
+ storage: 's3',
155
+ uploadDate: new Date(),
156
+ };
157
+ await collection.insertOne(fileInfo);
158
+ return fileInfo;
159
+ }
160
+
161
+ /**
162
+ * Find file metadata by ID
163
+ */
164
+ static async findFileById(collection: FileCollection, id: string | Types.ObjectId): Promise<null | S3FileInfo> {
165
+ return findMetadataById(collection, id);
166
+ }
167
+
168
+ /**
169
+ * Find file metadata by filename
170
+ */
171
+ static async findFileByName(collection: FileCollection, filename: string): Promise<null | S3FileInfo> {
172
+ return findMetadataByName(collection, filename);
173
+ }
174
+
175
+ /**
176
+ * Find files with filter and options
177
+ */
178
+ static async findFiles(collection: FileCollection, filter: any = {}, options: any = {}): Promise<S3FileInfo[]> {
179
+ return findMetadata(collection, filter, options);
180
+ }
181
+
182
+ /**
183
+ * Delete a file from S3 and remove its metadata
184
+ */
185
+ static async deleteFile(
186
+ s3Service: CoreS3Service,
187
+ collection: FileCollection,
188
+ id: string | Types.ObjectId,
189
+ ): Promise<void> {
190
+ const objectId = typeof id === 'string' ? new Types.ObjectId(id) : id;
191
+ await s3Service.deleteObject(objectId.toHexString());
192
+ await collection.deleteOne({ _id: objectId });
193
+ }
194
+
195
+ /**
196
+ * Get the download stream of a file
197
+ */
198
+ static async getStream(s3Service: CoreS3Service, id: string | Types.ObjectId): Promise<Readable> {
199
+ const objectId = typeof id === 'string' ? new Types.ObjectId(id) : id;
200
+ return (await s3Service.getObject(objectId.toHexString())).body;
201
+ }
202
+
203
+ /**
204
+ * Get the content of a file as buffer
205
+ */
206
+ static async getBuffer(s3Service: CoreS3Service, id: string | Types.ObjectId): Promise<Buffer> {
207
+ return streamToBuffer(await S3FileHelper.getStream(s3Service, id));
208
+ }
209
+ }
@@ -146,6 +146,54 @@ Three in-memory ring buffers (fixed capacity, no timers, per-app-instance — pa
146
146
  - **Queries** — MongoDB driver command monitoring; records value-free query SHAPES (N+1 templates), never values.
147
147
  Enabling it opts the driver into `monitorCommands` from `core.module.ts`.
148
148
 
149
+ The mailbox (`hub.mailbox`) uses the same buffer.
150
+
151
+ ### Multi-replica
152
+
153
+ **Without Redis** each buffer is process-local, so a pod only ever shows the logs, traces,
154
+ queries and mails it produced itself. Behind a load balancer that makes the panels look
155
+ lossy — every poll may hit a different pod and show a different slice. Route `/hub` to a
156
+ single replica (sticky routing) if you need a coherent view.
157
+
158
+ **With Redis** (`ServerOptions.redis`) all four collectors mirror into shared, capped Redis
159
+ lists (`<keyPrefix>:hub:{logs,traces,queries,mailbox}`) and read from there, so every pod
160
+ shows the merged cluster-wide view and "clear" clears it everywhere. Sequence numbers come
161
+ from a shared counter, so the panels' cursor-based polling keeps working across replicas.
162
+
163
+ Writes are fire-and-forget — the collectors sit in hot paths and their API stays synchronous.
164
+ If Redis is unreachable a read silently falls back to the local buffer (a diagnostics panel
165
+ must not be the thing that breaks), so a partial view during an outage is expected.
166
+
167
+ #### Cost: Redis write volume scales with your traffic
168
+
169
+ **Know this before enabling `redis` together with `hub.collectors`.** Every mirrored entry costs a
170
+ 3-command `MULTI` (`INCR` the shared sequence counter, `LPUSH` the JSON-serialized entry, `LTRIM`
171
+ back to the buffer capacity). There is **no sampling** — each retained entry is one `MULTI`:
172
+
173
+ | Collector | One mirror write per | Rough volume |
174
+ | --------- | ------------------------------------------------------- | -------------------------------------------------------------------- |
175
+ | Logs | log line at a captured level (`collectors.logs.levels`) | 3 Redis commands × your log rate |
176
+ | Traces | HTTP request outside the excluded path prefixes | 3 Redis commands × your request rate |
177
+ | Queries | monitored MongoDB command (opt-in) | 3 Redis commands × your query rate — usually the highest of the four |
178
+ | Mailbox | outgoing mail | negligible |
179
+
180
+ At 500 req/s with request tracing on, that is ~1 500 extra Redis commands per second before the log
181
+ lines are counted. The buffers stay capped in memory (`LTRIM` holds them at the configured
182
+ capacity), so this is a **throughput** cost on the Redis instance, not a growth problem — but it is
183
+ a cost that lands on the same Redis your rate-limit counters, cron leases and subscriptions use.
184
+
185
+ Two ways to keep it out of the way, both deliberate choices rather than defaults:
186
+
187
+ - **Give the Hub a dedicated Redis database or instance.** `redis.db` picks a numbered database on
188
+ the same server (cheap, still one instance's throughput); a separate instance isolates it fully.
189
+ Either way set `redis.keyPrefix` so environments cannot collide.
190
+ - **Accept the volume knowingly**, and keep `collectors.queries` off (it is opt-in for exactly this
191
+ reason) unless you are actively profiling. Turning individual collectors off via
192
+ `hub.collectors` removes their mirror writes entirely.
193
+
194
+ A single-replica deployment gains nothing from mirroring — leave `redis` unset, or the Hub
195
+ collectors off, and every buffer stays process-local at zero Redis cost.
196
+
149
197
  ## Overrides
150
198
 
151
199
  ```typescript
@@ -97,8 +97,8 @@ export class CoreHubController {
97
97
  }
98
98
 
99
99
  @Get('diagnostics.json')
100
- diagnosticsJson(@Res() res: Response): void {
101
- this.sendJson(res, this.hubService.getDiagnostics(this.collectorBuffers()));
100
+ async diagnosticsJson(@Res() res: Response): Promise<void> {
101
+ this.sendJson(res, this.hubService.getDiagnostics(await this.collectorBuffers()));
102
102
  }
103
103
 
104
104
  @Get('files.json')
@@ -117,17 +117,17 @@ export class CoreHubController {
117
117
  }
118
118
 
119
119
  @Get('mailbox.json')
120
- mailboxJson(@Query('since') since: string | undefined, @Res() res: Response): void {
120
+ async mailboxJson(@Query('since') since: string | undefined, @Res() res: Response): Promise<void> {
121
121
  if (!this.mailboxService) {
122
122
  this.sendJson(res, { available: false, hint: 'The mailbox is not enabled.' });
123
123
  return;
124
124
  }
125
- this.sendJson(res, this.mailboxService.getMailbox(since !== undefined ? Number(since) : undefined));
125
+ this.sendJson(res, await this.mailboxService.getMailbox(since !== undefined ? Number(since) : undefined));
126
126
  }
127
127
 
128
128
  @Get('mailbox/:seq/html')
129
- mailboxHtml(@Param('seq') seq: string, @Res() res: Response): void {
130
- const html = this.mailboxService?.getMailHtml(Number(seq));
129
+ async mailboxHtml(@Param('seq') seq: string, @Res() res: Response): Promise<void> {
130
+ const html = await this.mailboxService?.getMailHtml(Number(seq));
131
131
  res.set({
132
132
  'Cache-Control': 'no-store',
133
133
  'Content-Security-Policy': "default-src 'none'; img-src * data:; style-src 'unsafe-inline'; font-src data:",
@@ -143,12 +143,12 @@ export class CoreHubController {
143
143
  }
144
144
 
145
145
  @Get('logs.json')
146
- logsJson(@Query('since') since: string | undefined, @Res() res: Response): void {
146
+ async logsJson(@Query('since') since: string | undefined, @Res() res: Response): Promise<void> {
147
147
  if (!this.logBuffer.enabled) {
148
148
  this.sendJson(res, { available: false, hint: 'The logs collector is disabled.' });
149
149
  return;
150
150
  }
151
- this.sendJson(res, this.logBuffer.getData(since !== undefined ? Number(since) : undefined));
151
+ this.sendJson(res, await this.logBuffer.getData(since !== undefined ? Number(since) : undefined));
152
152
  }
153
153
 
154
154
  @Get('models.json')
@@ -167,7 +167,7 @@ export class CoreHubController {
167
167
  }
168
168
 
169
169
  @Get('queries.json')
170
- queriesJson(@Res() res: Response): void {
170
+ async queriesJson(@Res() res: Response): Promise<void> {
171
171
  if (!this.queryProfiler.enabled) {
172
172
  this.sendJson(res, {
173
173
  available: false,
@@ -175,16 +175,16 @@ export class CoreHubController {
175
175
  });
176
176
  return;
177
177
  }
178
- this.sendJson(res, this.queryProfiler.getData());
178
+ this.sendJson(res, await this.queryProfiler.getData());
179
179
  }
180
180
 
181
181
  @Get('traces.json')
182
- tracesJson(@Query('since') since: string | undefined, @Res() res: Response): void {
182
+ async tracesJson(@Query('since') since: string | undefined, @Res() res: Response): Promise<void> {
183
183
  if (!this.traceBuffer.enabled) {
184
184
  this.sendJson(res, { available: false, hint: 'The traces collector is disabled.' });
185
185
  return;
186
186
  }
187
- this.sendJson(res, this.traceBuffer.getData(since !== undefined ? Number(since) : undefined));
187
+ this.sendJson(res, await this.traceBuffer.getData(since !== undefined ? Number(since) : undefined));
188
188
  }
189
189
 
190
190
  @Get('routes.json')
@@ -261,10 +261,12 @@ export class CoreHubController {
261
261
  }
262
262
 
263
263
  /** Buffer fill levels for the diagnostics panel. */
264
- protected collectorBuffers(): Record<string, { capacity: number; enabled: boolean; size: number }> {
265
- const logs = this.logBuffer.getData();
266
- const traces = this.traceBuffer.getData();
267
- const queries = this.queryProfiler.getData();
264
+ protected async collectorBuffers(): Promise<Record<string, { capacity: number; enabled: boolean; size: number }>> {
265
+ const [logs, traces, queries] = await Promise.all([
266
+ this.logBuffer.getData(),
267
+ this.traceBuffer.getData(),
268
+ this.queryProfiler.getData(),
269
+ ]);
268
270
  return {
269
271
  logs: { capacity: 0, enabled: this.logBuffer.enabled, size: logs.records.length },
270
272
  queries: { capacity: 0, enabled: this.queryProfiler.enabled, size: queries.recent.length },
@@ -6,12 +6,29 @@
6
6
  * keys IN PLACE, which would corrupt the running server config. Cloning here is the guarantee.
7
7
  */
8
8
 
9
- /** Key-name heuristics that mark a value as secret. Matched case-insensitively against each key. */
9
+ /**
10
+ * Key-name heuristics that mark a value as secret. Matched case-insensitively against each key.
11
+ *
12
+ * `access[-_]?key` also covers `accessKeyId`: the trailing `(^|[^a-z])key([^a-z]|$)` alternative
13
+ * does NOT, because the `i` flag makes `[^a-z]` reject an uppercase letter, so `…sKeyI…` fails it.
14
+ * `s3.secretAccessKey` was already caught by `secret`; the ID was not.
15
+ */
10
16
  const SECRET_KEY_PATTERN =
11
- /secret|passwd|password|passphrase|credential|api[-_]?key|access[-_]?token|refresh[-_]?token|private[-_]?key|encryption|\btoken\b|\bpass\b|(^|[^a-z])key([^a-z]|$)/i;
17
+ /secret|passwd|password|passphrase|credential|api[-_]?key|access[-_]?key|access[-_]?token|refresh[-_]?token|private[-_]?key|encryption|\btoken\b|\bpass\b|(^|[^a-z])key([^a-z]|$)/i;
12
18
 
13
- /** Value-shape heuristic: a connection URI carrying `scheme://user:password@host`. */
14
- const URI_CREDENTIAL_PATTERN = /^([a-z][a-z0-9+.-]*:\/\/[^/@:\s]+:)([^@/\s]+)(@)/i;
19
+ /**
20
+ * Value-shape heuristic: a connection URI carrying `scheme://user:password@host`.
21
+ *
22
+ * The user part is `*`, not `+`, so the PASSWORD-ONLY form is matched too:
23
+ * `redis://:password@host` is the canonical Redis URL (Redis < 6 has no username, and
24
+ * `redis-cli -u`, Heroku Redis and ElastiCache AUTH all emit it). Requiring a non-empty
25
+ * user printed that shape verbatim in the Hub config panel. `redis.url` became a
26
+ * first-class option in 11.33.0, so the shape is newly reachable here.
27
+ *
28
+ * Still safe against false positives: the password group cannot contain `/`, and the `@`
29
+ * must follow it immediately — so `http://host:8080/x@y` does not match.
30
+ */
31
+ const URI_CREDENTIAL_PATTERN = /^([a-z][a-z0-9+.-]*:\/\/[^/@:\s]*:)([^@/\s]+)(@)/i;
15
32
 
16
33
  const MASK = '***';
17
34
 
@@ -0,0 +1,198 @@
1
+ import { CoreRedisService } from '../../common/services/core-redis.service';
2
+ import { HubBufferEntry, HubRingBuffer } from './hub-ring-buffer';
3
+
4
+ /** A buffer read, shaped like the `cursor` / `dropped` fields every Hub panel payload carries. */
5
+ export interface HubBufferData<T> {
6
+ /** Highest assigned seq (`-1` when nothing was ever added) — the client's next cursor. */
7
+ cursor: number;
8
+ /** Seq of the oldest retained entry (`-1` when empty) — clients detect eviction gaps against this. */
9
+ dropped: number;
10
+ entries: T[];
11
+ }
12
+
13
+ /**
14
+ * How long a mirrored buffer survives without being written to, in seconds.
15
+ *
16
+ * The list is size-bounded by LTRIM but was never time-bounded, so its contents outlived the
17
+ * process, the deployment and every Redis snapshot taken in between. That matters because of WHAT
18
+ * these buffers hold: log lines, full request traces and — in mailbox capture mode — whole
19
+ * outgoing emails, including verification and password-reset links that are still valid.
20
+ * Diagnostics are worth keeping for a shift, not forever.
21
+ */
22
+ const DEFAULT_SHARED_TTL_SECONDS = 24 * 60 * 60;
23
+
24
+ /**
25
+ * Entries fetched per poll before the range is widened.
26
+ *
27
+ * A Hub panel polls every 5s and normally sees single-digit deltas, while the trace buffer holds
28
+ * up to 1000 entries — reading and parsing the whole list on every poll of every open tab was
29
+ * paying 1000× for that delta. The widen-and-retry loop below keeps the read exact when a client
30
+ * really is further behind.
31
+ */
32
+ const SHARED_READ_CHUNK = 64;
33
+
34
+ /**
35
+ * A collector buffer with an OPTIONAL Redis backing — the single boundary at which the Hub's
36
+ * diagnostic collectors (logs, traces, queries, mailbox) become multi-replica aware.
37
+ *
38
+ * Without Redis this is exactly the process-local {@link HubRingBuffer} and nothing changes.
39
+ * With Redis (`ServerOptions.redis`) every append is ALSO mirrored into a capped Redis list
40
+ * (`LPUSH` + `LTRIM` to the same capacity) and reads come from that list, so every pod shows
41
+ * the merged view instead of only its own slice.
42
+ *
43
+ * **Sequence numbers stay meaningful across replicas.** The mirror keeps a companion counter:
44
+ * the write is `MULTI: INCR seq / LPUSH / LTRIM` and the read is `MULTI: GET seq / LRANGE`,
45
+ * both atomic, so the newest list element is always `seq = total - 1` and the counter can
46
+ * never disagree with the list. `clear()` deletes only the list — the counter keeps advancing,
47
+ * so existing client cursors stay valid, matching `HubRingBuffer.clear()`.
48
+ *
49
+ * **Writes are fire-and-forget.** The collectors sit in hot paths (every log line, every
50
+ * request) and their API towards callers must stay synchronous. A Redis failure is logged at
51
+ * debug level; reads then fall back to the local buffer rather than failing the panel.
52
+ */
53
+ export class HubBuffer<T extends HubBufferEntry> {
54
+ protected readonly listKey: string;
55
+ protected readonly local: HubRingBuffer<T>;
56
+ protected readonly redis?: CoreRedisService;
57
+ protected readonly seqKey: string;
58
+
59
+ /** Retention of the mirrored list, refreshed on every append. */
60
+ protected readonly ttlSeconds: number;
61
+
62
+ constructor(capacity: number, name: string, redis?: CoreRedisService, ttlSeconds = DEFAULT_SHARED_TTL_SECONDS) {
63
+ this.local = new HubRingBuffer<T>(capacity);
64
+ this.redis = redis?.enabled ? redis : undefined;
65
+ this.listKey = this.redis?.key('hub', name) ?? '';
66
+ this.seqKey = this.redis?.key('hub', name, 'seq') ?? '';
67
+ this.ttlSeconds = ttlSeconds;
68
+ }
69
+
70
+ /** Append an entry. Returns the local entry (with its process-local seq) synchronously. */
71
+ add(entry: Omit<T, 'seq' | 'timestamp'> & { timestamp?: number }): T {
72
+ const full = this.local.add(entry);
73
+ this.mirror('append', (client) =>
74
+ client
75
+ .multi()
76
+ .incr(this.seqKey)
77
+ .lpush(this.listKey, JSON.stringify(full))
78
+ .ltrim(this.listKey, 0, this.local.capacity - 1)
79
+ // Refreshed on every append, so an actively used buffer never expires and an abandoned one
80
+ // (a scaled-down replica, a torn-down environment) stops being a durable copy of captured
81
+ // logs, traces and emails. Only the LIST expires — the seq counter must survive so client
82
+ // cursors stay valid, exactly as they do across clear().
83
+ .expire(this.listKey, this.ttlSeconds)
84
+ .exec(),
85
+ );
86
+ return full;
87
+ }
88
+
89
+ /** Drop all retained entries — on every replica when Redis-backed. */
90
+ clear(): void {
91
+ this.local.clear();
92
+ this.mirror('clear', (client) => client.del(this.listKey));
93
+ }
94
+
95
+ /**
96
+ * Run a fire-and-forget mirror write. Swallows BOTH failure modes: `getClient()` throws
97
+ * synchronously before `CoreRedisService.onModuleInit` has run (log lines during bootstrap
98
+ * reach the collector earlier than that), and the command itself rejects on an outage.
99
+ * Neither may escape into a hot logging or request path.
100
+ */
101
+ protected mirror(label: string, run: (client: ReturnType<CoreRedisService['getClient']>) => Promise<unknown>): void {
102
+ if (!this.redis) {
103
+ return;
104
+ }
105
+ const failed = (err: Error): void =>
106
+ // console, NOT Logger: the Hub logs collector captures Logger output INTO a HubBuffer,
107
+ // so reporting a mirror failure through Logger re-enters this very method. With a
108
+ // synchronous throw from getClient() — exactly what happens for log lines emitted
109
+ // before CoreRedisService has connected — that recurses on one stack until it
110
+ // overflows, turning a Redis hiccup into a crashed process.
111
+ console.debug(`Hub buffer ${label} failed (${this.listKey}): ${err.message}`);
112
+ try {
113
+ run(this.redis.getClient()).catch(failed);
114
+ } catch (err) {
115
+ failed(err as Error);
116
+ }
117
+ }
118
+
119
+ /** Entries oldest→newest, optionally only those newer than `since`. */
120
+ async read(since?: number, limit?: number): Promise<HubBufferData<T>> {
121
+ const shared = this.redis ? await this.readShared(since, limit) : undefined;
122
+ if (shared) {
123
+ return shared;
124
+ }
125
+ return {
126
+ cursor: this.local.lastSeq,
127
+ dropped: this.local.firstRetainedSeq,
128
+ entries: since === undefined ? this.local.recent(limit) : this.local.since(since, limit),
129
+ };
130
+ }
131
+
132
+ /**
133
+ * Read the shared list, or undefined when Redis is unreachable (caller falls back to local).
134
+ *
135
+ * The list is newest-first, so the entry with seq `s` sits at index `total - 1 - s` and the
136
+ * entries a client with cursor `since` still needs are exactly the first `total - 1 - since`.
137
+ * `total` is only known once the read has happened, so the range starts at a poll-sized chunk
138
+ * and is widened only when that turns out not to have covered the client's cursor — which keeps
139
+ * the common poll O(delta) instead of O(capacity) without ever returning a short answer.
140
+ */
141
+ protected async readShared(since?: number, limit?: number): Promise<HubBufferData<T> | undefined> {
142
+ try {
143
+ const client = this.redis!.getClient();
144
+ const capacity = this.local.capacity;
145
+ const cap = Math.min(limit ?? capacity, capacity);
146
+ // Without a cursor the caller wants the newest window, which LTRIM already bounds by
147
+ // capacity — so the full range is exact rather than a guess.
148
+ let want = Math.max(1, since === undefined ? cap : Math.min(cap, SHARED_READ_CHUNK));
149
+
150
+ for (let attempt = 0; ; attempt++) {
151
+ const result = await client
152
+ .multi()
153
+ .get(this.seqKey)
154
+ .lrange(this.listKey, 0, want - 1)
155
+ .exec();
156
+ const total = Number(result?.[0]?.[1] ?? 0);
157
+ const raw = (result?.[1]?.[1] ?? []) as string[];
158
+ const needed = since === undefined ? cap : Math.max(0, Math.min(cap, total - 1 - since));
159
+
160
+ // Enough in hand, or the list simply ended before the bound, or we have widened often
161
+ // enough — three more round trips is already pathological, and answering with what we
162
+ // have beats spinning while the panel waits.
163
+ if (raw.length >= needed || raw.length < want || attempt >= 2) {
164
+ return this.shape(total, raw, needed);
165
+ }
166
+ want = Math.min(Math.max(needed, want * 4), capacity);
167
+ }
168
+ } catch (err) {
169
+ // console, NOT Logger — same re-entrancy as in mirror() above
170
+ console.debug(`Hub buffer read failed (${this.listKey}), using the local buffer: ${(err as Error).message}`);
171
+ return undefined;
172
+ }
173
+ }
174
+
175
+ /**
176
+ * Turn a newest-first slice into the oldest→newest payload, stamping the SHARED seq over the
177
+ * per-replica one that was serialized.
178
+ *
179
+ * `dropped` is derived from what the slice covers, so for a polling client it reports "nothing
180
+ * you had was evicted" — and when entries really were evicted (the client is further behind than
181
+ * the buffer is deep) the slice ends early and the true gap surfaces.
182
+ */
183
+ protected shape(total: number, raw: string[], needed: number): HubBufferData<T> {
184
+ const entries: T[] = [];
185
+ for (let i = Math.min(raw.length, needed) - 1; i >= 0; i--) {
186
+ try {
187
+ entries.push({ ...JSON.parse(raw[i]), seq: total - 1 - i } as T);
188
+ } catch {
189
+ // A single unparseable element must not blank the whole panel.
190
+ }
191
+ }
192
+ return {
193
+ cursor: total - 1,
194
+ dropped: raw.length ? total - raw.length : -1,
195
+ entries,
196
+ };
197
+ }
198
+ }
@@ -1,8 +1,9 @@
1
- import { Inject, Injectable } from '@nestjs/common';
1
+ import { Inject, Injectable, Optional } from '@nestjs/common';
2
2
 
3
3
  import { redactSensitiveText } from '../../../common/helpers/logging.helper';
4
+ import { CoreRedisService } from '../../../common/services/core-redis.service';
4
5
  import { HUB_CONFIG } from '../hub.constants';
5
- import { HubRingBuffer } from '../hub-ring-buffer';
6
+ import { HubBuffer } from '../hub-buffer';
6
7
  import { HubMailboxData, HubMailboxEntry } from '../interfaces/hub-panels.interface';
7
8
  import { IHubCapturedMailInput, IHubEmailCapture, ResolvedHubConfig } from '../interfaces/hub-config.interface';
8
9
 
@@ -24,14 +25,17 @@ const TRUNCATION_MARKER = '… [truncated]';
24
25
  */
25
26
  @Injectable()
26
27
  export class CoreHubMailboxService implements IHubEmailCapture {
27
- protected readonly buffer: HubRingBuffer<StoredMail>;
28
+ protected readonly buffer: HubBuffer<StoredMail>;
28
29
  protected readonly mode: 'capture' | 'copy';
29
30
 
30
- constructor(@Inject(HUB_CONFIG) protected readonly config: ResolvedHubConfig) {
31
+ constructor(
32
+ @Inject(HUB_CONFIG) protected readonly config: ResolvedHubConfig,
33
+ @Optional() protected readonly redis?: CoreRedisService,
34
+ ) {
31
35
  const mailbox =
32
36
  config.mailbox === false ? { capacity: 100, maxMailSize: 262144, mode: 'capture' as const } : config.mailbox;
33
37
  this.mode = mailbox.mode;
34
- this.buffer = new HubRingBuffer<StoredMail>(mailbox.capacity);
38
+ this.buffer = new HubBuffer<StoredMail>(mailbox.capacity, 'mailbox', redis);
35
39
  }
36
40
 
37
41
  /** Record a mail. Returns true when the caller should SKIP the transport (capture mode). */
@@ -40,7 +44,13 @@ export class CoreHubMailboxService implements IHubEmailCapture {
40
44
  return this.mode === 'capture';
41
45
  }
42
46
 
43
- /** Convenience for tests/actions: capture and return the assigned seq. */
47
+ /**
48
+ * Convenience for tests/actions: capture and return the assigned seq.
49
+ *
50
+ * The returned seq is the PROCESS-LOCAL one. When the mailbox is Redis-backed, the seq the
51
+ * panels (and {@link CoreHubMailboxService.getMailHtml}) use comes from the shared counter and
52
+ * will differ — read it from `getMailbox()` instead of assuming this value.
53
+ */
44
54
  captureAndGetSeq(mail: IHubCapturedMailInput): number {
45
55
  return this.store(mail).seq;
46
56
  }
@@ -51,8 +61,8 @@ export class CoreHubMailboxService implements IHubEmailCapture {
51
61
  }
52
62
 
53
63
  /** Stored HTML (or text fallback) for a mail, for the sandboxed preview iframe. */
54
- getMailHtml(seq: number): string | undefined {
55
- const mail = this.buffer.recent().find((m) => m.seq === seq);
64
+ async getMailHtml(seq: number): Promise<string | undefined> {
65
+ const mail = (await this.buffer.read()).entries.find((m) => m.seq === seq);
56
66
  if (!mail) {
57
67
  return undefined;
58
68
  }
@@ -60,11 +70,11 @@ export class CoreHubMailboxService implements IHubEmailCapture {
60
70
  }
61
71
 
62
72
  /** Mailbox listing (metadata only), oldest→newest, optionally since a cursor. */
63
- getMailbox(since?: number): HubMailboxData {
64
- const entries = since === undefined ? this.buffer.recent() : this.buffer.since(since);
73
+ async getMailbox(since?: number): Promise<HubMailboxData> {
74
+ const { cursor, dropped, entries } = await this.buffer.read(since);
65
75
  return {
66
- cursor: this.buffer.lastSeq,
67
- dropped: this.buffer.firstRetainedSeq,
76
+ cursor,
77
+ dropped,
68
78
  mails: entries.map(toEntry),
69
79
  mode: this.mode,
70
80
  };