@lenne.tech/nest-server 11.41.6 → 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 (33) 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 +12 -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.6-to-11.41.7.md +192 -0
  23. package/package.json +18 -14
  24. package/src/core/common/helpers/file.helper.ts +4 -0
  25. package/src/core/common/helpers/gridfs.helper.ts +1 -1
  26. package/src/core/common/services/core-s3.service.ts +125 -7
  27. package/src/core/modules/ai/README.md +8 -0
  28. package/src/core/modules/ai/core-ai-mcp.controller.ts +33 -0
  29. package/src/core/modules/file/INTEGRATION-CHECKLIST.md +39 -11
  30. package/src/core/modules/file/README.md +30 -5
  31. package/src/core/modules/file/core-file.service.ts +15 -6
  32. package/src/core/modules/file/interfaces/file-upload.interface.ts +18 -0
  33. package/src/core/modules/file/s3-file.helper.ts +28 -9
@@ -42,7 +42,7 @@ The `CoreModule` is a dynamic module that bootstraps the entire framework:
42
42
  | **Mongoose Plugins** | Auto-registration of ID, password, audit, and role guard plugins |
43
43
  | **GraphQL Subscriptions** | WebSocket support with JWT/session authentication (cluster-wide when `redis` is configured — see the Subscriptions row under GraphQL Features) |
44
44
  | **Central Redis (optional)** | `CoreRedisService` — globally provided **and exported** by `CoreModule`, always present but **inert unless `redis` is configured** ("presence implies enabled"). Injected with `@Optional()`; every consumer keeps a process-local fallback. One service serves all features: shared client (`getClient()`), one cached subscriber (`getSubscriber()` — a subscribing client cannot run commands), dedicated connections (`createClient(label)`); all tracked and quit on shutdown. Keys are namespaced by the framework per key via `key(...)`, **not** through ioredis's own `keyPrefix` (that would collide with BullMQ's prefix). Requires the OPTIONAL peer `ioredis` — configured but missing **fails the boot**. Switches on: exact cross-replica rate limits, cron deduplication, `CoreRedisPubSub` as `PUB_SUB`, tenant-cache invalidation broadcast, Hub collector mirroring, MCP session registry |
45
- | **Central S3 (optional)** | `CoreS3Service` — globally provided **and exported** by `CoreModule`, inert unless `s3` is configured *and* names a `bucket` (a bucket-less block is ignored with a warning). Backs `file.storage: 's3'` and TUS staging (`tus.s3Staging`). Requires the OPTIONAL peers `@aws-sdk/client-s3` (+ `@aws-sdk/s3-request-presigner` for `presignedDownloads`) — configured but missing **fails the boot** |
45
+ | **Central S3 (optional)** | `CoreS3Service` — globally provided **and exported** by `CoreModule`, inert unless `s3` is configured *and* names a `bucket` (a bucket-less block is ignored with a warning). Backs `file.storage: 's3'` and TUS staging (`tus.s3Staging`). Requires the OPTIONAL peers `@aws-sdk/client-s3` (+ `@aws-sdk/s3-request-presigner` for `presignedDownloads`) — configured but missing **fails the boot**. `@aws-sdk/lib-storage` (optional) uploads streams of unknown length in parts; without it they are buffered in memory, with one boot warning |
46
46
  | **Configuration System** | `config.env.ts` with ENV variables, `NEST_SERVER_CONFIG` JSON, `NSC__*` prefixes |
47
47
  | **Cookie Handling** | Enabled by default (`cookies: true`), configurable via `ICookiesConfig` with `exposeTokenInBody` option |
48
48
  | **Unified CORS** | Single `cors` config propagates to GraphQL, REST, and BetterAuth layers |
@@ -18,7 +18,7 @@ The framework pulls in three transitive packages that its `@nestjs/*` dependenci
18
18
  |---------|----------|------------------------------------------|
