@lenne.tech/nest-server 11.41.5 → 11.41.7

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 (34) hide show
  1. package/.claude/rules/configurable-features.md +1 -1
  2. package/.claude/rules/package-management.md +8 -0
  3. package/.claude/rules/testing.md +28 -9
  4. package/CLAUDE.md +1 -1
  5. package/FRAMEWORK-API.md +1 -1
  6. package/dist/core/common/helpers/file.helper.js.map +1 -1
  7. package/dist/core/common/services/core-s3.service.d.ts +8 -0
  8. package/dist/core/common/services/core-s3.service.js +44 -3
  9. package/dist/core/common/services/core-s3.service.js.map +1 -1
  10. package/dist/core/modules/ai/core-ai-mcp.controller.d.ts +1 -0
  11. package/dist/core/modules/ai/core-ai-mcp.controller.js +10 -0
  12. package/dist/core/modules/ai/core-ai-mcp.controller.js.map +1 -1
  13. package/dist/core/modules/file/core-file.service.js +3 -2
  14. package/dist/core/modules/file/core-file.service.js.map +1 -1
  15. package/dist/core/modules/file/interfaces/file-upload.interface.d.ts +1 -0
  16. package/dist/core/modules/file/s3-file.helper.d.ts +1 -0
  17. package/dist/core/modules/file/s3-file.helper.js +10 -4
  18. package/dist/core/modules/file/s3-file.helper.js.map +1 -1
  19. package/dist/tsconfig.build.tsbuildinfo +1 -1
  20. package/docs/REQUEST-LIFECYCLE.md +1 -1
  21. package/docs/security-overrides.md +1 -1
  22. package/migration-guides/11.41.5-to-11.41.6.md +105 -0
  23. package/migration-guides/11.41.6-to-11.41.7.md +192 -0
  24. package/package.json +19 -15
  25. package/src/core/common/helpers/file.helper.ts +4 -0
  26. package/src/core/common/helpers/gridfs.helper.ts +1 -1
  27. package/src/core/common/services/core-s3.service.ts +125 -7
  28. package/src/core/modules/ai/README.md +8 -0
  29. package/src/core/modules/ai/core-ai-mcp.controller.ts +33 -0
  30. package/src/core/modules/file/INTEGRATION-CHECKLIST.md +39 -11
  31. package/src/core/modules/file/README.md +30 -5
  32. package/src/core/modules/file/core-file.service.ts +15 -6
  33. package/src/core/modules/file/interfaces/file-upload.interface.ts +18 -0
  34. package/src/core/modules/file/s3-file.helper.ts +28 -9
@@ -121,6 +121,27 @@ export class CoreAiMcpController implements OnModuleDestroy {
121
121
  this.foreignSession(res, owner);
122
122
  return;
123
123
  }
124
+
125
+ // A session id the client still holds that no replica knows — the state every restart
126
+ // produces, because the transport map is process memory. 404 is the only status a client
127
+ // reacts to by starting a fresh session itself (the spec requires it to), and it is what the
128
+ // SDK's own server returns for a mismatched id.
129
+ //
130
+ // Falling through to a new transport instead, as this did until now, looked like a silent
131
+ // recovery and was not one: after a restart the client's next request is a `tools/call` or
132
+ // `tools/list`, never an `initialize`, so the fresh transport's `validateSession()` answered
133
+ // `400 Bad Request: Server not initialized` — the same dead session, one layer down, and a
134
+ // 400 is a status no client recovers from. Found in a consumer project whose own older copy
135
+ // of this controller had the identical defect: after every deploy, every connected client
136
+ // stayed dead until somebody reconnected by hand.
137
+ //
138
+ // An `initialize` carrying a stale id still gets a new session rather than a 404: a client
139
+ // that keeps sending the old id would otherwise loop. No SDK client does that — it skips
140
+ // `initialize` entirely once its transport has an id — but the case costs one check.
141
+ if (!this.isInitializeRequest(req.body)) {
142
+ res.status(404).json({ error: 'Unknown or expired MCP session' });
143
+ return;
144
+ }
124
145
  }
125
146
 
