@lenne.tech/nest-server 11.32.2 → 11.32.4

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 (40) hide show
  1. package/.claude/rules/configurable-features.md +1 -1
  2. package/FRAMEWORK-API.md +3 -1
  3. package/bin/migrate.js +13 -3
  4. package/dist/core/common/helpers/file.helper.d.ts +14 -2
  5. package/dist/core/common/helpers/file.helper.js +48 -9
  6. package/dist/core/common/helpers/file.helper.js.map +1 -1
  7. package/dist/core/common/interfaces/server-options.interface.d.ts +2 -0
  8. package/dist/core/modules/ai/inputs/core-ai-connection.input.js +2 -0
  9. package/dist/core/modules/ai/inputs/core-ai-connection.input.js.map +1 -1
  10. package/dist/core/modules/ai/services/core-ai-connection.service.d.ts +1 -0
  11. package/dist/core/modules/ai/services/core-ai-connection.service.js +68 -0
  12. package/dist/core/modules/ai/services/core-ai-connection.service.js.map +1 -1
  13. package/dist/core/modules/file/core-file.controller.d.ts +4 -1
  14. package/dist/core/modules/file/core-file.controller.js +39 -6
  15. package/dist/core/modules/file/core-file.controller.js.map +1 -1
  16. package/dist/core/modules/migrate/cli/migrate-cli.d.ts +3 -1
  17. package/dist/core/modules/migrate/cli/migrate-cli.js +29 -4
  18. package/dist/core/modules/migrate/cli/migrate-cli.js.map +1 -1
  19. package/dist/core/modules/migrate/helpers/migration.helper.d.ts +1 -0
  20. package/dist/core/modules/migrate/helpers/migration.helper.js +51 -4
  21. package/dist/core/modules/migrate/helpers/migration.helper.js.map +1 -1
  22. package/dist/tsconfig.build.tsbuildinfo +1 -1
  23. package/docs/security-overrides.md +9 -2
  24. package/migration-guides/11.32.2-to-11.32.3.md +129 -0
  25. package/migration-guides/11.32.3-to-11.32.4.md +323 -0
  26. package/package.json +1 -1
  27. package/src/core/common/helpers/file.helper.spec.ts +145 -0
  28. package/src/core/common/helpers/file.helper.ts +148 -10
  29. package/src/core/common/interfaces/server-options.interface.ts +23 -0
  30. package/src/core/modules/ai/README.md +6 -0
  31. package/src/core/modules/ai/inputs/core-ai-connection.input.ts +2 -0
  32. package/src/core/modules/ai/interfaces/ai-tool.interface.ts +18 -3
  33. package/src/core/modules/ai/services/core-ai-connection.service.ts +135 -0
  34. package/src/core/modules/file/README.md +59 -0
  35. package/src/core/modules/file/core-file.controller.spec.ts +164 -0
  36. package/src/core/modules/file/core-file.controller.ts +100 -8
  37. package/src/core/modules/migrate/README.md +35 -0
  38. package/src/core/modules/migrate/cli/migrate-cli.ts +69 -6
  39. package/src/core/modules/migrate/helpers/migration.helper.spec.ts +85 -0
  40. package/src/core/modules/migrate/helpers/migration.helper.ts +131 -4
@@ -11,16 +11,18 @@ A green `pnpm audit` inside the framework repo says nothing about your tree.
11
11
 
12
12
  ## What this concretely means for you
13
13
 
14
- The framework pulls in two transitive packages that resolve to a **vulnerable** version unless you
14
+ The framework pulls in three transitive packages that resolve to a **vulnerable** version unless you
15
15
  override them yourself:
16
16
 
17
17
  | Package | Advisory | Why it cannot resolve forward on its own |
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` | `@nestjs/graphql` declares `"ws": "8.20.1"` — an **exact pin**, not a caret. No amount of updating moves it |
20
20
  | `@hono/node-server` | [GHSA-frvp-7c67-39w9](https://github.com/advisories/GHSA-frvp-7c67-39w9) (static-file path traversal) + [GHSA-9mqv-5hh9-4cgg](https://github.com/advisories/GHSA-9mqv-5hh9-4cgg) (unauthenticated memory leak). Patched `>=2.0.10` | `@modelcontextprotocol/sdk` declares `^1.19.9` and ships **no 1.x fix line**, so the fix is only available across a major |
21
+ | `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` | `@nestjs/swagger` declares `"js-yaml": "5.2.1"` — an **exact pin**, the same shape as the `ws` case. It cannot resolve forward |
21
22
 
22
23
  `@nestjs/graphql` is a plain `dependencies` entry, so `ws` is installed even when you run with
23
- `graphQl: false`. Neither package is optional in practice.
24
+ `graphQl: false`. `@nestjs/swagger` is likewise a plain `dependencies` entry. None of the three is
25
+ optional in practice.
24
26
 
25
27
  ## The fix
26
28
 
@@ -40,6 +42,11 @@ overrides:
40
42
  # `(fetchCallback, options?)` is unchanged in 2.x. Engines >=20 and peer hono@^4 both fit.
41
43
  # Remove once @modelcontextprotocol/sdk moves its own range to ^2.
42
44
  '@hono/node-server@<2.0.10': '2.0.11'
45
+
46
+ # @nestjs/swagger exact-pins js-yaml@5.2.1 (GHSA-pm4m-ph32-ghv5, high, patched >=5.2.2).
47
+ # Same shape as the ws entry: an exact pin cannot resolve forward.
48
+ # Remove once @nestjs/swagger stops pinning it.
49
+ 'js-yaml@>=5.0.0 <5.2.2': '5.2.2'
43
50
  ```
