@lenne.tech/nest-server 11.32.3 → 11.33.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (262) hide show
  1. package/.claude/rules/architecture.md +16 -0
  2. package/.claude/rules/configurable-features.md +11 -3
  3. package/.claude/rules/testing.md +101 -1
  4. package/CLAUDE.md +5 -0
  5. package/FRAMEWORK-API.md +58 -3
  6. package/bin/migrate.js +13 -3
  7. package/dist/core/common/helpers/content-disposition.helper.d.ts +1 -0
  8. package/dist/core/common/helpers/content-disposition.helper.js +14 -0
  9. package/dist/core/common/helpers/content-disposition.helper.js.map +1 -0
  10. package/dist/core/common/helpers/file.helper.d.ts +21 -2
  11. package/dist/core/common/helpers/file.helper.js +69 -13
  12. package/dist/core/common/helpers/file.helper.js.map +1 -1
  13. package/dist/core/common/helpers/graceful-shutdown.helper.d.ts +2 -0
  14. package/dist/core/common/helpers/graceful-shutdown.helper.js +55 -0
  15. package/dist/core/common/helpers/graceful-shutdown.helper.js.map +1 -0
  16. package/dist/core/common/helpers/project-name.helper.d.ts +2 -0
  17. package/dist/core/common/helpers/project-name.helper.js +38 -0
  18. package/dist/core/common/helpers/project-name.helper.js.map +1 -0
  19. package/dist/core/common/interfaces/cron-job-config.interface.d.ts +1 -0
  20. package/dist/core/common/interfaces/server-options.interface.d.ts +39 -0
  21. package/dist/core/common/services/core-cron-jobs.initializer.d.ts +10 -0
  22. package/dist/core/common/services/core-cron-jobs.initializer.js +46 -0
  23. package/dist/core/common/services/core-cron-jobs.initializer.js.map +1 -0
  24. package/dist/core/common/services/core-cron-jobs.registry.d.ts +8 -0
  25. package/dist/core/common/services/core-cron-jobs.registry.js +12 -0
  26. package/dist/core/common/services/core-cron-jobs.registry.js.map +1 -0
  27. package/dist/core/common/services/core-cron-jobs.service.d.ts +37 -7
  28. package/dist/core/common/services/core-cron-jobs.service.js +278 -27
  29. package/dist/core/common/services/core-cron-jobs.service.js.map +1 -1
  30. package/dist/core/common/services/core-redis-pubsub.d.ts +20 -0
  31. package/dist/core/common/services/core-redis-pubsub.js +112 -0
  32. package/dist/core/common/services/core-redis-pubsub.js.map +1 -0
  33. package/dist/core/common/services/core-redis.service.d.ts +25 -0
  34. package/dist/core/common/services/core-redis.service.js +154 -0
  35. package/dist/core/common/services/core-redis.service.js.map +1 -0
  36. package/dist/core/common/services/core-s3.service.d.ts +51 -0
  37. package/dist/core/common/services/core-s3.service.js +261 -0
  38. package/dist/core/common/services/core-s3.service.js.map +1 -0
  39. package/dist/core/common/services/core-trust-proxy.initializer.d.ts +16 -0
  40. package/dist/core/common/services/core-trust-proxy.initializer.js +77 -0
  41. package/dist/core/common/services/core-trust-proxy.initializer.js.map +1 -0
  42. package/dist/core/common/services/rate-limit-store.d.ts +61 -0
  43. package/dist/core/common/services/rate-limit-store.js +235 -0
  44. package/dist/core/common/services/rate-limit-store.js.map +1 -0
  45. package/dist/core/modules/ai/core-ai-mcp.controller.d.ts +19 -3
  46. package/dist/core/modules/ai/core-ai-mcp.controller.js +148 -18
  47. package/dist/core/modules/ai/core-ai-mcp.controller.js.map +1 -1
  48. package/dist/core/modules/ai/services/core-ai.service.d.ts +7 -2
  49. package/dist/core/modules/ai/services/core-ai.service.js +24 -20
  50. package/dist/core/modules/ai/services/core-ai.service.js.map +1 -1
  51. package/dist/core/modules/auth/core-auth.module.js +4 -1
  52. package/dist/core/modules/auth/core-auth.module.js.map +1 -1
  53. package/dist/core/modules/auth/guards/legacy-auth-rate-limit.guard.d.ts +1 -1
  54. package/dist/core/modules/auth/guards/legacy-auth-rate-limit.guard.js +3 -14
  55. package/dist/core/modules/auth/guards/legacy-auth-rate-limit.guard.js.map +1 -1
  56. package/dist/core/modules/auth/services/legacy-auth-rate-limiter.service.d.ts +12 -10
  57. package/dist/core/modules/auth/services/legacy-auth-rate-limiter.service.js +30 -59
  58. package/dist/core/modules/auth/services/legacy-auth-rate-limiter.service.js.map +1 -1
  59. package/dist/core/modules/better-auth/core-better-auth-email-verification.service.d.ts +8 -2
  60. package/dist/core/modules/better-auth/core-better-auth-email-verification.service.js +118 -45
  61. package/dist/core/modules/better-auth/core-better-auth-email-verification.service.js.map +1 -1
  62. package/dist/core/modules/better-auth/core-better-auth-rate-limit.middleware.d.ts +1 -1
  63. package/dist/core/modules/better-auth/core-better-auth-rate-limit.middleware.js +2 -11
  64. package/dist/core/modules/better-auth/core-better-auth-rate-limit.middleware.js.map +1 -1
  65. package/dist/core/modules/better-auth/core-better-auth-rate-limiter.service.d.ts +12 -10
  66. package/dist/core/modules/better-auth/core-better-auth-rate-limiter.service.js +30 -85
  67. package/dist/core/modules/better-auth/core-better-auth-rate-limiter.service.js.map +1 -1
  68. package/dist/core/modules/better-auth/core-better-auth-user.mapper.js +0 -1
  69. package/dist/core/modules/better-auth/core-better-auth-user.mapper.js.map +1 -1
  70. package/dist/core/modules/file/core-file.controller.d.ts +15 -3
  71. package/dist/core/modules/file/core-file.controller.js +90 -20
  72. package/dist/core/modules/file/core-file.controller.js.map +1 -1
  73. package/dist/core/modules/file/core-file.resolver.d.ts +4 -4
  74. package/dist/core/modules/file/core-file.resolver.js +23 -16
  75. package/dist/core/modules/file/core-file.resolver.js.map +1 -1
  76. package/dist/core/modules/file/core-file.service.d.ts +41 -7
  77. package/dist/core/modules/file/core-file.service.js +227 -7
  78. package/dist/core/modules/file/core-file.service.js.map +1 -1
  79. package/dist/core/modules/file/file-metadata.helper.d.ts +16 -0
  80. package/dist/core/modules/file/file-metadata.helper.js +33 -0
  81. package/dist/core/modules/file/file-metadata.helper.js.map +1 -0
  82. package/dist/core/modules/file/file-roles.helper.d.ts +4 -0
  83. package/dist/core/modules/file/file-roles.helper.js +53 -0
  84. package/dist/core/modules/file/file-roles.helper.js.map +1 -0
  85. package/dist/core/modules/file/file-storage.helper.d.ts +13 -0
  86. package/dist/core/modules/file/file-storage.helper.js +65 -0
  87. package/dist/core/modules/file/file-storage.helper.js.map +1 -0
  88. package/dist/core/modules/file/filesystem-file.helper.d.ts +22 -0
  89. package/dist/core/modules/file/filesystem-file.helper.js +77 -0
  90. package/dist/core/modules/file/filesystem-file.helper.js.map +1 -0
  91. package/dist/core/modules/file/interfaces/file-service-options.interface.d.ts +1 -0
  92. package/dist/core/modules/file/interfaces/file-upload.interface.d.ts +7 -1
  93. package/dist/core/modules/file/s3-file.helper.d.ts +37 -0
  94. package/dist/core/modules/file/s3-file.helper.js +81 -0
  95. package/dist/core/modules/file/s3-file.helper.js.map +1 -0
  96. package/dist/core/modules/hub/core-hub.controller.d.ts +8 -8
  97. package/dist/core/modules/hub/core-hub.controller.js +24 -22
  98. package/dist/core/modules/hub/core-hub.controller.js.map +1 -1
  99. package/dist/core/modules/hub/helpers/hub-mask.helper.js +2 -2
  100. package/dist/core/modules/hub/helpers/hub-mask.helper.js.map +1 -1
  101. package/dist/core/modules/hub/hub-buffer.d.ts +23 -0
  102. package/dist/core/modules/hub/hub-buffer.js +101 -0
  103. package/dist/core/modules/hub/hub-buffer.js.map +1 -0
  104. package/dist/core/modules/hub/services/core-hub-mailbox.service.d.ts +7 -5
  105. package/dist/core/modules/hub/services/core-hub-mailbox.service.js +14 -10
  106. package/dist/core/modules/hub/services/core-hub-mailbox.service.js.map +1 -1
  107. package/dist/core/modules/hub/services/hub-log-buffer.service.d.ts +5 -2
  108. package/dist/core/modules/hub/services/hub-log-buffer.service.js +17 -8
  109. package/dist/core/modules/hub/services/hub-log-buffer.service.js.map +1 -1
  110. package/dist/core/modules/hub/services/hub-query-profiler.service.d.ts +4 -2
  111. package/dist/core/modules/hub/services/hub-query-profiler.service.js +13 -7
  112. package/dist/core/modules/hub/services/hub-query-profiler.service.js.map +1 -1
  113. package/dist/core/modules/hub/services/hub-trace-buffer.service.d.ts +4 -2
  114. package/dist/core/modules/hub/services/hub-trace-buffer.service.js +13 -8
  115. package/dist/core/modules/hub/services/hub-trace-buffer.service.js.map +1 -1
  116. package/dist/core/modules/migrate/cli/migrate-cli.d.ts +3 -1
  117. package/dist/core/modules/migrate/cli/migrate-cli.js +29 -4
  118. package/dist/core/modules/migrate/cli/migrate-cli.js.map +1 -1
  119. package/dist/core/modules/migrate/helpers/migration.helper.d.ts +3 -6
  120. package/dist/core/modules/migrate/helpers/migration.helper.js +55 -8
  121. package/dist/core/modules/migrate/helpers/migration.helper.js.map +1 -1
  122. package/dist/core/modules/migrate/migration-runner.d.ts +1 -0
  123. package/dist/core/modules/migrate/migration-runner.js +4 -0
  124. package/dist/core/modules/migrate/migration-runner.js.map +1 -1
  125. package/dist/core/modules/migrate/mongo-state-store.d.ts +1 -0
  126. package/dist/core/modules/migrate/mongo-state-store.js +98 -9
  127. package/dist/core/modules/migrate/mongo-state-store.js.map +1 -1
  128. package/dist/core/modules/system-setup/core-system-setup.service.d.ts +2 -0
  129. package/dist/core/modules/system-setup/core-system-setup.service.js +31 -1
  130. package/dist/core/modules/system-setup/core-system-setup.service.js.map +1 -1
  131. package/dist/core/modules/tenant/core-tenant.guard.d.ts +17 -3
  132. package/dist/core/modules/tenant/core-tenant.guard.js +92 -15
  133. package/dist/core/modules/tenant/core-tenant.guard.js.map +1 -1
  134. package/dist/core/modules/tenant/core-tenant.module.js +3 -4
  135. package/dist/core/modules/tenant/core-tenant.module.js.map +1 -1
  136. package/dist/core/modules/tus/core-tus.controller.d.ts +2 -0
  137. package/dist/core/modules/tus/core-tus.controller.js +29 -3
  138. package/dist/core/modules/tus/core-tus.controller.js.map +1 -1
  139. package/dist/core/modules/tus/core-tus.service.d.ts +24 -1
  140. package/dist/core/modules/tus/core-tus.service.js +182 -28
  141. package/dist/core/modules/tus/core-tus.service.js.map +1 -1
  142. package/dist/core/modules/tus/interfaces/tus-config.interface.js +3 -0
  143. package/dist/core/modules/tus/interfaces/tus-config.interface.js.map +1 -1
  144. package/dist/core/modules/tus/tus-redis-locker.d.ts +25 -0
  145. package/dist/core/modules/tus/tus-redis-locker.js +87 -0
  146. package/dist/core/modules/tus/tus-redis-locker.js.map +1 -0
  147. package/dist/core/modules/tus/tus.module.d.ts +1 -0
  148. package/dist/core/modules/tus/tus.module.js +30 -4
  149. package/dist/core/modules/tus/tus.module.js.map +1 -1
  150. package/dist/core.module.js +18 -1
  151. package/dist/core.module.js.map +1 -1
  152. package/dist/index.d.ts +10 -0
  153. package/dist/index.js +10 -0
  154. package/dist/index.js.map +1 -1
  155. package/dist/main.js +2 -1
  156. package/dist/main.js.map +1 -1
  157. package/dist/server/modules/file/file.controller.js.map +1 -1
  158. package/dist/server/modules/file/file.resolver.js +1 -12
  159. package/dist/server/modules/file/file.resolver.js.map +1 -1
  160. package/dist/server/modules/file/file.service.d.ts +5 -1
  161. package/dist/server/modules/file/file.service.js +12 -3
  162. package/dist/server/modules/file/file.service.js.map +1 -1
  163. package/dist/server/modules/user/avatar.controller.d.ts +5 -1
  164. package/dist/server/modules/user/avatar.controller.js +26 -10
  165. package/dist/server/modules/user/avatar.controller.js.map +1 -1
  166. package/dist/server/modules/user/user.model.js +3 -2
  167. package/dist/server/modules/user/user.model.js.map +1 -1
  168. package/dist/server/modules/user/user.module.js +6 -2
  169. package/dist/server/modules/user/user.module.js.map +1 -1
  170. package/dist/server/modules/user/user.service.d.ts +1 -1
  171. package/dist/server/modules/user/user.service.js +6 -13
  172. package/dist/server/modules/user/user.service.js.map +1 -1
  173. package/dist/tsconfig.build.tsbuildinfo +1 -1
  174. package/docs/REQUEST-LIFECYCLE.md +57 -8
  175. package/docs/security-overrides.md +30 -5
  176. package/migration-guides/11.32.3-to-11.32.4.md +335 -0
  177. package/migration-guides/11.32.x-to-11.33.x.md +1551 -0
  178. package/package.json +74 -42
  179. package/src/core/common/helpers/content-disposition.helper.ts +90 -0
  180. package/src/core/common/helpers/file.helper.ts +201 -20
  181. package/src/core/common/helpers/graceful-shutdown.helper.ts +116 -0
  182. package/src/core/common/helpers/project-name.helper.ts +71 -0
  183. package/src/core/common/interfaces/cron-job-config.interface.ts +16 -0
  184. package/src/core/common/interfaces/server-options.interface.ts +439 -2
  185. package/src/core/common/services/core-cron-jobs.initializer.ts +39 -0
  186. package/src/core/common/services/core-cron-jobs.registry.ts +45 -0
  187. package/src/core/common/services/core-cron-jobs.service.ts +589 -47
  188. package/src/core/common/services/core-redis-pubsub.ts +183 -0
  189. package/src/core/common/services/core-redis.service.ts +249 -0
  190. package/src/core/common/services/core-s3.service.ts +465 -0
  191. package/src/core/common/services/core-trust-proxy.initializer.ts +112 -0
  192. package/src/core/common/services/rate-limit-store.ts +492 -0
  193. package/src/core/modules/ai/README.md +25 -0
  194. package/src/core/modules/ai/core-ai-mcp.controller.ts +253 -22
  195. package/src/core/modules/ai/services/core-ai.service.ts +33 -24
  196. package/src/core/modules/auth/core-auth.module.ts +7 -1
  197. package/src/core/modules/auth/guards/legacy-auth-rate-limit.guard.ts +12 -21
  198. package/src/core/modules/auth/services/legacy-auth-rate-limiter.service.ts +49 -93
  199. package/src/core/modules/better-auth/core-better-auth-email-verification.service.ts +202 -66
  200. package/src/core/modules/better-auth/core-better-auth-rate-limit.middleware.ts +11 -18
  201. package/src/core/modules/better-auth/core-better-auth-rate-limiter.service.ts +51 -132
  202. package/src/core/modules/better-auth/core-better-auth-user.mapper.ts +7 -1
  203. package/src/core/modules/file/INTEGRATION-CHECKLIST.md +108 -0
  204. package/src/core/modules/file/README.md +324 -41
  205. package/src/core/modules/file/core-file.controller.ts +267 -19
  206. package/src/core/modules/file/core-file.resolver.ts +46 -12
  207. package/src/core/modules/file/core-file.service.ts +482 -16
  208. package/src/core/modules/file/file-metadata.helper.ts +142 -0
  209. package/src/core/modules/file/file-roles.helper.ts +110 -0
  210. package/src/core/modules/file/file-storage.helper.ts +163 -0
  211. package/src/core/modules/file/filesystem-file.helper.ts +184 -0
  212. package/src/core/modules/file/interfaces/file-service-options.interface.ts +14 -0
  213. package/src/core/modules/file/interfaces/file-upload.interface.ts +17 -1
  214. package/src/core/modules/file/s3-file.helper.ts +209 -0
  215. package/src/core/modules/hub/README.md +48 -0
  216. package/src/core/modules/hub/core-hub.controller.ts +18 -16
  217. package/src/core/modules/hub/helpers/hub-mask.helper.ts +21 -4
  218. package/src/core/modules/hub/hub-buffer.ts +198 -0
  219. package/src/core/modules/hub/services/core-hub-mailbox.service.ts +22 -12
  220. package/src/core/modules/hub/services/hub-log-buffer.service.ts +24 -8
  221. package/src/core/modules/hub/services/hub-query-profiler.service.ts +9 -6
  222. package/src/core/modules/hub/services/hub-trace-buffer.service.ts +14 -9
  223. package/src/core/modules/migrate/README.md +68 -6
  224. package/src/core/modules/migrate/cli/migrate-cli.ts +69 -6
  225. package/src/core/modules/migrate/helpers/migration.helper.ts +144 -10
  226. package/src/core/modules/migrate/migration-runner.ts +10 -1
  227. package/src/core/modules/migrate/mongo-state-store.ts +179 -11
  228. package/src/core/modules/system-setup/README.md +7 -2
  229. package/src/core/modules/system-setup/core-system-setup.service.ts +95 -1
  230. package/src/core/modules/tenant/README.md +21 -1
  231. package/src/core/modules/tenant/core-tenant.guard.ts +135 -14
  232. package/src/core/modules/tenant/core-tenant.module.ts +8 -4
  233. package/src/core/modules/tus/INTEGRATION-CHECKLIST.md +93 -21
  234. package/src/core/modules/tus/README.md +35 -2
  235. package/src/core/modules/tus/core-tus.controller.ts +64 -12
  236. package/src/core/modules/tus/core-tus.service.ts +358 -34
  237. package/src/core/modules/tus/interfaces/tus-config.interface.ts +9 -0
  238. package/src/core/modules/tus/tus-redis-locker.ts +139 -0
  239. package/src/core/modules/tus/tus.module.ts +78 -6
  240. package/src/core.module.ts +26 -1
  241. package/src/index.ts +10 -0
  242. package/src/main.ts +7 -1
  243. package/src/server/modules/file/file.controller.ts +7 -3
  244. package/src/server/modules/file/file.resolver.ts +5 -17
  245. package/src/server/modules/file/file.service.ts +42 -3
  246. package/src/server/modules/user/avatar.controller.ts +39 -9
  247. package/src/server/modules/user/user.model.ts +9 -3
  248. package/src/server/modules/user/user.module.ts +12 -3
  249. package/src/server/modules/user/user.service.ts +20 -17
  250. package/src/test/README.md +7 -2
  251. package/src/core/common/helpers/logging.helper.spec.ts +0 -61
  252. package/src/core/common/helpers/process-diagnostics.helper.spec.ts +0 -310
  253. package/src/core/common/services/brevo.service.spec.ts +0 -266
  254. package/src/core/modules/hub/core-hub.module.spec.ts +0 -108
  255. package/src/core/modules/hub/helpers/hub-command-shape.helper.spec.ts +0 -48
  256. package/src/core/modules/hub/helpers/hub-mask.helper.spec.ts +0 -67
  257. package/src/core/modules/hub/helpers/hub-mermaid.helper.spec.ts +0 -61
  258. package/src/core/modules/hub/helpers/hub-shell.helper.spec.ts +0 -106
  259. package/src/core/modules/hub/hub-config.helper.spec.ts +0 -108
  260. package/src/core/modules/hub/hub-ring-buffer.spec.ts +0 -95
  261. package/src/core/modules/hub/services/core-hub-mailbox.service.spec.ts +0 -116
  262. package/src/core/modules/hub/services/hub-trace-buffer.service.spec.ts +0 -112
