@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.
- package/.claude/rules/configurable-features.md +1 -1
- package/.claude/rules/package-management.md +8 -0
- package/.claude/rules/testing.md +12 -9
- package/CLAUDE.md +1 -1
- package/FRAMEWORK-API.md +1 -1
- package/dist/core/common/helpers/file.helper.js.map +1 -1
- package/dist/core/common/services/core-s3.service.d.ts +8 -0
- package/dist/core/common/services/core-s3.service.js +44 -3
- package/dist/core/common/services/core-s3.service.js.map +1 -1
- package/dist/core/modules/ai/core-ai-mcp.controller.d.ts +1 -0
- package/dist/core/modules/ai/core-ai-mcp.controller.js +10 -0
- package/dist/core/modules/ai/core-ai-mcp.controller.js.map +1 -1
- package/dist/core/modules/file/core-file.service.js +3 -2
- package/dist/core/modules/file/core-file.service.js.map +1 -1
- package/dist/core/modules/file/interfaces/file-upload.interface.d.ts +1 -0
- package/dist/core/modules/file/s3-file.helper.d.ts +1 -0
- package/dist/core/modules/file/s3-file.helper.js +10 -4
- package/dist/core/modules/file/s3-file.helper.js.map +1 -1
- package/dist/tsconfig.build.tsbuildinfo +1 -1
- package/docs/REQUEST-LIFECYCLE.md +1 -1
- package/docs/security-overrides.md +1 -1
- package/migration-guides/11.41.6-to-11.41.7.md +192 -0
- package/package.json +18 -14
- package/src/core/common/helpers/file.helper.ts +4 -0
- package/src/core/common/helpers/gridfs.helper.ts +1 -1
- package/src/core/common/services/core-s3.service.ts +125 -7
- package/src/core/modules/ai/README.md +8 -0
- package/src/core/modules/ai/core-ai-mcp.controller.ts +33 -0
- package/src/core/modules/file/INTEGRATION-CHECKLIST.md +39 -11
- package/src/core/modules/file/README.md +30 -5
- package/src/core/modules/file/core-file.service.ts +15 -6
- package/src/core/modules/file/interfaces/file-upload.interface.ts +18 -0
- 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,
|
|
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.
|
|
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
|
|
50
|
-
"reinit:
|
|
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.
|
|
99
|
-
"@nestjs/core": "11.2.
|
|
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.
|
|
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.
|
|
182
|
-
"@aws-sdk/
|
|
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.
|
|
192
|
+
"@nestjs/testing": "11.2.7",
|
|
189
193
|
"@swc/cli": "0.8.1",
|
|
190
|
-
"@swc/core": "1.16.
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
-
* (
|
|
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
|
|
267
|
-
* encoding and then fails on a missing `x-amz-decoded-content-length
|
|
268
|
-
*
|
|
269
|
-
*
|
|
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
|
|
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
|
|
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
|
*/
|