44
51
 
45
52
  Then:
@@ -0,0 +1,129 @@
1
+ # Migration Guide: 11.32.2 → 11.32.3
2
+
3
+ ## Overview
4
+
5
+ | Category | Details |
6
+ |----------|---------|
7
+ | **Breaking Changes** | None |
8
+ | **New Features** | `ai.capabilityDriftCheck` — opt-in boot warning when an explicit connection capability contradicts the endpoint; `ai.defaultConnection.contextWindow` — seed a connection's context window from config |
9
+ | **Bugfixes** | The AI connection `contextWindow` admin input now rejects non-positive / non-integer values |
10
+ | **Migration Effort** | ~2 minutes — everything is opt-in; read §3 only if your admin tooling writes `contextWindow` |
11
+
12
+ ---
13
+
14
+ ## Quick Migration
15
+
16
+ ```bash
17
+ pnpm update @lenne.tech/nest-server@11.32.3
18
+ pnpm run build
19
+ pnpm test
20
+ ```
21
+
22
+ No configuration change is required. Everything below is opt-in or a
23
+ tightening you inherit automatically.
24
+
25
+ ---
26
+
27
+ ## 1. New: `ai.capabilityDriftCheck` (opt-in, default `false`)
28
+
29
+ A connection's `supportsNativeTools` / `supportsJsonResponse` flags are
30
+ auto-detected while they are left **undefined**, but an **explicit** value is
31
+ authoritative and is never re-probed. A wrong explicit flag therefore degrades the
32
+ assistant silently — e.g. `supportsNativeTools: false` on a backend that *does*
33
+ support native function calling forces fragile emulated tool-calling.
34
+
35
+ Enable this opt-in boot self-check to surface such a mismatch:
36
+
37
+ ```typescript
38
+ ai: {
39
+ capabilityDriftCheck: true, // default false
40
+ }
41
+ ```
42
+
43
+ When enabled, at startup the module probes each enabled connection that declares an
44
+ explicit flag and logs a **warning** on mismatch. It never changes the stored value
45
+ (your explicit choice stays authoritative — clear the flag in the admin UI to
46
+ re-enable auto-detection).
47
+
48
+ **It is off by default because it makes outbound calls to the LLM endpoints on
49
+ every boot** (and is additionally skipped in the `ci`/`e2e` runners). Note this is a
50
+ diagnostic log only: if you deliberately override a flag against what the endpoint
51
+ reports (e.g. native tools are advertised but unreliable on your model), the warning
52
+ is expected and can be ignored.
53
+
54
+ **Action: none** unless you want the diagnostic — then set the flag.
55
+
56
+ ---
57
+
58
+ ## 2. New: `ai.defaultConnection.contextWindow`
59
+
60
+ The one-time `ai.defaultConnection` seed now accepts `contextWindow`, so a
61
+ config-seeded connection can carry its context window from the start instead of
62
+ relying on auto-detection:
63
+
64
+ ```typescript
65
+ ai: {
66
+ defaultConnection: {
67
+ name: 'default',
68
+ baseUrl: '...',
69
+ model: '...',
70
+ contextWindow: 32768, // optional — omit to auto-detect
71
+ },
72
+ }
73
+ ```
74
+
75
+ Omit it to keep the existing behaviour (auto-detect by probing the endpoint /
76
+ `knownContextWindow()`, falling back to the global `ai.contextWindow` default of
77
+ 8192).
78
+
79
+ **Action: none** — additive and optional.
80
+
81
+ ---
82
+
83
+ ## 3. Tightened: `contextWindow` admin input validation
84
+
85
+ The `contextWindow` field on the AI connection create/update input is now validated
86
+ as a **positive integer** (`@IsInt` + `@Min(1)`). Previously any number (including
87
+ `0`, a negative, or a float) was accepted verbatim, and an explicit `0` is not
88
+ "unset" — it would have fed the orchestrator's context budget as a real (broken)
89
+ value instead of falling back to the safe default.
90
+
91
+ **Action required if** your admin tooling or tests send a non-integer or
92
+ `< 1` `contextWindow` to `createAiConnection` / `updateAiConnection` — those calls
93
+ now return a validation error. Send a positive integer, or omit the field to
94
+ auto-detect.
95
+
96
+ ---
97
+
98
+ ## 4. Documentation: the MCP confirmation-gate boundary
99
+
100
+ No code change — a clarification of the `IAiTool.destructive` / `.mutating` JSDoc
101
+ (reinforcing §5 of the 11.32.2 guide). Over `/ai/mcp` there is **no confirmation
102
+ gate**: `mcpCallTool` consults neither flag, so a destructive tool executes on the
103
+ first call. The barriers that *do* hold on every path are the registry **role
104
+ filter** (`forUser()`, applied before `execute()`) and the authorization inside
105
+ `execute()` itself — so a destructive tool restricted to a real role stays
106
+ unreachable by lesser-privileged MCP clients; MCP only skips the extra confirmation
107
+ step for clients that may already see the tool.
108
+
109
+ **Action:** expose `/ai/mcp` only to clients you trust to obtain user consent
110
+ themselves, and keep data-level authorization inside `execute()` (not only in the
111
+ plan-mode `authorize()`).
112
+
113
+ ---
114
+
115
+ ## Module Documentation
116
+
117
+ - [AI module README](../src/core/modules/ai/README.md) — see "Capability auto-detection"
118
+ - [AI INTEGRATION-CHECKLIST](../src/core/modules/ai/INTEGRATION-CHECKLIST.md) — advanced configuration
119
+ - [Configurable features](../.claude/rules/configurable-features.md) — the AI Assistant row
120
+
121
+ ---
122
+
123
+ ## Troubleshooting
124
+
125
+ | Symptom | Cause | Fix |
126
+ |---------|-------|-----|
127
+ | Boot logs "capability drift" for a connection | §1 — an explicit flag disagrees with the endpoint | Correct the flag in the admin UI, or clear it to auto-detect; ignore if the override is deliberate |
128
+ | `createAiConnection` rejects `contextWindow` | §3 — it must be a positive integer now | Send an integer `>= 1`, or omit to auto-detect |
129
+ | A destructive tool ran over `/ai/mcp` without confirmation | §4 — MCP has no confirmation gate by design | Only expose MCP to trusted clients; enforce data-level checks in `execute()` |
@@ -0,0 +1,323 @@
1
+ # Migration Guide: 11.32.3 → 11.32.4
2
+
3
+ ## Overview
4
+
5
+ | Category | Details |
6
+ |----------|---------|
7
+ | **Breaking Changes** | None in signatures. Six **runtime behaviour** changes you inherit automatically — see §1, §2, §4, §5, §6, §7 |
8
+ | **New Features** | `assertGridFsFileComplete()` — verify a stored GridFS file's chunk completeness; `UploadAllowList` — exact-matching upload filters, with `SCRIPTABLE_UPLOAD_MIME_TYPES` / `SCRIPTABLE_UPLOAD_EXTENSIONS` and the `allowScriptableTypes` opt-out |
9
+ | **Bugfixes** | GridFS uploads no longer report success for incomplete files, no longer hang on an unreadable source, and no longer leak their connection; file downloads answer an honest 404 instead of dropping the socket; the migrate CLI now actually terminates and no longer truncates its own output; the upload filter no longer matches mimetypes and extensions as SUBSTRINGS (`te?xt` accepted `text/html`), no longer reports rejections as a bare string, and `multerOptionsForImageUpload` no longer silently disables all filtering when `fileTypeRegex` is `undefined` |
10
+ | **Migration Effort** | No code changes for most projects, and nothing to configure. Read §1 if you have seed migrations that upload assets, §4 if any test asserts on download error messages, §5–§7 if your project accepts uploads |
11
+
12
+ ---
13
+
14
+ ## Quick Migration
15
+
16
+ ```bash
17
+ pnpm update @lenne.tech/nest-server@11.32.4
18
+ pnpm run build
19
+ pnpm test
20
+ ```
21
+
22
+ No configuration change is required. Existing `fileTypeRegex` arguments keep working.
23
+
24
+ **Vendor-mode projects:** four modified files under `src/core/`
25
+ (`common/helpers/file.helper.ts`, `modules/file/core-file.controller.ts`,
26
+ `modules/migrate/cli/migrate-cli.ts`, `modules/migrate/helpers/migration.helper.ts`) plus
27
+ `bin/migrate.js`. No moved files — there is no atomic file-set hazard for a partial sync.
28
+
29
+ ---
30
+
31
+ ## 1. `uploadFileToGridFS()` now fails loudly instead of quietly
32
+
33
+ Previously the helper resolved as soon as the write stream emitted `'finish'`. That event says the
34
+ stream ended — it does **not** prove every chunk document is durably stored. A connection lost at the
35
+ wrong moment left a files document promising more bytes than existed, the migration reported success,
36
+ and the defect surfaced much later as a broken download from a record that looked perfectly healthy.
37
+
38
+ It now verifies chunk completeness before resolving, and **rejects** when the file is incomplete
39
+ (removing the incomplete file so retries do not accumulate orphans).
40
+
41
+ **What you may notice:** a seed migration that previously "succeeded" while silently storing a broken
42
+ asset now fails. Since `docker-entrypoint.sh` defaults to `MIGRATE_FAILURE_POLICY=abort`, that failure
43
+ keeps the container from starting.
44
+
45
+ **This is intentional** — finding exactly this is why migrations run before the server. To recover:
46
+
47
+ ```bash
48
+ # 1. Identify the incomplete file from the migration error, which names it:
49
+ # GridFS file 'logo.png' is incomplete: 1 of 3 chunks stored (id 6a6b…)
50
+ # 2. Verify the source asset is actually present and readable in the image/checkout.
51
+ # 3. Re-run. The helper deletes its own incomplete upload, but a file left behind by
52
+ # an OLDER version of the helper must be removed manually:
53
+ # db.getCollection('<bucket>.files').deleteOne({ _id: ObjectId('…') })
54
+ # db.getCollection('<bucket>.chunks').deleteMany({ files_id: ObjectId('…') })
55
+ ```
56
+
57
+ Two more changes to the same helper, both strictly better and requiring no action:
58
+
59
+ - **An unreadable source rejects instead of hanging.** `pipe()` does not forward read-stream errors,
60
+ so a missing file used to leave the promise pending forever — a migration that never returned.
61
+ It now rejects with the underlying `ENOENT`.
62
+ - **The connection is always closed.** The client it opens is registered and closed on every path.
63
+ A leaked client keeps an SDAM monitor timer alive, which kept the whole CLI from exiting.
64
+
65
+ **Note on empty files:** GridFS stores a zero-byte file with **no** chunk documents at all, and the
66
+ completeness check accounts for that. Uploading an empty placeholder asset is valid and passes.
67
+
68
+ ---
69
+
70
+ ## 2. `migrate up` now terminates explicitly
71
+
72
+ The CLI drains stdout/stderr and then exits, instead of waiting for the event loop to empty. A single
73
+ handle left behind by MongoDB, GridFS or the state store used to keep the process alive after the work
74
+ was done: the CLI printed "All migrations completed successfully" and never returned. On a developer
75
+ machine that is invisible; in CI the job blocks until its timeout, and a container that runs
76
+ migrations before starting the server never reaches the server at all.
77
+
78
+ Exit codes are unchanged (`0` success, `1` failure), and the drain happens **before** the exit, so no
79
+ output is lost — `process.exit()` does not flush an asynchronous pipe, which is exactly what Docker's
80
+ log driver and CI log collectors are.
81
+
82
+ **Action required: none**, unless you invoke the CLI in a non-standard way. If you call the CLI
83
+ module directly rather than through the `migrate` / `nest-migrate` bin, use the new exported
84
+ `runCli()` (which drains and exits) instead of `main()` (which merely resolves):
85
+
86
+ ```typescript
87
+ // Before — resolves, but relies on the event loop draining by itself
88
+ const { main } = require('@lenne.tech/nest-server/dist/core/modules/migrate/cli/migrate-cli');
89
+ main();
90
+
91
+ // After
92
+ const { runCli } = require('@lenne.tech/nest-server/dist/core/modules/migrate/cli/migrate-cli');
93
+ void runCli();
94
+ ```
95
+
96
+ The shipped `bin/migrate.js` already prefers `runCli()` and falls back to `main()`, so a mixed
97
+ version pair keeps working.
98
+
99
+ ---
100
+
101
+ ## 3. New: `assertGridFsFileComplete()`
102
+
103
+ Exported for direct use when you need to check a file that something else wrote — a restored dump,
104
+ another service, a manual upload:
105
+
106
+ ```typescript
107
+ import { assertGridFsFileComplete, getDb } from '@lenne.tech/nest-server';
108
+
109
+ const db = await getDb(process.env.MONGODB_URL);
110
+ await assertGridFsFileComplete(db, 'images', fileId, 'logo.png'); // throws if incomplete
111
+ ```
112
+
113
+ It counts chunk documents rather than reading bytes back, so a chunk that was written but truncated
114
+ is not detected. See `src/core/modules/migrate/README.md`.
115
+
116
+ ---
117
+
118
+ ## 4. File downloads: error responses changed
119
+
120
+ Two changes on `GET /files/id/:id` and `GET /files/:filename`.
121
+
122
+ **(a) A GridFS read error is now a 404, not a dropped socket.** The stream error previously went
123
+ unhandled, Node destroyed the socket mid-response, and a reverse proxy turned that into
124
+ **502 Bad Gateway** — reading as "the server is down" while every other route answered normally.
125
+ The response is now:
126
+
127
+ ```json
128
+ { "error": "Not Found", "message": "#LTNS_0500: File not found", "statusCode": 404 }
129
+ ```
130
+
131
+ Once bytes are already on the wire there is no status left to send, so the connection is still closed
132
+ — at that point it genuinely is a truncated transfer.
133
+
134
+ **(b) Exception messages now carry their ErrorCode.** The controller uses the framework registry
135
+ instead of raw strings, matching every other core module:
136
+
137
+ | Before | After |
138
+ |--------|-------|
139
+ | `'File not found'` | `ErrorCode.FILE_NOT_FOUND` → `'#LTNS_0500: File not found'` |
140
+ | `'Missing file ID for download'` | `ErrorCode.REQUIRED_FIELD_MISSING` → `'#LTNS_0301: Required field missing'` |
141
+
142
+ **Action required:** if a test asserts on the exact message, loosen it to a substring match — the old
143
+ text is still contained in the new one. Frontends using `useLtErrorTranslation()` gain a translated
144
+ message where they previously showed raw English.
145
+
146
+ **Also:** a project that restricted downloads by overriding `CoreFileService.checkRights()` used to
147
+ get a **500** (the refusal produced `null.pipe(res)` → `TypeError`). It is now a **404** —
148
+ deliberately the same answer as an unknown id, so the endpoint cannot be used to probe which files
149
+ exist.
150
+
151
+ To customise the status, body or logging of the streaming error path, override the new
152
+ `protected pipeFileToResponse()` method on your controller:
153
+
154
+ ```typescript
155
+ export class FileController extends CoreFileController {
156
+ protected override pipeFileToResponse(stream: Readable, res: Response): Response {
157
+ // e.g. report a different status, or add your own telemetry
158
+ return super.pipeFileToResponse(stream, res);
159
+ }
160
+ }
161
+ ```
162
+
163
+ ---
164
+
165
+ ## 5. The upload filter matched substrings, not values
166
+
167
+ **What changed:** `multerFileFilter` compared the mimetype and the extension by
168
+ `RegExp.test()` — a substring search. Every alternative therefore matched
169
+ anywhere inside either value.
170
+
171
+ **Why it matters:** an allow-list containing `text` or `txt` also accepted
172
+ `text/html` and `text/xml`. A file named `x.txt` and sent as `text/html` passed
173
+ both halves of a filter whose own comment said "no html". `md` matched every
174
+ mimetype containing "md", `zip` every one containing "zip". The single
175
+ alternative was never the bug — the substring semantics were.
176
+
177
+ Anchoring the expression was not an option: the SAME expression was tested
178
+ against two different value spaces, so it had to carry mimetype FRAGMENTS
179
+ (`wordprocessingml`, `ms-excel`) next to bare extensions, and no `^…$` satisfies
180
+ both at once.
181
+
182
+ **What to do:** nothing, if your filter only ever listed image formats — those
183
+ never matched markup. If your filter lists text-ish or document formats, move to
184
+ the new exact-matching form:
185
+
186
+ ```ts
187
+ // Before — substring matching
188
+ multerOptionsForImageUpload({ fileTypeRegex: /jpeg|jpg|png|pdf|te?xt|csv/ });
189
+
190
+ // After — whole-value matching
191
+ multerOptionsForImageUpload({
192
+ allowList: {
193
+ extensions: ['.csv', '.jpeg', '.jpg', '.pdf', '.png', '.txt'],
194
+ mimeTypes: ['application/pdf', 'image/jpeg', 'image/png', 'text/csv', 'text/plain'],
195
+ },
196
+ });
197
+ ```
198
+
199
+ The two conditions stay **independent**: either one alone rejects the file, but a
200
+ pair that is odd yet individually allowed (`report.txt` announced as
201
+ `application/pdf`) passes. An extension→mimetype MAPPING is deliberately not
202
+ enforced — user agents genuinely disagree about office and audio types (macOS
203
+ reports `.csv` as `text/plain`), so a mapping rejects legitimate uploads.
204
+
205
+ `fileTypeRegex` still works and is not removed; it is marked deprecated.
206
+
207
+ ---
208
+
209
+ ## 6. Scriptable types are now rejected on BOTH forms
210
+
211
+ **What changed:** `text/html`, `text/xml`, `application/xhtml+xml`,
212
+ `image/svg+xml`, JavaScript types and the matching extensions (`.html`, `.svg`,
213
+ `.js`, `.xml`, …) are rejected before the allow-list is consulted — including
214
+ when a legacy `fileTypeRegex` would have matched them.
215
+
216
+ **Why it matters:** this is what closes §5 for expressions that already exist in
217
+ consumer projects, without anyone having to rewrite them. The danger does not
218
+ depend on what an endpoint meant to accept: a stored upload served back from the
219
+ API origin with one of these content types executes in that origin, with the
220
+ victim's session.
221
+
222
+ **What to do:** if you deliberately accept SVG logos or HTML fragments, opt out
223
+ explicitly:
224
+
225
+ ```ts
226
+ multerFileFilter(
227
+ { extensions: ['.svg'], mimeTypes: ['image/svg+xml'] },
228
+ { allowScriptableTypes: true },
229
+ );
230
+ ```
231
+
232
+ Only do that when the file is never served from an origin that carries a session
233
+ — e.g. a separate download host, or a route that always answers with
234
+ `Content-Disposition: attachment` **and** `X-Content-Type-Options: nosniff`.
235
+
236
+ ---
237
+
238
+ ## 7. `fileTypeRegex: undefined` no longer disables filtering
239
+
240
+ **What changed:** `multerOptionsForImageUpload` built its config as
241
+ `{ fileTypeRegex: /jpeg|jpg|png/, ...options }` and then installed a filter only
242
+ `if (config.fileTypeRegex)`. Passing the key explicitly as `undefined`
243
+ overwrote the default, so **no filter was installed at all** and every file type
244
+ was accepted — on a helper named "ImageUpload".
245
+
246
+ **Why it matters:** this is easy to trigger by accident rather than intent:
247
+
248
+ ```ts
249
+ // The author meant "then just the standard image types".
250
+ // Before: they got "then everything".
251
+ multerOptionsForImageUpload({ fileTypeRegex: allowSvg ? /jpeg|jpg|png|svg/ : undefined });
252
+ ```
253
+
254
+ Any optional variable threaded into that option had the same effect.
255
+
256
+ **What to do:** if a project relied on this to accept arbitrary types, it now
257
+ gets JPEG/PNG only and those uploads start failing. Pass an explicit `allowList`
258
+ naming what the endpoint really accepts. There is intentionally no "accept
259
+ everything" switch — an upload endpoint that takes any type should not be built
260
+ on `multerOptionsForImageUpload`.
261
+
262
+ ---
263
+
264
+ ## 8. Upload rejections are a real `Error`
265
+
266
+ **What changed:** the filter called `cb('Error: File upload only supports …')`
267
+ with a bare **string**. It now passes an `Error`.
268
+
269
+ **Why it matters:** a string has no `message`, so NestJS's `transformException`
270
+ could not map it and the request surfaced as a 500 instead of a 4xx.
271
+
272
+ **What to do:** nothing, unless a test asserts on the exact error value. Assert
273
+ on `error.message` instead.
274
+
275
+ ---
276
+
277
+ ## 9. Dependency housekeeping (no action required)
278
+
279
+ - `js-yaml` gained an override for [GHSA-pm4m-ph32-ghv5](https://github.com/advisories/GHSA-pm4m-ph32-ghv5)
280
+ (high). **`@nestjs/swagger` exact-pins `js-yaml@5.2.1`, so your project needs this override too** —
281
+ a framework override does not reach consumer trees. See
282
+ [`docs/security-overrides.md`](../docs/security-overrides.md), which now lists all three affected
283
+ packages.
284
+ - The `minimatch` override range was narrowed to `>=9.0.0`; majors 5–8 export a callable function and
285
+ would break under a forced lift to 10.
286
+
287
+ ---
288
+
289
+ ## Troubleshooting
290
+
291
+ | Symptom | Cause | Fix |
292
+ |---------|-------|-----|
293
+ | Migration now fails with `is incomplete: N of M chunks stored` | The asset was already broken in the database; the old helper never checked | See §1 — verify the source asset, remove the orphan, re-run |
294
+ | Migration now fails with `ENOENT` | The source path was always wrong; it used to hang instead of failing | Fix the path. It is resolved against the **helper module's** directory |
295
+ | A test asserting `'File not found'` fails | The message now carries its ErrorCode prefix | Match on a substring, or on `ErrorCode.FILE_NOT_FOUND` |
296
+ | A download that returned 500 now returns 404 | `checkRights()` refusal is handled properly instead of crashing | Intended — see §4 |
297
+ | An upload fails with `may execute as script` | The type is markup or script and is now rejected regardless of the filter | Intended — see §6, and opt out only under the conditions named there |
298
+ | An upload of a document type that used to pass now fails | The filter matched it as a substring before | Move to `allowList` naming the type exactly — see §5 |
299
+ | Uploads that accepted every type now accept JPEG/PNG only | `fileTypeRegex: undefined` no longer disables the filter | Pass an explicit `allowList` — see §7 |
300
+ | A test asserting on the filter's rejection string fails | Rejections are an `Error` now, not a string | Assert on `error.message` — see §8 |
301
+
302
+ ---
303
+
304
+ ## Verification
305
+
306
+ ```bash
307
+ pnpm test
308
+ ```
309
+
310
+ `src/core/common/helpers/file.helper.spec.ts` covers the substring class (values that merely CONTAIN
311
+ an allowed token), the scriptable-type rejection on both forms, the opt-out, and that the legacy
312
+ `RegExp` form still accepts what it accepted before.
313
+ `src/core/modules/migrate/helpers/migration.helper.spec.ts` and
314
+ `tests/migrate/upload-file-to-gridfs.e2e-spec.ts` cover the completeness check against a real GridFS
315
+ bucket; `src/core/modules/file/core-file.controller.spec.ts` covers the download error paths.
316
+
317
+ ---
318
+
319
+ ## Related Documentation
320
+
321
+ - [`src/core/modules/migrate/README.md`](../src/core/modules/migrate/README.md) — `uploadFileToGridFS()`, `assertGridFsFileComplete()`
322
+ - [`src/core/modules/file/README.md`](../src/core/modules/file/README.md) — download endpoints, error responses
323
+ - [`docs/security-overrides.md`](../docs/security-overrides.md) — overrides consumers must replicate
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lenne.tech/nest-server",
3
- "version": "11.32.2",
3
+ "version": "11.32.4",
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",
@@ -0,0 +1,145 @@
1
+ import { describe, expect, it } from 'vitest';
2
+
3
+ import { IMAGE_UPLOAD_ALLOW_LIST, multerFileFilter, multerOptionsForImageUpload, UploadAllowList } from './file.helper';
4
+
5
+ /**
6
+ * The filter used to be ONE unanchored `RegExp`, `.test()`ed against the
7
+ * mimetype AND the extension. `.test()` searches for a substring, so every
8
+ * alternative matched anywhere inside either value — `te?xt` matched the "text"
9
+ * in `text/html`, and a file named `x.txt` sent as `text/html` passed both
10
+ * halves of a filter documented as "no html".
11
+ *
12
+ * Consumer projects hit this for real. The tests below therefore cover three
13
+ * things: the exact-matching allow-list, that the legacy `RegExp` form still
14
+ * works, and — the point of the fix — that scriptable types are rejected on
15
+ * BOTH paths, so an expression that already exists in a consumer project is
16
+ * safe without being rewritten.
17
+ */
18
+
19
+ /** Run a filter the way multer does and report its verdict. */
20
+ function verdict(
21
+ filter: ReturnType<typeof multerFileFilter>,
22
+ mimetype: string,
23
+ originalname: string,
24
+ ): { accepted: boolean; error?: string } {
25
+ let accepted = false;
26
+ let error: string | undefined;
27
+ filter({}, { mimetype, originalname }, (err: any, result?: boolean) => {
28
+ if (err) {
29
+ error = err instanceof Error ? err.message : String(err);
30
+ }
31
+ accepted = result === true;
32
+ });
33
+ return { accepted, error };
34
+ }
35
+
36
+ const DOCUMENTS: UploadAllowList = {
37
+ extensions: ['.csv', '.pdf', '.txt'],
38
+ mimeTypes: ['application/pdf', 'text/csv', 'text/plain'],
39
+ };
40
+
41
+ describe('multerFileFilter', () => {
42
+ describe('allow-list form', () => {
43
+ it('accepts a mimetype and extension that are both on the list', () => {
44
+ expect(verdict(multerFileFilter(DOCUMENTS), 'text/plain', 'notes.txt').accepted).toBe(true);
45
+ expect(verdict(multerFileFilter(DOCUMENTS), 'application/pdf', 'report.pdf').accepted).toBe(true);
46
+ });
47
+
48
+ it('rejects as soon as one half is missing from the list', () => {
49
+ expect(verdict(multerFileFilter(DOCUMENTS), 'application/x-msdownload', 'setup.pdf').accepted).toBe(false);
50
+ expect(verdict(multerFileFilter(DOCUMENTS), 'application/pdf', 'setup.exe').accepted).toBe(false);
51
+ });
52
+
53
+ it('matches whole values, never substrings', () => {
54
+ // Only an unanchored matcher would let these through.
55
+ expect(verdict(multerFileFilter(DOCUMENTS), 'evil/text-plain', 'notes.txt').accepted).toBe(false);
56
+ expect(verdict(multerFileFilter(DOCUMENTS), 'text/plain', 'notes.txtx').accepted).toBe(false);
57
+ });
58
+
59
+ it('ignores mimetype parameters and casing', () => {
60
+ expect(verdict(multerFileFilter(DOCUMENTS), 'text/plain; charset=utf-8', 'notes.txt').accepted).toBe(true);
61
+ expect(verdict(multerFileFilter(IMAGE_UPLOAD_ALLOW_LIST), 'IMAGE/PNG', 'PHOTO.PNG').accepted).toBe(true);
62
+ });
63
+
64
+ it('rejects a file without an extension', () => {
65
+ expect(verdict(multerFileFilter(DOCUMENTS), 'text/plain', 'README').accepted).toBe(false);
66
+ });
67
+ });
68
+
69
+ describe('scriptable types are rejected on BOTH forms', () => {
70
+ // This is what makes an ALREADY EXISTING consumer expression safe.
71
+ const legacy = /jpeg|jpg|png|te?xt|csv/;
72
+
73
+ it('rejects text/html even when the expression matches it as a substring', () => {
74
+ const result = verdict(multerFileFilter(legacy), 'text/html', 'invoice.txt');
75
+ expect(result.accepted).toBe(false);
76
+ expect(result.error).toMatch(/may execute as script/);
77
+ });
78
+
79
+ it('rejects text/xml and application/xhtml+xml', () => {
80
+ expect(verdict(multerFileFilter(legacy), 'text/xml', 'notes.txt').accepted).toBe(false);
81
+ expect(verdict(multerFileFilter(legacy), 'application/xhtml+xml', 'notes.txt').accepted).toBe(false);
82
+ });
83
+
84
+ it('rejects SVG on the image default', () => {
85
+ expect(verdict(multerFileFilter(), 'image/svg+xml', 'logo.svg').accepted).toBe(false);
86
+ });
87
+
88
+ it('rejects a scriptable EXTENSION even under a harmless mimetype', () => {
89
+ expect(verdict(multerFileFilter(legacy), 'text/plain', 'payload.html').accepted).toBe(false);
90
+ expect(verdict(multerFileFilter(legacy), 'image/png', 'payload.svg').accepted).toBe(false);
91
+ });
92
+
93
+ it('allows them only when the caller opts in explicitly', () => {
94
+ const filter = multerFileFilter(
95
+ { extensions: ['.svg'], mimeTypes: ['image/svg+xml'] },
96
+ {
97
+ allowScriptableTypes: true,
98
+ },
99
+ );
100
+ expect(verdict(filter, 'image/svg+xml', 'logo.svg').accepted).toBe(true);
101
+ });
102
+ });
103
+
104
+ describe('legacy RegExp form stays usable', () => {
105
+ it('still accepts what it accepted before', () => {
106
+ const filter = multerFileFilter(/jpeg|jpg|png/);
107
+ expect(verdict(filter, 'image/jpeg', 'photo.jpg').accepted).toBe(true);
108
+ expect(verdict(filter, 'image/png', 'photo.png').accepted).toBe(true);
109
+ expect(verdict(filter, 'application/pdf', 'report.pdf').accepted).toBe(false);
110
+ });
111
+ });
112
+
113
+ describe('rejection reporting', () => {
114
+ it('reports a real Error, not a bare string', () => {
115
+ let received: unknown;
116
+ multerFileFilter(DOCUMENTS)({}, { mimetype: 'application/x-foo', originalname: 'x.foo' }, (err: unknown) => {
117
+ received = err;
118
+ });
119
+ // A bare string leaves multer with an "error" that has no `message`,
120
+ // which NestJS's transformException cannot map to a 4xx.
121
+ expect(received).toBeInstanceOf(Error);
122
+ });
123
+ });
124
+ });
125
+
126
+ describe('multerOptionsForImageUpload', () => {
127
+ it('applies the image allow-list by default', () => {
128
+ const filter = multerOptionsForImageUpload({}).fileFilter!;
129
+ expect(verdict(filter as any, 'image/png', 'a.png').accepted).toBe(true);
130
+ expect(verdict(filter as any, 'image/svg+xml', 'a.svg').accepted).toBe(false);
131
+ });
132
+
133
+ it('still installs a filter when fileTypeRegex is explicitly undefined', () => {
134
+ // Previously this disabled filtering entirely — on a helper named
135
+ // "ImageUpload". Safe-by-default instead; see the migration guide.
136
+ const filter = multerOptionsForImageUpload({ fileTypeRegex: undefined }).fileFilter;
137
+ expect(filter).toBeDefined();
138
+ expect(verdict(filter as any, 'text/html', 'a.txt').accepted).toBe(false);
139
+ });
140
+
141
+ it('honours an explicit allowList', () => {
142
+ const filter = multerOptionsForImageUpload({ allowList: DOCUMENTS }).fileFilter!;
143
+ expect(verdict(filter as any, 'application/pdf', 'report.pdf').accepted).toBe(true);
144
+ });
145
+ });