@@ -0,0 +1,142 @@
1
+ import { mongo, Types } from 'mongoose';
2
+
3
+ /**
4
+ * The metadata shape every non-GridFS storage driver writes, and the queries against it.
5
+ *
6
+ * WHY THIS FILE EXISTS: `S3FileHelper` and `FilesystemFileHelper` grew the same
7
+ * six-field document, the same `FileCollection` alias and the same four lookups
8
+ * independently. Two consequences, and the second one is the reason this is not
9
+ * merely tidiness:
10
+ *
11
+ * 1. The two drifted. `getRawFileInfoByName()` consulted S3 and GridFS but not the
12
+ * filesystem, so a by-name authorization rule saw a different set of files than
13
+ * the download served.
14
+ * 2. Because the documents are structurally identical, NOTHING in a fetched file
15
+ * info said which store it came from — so every caller that needed to know had
16
+ * to go and ask all three stores again. A single download resolved the same id
17
+ * up to three times.
18
+ *
19
+ * (2) is solved by `CoreFileService.resolveFile()`, which remembers WHICH PROBE
20
+ * ANSWERED and hands that down instead of letting the next caller re-probe. Not by
21
+ * the `storage` marker below — see its own doc for why the collection, not the
22
+ * field, is authoritative when reading.
23
+ *
24
+ * DELIBERATELY IMPORT-FREE apart from mongoose types, so it stays a leaf. See
25
+ * `.claude/rules/architecture.md` → "DI Token Placement (SWC-Safe)".
26
+ */
27
+
28
+ /** Metadata collection handle (no Mongoose schema — these collections are driver-managed) */
29
+ export type FileCollection = mongo.Collection<any>;
30
+
31
+ /**
32
+ * Which store holds a file's bytes, as recorded on the metadata document.
33
+ *
34
+ * GridFS is absent on purpose: its documents live in `fs.files` and are written by
35
+ * the driver, which will never carry this field. "No marker and found in fs.files"
36
+ * IS the GridFS case.
37
+ */
38
+ export type FileStorageMarker = 'filesystem' | 's3';
39
+
40
+ /**
41
+ * Metadata of a file whose bytes live outside GridFS.
42
+ *
43
+ * The first six fields are exactly a GridFS `fs.files` document, which is what lets
44
+ * `prepareOutput()` map all three stores onto `CoreFileInfo` unchanged.
45
+ */
46
+ export interface FileMetadataInfo {
47
+ _id: Types.ObjectId;
48
+ contentType?: string;
49
+ filename: string;
50
+ length: number;
51
+ metadata?: Record<string, any>;
52
+ /**
53
+ * Which driver wrote these bytes.
54
+ *
55
+ * FOR OPERATORS AND DIAGNOSTICS, not for dispatch. Code must derive the store from
56
+ * the COLLECTION a document was found in, never from this field: the two can only
57
+ * ever disagree through corruption or a hand-edit, and trusting the field there
58
+ * would send a read to the wrong store. It earns its place by making the store
59
+ * answerable in a plain query (`db['s3-files'].countDocuments({ storage: 's3' })`)
60
+ * and by surviving an export, where the collection name does not.
61
+ *
62
+ * Absent on documents written before 11.33.0, and on every GridFS document.
63
+ */
64
+ storage?: FileStorageMarker;
65
+ uploadDate: Date;
66
+ }
67
+
68
+ /** Find one metadata document by id */
69
+ export async function findMetadataById(
70
+ collection: FileCollection,
71
+ id: string | Types.ObjectId,
72
+ ): Promise<FileMetadataInfo | null> {
73
+ return (await collection.findOne({ _id: new Types.ObjectId(id) })) as FileMetadataInfo | null;
74
+ }
75
+
76
+ /**
77
+ * Find one metadata document by filename.
78
+ *
79
+ * Resolves the FIRST match — filenames are not unique in any of these stores, and
80
+ * are client-supplied on both the multer and the tus path. Prefer the id lookup
81
+ * wherever the caller has an id.
82
+ */
83
+ export async function findMetadataByName(
84
+ collection: FileCollection,
85
+ filename: string,
86
+ ): Promise<FileMetadataInfo | null> {
87
+ return (await collection.findOne({ filename })) as FileMetadataInfo | null;
88
+ }
89
+
90
+ /** Find metadata documents by filter */
91
+ export async function findMetadata(
92
+ collection: FileCollection,
93
+ filter: any = {},
94
+ options: any = {},
95
+ ): Promise<FileMetadataInfo[]> {
96
+ return (await collection.find(filter, options).toArray()) as FileMetadataInfo[];
97
+ }
98
+
99
+ /**
100
+ * Namespaces whose `filename` index has already been ensured in this process.
101
+ *
102
+ * Keyed by `<db>.<collection>` rather than by object identity: the service builds a
103
+ * fresh collection handle per instance, and two instances in one process (the tests
104
+ * do exactly that) would otherwise each pay the round trip.
105
+ */
106
+ const filenameIndexEnsured = new Set<string>();
107
+
108
+ /**
109
+ * Ensure the `filename` index, ON THE WRITE PATH ONLY.
110
+ *
111
+ * These collections have no Mongoose schema, so nothing creates their indexes
112
+ * implicitly the way the driver does for GridFS's `fs.files`. Without one,
113
+ * `GET /files/:filename` is a collection scan that grows with the file count.
114
+ *
115
+ * WHY THIS IS NOT CALLED WHEN READING: `createIndex` CREATES the collection. Calling
116
+ * it on the read path made a deployment that only ever uses GridFS grow an empty
117
+ * `s3-files` and an empty `filesystem-files` — collections it has no reason to have,
118
+ * which then show up in backups and in the Hub's DB panel as if the driver were in
119
+ * use. A store that is never written to now stays absent, and a store that HAS been
120
+ * written to necessarily went through here, so the index exists exactly where there
121
+ * is data to index.
122
+ *
123
+ * NEVER THROWS. A missing index makes later reads slow, not wrong, and must not turn
124
+ * an upload into a 500 — so a failure only un-marks the namespace, and the next write
125
+ * tries again rather than pinning the failure for the process lifetime.
126
+ *
127
+ * @returns whether the index is now known to exist
128
+ */
129
+ export async function ensureFilenameIndex(collection: FileCollection): Promise<boolean> {
130
+ const namespace = `${collection.dbName}.${collection.collectionName}`;
131
+ if (filenameIndexEnsured.has(namespace)) {
132
+ return true;
133
+ }
134
+ filenameIndexEnsured.add(namespace);
135
+ try {
136
+ await collection.createIndex({ filename: 1 });
137
+ return true;
138
+ } catch {
139
+ filenameIndexEnsured.delete(namespace);
140
+ return false;
141
+ }
142
+ }
@@ -0,0 +1,110 @@
1
+ import { Logger } from '@nestjs/common';
2
+
3
+ import { RoleEnum } from '../../common/enums/role.enum';
4
+ import { IFileConfig } from '../../common/interfaces/server-options.interface';
5
+ import { CoreFileController } from './core-file.controller';
6
+ import { CoreFileResolver } from './core-file.resolver';
7
+
8
+ const logger = new Logger('CoreFileRoles');
9
+
10
+ /**
11
+ * Roles applied when `file` is not configured at all.
12
+ *
13
+ * Restrictive on purpose: one GridFS bucket is shared by every feature of the
14
+ * consuming project, and the ObjectIds naming its blobs are not secrets.
15
+ */
16
+ export type FileRoleKey = 'deleteRoles' | 'downloadRoles' | 'uploadRoles';
17
+
18
+ export const FILE_ROLE_DEFAULTS: Record<FileRoleKey, string[]> = {
19
+ deleteRoles: [RoleEnum.ADMIN],
20
+ downloadRoles: [RoleEnum.ADMIN],
21
+ uploadRoles: [RoleEnum.ADMIN],
22
+ };
23
+
24
+ /**
25
+ * Which member is governed by which knob.
26
+ *
27
+ * `getFileInfo` rides with `downloadRoles` rather than getting its own knob:
28
+ * it answers filename, size and content type for a blob, which is the metadata
29
+ * half of a download. Splitting it would let a project accidentally publish the
30
+ * bucket's contents list while believing downloads were still closed.
31
+ */
32
+ const ROLE_TARGETS: { key: FileRoleKey; member: string; owner: () => unknown }[] = [
33
+ {
34
+ key: 'downloadRoles',
35
+ member: 'CoreFileController.getFileById',
36
+ owner: () => CoreFileController.prototype.getFileById,
37
+ },
38
+ { key: 'downloadRoles', member: 'CoreFileController.getFile', owner: () => CoreFileController.prototype.getFile },
39
+ { key: 'downloadRoles', member: 'CoreFileResolver.getFileInfo', owner: () => CoreFileResolver.prototype.getFileInfo },
40
+ { key: 'uploadRoles', member: 'CoreFileResolver.uploadFile', owner: () => CoreFileResolver.prototype.uploadFile },
41
+ { key: 'uploadRoles', member: 'CoreFileResolver.uploadFiles', owner: () => CoreFileResolver.prototype.uploadFiles },
42
+ { key: 'deleteRoles', member: 'CoreFileResolver.deleteFile', owner: () => CoreFileResolver.prototype.deleteFile },
43
+ ];
44
+
45
+ /**
46
+ * Resolve one knob to the role list that will actually be applied.
47
+ *
48
+ * An empty array is treated as "not configured". It cannot mean "nobody": the
49
+ * guards read an all-empty role set as "no roles required" and return true, so
50
+ * honouring it literally would OPEN the route instead of closing it — the exact
51
+ * opposite of what someone writing `[]` intends.
52
+ */
53
+ function resolveRoles(key: FileRoleKey, config?: IFileConfig): string[] {
54
+ const configured = config?.[key];
55
+
56
+ if (configured === undefined) {
57
+ return FILE_ROLE_DEFAULTS[key];
58
+ }
59
+
60
+ if (!Array.isArray(configured) || configured.length === 0 || configured.some((role) => typeof role !== 'string')) {
61
+ logger.warn(
62
+ `Ignoring file.${key}: expected a non-empty array of role strings, got ${JSON.stringify(configured)}. ` +
63
+ `Falling back to ${JSON.stringify(FILE_ROLE_DEFAULTS[key])}.`,
64
+ );
65
+ return FILE_ROLE_DEFAULTS[key];
66
+ }
67
+
68
+ return configured;
69
+ }
70
+
71
+ /**
72
+ * Apply the configured file roles to the core file endpoints.
73
+ *
74
+ * Uses `Reflect.defineMetadata` rather than `@Roles()` for the same reason
75
+ * `CorePermissionsModule` does: the value is only known at runtime, from the
76
+ * configuration. `RolesGuard` / `BetterAuthRolesGuard` read exactly this key.
77
+ *
78
+ * TWO PROPERTIES A READER SHOULD KNOW ABOUT:
79
+ *
80
+ * 1. **ADMIN always keeps access.** Both classes carry a class-level
81
+ * `@Roles(RoleEnum.ADMIN)`, and the guards UNION handler and class metadata
82
+ * (`mergeRolesMetadata`) rather than letting one override the other. So
83
+ * `downloadRoles: ['editor']` grants editors *in addition to* admins. That is
84
+ * intended — it keeps a misconfiguration from locking the owner out — but it
85
+ * does mean these knobs cannot be used to exclude admins.
86
+ *
87
+ * 2. **A subclass that OVERRIDES a member opts out of the configuration.**
88
+ * Decorator metadata lives on the function object; an override is a different
89
+ * function, so what this writes onto the base method no longer applies.
90
+ * Consumers who want config-driven roles must inherit the member rather than
91
+ * re-declaring it. This is precisely the trap that kept `nest-server-starter`
92
+ * serving public downloads after the core default had already been closed.
93
+ */
94
+ export function applyFileRoles(config?: IFileConfig): void {
95
+ const resolved = new Map<FileRoleKey, string[]>();
96
+
97
+ for (const { key, member, owner } of ROLE_TARGETS) {
98
+ if (!resolved.has(key)) {
99
+ resolved.set(key, resolveRoles(key, config));
100
+ }
101
+
102
+ const target = owner();
103
+ if (typeof target !== 'function') {
104
+ logger.warn(`Cannot apply file.${key}: ${member} is not a function — skipping.`);
105
+ continue;
106
+ }
107
+
108
+ Reflect.defineMetadata('roles', resolved.get(key), target);
109
+ }
110
+ }
@@ -0,0 +1,163 @@
1
+ import { Logger } from '@nestjs/common';
2
+
3
+ import { IServerOptions } from '../../common/interfaces/server-options.interface';
4
+
5
+ const logger = new Logger('CoreFileStorage');
6
+
7
+ /**
8
+ * The three storage drivers. All equivalent in what they offer through
9
+ * `CoreFileService`; they differ only in where the bytes end up.
10
+ *
11
+ * - `'s3'` — an S3-compatible bucket. The only one that survives horizontal scaling.
12
+ * - `'gridfs'` — MongoDB GridFS. No extra infrastructure, bytes share the database.
13
+ * - `'filesystem'` — the local disk. Pod-local: not shared between replicas, lost on restart
14
+ * unless the path is a mounted volume.
15
+ */
16
+ export type FileStorageDriver = 'filesystem' | 'gridfs' | 's3';
17
+
18
+ export const FILE_STORAGE_DRIVERS: FileStorageDriver[] = ['filesystem', 'gridfs', 's3'];
19
+
20
+ /**
21
+ * How a driver was arrived at — for the boot log and for error messages, so a
22
+ * surprising choice can be traced back to the setting that produced it.
23
+ */
24
+ export interface FileStorageResolution {
25
+ driver: FileStorageDriver;
26
+ explicit: boolean;
27
+ reason: string;
28
+ }
29
+
30
+ /**
31
+ * Whether the S3 configuration names a concrete bucket to write to.
32
+ *
33
+ * A bucket is the one thing S3 cannot default: region, endpoint and credentials
34
+ * all have fallbacks (the AWS default credential chain resolves an IAM role,
35
+ * an instance profile or `AWS_*` environment variables without anything in the
36
+ * config), but "which bucket" has to be stated. So the presence of a bucket is
37
+ * what makes an S3 configuration usable — and therefore what makes S3 eligible
38
+ * as the automatic default.
39
+ */
40
+ export function hasUsableS3Config(config?: Partial<IServerOptions>): boolean {
41
+ const s3 = config?.s3;
42
+ if (!s3 || typeof s3 !== 'object' || (s3 as { enabled?: boolean }).enabled === false) {
43
+ return false;
44
+ }
45
+ return !!(s3 as { bucket?: string }).bucket;
46
+ }
47
+
48
+ /**
49
+ * Whether a database connection is configured.
50
+ *
51
+ * Note "configured", not "reachable": an unreachable but configured database is
52
+ * an ERROR, never a reason to quietly store files somewhere else. Mongoose fails
53
+ * the boot on its own in that case, which is the behaviour we want — silently
54
+ * degrading to the local disk would scatter a project's files across two stores
55
+ * during an outage and leave no trace of which file went where.
56
+ */
57
+ export function hasDatabaseConfig(config?: Partial<IServerOptions>): boolean {
58
+ return !!config?.mongoose?.uri;
59
+ }
60
+
61
+ /**
62
+ * Decide which storage driver to use.
63
+ *
64
+ * An EXPLICIT `file.storage` always wins and is never second-guessed here —
65
+ * whether the chosen store is actually reachable is asserted separately, at
66
+ * boot, by `assertFileStorageAvailable()`. That split matters: this function
67
+ * answers "what did the project ask for", not "did it work".
68
+ *
69
+ * Without an explicit value the default is derived, most capable first:
70
+ *
71
+ * 1. `s3` — when the S3 config names a bucket
72
+ * 2. `gridfs` — when a database is configured
73
+ * 3. `filesystem` — when neither is (today a theoretical case: `CoreModule`
74
+ * always registers Mongoose, so a project without a database
75
+ * does not boot at all)
76
+ *
77
+ * @param config the merged server configuration
78
+ */
79
+ export function resolveFileStorage(config?: Partial<IServerOptions>): FileStorageResolution {
80
+ const configured = config?.file?.storage;
81
+
82
+ if (configured !== undefined) {
83
+ if (!FILE_STORAGE_DRIVERS.includes(configured as FileStorageDriver)) {
84
+ throw new Error(
85
+ `Invalid file.storage: ${JSON.stringify(configured)}. ` +
86
+ `Expected one of ${FILE_STORAGE_DRIVERS.map((d) => `'${d}'`).join(', ')}.`,
87
+ );
88
+ }
89
+ return {
90
+ driver: configured as FileStorageDriver,
91
+ explicit: true,
92
+ reason: `file.storage is set to '${configured}'`,
93
+ };
94
+ }
95
+
96
+ if (hasUsableS3Config(config)) {
97
+ return { driver: 's3', explicit: false, reason: 's3.bucket is configured' };
98
+ }
99
+
100
+ if (hasDatabaseConfig(config)) {
101
+ return { driver: 'gridfs', explicit: false, reason: 'a database is configured and no S3 bucket is' };
102
+ }
103
+
104
+ return {
105
+ driver: 'filesystem',
106
+ explicit: false,
107
+ reason: 'neither an S3 bucket nor a database is configured',
108
+ };
109
+ }
110
+
111
+ /**
112
+ * Fail the boot when the resolved driver cannot actually be used.
113
+ *
114
+ * This is the deliberate opposite of the previous behaviour, which silently fell
115
+ * back to GridFS whenever S3 was selected but unavailable. That failure mode is
116
+ * the worst kind: the application keeps working, so nothing looks broken, while
117
+ * files land in a store the operator does not believe they are in — and no
118
+ * migration path exists afterwards, because nobody knows which file went where.
119
+ *
120
+ * A misconfiguration should stop the process at boot, where it is cheap.
121
+ *
122
+ * Both an explicit and a derived choice are enforced — only the message differs.
123
+ * A derived one is not automatically safe: `s3.bucket` in the config makes S3
124
+ * the default, but the bytes still go nowhere unless the project's own
125
+ * `FileService` forwards `s3Service` to `super()`.
126
+ *
127
+ * @param resolution result of `resolveFileStorage()`
128
+ * @param available whether the driver's backing service reports itself usable
129
+ */
130
+ export function assertFileStorageAvailable(resolution: FileStorageResolution, available: boolean): void {
131
+ if (available) {
132
+ return;
133
+ }
134
+
135
+ if (!resolution.explicit) {
136
+ // A derived driver can still be unusable — the commonest case being an `s3`
137
+ // block that names a bucket while the project's own FileService never
138
+ // forwards `s3Service` to `super()`, so the driver has nothing to write
139
+ // through. Failing here is the point: the alternative is files silently
140
+ // landing in GridFS while the operator reads the config and believes S3.
141
+ throw new Error(
142
+ `File storage '${resolution.driver}' was selected automatically (${resolution.reason}) but is not available. ` +
143
+ 'Either make it available (for S3: forward `{ configService, s3Service }` to `super()` in your FileService ' +
144
+ 'and install `@aws-sdk/client-s3`), or pin a different driver with `file.storage`.',
145
+ );
146
+ }
147
+
148
+ const hint =
149
+ resolution.driver === 's3'
150
+ ? 'Configure `s3` (bucket, credentials/endpoint) and install `@aws-sdk/client-s3`, or choose a different `file.storage`.'
151
+ : `Check the configuration for the '${resolution.driver}' driver, or choose a different \`file.storage\`.`;
152
+
153
+ throw new Error(`file.storage is set to '${resolution.driver}', but that storage is not available. ${hint}`);
154
+ }
155
+
156
+ /**
157
+ * Log the resolved driver once, so the store in use is visible in the boot log
158
+ * rather than having to be inferred from where files stop appearing.
159
+ */
160
+ export function logFileStorage(resolution: FileStorageResolution): void {
161
+ const how = resolution.explicit ? 'configured' : 'defaulted';
162
+ logger.log(`File storage: ${resolution.driver} (${how} — ${resolution.reason})`);
163
+ }
@@ -0,0 +1,184 @@
1
+ import { createReadStream, createWriteStream } from 'fs';
2
+ import { mkdir, rm, stat } from 'fs/promises';
3
+ import { Types } from 'mongoose';
4
+ import * as path from 'path';
5
+ import { pipeline } from 'stream/promises';
6
+ import { Readable } from 'stream';
7
+
8
+ import {
9
+ ensureFilenameIndex,
10
+ FileCollection,
11
+ FileMetadataInfo,
12
+ findMetadata,
13
+ findMetadataById,
14
+ findMetadataByName,
15
+ } from './file-metadata.helper';
16
+ import { streamToBuffer } from './s3-file.helper';
17
+
18
+ /** Metadata collection for files stored on the local filesystem */
19
+ export const FILESYSTEM_FILES_COLLECTION = 'filesystem-files';
20
+
21
+ /** Default directory for the filesystem storage driver */
22
+ export const DEFAULT_FILESYSTEM_DIR = 'uploads/files';
23
+
24
+ /**
25
+ * Metadata of a file stored on the local filesystem.
26
+ *
27
+ * Alias of the shared {@link FileMetadataInfo} — same document as `S3FileInfo` and,
28
+ * in its first six fields, as a GridFS `fs.files` document, which is what lets
29
+ * `prepareOutput()` map all three onto `CoreFileInfo` unchanged.
30
+ */
31
+ export type FilesystemFileInfo = FileMetadataInfo;
32
+
33
+ /**
34
+ * Helper for files stored on the local filesystem with their metadata in MongoDB.
35
+ *
36
+ * Counterpart of `GridFSHelper` and `S3FileHelper`: same operations, the local
37
+ * disk as the byte store and `filesystem-files` as the metadata store.
38
+ *
39
+ * WHY THE METADATA STILL LIVES IN MONGO: filename, content type, length and the
40
+ * custom `metadata` a per-file authorization rule reads have to be queryable —
41
+ * `findFileInfo()` filters and pages over them, and `checkRights()` reads them
42
+ * per request. A directory listing answers none of that, and sidecar files would
43
+ * reinvent an index that the database already is. So this driver moves the
44
+ * BYTES off the database, not the bookkeeping.
45
+ *
46
+ * OPERATIONAL CONSEQUENCE: the directory is pod-local. Two replicas do not share
47
+ * it, and a container restart discards it unless the path is a mounted volume.
48
+ * That makes this the right driver for a single-instance deployment or a real
49
+ * volume, and the wrong one for a horizontally scaled service — use S3 there.
50
+ */
51
+ export class FilesystemFileHelper {
52
+ /**
53
+ * Resolve the absolute path of a stored file.
54
+ *
55
+ * The id is an ObjectId, so its hex form cannot contain a path separator or
56
+ * `..` — the join can never escape the base directory. Callers must not pass
57
+ * a user-supplied filename here.
58
+ */
59
+ static filePath(directory: string, id: string | Types.ObjectId): string {
60
+ const objectId = typeof id === 'string' ? new Types.ObjectId(id) : id;
61
+ return path.join(path.resolve(directory), objectId.toHexString());
62
+ }
63
+
64
+ /**
65
+ * Store a file on disk and record its metadata.
66
+ *
67
+ * The written size is verified via `stat()` before the metadata document is
68
+ * written, so a file info is never returned for bytes that did not fully
69
+ * arrive — the same guarantee `GridFSHelper` gives by reading the file
70
+ * document back, and `S3FileHelper` by issuing a HEAD.
71
+ *
72
+ * On any failure the partial file is removed before the error propagates,
73
+ * otherwise a failed upload would leave an orphan the metadata never names.
74
+ */
75
+ static async writeFile(
76
+ directory: string,
77
+ collection: FileCollection,
78
+ options: {
79
+ body?: Readable;
80
+ buffer?: Buffer;
81
+ contentType?: string;
82
+ filename: string;
83
+ metadata?: Record<string, any>;
84
+ },
85
+ ): Promise<FilesystemFileInfo> {
86
+ const _id = new Types.ObjectId();
87
+ const target = FilesystemFileHelper.filePath(directory, _id);
88
+
89
+ await mkdir(path.dirname(target), { recursive: true });
90
+
91
+ try {
92
+ if (options.buffer) {
93
+ await pipeline(Readable.from(options.buffer), createWriteStream(target));
94
+ } else if (options.body) {
95
+ await pipeline(options.body, createWriteStream(target));
96
+ } else {
97
+ throw new Error('FilesystemFileHelper.writeFile requires either `buffer` or `body`');
98
+ }
99
+
100
+ const stats = await stat(target);
101
+ if (!stats.size && (options.buffer?.length || 0) > 0) {
102
+ throw new Error(`File ${_id.toHexString()} was written empty`);
103
+ }
104
+
105
+ // On the WRITE path, not the read path: createIndex creates the collection, so
106
+ // ensuring it when reading gave a GridFS-only deployment an empty
107
+ // `filesystem-files`. Here the collection is about to exist anyway. Never throws.
108
+ await ensureFilenameIndex(collection);
109
+
110
+ const fileInfo: FilesystemFileInfo = {
111
+ _id,
112
+ contentType: options.contentType,
113
+ filename: options.filename,
114
+ length: stats.size,
115
+ ...(options.metadata ? { metadata: options.metadata } : {}),
116
+ // Records WHERE the bytes went, so a reader never has to probe all three
117
+ // stores to find out. Legacy documents lack it and are handled by probing.
118
+ storage: 'filesystem',
119
+ uploadDate: new Date(),
120
+ };
121
+ await collection.insertOne(fileInfo as any);
122
+ return fileInfo;
123
+ } catch (error) {
124
+ await rm(target, { force: true }).catch(() => undefined);
125
+ throw error;
126
+ }
127
+ }
128
+
129
+ /**
130
+ * Find file metadata by ID
131
+ */
132
+ static async findFileById(
133
+ collection: FileCollection,
134
+ id: string | Types.ObjectId,
135
+ ): Promise<FilesystemFileInfo | null> {
136
+ return findMetadataById(collection, id);
137
+ }
138
+
139
+ /**
140
+ * Find file metadata by filename
141
+ */
142
+ static async findFileByName(collection: FileCollection, filename: string): Promise<FilesystemFileInfo | null> {
143
+ return findMetadataByName(collection, filename);
144
+ }
145
+
146
+ /**
147
+ * Find files with filter and options
148
+ */
149
+ static async findFiles(
150
+ collection: FileCollection,
151
+ filter: any = {},
152
+ options: any = {},
153
+ ): Promise<FilesystemFileInfo[]> {
154
+ return findMetadata(collection, filter, options);
155
+ }
156
+
157
+ /**
158
+ * Delete a file from disk and remove its metadata.
159
+ *
160
+ * The metadata goes last: a missing file with a metadata row answers 404
161
+ * through the normal "file document without bytes" path, while a stored file
162
+ * with no metadata row is invisible to every lookup and can never be cleaned
163
+ * up through the API.
164
+ */
165
+ static async deleteFile(directory: string, collection: FileCollection, id: string | Types.ObjectId): Promise<void> {
166
+ const objectId = typeof id === 'string' ? new Types.ObjectId(id) : id;
167
+ await rm(FilesystemFileHelper.filePath(directory, objectId), { force: true });
168
+ await collection.deleteOne({ _id: objectId });
169
+ }
170
+
171
+ /**
172
+ * Get the download stream of a file
173
+ */
174
+ static getStream(directory: string, id: string | Types.ObjectId): Readable {
175
+ return createReadStream(FilesystemFileHelper.filePath(directory, id));
176
+ }
177
+
178
+ /**
179
+ * Get the content of a file as a buffer
180
+ */
181
+ static async getBuffer(directory: string, id: string | Types.ObjectId): Promise<Buffer> {
182
+ return streamToBuffer(FilesystemFileHelper.getStream(directory, id));
183
+ }
184
+ }
@@ -4,5 +4,19 @@
4
4
  export interface FileServiceOptions {
5
5
  currentUser?: { hasRole: (roles: string[]) => boolean; id: any };
6
6
  force?: boolean;
7
+
8
+ /**
9
+ * Custom GridFS metadata to store alongside the file (create operations only).
10
+ *
11
+ * This is what a per-file authorization rule is meant to be built on: write
12
+ * an owner or tenant here at upload time, then compare against it in an
13
+ * overridden `checkRights()` (use `getRawFileInfo()` to read it back — the
14
+ * public `getFileInfo()` strips restricted fields).
15
+ *
16
+ * `contentType` is reserved: `GridFSHelper.writeFileFromStream` stores the
17
+ * file's content type under that key and will overwrite whatever is passed.
18
+ */
19
+ metadata?: Record<string, any>;
20
+
7
21
  roles?: string | string[];
8
22
  }
@@ -1,10 +1,26 @@
1
1
  import { WriteStream } from 'fs-capacitor';
2
2
  import { Readable } from 'stream';
3
3
 
4
+ /**
5
+ * What a file store actually needs from an upload.
6
+ *
7
+ * `FileUpload` additionally carries the graphql-upload `capacitor`, which no
8
+ * store touches — so requiring the full type would exclude every upload that did
9
+ * not arrive over GraphQL (a multer REST upload being the case in point, see
10
+ * `multerFileToUpload()`). `FileUpload` satisfies this structurally, so both
11
+ * paths share a single service signature.
12
+ */
13
+ export interface FileUploadSource {
14
+ createReadStream: (options?: unknown) => Readable;
15
+ encoding?: string;
16
+ filename: string;
17
+ mimetype: string;
18
+ }
19
+
4
20
  /**
5
21
  * Interface for file uploads
6
22
  */
7
- export interface FileUpload {
23
+ export interface FileUpload extends FileUploadSource {
8
24
  /**
9
25
  * A private implementation detail that shouldn’t be used outside
10
26
  */