126
147
  if (!entry) {
@@ -216,6 +237,18 @@ export class CoreAiMcpController implements OnModuleDestroy {
216
237
  return null;
217
238
  }
218
239
 
240
+ /**
241
+ * Whether the body is an `initialize` — the one request that legitimately starts a session.
242
+ *
243
+ * A batch counts when any member is an initialize, matching how the SDK transport reads it.
244
+ */
245
+ protected isInitializeRequest(body: any): boolean {
246
+ if (Array.isArray(body)) {
247
+ return body.some((message: any) => message?.method === 'initialize');
248
+ }
249
+ return body?.method === 'initialize';
250
+ }
251
+
219
252
  /**
220
253
  * Whether the OAuth 2.1 layer is enabled via `ai.mcp.oauth`.
221
254
  */
@@ -88,6 +88,31 @@ If some files are genuinely public and others are not, do not solve it with a ro
88
88
  record a visibility flag in the metadata and branch in `checkRights()`, or expose a separate public
89
89
  route for exactly the public files and leave the core routes gated.
90
90
 
91
+ ### 6. Switching to the S3 driver — in this order
92
+
93
+ Each step fails on its own if skipped, and only the first one fails loudly:
94
+
95
+ 1. **Forward the services to `super()`.** `FileService` must call
96
+ `super(connection, 'fs', { configService, s3Service })`, as the reference implementation does. With
97
+ `super(connection)` the S3 store counts as unavailable and `file.storage: 's3'` fails the boot
98
+ with "that storage is not available".
99
+ 2. **Install the S3 packages as `dependencies`, not `devDependencies`:** `@aws-sdk/client-s3` and
100
+ `@aws-sdk/lib-storage` (same version), plus `@aws-sdk/s3-request-presigner` for
101
+ `s3.presignedDownloads`. A production image installed with `--prod` otherwise boots without them.
102
+ Vendor mode has them installed already (the vendored core references their types) — check which
103
+ section they are in.
104
+ 3. **Count stored files across all three metadata collections.** `fs.files` is GridFS only; the
105
+ filesystem driver records in `filesystem-files` and S3 in `s3-files`. Project code that
106
+ aggregates sizes or counts directly — a quota, a statistic — must cover all three, or it reads 0
107
+ after the switch and the quota never refuses anything. `findFileInfo()` already merges every
108
+ store.
109
+ 4. **Then switch the driver** — `file.storage: 's3'`, or leave it unset and let `s3.bucket` derive
110
+ it. Files written before stay readable: reads consult every store.
111
+
112
+ An `s3` block with a `bucket` turns `CoreS3Service` on whatever `file.storage` says (TUS, where enabled, stages
113
+ uploads in S3 as soon as it is on), so it needs `@aws-sdk/client-s3` at boot. To prepare the block
114
+ without activating it, set `enabled: false`.
115
+
91
116
  ## Pick your project class first — the whole model is one dial with five settings
92
117
 
93
118
  `file.access` is the per-file rule as a DECLARATION instead of code. Until 11.35.0 the framework shipped
@@ -201,14 +226,17 @@ personal or medical, a per-file rule is not optional.
201
226
 
202
227
  ## Common Mistakes
203
228
 
204
- | Mistake | Symptom | Fix |
205
- | -------------------------------------------------- | ---------------------------------------- | ---------------------------------------------------------------------------------------------------------------------- |
206
- | Overriding `getFileById` to add Swagger decorators | `file.downloadRoles` silently ignored | Don't override; document at class level |
207
- | Overriding without re-declaring `@Get(...)` | Route 404s for everyone, no error logged | Don't override |
208
- | `downloadRoles: []` | Warning logged, default applied | Use a non-empty array; `[]` would read as "no roles required" and open the route |
209
- | Expecting `metadata` back from `getFileInfo()` | `undefined` | Use `getRawFileInfo()` inside `checkRights()` |
210
- | `downloadRoles: ['member']` with multiTenancy | Works from code, fails from `<img>` | Both file classes carry `@SkipTenantCheck()`; roles resolve against `user.roles`. Use `checkRights()` for tenant rules |
211
- | Signed-in user can upload via TUS but not download | 403 on their own file | `tus.roles` and `file.downloadRoles` are separate. Add an owner to the metadata and authorize per file |
212
- | Rule narrows only `'id'` / `'filename'` | `findFileInfo()` returns every file | Refuse `'filterArgs'`; force a per-user filter server-side with `{ force: true }` |
213
- | Treating the file id as unguessable | Enumerable inventory | Ids share a per-process random part and an incrementing counter — authorize every read, do not rely on the id |
214
- | Approving the caller's `filterArgs` as "narrowed" | Bypass via a different filter shape | `filterArgs` is client-controlled. Override the filter; never approve it |
229
+ | Mistake | Symptom | Fix |
230
+ | -------------------------------------------------------------- | ------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------- |
231
+ | Overriding `getFileById` to add Swagger decorators | `file.downloadRoles` silently ignored | Don't override; document at class level |
232
+ | Overriding without re-declaring `@Get(...)` | Route 404s for everyone, no error logged | Don't override |
233
+ | `downloadRoles: []` | Warning logged, default applied | Use a non-empty array; `[]` would read as "no roles required" and open the route |
234
+ | Expecting `metadata` back from `getFileInfo()` | `undefined` | Use `getRawFileInfo()` inside `checkRights()` |
235
+ | `downloadRoles: ['member']` with multiTenancy | Works from code, fails from `<img>` | Both file classes carry `@SkipTenantCheck()`; roles resolve against `user.roles`. Use `checkRights()` for tenant rules |
236
+ | Signed-in user can upload via TUS but not download | 403 on their own file | `tus.roles` and `file.downloadRoles` are separate. Add an owner to the metadata and authorize per file |
237
+ | Rule narrows only `'id'` / `'filename'` | `findFileInfo()` returns every file | Refuse `'filterArgs'`; force a per-user filter server-side with `{ force: true }` |
238
+ | Treating the file id as unguessable | Enumerable inventory | Ids share a per-process random part and an incrementing counter — authorize every read, do not rely on the id |
239
+ | Approving the caller's `filterArgs` as "narrowed" | Bypass via a different filter shape | `filterArgs` is client-controlled. Override the filter; never approve it |
240
+ | `super(connection)` in `FileService` with `file.storage: 's3'` | Boot fails: "that storage is not available" | Forward `{ configService, s3Service }` — step 6 |
241
+ | S3 packages in `devDependencies` | Production image cannot load S3 | Move them to `dependencies` — step 6 |
242
+ | A quota aggregating only `fs.files` | Reads 0 on the S3 / filesystem driver | Aggregate `fs.files`, `filesystem-files` and `s3-files` — step 6 |
@@ -45,11 +45,11 @@ file: {
45
45
 
46
46
  Three equivalent options. They differ only in where the bytes end up:
47
47
 
48
- | Driver | Bytes | Survives a restart | Shared between replicas | Needs |
49
- | -------------- | ------------------------- | ------------------------ | ----------------------- | ---------------------------------- |
50
- | `'s3'` | S3-compatible bucket | yes | **yes** | `s3` config + `@aws-sdk/client-s3` |
51
- | `'gridfs'` | MongoDB GridFS | yes | yes | nothing beyond the database |
52
- | `'filesystem'` | local disk (`storageDir`) | only on a mounted volume | **no** | nothing |
48
+ | Driver | Bytes | Survives a restart | Shared between replicas | Needs |
49
+ | -------------- | ------------------------- | ------------------------ | ----------------------- | ------------------------------------------------------------------------ |
50
+ | `'s3'` | S3-compatible bucket | yes | **yes** | `s3` config + `@aws-sdk/client-s3` (+ `@aws-sdk/lib-storage`, see below) |
51
+ | `'gridfs'` | MongoDB GridFS | yes | yes | nothing beyond the database |
52
+ | `'filesystem'` | local disk (`storageDir`) | only on a mounted volume | **no** | nothing |
53
53
 
54
54
  **Metadata always lives in the database**, whichever driver holds the bytes. Filename, content type,
55
55
  length and the custom `metadata` a per-file rule reads have to be queryable — `findFileInfo()` filters
@@ -57,6 +57,31 @@ and pages over them, `checkRights()` reads them per request. A directory listing
57
57
  that, and sidecar files would reinvent an index the database already is. So `'filesystem'` moves the
58
58
  bytes off the database, not the bookkeeping.
59
59
 
60
+ ### How an upload reaches S3
61
+
62
+ No upload is read into memory on its way to S3. Which request carries it depends on whether its
63
+ length is known:
64
+
65
+ | Length | Request | Memory |
66
+ | ----------------------------------------------------------------- | ----------------------------------------------------------------------------------- | ------------------------ |
67
+ | known, up to 5 GiB — `size` on the upload, a TUS upload, a buffer | one streamed `PutObject` | constant |
68
+ | known, above 5 GiB | multipart upload via `@aws-sdk/lib-storage` (AWS S3 refuses larger single requests) | at most four parts |
69
+ | unknown — every GraphQL and streamed REST upload | multipart upload via `@aws-sdk/lib-storage` | at most four 5 MiB parts |
70
+ | unknown, and `@aws-sdk/lib-storage` not installed | one `PutObject` of the whole file, read into memory first | **the file's size** |
71
+
72
+ Install the optional peer next to `@aws-sdk/client-s3` (`pnpm add @aws-sdk/lib-storage`, same
73
+ version). Without it uploads still work, but a large one of unknown length costs its own size in
74
+ memory — a multi-GB file then gets the container OOM-killed — and a known length above 5 GiB goes up
75
+ as one request, which AWS S3 refuses (MinIO and RustFS accept it). The boot log says so once when
76
+ the package is missing.
77
+
78
+ Pass `size` on a `FileUploadSource` only when it is the file's EXACT length — a `stat()` of a file on
79
+ disk. Never pass the `Content-Length` of a multipart/form-data request: it counts the whole envelope
80
+ and is always larger than the file, and a wrong length breaks the upload. Leaving `size` out costs
81
+ nothing but the multipart round trips, and for data already in memory it is the better choice: the
82
+ SDK retries the Buffer parts of a multipart upload after a transient S3 error, but not one streamed
83
+ request.
84
+
60
85
  ### Choosing the driver
61
86
 
62
87
  **Set it explicitly and it is enforced.** If the chosen store is not available, the boot **fails**:
@@ -31,7 +31,7 @@ import {
31
31
  FilesystemFileHelper,
32
32
  FilesystemFileInfo,
33
33
  } from './filesystem-file.helper';
34
- import { S3_FILES_COLLECTION, S3FileHelper, S3FileInfo, streamToBuffer } from './s3-file.helper';
34
+ import { S3_FILES_COLLECTION, S3FileHelper, S3FileInfo } from './s3-file.helper';
35
35
 
36
36
  /**
37
37
  * Type for checking input
@@ -160,7 +160,7 @@ export abstract class CoreFileService {
160
160
  if (!(await this.checkRights(file, { ...serviceOptions, checkInputType: 'file' }))) {
161
161
  return null;
162
162
  }
163
- const { createReadStream, filename, mimetype } = await file;
163
+ const { createReadStream, filename, mimetype, size } = await file;
164
164
  const readStream = createReadStream();
165
165
  // Resolved ONCE for all three driver branches: under an `'owner'` / `'tenant'` preset this adds
166
166
  // the very fields the preset decides on, so an upload through this service is authorizable
@@ -176,11 +176,20 @@ export abstract class CoreFileService {
176
176
  return this.prepareOutput(fsFileInfo as unknown as CoreFileInfo, serviceOptions);
177
177
  }
178
178
  if (this.storageDriver === 's3') {
179
- // Only buffer when the size is unknown: a GraphQL upload stream carries no length, and the
180
- // S3 SDK needs one. Callers that know it (TUS) pass `body` + `contentLength` instead and
181
- // stream straight through — see S3FileHelper.writeFile.
179
+ // The stream is handed to S3 as it is, never read into memory here. With a `size` the object
180
+ // goes up as one streamed PutObject; without one, `CoreS3Service.putObject()` uses a multipart
181
+ // upload, which needs no total up front (and only buffers when its optional peer dependency
182
+ // is missing). Reading the stream into a Buffer at this point — what this branch used to do
183
+ // for every upload — materialised the whole file in one process: a multi-GB upload then
184
+ // threw "Array buffer allocation failed" or got the container OOM-killed at 100 % progress,
185
+ // taking every other in-flight request with it.
186
+ //
187
+ // A GraphQL or streamed REST upload carries no trustworthy length (a multipart request's
188
+ // `Content-Length` counts the whole envelope), so the unknown-length case is the common one,
189
+ // not a fallback.
182
190
  const s3FileInfo = await S3FileHelper.writeFile(this.options.s3Service, this.s3Files, {
183
- buffer: await streamToBuffer(readStream),
191
+ body: readStream,
192
+ contentLength: size, // validated in writeFile(): anything but a positive integer counts as unknown
184
193
  contentType: mimetype,
185
194
  filename,
186
195
  ...(metadata ? { metadata } : {}),
@@ -15,6 +15,24 @@ export interface FileUploadSource {
15
15
  encoding?: string;
16
16
  filename: string;
17
17
  mimetype: string;
18
+
19
+ /**
20
+ * EXACT size of the file in bytes, when the caller knows it — otherwise leave it out.
21
+ *
22
+ * Only the S3 driver reads it: with a size the object goes up as one streamed request (as a
23
+ * multipart upload above 5 GiB, which AWS S3 refuses in one request), without one it goes up as a
24
+ * multipart upload (`@aws-sdk/lib-storage`, an optional peer — without it the
25
+ * file is read into memory). Omitting it is not a fallback: a GraphQL or streamed REST upload never
26
+ * knows its length, and that is the common case.
27
+ *
28
+ * A WRONG size breaks the upload instead of slowing it down. So never pass the `Content-Length` of a
29
+ * multipart/form-data request: it counts the whole envelope (boundaries, part headers, other
30
+ * fields) and is always larger than the file. Pass it only when it is the file's own length — a
31
+ * TUS upload's `upload.size`, a `stat()` of a file on disk. For data already in memory leave it
32
+ * out: the SDK retries the Buffer parts of a multipart upload after a transient S3 error, but not
33
+ * one streamed request. Anything but a positive integer is treated as unknown.
34
+ */
35
+ size?: number;
18
36
  }
19
37
 
20
38
  /**
@@ -30,6 +30,20 @@ export const S3_FILES_COLLECTION = 's3-files';
30
30
  */
31
31
  export type S3FileInfo = FileMetadataInfo;
32
32
 
33
+ /**
34
+ * Whether an upload size can be handed to S3 as the object's `Content-Length`.
35
+ *
36
+ * Only a positive safe integer qualifies. Anything else — `undefined`, `0`, a negative number,
37
+ * `NaN`, a fraction — is treated as "length unknown" and goes through the multipart path, which
38
+ * needs no total. The direction matters: a WRONG length is not a slower upload but a broken one,
39
+ * whereas an unknown length only costs the multipart round trips. `0` is included in "unknown" because it is
40
+ * what an unset numeric field defaults to far more often than it is a real empty file — and an
41
+ * empty file stores correctly through the multipart path too.
42
+ */
43
+ export function isKnownUploadSize(size: unknown): size is number {
44
+ return typeof size === 'number' && Number.isSafeInteger(size) && size > 0;
45
+ }
46
+
33
47
  /**
34
48
  * Read a stream completely into a buffer
35
49
  */
@@ -70,21 +84,22 @@ export class S3FileHelper {
70
84
  const _id = new Types.ObjectId();
71
85
  const key = _id.toHexString();
72
86
 
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.
87
+ // A stream is never read into a Buffer here. Doing so — which every caller used to do —
88
+ // materialises the WHOLE file in one process: a 4 GB resumable upload (well inside the 50 GB
89
+ // default cap) then either throws "Array buffer allocation failed" or gets the container
90
+ // OOM-killed at 100% progress, taking every other in-flight request with it. With a known
91
+ // length the stream goes up as one PutObject; without one, `putObject()` uploads it in parts.
78
92
  const body = options.body ?? options.buffer;
79
93
  if (!body) {
80
94
  throw new Error('S3FileHelper.writeFile needs either a body stream or a buffer');
81
95
  }
82
- await s3Service.putObject(key, body, options.contentType, options.contentLength ?? options.buffer?.length);
96
+ const contentLength = isKnownUploadSize(options.contentLength) ? options.contentLength : options.buffer?.length;
97
+ await s3Service.putObject(key, body, options.contentType, contentLength);
83
98
 
84
99
  return S3FileHelper.recordFile(s3Service, collection, _id, {
85
100
  contentType: options.contentType,
86
101
  filename: options.filename,
87
- length: options.contentLength ?? options.buffer?.length,
102
+ length: contentLength,
88
103
  metadata: options.metadata,
89
104
  });
90
105
  }
@@ -134,7 +149,11 @@ export class S3FileHelper {
134
149
  _id: Types.ObjectId,
135
150
  options: { contentType?: string; filename: string; length?: number; metadata?: Record<string, any> },
136
151
  ): Promise<S3FileInfo> {
137
- if (!(await s3Service.objectExists(_id.toHexString()))) {
152
+ // One HEAD answers both questions: whether the bytes arrived, and — for a stream whose length
153
+ // nobody knew up front — how many there are. The stored object is the authority on its size;
154
+ // a length guessed by the caller (a multipart request's `Content-Length`, say) is not.
155
+ const stat = await s3Service.statObject(_id.toHexString());
156
+ if (!stat) {
138
157
  throw new Error('File uploaded but not found in S3');
139
158
  }
140
159
 
@@ -147,7 +166,7 @@ export class S3FileHelper {
147
166
  _id,
148
167
  contentType: options.contentType,
149
168
  filename: options.filename,
150
- length: options.length,
169
+ length: options.length ?? stat.contentLength,
151
170
  metadata: options.metadata,
152
171
  // Records WHERE the bytes went, so a reader never has to probe all three
153
172
  // stores to find out. Legacy documents lack it and are handled by probing.