19
19
  | `ws` | [GHSA-96hv-2xvq-fx4p](https://github.com/advisories/GHSA-96hv-2xvq-fx4p) — high: memory-exhaustion DoS + uninitialized memory disclosure. Patched `>=8.21.0` | Older `@nestjs/graphql` releases declare `"ws": "8.20.1"` — an **exact pin**, not a caret. 13.4.5, the version this framework declares, pins `8.21.3` |
20
20
  | `js-yaml` | [GHSA-pm4m-ph32-ghv5](https://github.com/advisories/GHSA-pm4m-ph32-ghv5) — high: exponential parsing time in flow collections (DoS), patched `>=5.2.2`; [GHSA-r3ph-w7gj-g6xm](https://github.com/advisories/GHSA-r3ph-w7gj-g6xm) — moderate: `maxTotalMergeKeys` does not bound CPU use for empty merge sources, covers `<=5.4.0`, patched `>=5.4.1` (published 2026-09-29) | `@nestjs/swagger` declares js-yaml as an **exact pin**, the same shape as the `ws` case: older releases `5.2.1`, 11.4.7 — the version this framework declares, and the newest 11.x — `5.3.0`. No swagger update reaches the fix, so **this entry is load-bearing again** |
21
- | `multer` | [GHSA-wc9g-mqfw-jrwm](https://github.com/advisories/GHSA-wc9g-mqfw-jrwm), [GHSA-535w-7cp7-47q4](https://github.com/advisories/GHSA-535w-7cp7-47q4), [GHSA-qfvm-cv95-jqjf](https://github.com/advisories/GHSA-qfvm-cv95-jqjf) — high: DoS via crafted multipart input; [GHSA-qvfw-j98x-7q72](https://github.com/advisories/GHSA-qvfw-j98x-7q72) — low: file size limit bypass. Patched `>=2.3.0` | `@nestjs/platform-express` up to 11.2.5 declares `"multer": "2.2.0"` — an **exact pin**. A direct `multer` dependency does not move it: you get both copies, and FileInterceptor uses the vulnerable one. 11.2.6, the version this framework declares since 11.41.4, pins `2.4.0` |
21
+ | `multer` | [GHSA-wc9g-mqfw-jrwm](https://github.com/advisories/GHSA-wc9g-mqfw-jrwm), [GHSA-535w-7cp7-47q4](https://github.com/advisories/GHSA-535w-7cp7-47q4), [GHSA-qfvm-cv95-jqjf](https://github.com/advisories/GHSA-qfvm-cv95-jqjf) — high: DoS via crafted multipart input; [GHSA-qvfw-j98x-7q72](https://github.com/advisories/GHSA-qvfw-j98x-7q72) — low: file size limit bypass. Patched `>=2.3.0` | `@nestjs/platform-express` up to 11.2.5 declares `"multer": "2.2.0"` — an **exact pin**. A direct `multer` dependency does not move it: you get both copies, and FileInterceptor uses the vulnerable one. 11.2.6, which this framework declared from 11.41.4, pins `2.4.0`, and so does 11.2.7, declared since 11.41.7 |
22
22
 
23
23
  **Status since 11.41.5:** `ws` and `multer` resolve to a patched version on their own with the
24
24
  `@nestjs/*` versions this framework declares; their entries below are insurance for a project whose
@@ -0,0 +1,192 @@
1
+ # Migration Guide: 11.41.6 → 11.41.7
2
+
3
+ ## Overview
4
+
5
+ | Category | Details |
6
+ | --------------------- | ------------------------------------------------------------------------------------------- |
7
+ | **Breaking Changes** | None in npm mode. Vendor mode: one package to install (§1) |
8
+ | **Bugfixes** | Uploads to S3 are no longer read into memory — a large upload could OOM-kill the container. A known length above 5 GiB (TUS) no longer fails on AWS S3. MCP clients reconnect on their own after a restart (§4) |
9
+ | **New optional peer** | `@aws-sdk/lib-storage` — uploads of unknown length go to S3 in parts |
10
+ | **New field** | `FileUploadSource.size` — the exact file length, when the caller knows it |
11
+ | **Migration Effort** | `pnpm run update` (raises the `@nestjs/*` pins, §6). Projects using the S3 driver: install one package. Starter-based projects: assess two dev-only advisories (§5) |
12
+
13
+ ## Quick Migration
14
+
15
+ ```bash
16
+ pnpm update @lenne.tech/nest-server
17
+ pnpm run update # starter-based projects: raises the @nestjs/* pins to 11.2.7 (§6)
18
+ pnpm add @aws-sdk/lib-storage@<your @aws-sdk/client-s3 version> # only with file.storage 's3'
19
+ pnpm run build && pnpm test
20
+ ```
21
+
22
+ **Vendor-mode projects must install it, whether they use S3 or not.** The vendored core is compiled
23
+ as project code, and `core-s3.service.ts` now references the types of `@aws-sdk/lib-storage` — the
24
+ same way it already references `@aws-sdk/client-s3` and `@aws-sdk/s3-request-presigner`. Without the
25
+ package the typecheck fails with `Cannot find module '@aws-sdk/lib-storage'`. A project converted to
26
+ vendor mode from now on gets it from the CLI's import scan; the core update of an EXISTING vendor
27
+ project does not add packages, so add it there yourself, next to `@aws-sdk/client-s3`, at the same
28
+ version.
29
+
30
+ ## What's New in 11.41.7
31
+
32
+ ### 1. Uploads reach S3 as a stream
33
+
34
+ Until now the S3 driver read every upload into a Buffer before sending it: `createFile()` did so for
35
+ every GraphQL and multer upload, and `CoreS3Service.putObject()` for every stream without a length.
36
+ The memory cost of an upload was therefore its size. A multi-GB file threw
37
+ `Array buffer allocation failed` or got the container OOM-killed at 100 % progress — taking every
38
+ other request on that replica with it.
39
+
40
+ Now nothing on that path buffers:
41
+
42
+ | Length | Request | Memory |
43
+ | ------ | ------- | ------ |
44
+ | known, up to 5 GiB — `size` on the upload, a TUS upload, a buffer | one streamed `PutObject` | constant |
45
+ | known, above 5 GiB | multipart upload via `@aws-sdk/lib-storage` | at most four parts |
46
+ | unknown — every GraphQL and streamed REST upload | multipart upload via `@aws-sdk/lib-storage` | at most four 5 MiB parts |
47
+ | `@aws-sdk/lib-storage` not installed | as before: an unknown length is read into memory, a known one is one `PutObject` | the file's size (unknown length) |
48
+
49
+ The second row is a fix in its own right: AWS S3 refuses a single `PutObject` above 5 GiB
50
+ (`EntityTooLarge`). A TUS upload that large — the default `tus.maxSize` is 50 GiB — used to fail at
51
+ finalization, after the client had sent every byte, on every retry. MinIO and RustFS accept such a
52
+ request, which is why test setups never showed it.
53
+
54
+ The length recorded for an upload of unknown length is read back from the stored object.
55
+
56
+ **What to do:** with `file.storage: 's3'` (or an `s3` config that makes it the derived driver),
57
+ install `@aws-sdk/lib-storage` at the same version as your `@aws-sdk/client-s3`:
58
+
59
+ ```bash
60
+ pnpm add @aws-sdk/lib-storage@3.1145.0 # match your @aws-sdk/client-s3
61
+ ```
62
+
63
+ Without it nothing breaks — uploads keep the previous behaviour, and the boot log says once:
64
+
65
+ ```
66
+ Streaming an S3 upload of unknown length falls back to buffering it in memory because the optional
67
+ peer dependency "@aws-sdk/lib-storage" is not installed. Run: pnpm add @aws-sdk/lib-storage
68
+ ```
69
+
70
+ Use an S3-compatible store that supports multipart uploads (AWS S3, MinIO and RustFS do). Give the
71
+ bucket a lifecycle rule that aborts incomplete multipart uploads: a failed upload is aborted by the
72
+ framework, but a process killed mid-upload leaves its parts behind, and S3 keeps them until something
73
+ removes them.
74
+
75
+ ### 2. `FileUploadSource.size`
76
+
77
+ A caller that knows the file's EXACT length can say so, and the S3 driver then uses one streamed
78
+ request instead of a multipart upload (up to 5 GiB; above that it stays a multipart upload, with
79
+ parts sized to the total):
80
+
81
+ ```typescript
82
+ await fileService.createFile({
83
+ createReadStream: () => createReadStream(path),
84
+ filename,
85
+ mimetype,
86
+ size: (await stat(path)).size,
87
+ });
88
+ ```
89
+
90
+ Leaving it out is always safe — it only costs the multipart round trips — and for data that is
91
+ already in memory it is the better choice: the multipart path sends Buffer parts, which the SDK
92
+ retries after a transient S3 error, while one streamed request is attempted once.
93
+ `multerFileToUpload()` therefore does not set it.
94
+
95
+ **Never pass the `Content-Length` of a multipart/form-data request.** It counts the whole envelope
96
+ (boundaries, part headers, other fields) and is always larger than the file, and a wrong length
97
+ breaks the upload. Anything but a positive integer is treated as unknown.
98
+
99
+ ### 3. `CoreS3Service.statObject()`
100
+
101
+ New: size and content type of an object, or `null` when it does not exist — one HEAD request.
102
+ `objectExists()` now delegates to it.
103
+
104
+ ### 4. MCP clients recover from a restart
105
+
106
+ `/ai/mcp` keeps its sessions in process memory, so every restart — every deploy — forgets the
107
+ session ids its clients still hold. A request carrying such an id used to get a fresh transport,
108
+ which answered the client's next call (a `tools/call`, never an `initialize`) with
109
+ `400 Bad Request: Server not initialized`. Clients do not recover from a 400: every connected
110
+ client stayed dead until somebody reconnected it by hand.
111
+
112
+ Such a request now gets **404** `Unknown or expired MCP session` — the status the MCP specification
113
+ tells clients to answer with a new `initialize`, and what the SDK's own server returns. An
114
+ `initialize` carrying a stale id still starts a new session. With `redis` configured, a session
115
+ that lives on another replica of the same user still answers **409**, as before.
116
+
117
+ Nothing to do. A client or proxy that treated the old 400 as "session gone" sees 404 instead.
118
+
119
+ ### 5. Two advisories without a fix in the dev tooling
120
+
121
+ Two HIGH advisories surfaced in the audit on 2026-10-03, and neither has a patched release
122
+ anywhere yet:
123
+
124
+ | Advisory | Package | Reaches a project through |
125
+ | -------- | ------- | ------------------------- |
126
+ | GHSA-ch52-4w7c-c8xp | `http-cache-semantics` <= 4.2.0 | `@swc/cli` (also via `@nestjs/cli`) — its binary downloader |
127
+ | GHSA-vfj7-8cjw-p6xm | `braces` <= 3.0.3 | `nodemon` > `chokidar@3`; `cpy-cli` > `micromatch` |
128
+
129
+ Both paths are dev tooling, and no override can help while no fixed version exists. So
130
+ `pnpm audit` — and every `check` that runs it — is red in a starter-based project until you
131
+ assess them.
132
+
133
+ **What to do:** confirm neither package is in your production tree
134
+ (`pnpm why http-cache-semantics --prod`, `pnpm why braces --prod` — both must print nothing),
135
+ then suppress them in `pnpm-workspace.yaml` with the reasoning next to each entry. The starter's
136
+ file shows the shape:
137
+
138
+ ```yaml
139
+ auditConfig:
140
+ ignoreGhsas:
141
+ - GHSA-ch52-4w7c-c8xp # http-cache-semantics, dev-only via @swc/cli
142
+ - GHSA-vfj7-8cjw-p6xm # braces, dev-only via nodemon / cpy-cli
143
+ ```
144
+
145
+ A suppression hides the advisory everywhere and for good, so nest-server-starter 11.41.7 also
146
+ ships the two guards that keep one honest: `scripts/check-overrides.mjs` (wired into `check` as
147
+ `check:overrides`; it fails once a patched version exists) and
148
+ `tests/unit/audit-suppression-scope.spec.ts` (fails once a suppressed package reaches the
149
+ production tree). Copy both along with the entries. A project generated as a monorepo already
150
+ has the first one at its root, from lt-monorepo.
151
+
152
+ ### 6. Dependency updates
153
+
154
+ | Package | 11.41.6 | 11.41.7 |
155
+ | ------- | ------- | ------- |
156
+ | `@nestjs/common`, `@nestjs/core`, `@nestjs/platform-express` | 11.2.6 | **11.2.7** |
157
+ | dev: `@nestjs/testing` | 11.2.6 | 11.2.7 |
158
+ | dev / optional peers: `@aws-sdk/client-s3`, `@aws-sdk/lib-storage` (new), `@aws-sdk/s3-request-presigner` | 3.1143.0 | 3.1145.0 |
159
+ | dev: `@swc/core`, `@types/node`, `bullmq`, `vite` | 1.16.12, 26.6.3, 6.3.10, 8.3.1 | 1.16.13, 26.6.4, 6.3.11, 8.3.2 |
160
+
161
+ **Raise the `@nestjs/*` pins together with the framework.** Starter-based projects:
162
+ `pnpm run update` does it. A project that pins them itself must move all four to 11.2.7 — a
163
+ project still on 11.2.6 next to the framework's 11.2.7 gets two copies of `@nestjs/common`, and
164
+ then `CronJobs extends CoreCronJobs` stops type-checking with an error that does not name the cause.
165
+
166
+ ## Compatibility Notes
167
+
168
+ | Pattern | Status |
169
+ | ------- | ------ |
170
+ | GraphQL / multer uploads with the S3 driver | Streamed — multipart with `@aws-sdk/lib-storage`, buffered without it |
171
+ | `CoreS3Service.putObject(key, stream)` without a length | Same as above |
172
+ | `CoreS3Service.putObject(key, stream, type, length)` | Unchanged up to 5 GiB: one streamed `PutObject`. Above: multipart (with `@aws-sdk/lib-storage`) |
173
+ | TUS uploads | Unchanged up to 5 GiB. Above, on AWS S3: fixed — they failed at finalization with `EntityTooLarge`, now multipart (with `@aws-sdk/lib-storage`) |
174
+ | GridFS and filesystem drivers | Unchanged — they always streamed |
175
+ | A subclass overriding `CoreS3Service.collect()` | Called only when `@aws-sdk/lib-storage` is missing |
176
+ | `@nestjs/common` / `core` / `platform-express` / `testing` pinned at 11.2.6 | Raise to 11.2.7 with the framework (§6) |
177
+ | An MCP request with a session id the process no longer knows | 404 instead of 400 — the client re-initializes (§4) |
178
+ | `pnpm audit` red on GHSA-ch52-4w7c-c8xp / GHSA-vfj7-8cjw-p6xm | No fix exists yet; dev-only — suppress with the guards (§5) |
179
+
180
+ ## Troubleshooting
181
+
182
+ | Symptom | Cause | Fix |
183
+ | ------- | ----- | --- |
184
+ | Boot warning about `@aws-sdk/lib-storage` | The optional peer is not installed | `pnpm add @aws-sdk/lib-storage` |
185
+ | Peer warning: `@aws-sdk/lib-storage` wants a newer `@aws-sdk/client-s3` | The two packages are on different AWS SDK releases | Install both at the same version |
186
+ | S3 upload fails or never completes when `size` is passed | `size` is not the file's length — e.g. a request's `Content-Length` | Pass the file's own length, or leave `size` out |
187
+
188
+ ## Module Documentation
189
+
190
+ - [File module README](../src/core/modules/file/README.md) — "How an upload reaches S3"
191
+ - [File module INTEGRATION-CHECKLIST](../src/core/modules/file/INTEGRATION-CHECKLIST.md) — step 6, switching to the S3 driver
192
+ - [AI module README](../src/core/modules/ai/README.md) — MCP
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lenne.tech/nest-server",
3
- "version": "11.41.6",
3
+ "version": "11.41.7",
4
4
  "description": "Modern, fast, powerful Node.js web framework in TypeScript based on Nest with a GraphQL API and a connection to MongoDB (or other databases).",
5
5
  "keywords": [
6
6
  "node",
@@ -46,9 +46,8 @@
46
46
  "prestart:prod": "pnpm run build",
47
47
  "preversion": "pnpm run lint",
48
48
  "reinit": "rimraf pnpm-lock.yaml && rimraf node_modules && pnpm install && pnpm run lint && pnpm run test:e2e && pnpm run test:ci && pnpm run build",
49
- "reinit:clean": "rimraf pnpm-lock.yaml && rimraf node_modules && pnpm store prune && pnpm install && pnpm run test:e2e && pnpm run build",
50
- "reinit:force": "rimraf pnpm-lock.yaml && rimraf node_modules && pnpm store prune && pnpm install --force && pnpm run test:e2e",
51
- "reinit:legacy": "rimraf pnpm-lock.yaml && rimraf node_modules && pnpm store prune && pnpm install && pnpm run test:e2e",
49
+ "reinit:clean": "rimraf pnpm-lock.yaml && rimraf node_modules && pnpm install && pnpm run test:e2e && pnpm run build",
50
+ "reinit:legacy": "rimraf pnpm-lock.yaml && rimraf node_modules && pnpm install && pnpm run test:e2e",
52
51
  "spectaql:sync": "node extras/update-spectaql-version.mjs",
53
52
  "start": "pnpm run start:local",
54
53
  "start:debug": "nodemon --config nodemon-debug.json",
@@ -95,13 +94,13 @@
95
94
  "@getbrevo/brevo": "6.0.3",
96
95
  "@modelcontextprotocol/sdk": "1.31.0",
97
96
  "@nestjs/apollo": "13.4.5",
98
- "@nestjs/common": "11.2.6",
99
- "@nestjs/core": "11.2.6",
97
+ "@nestjs/common": "11.2.7",
98
+ "@nestjs/core": "11.2.7",
100
99
  "@nestjs/graphql": "13.4.5",
101
100
  "@nestjs/jwt": "11.0.2",
102
101
  "@nestjs/mongoose": "11.0.4",
103
102
  "@nestjs/passport": "11.0.5",
104
- "@nestjs/platform-express": "11.2.6",
103
+ "@nestjs/platform-express": "11.2.7",
105
104
  "@nestjs/schedule": "6.1.3",
106
105
  "@nestjs/swagger": "11.4.7",
107
106
  "@nestjs/terminus": "11.1.1",
@@ -143,6 +142,7 @@
143
142
  },
144
143
  "peerDependencies": {
145
144
  "@aws-sdk/client-s3": ">=3.1045.0 <4",
145
+ "@aws-sdk/lib-storage": ">=3.1045.0 <4",
146
146
  "@aws-sdk/s3-request-presigner": ">=3.1045.0 <4",
147
147
  "@better-auth/core": ">=1.7.1 <1.8.0",
148
148
  "@better-auth/passkey": ">=1.7.1 <1.8.0",
@@ -155,6 +155,9 @@
155
155
  "@aws-sdk/client-s3": {
156
156
  "optional": true
157
157
  },
158
+ "@aws-sdk/lib-storage": {
159
+ "optional": true
160
+ },
158
161
  "@aws-sdk/s3-request-presigner": {
159
162
  "optional": true
160
163
  },
@@ -178,16 +181,17 @@
178
181
  }
179
182
  },
180
183
  "devDependencies": {
181
- "@aws-sdk/client-s3": "3.1143.0",
182
- "@aws-sdk/s3-request-presigner": "3.1143.0",
184
+ "@aws-sdk/client-s3": "3.1145.0",
185
+ "@aws-sdk/lib-storage": "3.1145.0",
186
+ "@aws-sdk/s3-request-presigner": "3.1145.0",
183
187
  "@better-auth/core": "1.7.1",
184
188
  "@better-auth/passkey": "1.7.1",
185
189
  "@compodoc/compodoc": "2.0.0",
186
190
  "@nestjs/cli": "11.0.24",
187
191
  "@nestjs/schematics": "11.1.0",
188
- "@nestjs/testing": "11.2.6",
192
+ "@nestjs/testing": "11.2.7",
189
193
  "@swc/cli": "0.8.1",
190
- "@swc/core": "1.16.12",
194
+ "@swc/core": "1.16.13",
191
195
  "@tus/s3-store": "2.0.7",
192
196
  "@types/compression": "1.8.1",
193
197
  "@types/cookie-parser": "1.4.10",
@@ -195,12 +199,12 @@
195
199
  "@types/express": "5.0.6",
196
200
  "@types/lodash": "4.17.25",
197
201
  "@types/multer": "2.3.0",
198
- "@types/node": "26.6.3",
202
+ "@types/node": "26.6.4",
199
203
  "@types/passport": "1.0.17",
200
204
  "@vitest/coverage-v8": "4.1.11",
201
205
  "ansi-colors": "4.1.3",
202
206
  "better-auth": "1.7.1",
203
- "bullmq": "6.3.10",
207
+ "bullmq": "6.3.11",
204
208
  "cross-env": "10.1.0",
205
209
  "find-file-up": "2.0.1",
206
210
  "husky": "9.1.7",
@@ -218,7 +222,7 @@
218
222
  "tus-js-client": "4.3.1",
219
223
  "typescript": "5.9.3",
220
224
  "unplugin-swc": "2.0.0",
221
- "vite": "8.3.1",
225
+ "vite": "8.3.2",
222
226
  "vite-plugin-node": "8.0.0",
223
227
  "vitest": "4.1.11"
224
228
  },
@@ -272,6 +272,10 @@ export function multerFileToUpload(file: {
272
272
  encoding: '7bit',
273
273
  filename: file.originalname || randomBytes(16).toString('hex'),
274
274
  mimetype: file.mimetype || 'application/octet-stream',
275
+ // No `size`, although the buffer's length is exact: with a size, S3 receives one streamed
276
+ // PutObject, and the SDK does not retry a stream body after a transient error (503 SlowDown, a
277
+ // dropped socket). Without it the multipart path sends Buffer slices of this very buffer — no
278
+ // copy — which the SDK does retry.
275
279
  };
276
280
  }
277
281
 
@@ -80,7 +80,7 @@ export class GridFSHelper {
80
80
  * process down. That is reachable from ordinary traffic — a client that aborts a
81
81
  * GraphQL upload mid-body errors the capacitor stream — and it was
82
82
  * driver-conditional: the same abort is a rejected promise under the S3 driver
83
- * (`streamToBuffer` throws) and under the filesystem driver (`pipeline()`
83
+ * (the multipart upload rejects) and under the filesystem driver (`pipeline()`
84
84
  * forwards both ends), and a process crash under GridFS, the pre-11.33 default.
85
85
  * The migration helper's `uploadFileToGridFS()` already carried this handler; the
86
86
  * one on the request path did not.
@@ -74,6 +74,16 @@ export class CoreS3Service implements OnApplicationShutdown, OnModuleInit {
74
74
  */
75
75
  protected presigner?: typeof import('@aws-sdk/s3-request-presigner');
76
76
 
77
+ /**
78
+ * Lazy-imported `@aws-sdk/lib-storage`, used to upload a stream whose length is unknown.
79
+ *
80
+ * Three states, and the third is the point: `undefined` means "not tried yet", a module means
81
+ * "available", and `null` means "tried and absent". Without the `null` the import would be
82
+ * retried on every single upload, and since the absence is permanent that is a resolver miss per
83
+ * request plus a repeated warning in the log.
84
+ */
85
+ protected multipartUploader?: null | typeof import('@aws-sdk/lib-storage');
86
+
77
87
  constructor(protected readonly configService: ConfigService) {
78
88
  const raw = this.configService.getFastButReadOnly<IS3Config | undefined>('s3');
79
89
  if (!raw || raw.enabled === false) {
@@ -189,6 +199,10 @@ export class CoreS3Service implements OnApplicationShutdown, OnModuleInit {
189
199
  ...(accessKeyId && secretAccessKey ? { credentials: { accessKeyId, secretAccessKey } } : {}),
190
200
  });
191
201
 
202
+ // Resolved at boot rather than on the first upload, so a missing optional peer is reported
203
+ // where operators look — the startup log — instead of during the first large upload.
204
+ await this.loadMultipartUploader();
205
+
192
206
  await this.verifyBuckets();
193
207
  }
194
208
 
@@ -263,10 +277,26 @@ export class CoreS3Service implements OnApplicationShutdown, OnModuleInit {
263
277
  /**
264
278
  * Upload an object to the main bucket.
265
279
  *
266
- * A stream needs its length: without `Content-Length` the SDK switches to aws-chunked
267
- * encoding and then fails on a missing `x-amz-decoded-content-length`, so a stream of
268
- * unknown size is read into memory first. Pass `contentLength` whenever the size is
269
- * known — that streams the body straight through instead of buffering it.
280
+ * A `PutObject` needs the body's length: without `Content-Length` the SDK switches to
281
+ * aws-chunked encoding and then fails on a missing `x-amz-decoded-content-length`. So pass
282
+ * `contentLength` whenever the size is known — that streams the body straight through as a
283
+ * single request.
284
+ *
285
+ * A stream of UNKNOWN length goes through a multipart upload instead (`@aws-sdk/lib-storage`),
286
+ * which is what that package exists for: it reads the stream in parts and needs no total up
287
+ * front. It used to be read into memory instead, which made the memory cost of an upload equal
288
+ * to its size — a multi-GB object then either threw "Array buffer allocation failed" or got the
289
+ * container OOM-killed at 100 % progress, taking every other in-flight request with it. That is
290
+ * not a hypothetical shape: any streamed HTTP upload has it, because a multipart request's
291
+ * `Content-Length` covers the whole envelope and therefore cannot be used as the object length.
292
+ *
293
+ * A KNOWN length above {@link CoreS3Service.MAX_SINGLE_PUT_BYTES} goes through the multipart
294
+ * upload as well: AWS S3 refuses a single PutObject that large with `EntityTooLarge`, which for a
295
+ * finished TUS upload means after the client has sent every byte.
296
+ *
297
+ * Without the optional peer dependency both cases behave exactly as before: an unknown length is
298
+ * read into memory, a known one goes up as one PutObject whatever its size (MinIO and RustFS
299
+ * accept that; AWS S3 does not above 5 GiB).
270
300
  */
271
301
  async putObject(
272
302
  key: string,
@@ -278,6 +308,15 @@ export class CoreS3Service implements OnApplicationShutdown, OnModuleInit {
278
308
 
279
309
  let payload: Buffer | Readable | string = body;
280
310
  let length = contentLength;
311
+ // Multipart for a stream of UNKNOWN length — nothing else carries it without buffering — and for
312
+ // one whose known length exceeds what S3 accepts in a single request.
313
+ if (this.isStream(body) && (length === undefined || length > CoreS3Service.MAX_SINGLE_PUT_BYTES)) {
314
+ const uploader = await this.loadMultipartUploader();
315
+ if (uploader) {
316
+ await this.putObjectMultipart(uploader, key, body, contentType, length);
317
+ return;
318
+ }
319
+ }
281
320
  if (this.isStream(body) && length === undefined) {
282
321
  payload = await this.collect(body);
283
322
  length = payload.length;
@@ -318,6 +357,69 @@ export class CoreS3Service implements OnApplicationShutdown, OnModuleInit {
318
357
  await send();
319
358
  }
320
359
 
360
+ /**
361
+ * The multipart uploader, or `undefined` when the optional peer dependency is absent.
362
+ *
363
+ * Absence is NOT an error here, unlike the missing `@aws-sdk/client-s3` in `onModuleInit`:
364
+ * multipart is an optimisation of a path that already works by buffering, so a project that
365
+ * never installs it keeps the previous behaviour instead of failing to boot. The cost of that
366
+ * choice is memory, which is why the caller's doc states it.
367
+ */
368
+ protected async loadMultipartUploader(): Promise<typeof import('@aws-sdk/lib-storage') | undefined> {
369
+ if (this.multipartUploader !== undefined) {
370
+ return this.multipartUploader ?? undefined;
371
+ }
372
+ try {
373
+ this.multipartUploader = await import('@aws-sdk/lib-storage');
374
+ } catch {
375
+ // `null`, not `undefined`: the field doubles as the "already tried" marker, and a failed
376
+ // import must not be retried on every upload.
377
+ this.multipartUploader = null;
378
+ this.logger.warn(
379
+ 'Streaming an S3 upload of unknown length falls back to buffering it in memory because the ' +
380
+ 'optional peer dependency "@aws-sdk/lib-storage" is not installed. Run: pnpm add @aws-sdk/lib-storage',
381
+ );
382
+ }
383
+ return this.multipartUploader ?? undefined;
384
+ }
385
+
386
+ /**
387
+ * Upload a stream of unknown length as a multipart upload.
388
+ *
389
+ * Memory stays bounded by the uploader's defaults — parts of 5 MiB, at most four in flight —
390
+ * whatever the object's size. A stream that ends within the first part is sent as one ordinary
391
+ * PutObject by `Upload` itself, so a small file costs no multipart round trips.
392
+ *
393
+ * No `guardBodyStream` here, and that is deliberate: the guard exists because the SDK pipes a
394
+ * `PutObject` body into its request without listening for the source's `error`, so a failed
395
+ * source surfaces as a successful upload of a truncated object. `Upload` does listen — a source
396
+ * error rejects `done()` and the already-uploaded parts are aborted — so wrapping it would add a
397
+ * second error path without adding a guarantee.
398
+ */
399
+ protected async putObjectMultipart(
400
+ uploader: typeof import('@aws-sdk/lib-storage'),
401
+ key: string,
402
+ body: Readable,
403
+ contentType?: string,
404
+ contentLength?: number,
405
+ ): Promise<void> {
406
+ const { client, config } = this.requireInit();
407
+ const upload = new uploader.Upload({
408
+ client,
409
+ params: {
410
+ Body: body,
411
+ Bucket: config.bucket,
412
+ // A known total lets `Upload` size its parts (`ceil(total / 10 000)`, at least 5 MiB) so the
413
+ // 10 000-part limit holds, and refuse a stream that yields a different NUMBER of parts than
414
+ // that total implies (it checks the part count, not the byte count). Not sent per part.
415
+ ...(contentLength === undefined ? {} : { ContentLength: contentLength }),
416
+ ...(contentType ? { ContentType: contentType } : {}),
417
+ Key: key,
418
+ },
419
+ });
420
+ await upload.done();
421
+ }
422
+
321
423
  /**
322
424
  * Shield the SDK from a REQUEST-BODY stream that can fail, and keep the cause.
323
425
  *
@@ -397,6 +499,12 @@ export class CoreS3Service implements OnApplicationShutdown, OnModuleInit {
397
499
  */
398
500
  static readonly MAX_COPY_OBJECT_BYTES = 5 * 1024 * 1024 * 1024;
399
501
 
502
+ /**
503
+ * Largest object a single `PutObject` can carry (AWS S3 hard limit). {@link CoreS3Service.putObject}
504
+ * sends a known length above it as a multipart upload instead.
505
+ */
506
+ static readonly MAX_SINGLE_PUT_BYTES = 5 * 1024 * 1024 * 1024;
507
+
400
508
  /**
401
509
  * Build the `CopySource` value for {@link CoreS3Service.copyObject}
402
510
  */
@@ -453,13 +561,23 @@ export class CoreS3Service implements OnApplicationShutdown, OnModuleInit {
453
561
  * Whether an object exists in the main bucket
454
562
  */
455
563
  async objectExists(key: string): Promise<boolean> {
564
+ return (await this.statObject(key)) !== null;
565
+ }
566
+
567
+ /**
568
+ * Size and content type of an object in the main bucket, or `null` when it does not exist.
569
+ *
570
+ * One HEAD request. This is how the length of an object uploaded WITHOUT a known length is
571
+ * learned afterwards: the stored object is the authority on its own size.
572
+ */
573
+ async statObject(key: string): Promise<null | { contentLength?: number; contentType?: string }> {
456
574
  const { client, config, sdk } = this.requireInit();
457
575
  try {
458
- await client.send(new sdk.HeadObjectCommand({ Bucket: config.bucket, Key: key }));
459
- return true;
576
+ const result = await client.send(new sdk.HeadObjectCommand({ Bucket: config.bucket, Key: key }));
577
+ return { contentLength: result.ContentLength, contentType: result.ContentType };
460
578
  } catch (error: any) {
461
579
  if (error?.$metadata?.httpStatusCode === 404 || error?.name === 'NotFound') {
462
- return false;
580
+ return null;
463
581
  }
464
582
  throw error;
465
583
  }
@@ -621,6 +621,14 @@ the server log; the response carries only a stable `#LTNS_0901` code.
621
621
  framework's existing auth) — the MCP session is bound to that user, and
622
622
  `tools/list` / `tools/call` are filtered to and executed with their permissions.
623
623
  - Unauthenticated requests get `401` with a `WWW-Authenticate` header.
624
+ - A session id the server does not know — which every restart produces, because the
625
+ transport map is process memory — gets **`404`**, and so does another user's id. 404
626
+ is the status the MCP spec has a client react to by sending a fresh `initialize`, so
627
+ clients recover from a deploy on their own rather than sitting dead until someone
628
+ reconnects by hand. A request with **no** session id and no `initialize` body still
629
+ gets `400`: there is nothing to recover, and a 404 would start a reconnect loop.
630
+ - Answering a foreign id `404` rather than `403` is deliberate — a 403 would confirm
631
+ that the id exists, and the id travels in a response header and through client logs.
624
632
 
625
633
  ### Multi-replica / sticky sessions
626
634
 
@@ -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
  */