@lenne.tech/nest-server 11.33.0 → 11.34.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (55) hide show
  1. package/.claude/rules/testing.md +181 -3
  2. package/FRAMEWORK-API.md +1 -1
  3. package/dist/config.env.js +6 -0
  4. package/dist/config.env.js.map +1 -1
  5. package/dist/core/common/helpers/gridfs.helper.d.ts +1 -0
  6. package/dist/core/common/helpers/gridfs.helper.js +43 -5
  7. package/dist/core/common/helpers/gridfs.helper.js.map +1 -1
  8. package/dist/core/common/services/core-s3.service.d.ts +4 -0
  9. package/dist/core/common/services/core-s3.service.js +28 -1
  10. package/dist/core/common/services/core-s3.service.js.map +1 -1
  11. package/dist/core/modules/file/core-file.service.d.ts +4 -2
  12. package/dist/core/modules/file/core-file.service.js +47 -48
  13. package/dist/core/modules/file/core-file.service.js.map +1 -1
  14. package/dist/core/modules/hub/helpers/hub-client-js.helper.js +12 -1
  15. package/dist/core/modules/hub/helpers/hub-client-js.helper.js.map +1 -1
  16. package/dist/core/modules/hub/hub-action-messages.d.ts +1 -0
  17. package/dist/core/modules/hub/hub-action-messages.js +1 -0
  18. package/dist/core/modules/hub/hub-action-messages.js.map +1 -1
  19. package/dist/core/modules/hub/interfaces/hub-panels.interface.d.ts +9 -0
  20. package/dist/core/modules/hub/services/core-hub-actions.service.d.ts +2 -0
  21. package/dist/core/modules/hub/services/core-hub-actions.service.js.map +1 -1
  22. package/dist/core/modules/hub/services/core-hub-db.service.d.ts +10 -2
  23. package/dist/core/modules/hub/services/core-hub-db.service.js +85 -29
  24. package/dist/core/modules/hub/services/core-hub-db.service.js.map +1 -1
  25. package/dist/server/modules/file/file.controller.js +2 -2
  26. package/dist/server/modules/file/file.controller.js.map +1 -1
  27. package/dist/server/modules/file/file.resolver.js +2 -2
  28. package/dist/server/modules/file/file.resolver.js.map +1 -1
  29. package/dist/server/modules/file/file.service.d.ts +7 -2
  30. package/dist/server/modules/file/file.service.js +13 -2
  31. package/dist/server/modules/file/file.service.js.map +1 -1
  32. package/dist/server/modules/user/avatar.controller.js +1 -1
  33. package/dist/server/modules/user/avatar.controller.js.map +1 -1
  34. package/dist/tsconfig.build.tsbuildinfo +1 -1
  35. package/docs/REQUEST-LIFECYCLE.md +1 -1
  36. package/migration-guides/11.23.x-to-11.24.0.md +1 -1
  37. package/migration-guides/11.32.x-to-11.33.x.md +39 -4
  38. package/migration-guides/11.33.x-to-11.34.x.md +394 -0
  39. package/package.json +3 -1
  40. package/src/config.env.ts +45 -0
  41. package/src/core/common/helpers/gridfs.helper.ts +92 -7
  42. package/src/core/common/services/core-s3.service.ts +71 -9
  43. package/src/core/modules/file/INTEGRATION-CHECKLIST.md +16 -4
  44. package/src/core/modules/file/README.md +77 -10
  45. package/src/core/modules/file/core-file.service.ts +170 -64
  46. package/src/core/modules/hub/README.md +35 -9
  47. package/src/core/modules/hub/helpers/hub-client-js.helper.ts +12 -1
  48. package/src/core/modules/hub/hub-action-messages.ts +9 -1
  49. package/src/core/modules/hub/interfaces/hub-panels.interface.ts +31 -1
  50. package/src/core/modules/hub/services/core-hub-actions.service.ts +10 -2
  51. package/src/core/modules/hub/services/core-hub-db.service.ts +155 -31
  52. package/src/server/modules/file/file.controller.ts +7 -2
  53. package/src/server/modules/file/file.resolver.ts +6 -2
  54. package/src/server/modules/file/file.service.ts +97 -34
  55. package/src/server/modules/user/avatar.controller.ts +8 -1
@@ -162,7 +162,7 @@ JWT-based authentication for existing projects:
162
162
  | **File Module** | Upload/download with MongoDB GridFS storage |
163
163
  | **REST Endpoints** | `GET /files/id/:id`, `GET /files/:filename` (core, gated by `file.downloadRoles`, default ADMIN); `POST /files/upload`, `DELETE /files/:id` (project-specific) |
164
164
  | **GraphQL Endpoints** | `getFileInfo` (`file.downloadRoles`), `uploadFile` / `uploadFiles` (`file.uploadRoles`), `deleteFile` (`file.deleteRoles`) — all default ADMIN |
165
- | **File access control** | Roles are the coarse filter; per-file rules go in `CoreFileService.checkRights()` using metadata written at upload time. Both file classes carry `@SkipTenantCheck()` — GridFS is not tenant-scoped |
165
+ | **File access control** | Roles are the coarse filter; per-file rules go in `CoreFileService.checkRights()` using metadata written at upload time. Cover BOTH the `id` and the `filename` branch — the filename route authorizes on the by-name lookup alone when presigned S3 downloads are on, and `deleteFileByName()` always does. Working reference: `src/server/modules/file/file.service.ts` (with `file.downloadRoles: [S_USER]` in `src/config.env.ts`, so the rule is actually reached). Both file classes carry `@SkipTenantCheck()` — GridFS is not tenant-scoped |
166
166
  | **TUS Module** | Resumable uploads via tus.io protocol (creation, termination, expiration), gated by `tus.roles` (default `S_USER`); `OPTIONS` stays public for the CORS preflight |
167
167
  | **GridFS Migration** | Completed TUS uploads auto-migrate to GridFS |
168
168
  | **CORS Support** | Automatic CORS headers for browser uploads |
@@ -267,6 +267,6 @@ If your code accessed `result.author.password` after a CrudService call (within
267
267
 
268
268
  ## References
269
269
 
270
- - [SubDocument Array Optimization](../docs/subdocument-array-optimization-plan.md) Detailed OOM analysis and implementation notes
270
+ - SubDocument array handling (`pushToArray()` / `pullFromArray()`, and why a subdoc array must never pass through `CrudService.update()`) — see `CLAUDE.md` § High-Frequency Path Design Rules, rule 6. The former `docs/subdocument-array-optimization-plan.md` was a working document and no longer exists
271
271
  - [process() Performance Optimization](../docs/process-performance-optimization.md) — Pipeline optimization details
272
272
  - [nest-server-starter](https://github.com/lenneTech/nest-server-starter) — Reference implementation
@@ -207,18 +207,51 @@ export class FileService extends CoreFileService {
207
207
  input: any,
208
208
  options?: FileServiceOptions & { checkInputType: FileInputCheckType },
209
209
  ): Promise<boolean> {
210
- if (options?.checkInputType !== 'id' || options.force) {
210
+ // Writes, list queries and forced (system) calls stay on the coarse role gate
211
+ if (options?.force || (options?.checkInputType !== 'filename' && options?.checkInputType !== 'id')) {
211
212
  return true;
212
213
  }
213
- if (options.currentUser?.hasRole([RoleEnum.ADMIN])) {
214
+ if (options.currentUser?.hasRole?.([RoleEnum.ADMIN])) {
214
215
  return true;
215
216
  }
216
- const raw = await this.getRawFileInfo(input);
217
- return !!raw && String(raw.metadata?.ownerId) === String(options.currentUser?.id);
217
+ const raw = options.checkInputType === 'id'
218
+ ? await this.getRawFileInfo(input)
219
+ : await this.getRawFileInfoByName(input);
220
+ // Fails closed without a user, and on a file that records no owner.
221
+ return !!raw?.metadata?.ownerId && String(raw.metadata.ownerId) === String(options.currentUser?.id);
218
222
  }
219
223
  }
220
224
  ```
221
225
 
226
+ **Cover the `filename` branch, not only `id`.** An id-only rule is enough while bytes are streamed —
227
+ the filename route resolves an id and checks it again — but not once `s3.presignedDownloads` is on
228
+ (the filename route then authorizes on the by-name lookup alone and redirects), and not for
229
+ `deleteFileByName()`, which authorizes by name only. This is not hypothetical: 11.33.0 shipped a
230
+ `deleteFileByName()` that authorized the caller and then re-resolved the file with an EMPTY context,
231
+ so an overridden `checkRights()` was asked two different questions about one request and the owner
232
+ got `File not found` for their own file. Both halves — the by-name branch and the forwarding — are
233
+ covered by the reference server as of the next release.
234
+
235
+ **Do not add an `if (!options.currentUser) return true` branch.** It reads as "system-internal call,
236
+ the guard already decided", and that is not what it means: "no user in context" is also exactly what
237
+ an ANONYMOUS request looks like. While `downloadRoles` is narrower than `S_EVERYONE` the coarse gate
238
+ rejects those first and the branch looks harmless — widen the gate, which §A.2 explicitly offers, and
239
+ the same branch hands every file to everyone. The ownership rule then evaporates precisely when it
240
+ starts to matter. For the same reason the final comparison requires `metadata.ownerId` to be
241
+ **present**: `String(undefined) === String(undefined)` would match an owner-less file against an
242
+ absent user.
243
+
244
+ Callers that genuinely are internal should say so, rather than relying on the omission:
245
+ `{ force: true }` where a role decorator has already decided (an `@Roles(ADMIN)` admin endpoint), or
246
+ the real `{ currentUser }` where the user is in scope (a controller cleaning up a file it just
247
+ replaced — that delete should be COVERED by the ownership rule, not exempt from it). The reference
248
+ server does both; see `src/server/modules/file/` and `src/server/modules/user/avatar.controller.ts`.
249
+
250
+ The reference server also **runs** the rule above rather than documenting it: `src/config.env.ts`
251
+ sets `file: { downloadRoles: [RoleEnum.S_USER] }` in every environment and
252
+ `src/server/modules/file/file.service.ts` implements `checkRights()`. Copy from there — it is
253
+ compiled and exercised by the test suite; this guide is not.
254
+
222
255
  Three new pieces make this possible, and all three were missing before: `createFile()` now forwards
223
256
  `serviceOptions.metadata` to storage; `getRawFileInfo()` / `getRawFileInfoByName()` read the raw
224
257
  document (the public `getFileInfo()` runs `prepareOutput` → `check()` and strips the very field the
@@ -1264,6 +1297,8 @@ re-resolve. Update the lockfile and re-run `pnpm audit` rather than assuming the
1264
1297
  | Rate limiting enabled **behind a reverse proxy** | **Action required** | Set `trustProxy` (hop count) or every client shares one bucket — §11c. A boot warning names it |
1265
1298
  | Rate limiting enabled with **nothing in front of the app** | **Unchanged**, plus a boot warning | Set `trustProxy: false` to state it explicitly and silence the warning |
1266
1299
  | Project overriding `CoreFileService.getFileInfo()` | **Action required for `GET /files/id/:id`** | That route now resolves through `resolveFile()`; override it too — §14 |
1300
+ | Project calling `duplicateByName()` / `duplicateById()` | **Action required in 11.34.0** | They take an optional `serviceOptions` and are authorized now, and `duplicateByName()` resolves a `CoreFileInfo` instead of a write stream — [11.33.x → 11.34.x](./11.33.x-to-11.34.x.md) §1 |
1301
+ | Test asserting on the error of `deleteFile(<unknown id>)` | **Action required in 11.34.0** | `NotFoundException` (404) instead of `MongoRuntimeError` (500) — [11.33.x → 11.34.x](./11.33.x-to-11.34.x.md) §3 |
1267
1302
  | Project calling `findFileInfo()` **without** a `sort`, with `file.storage` other than `gridfs` | **Ordering changed** | The merged multi-store page defaults to `uploadDate` descending — §14 |
1268
1303
  | Overridden Hub collector `getData()` / mailbox reads | **Action required** | Await and widen the return type (§2) |
1269
1304
  | `const s: GridFSBucketReadStream = await getFileStream(...)` | **Action required** | Type is `Readable` now (§3) |
@@ -0,0 +1,394 @@
1
+ # Migration Guide: 11.33.x → 11.34.x
2
+
3
+ ## Why this is a MINOR
4
+
5
+ Per `.claude/rules/versioning.md` the MAJOR mirrors NestJS, so **MINOR is where breaking changes
6
+ live**. This release changes the declared return type of a public method (§1), so it is `11.34.0`
7
+ rather than a `11.33.2` patch — even though the change set is small and every other entry is a fix
8
+ you simply inherit.
9
+
10
+ ## Upgrade urgency
11
+
12
+ Three of the eight defects fixed here are severe. If you are on 11.33.x, these are the reason to
13
+ move now rather than at the next convenient moment:
14
+
15
+ | # | Defect | Exposure | Section |
16
+ |---|--------|----------|---------|
17
+ | 1 | **Duplication bypassed `checkRights()` entirely** on the GridFS driver — a per-file ownership rule did not apply to `duplicateByName()` at all | Any project with an overridden `checkRights()` that exposes duplication. The copy was made with **no** authorization | [§1](#1-security-duplication-bypassed-checkrights-on-gridfs) |
18
+ | 2 | **An aborted upload crashed the process** — an unhandled stream `'error'` is an uncaught exception in Node | Every GridFS upload; and every tus finalization into S3. A client that hangs up mid-upload takes the API down | [§2](#2-crash-an-aborted-upload-no-longer-takes-the-process-down-gridfs-and-s3) |
19
+ | 3 | Same crash on the **S3** streaming write path | `tus` + `file.storage: 's3'` | [§2](#2-crash-an-aborted-upload-no-longer-takes-the-process-down-gridfs-and-s3) |
20
+
21
+ Both crash paths are reachable from unauthenticated-adjacent traffic (an upload that is simply cut
22
+ off), and neither leaves a stack trace pointing at the file module — they surface as an unexplained
23
+ pod restart. If you have been seeing those on a replica that accepts uploads, §2 is a strong
24
+ candidate.
25
+
26
+ ## Overview
27
+
28
+ | Category | Details |
29
+ |----------|---------|
30
+ | **Security** | `duplicateByName()` no longer copies a file without a rights check on the GridFS driver (§1) |
31
+ | **Stability** | An upload whose SOURCE stream fails no longer crashes the process — on GridFS, and on the S3 driver's streaming (tus finalization) path (§2) |
32
+ | **Breaking Changes** | `duplicateByName()` resolves the COPY's `CoreFileInfo` instead of a raw `GridFSBucketWriteStream` (§1). `duplicateByName()` / `duplicateById()` now run `checkRights()` — a project with a fail-closed `checkRights()` that duplicated files without passing a context gets a refusal where the GridFS driver previously copied unchecked (§1). `deleteFile()` answers `NotFoundException` (404) for an unknown id instead of the driver's `MongoRuntimeError` (500) (§3) |
33
+ | **New Features** | `duplicateByName()` / `duplicateById()` accept an optional `serviceOptions` (§1). `CoreFileService.duplicateFile()` as the shared, driver-agnostic write half. The Hub's **Files panel covers all three storage drivers** (§6) |
34
+ | **Bugfixes** | `findFileInfo()` sorts correctly on a DOTTED path (`metadata.ownerId`) across merged stores — a wrong order there returned the wrong ROWS, not merely the wrong order (§4). `findFileInfo()` can filter and sort on `contentType` across all three drivers (§5). `duplicateByName()` keeps the source's content type on the GridFS driver |
35
+ | **Migration Effort** | Nothing to configure. Read §1 if you call `duplicateByName()` / `duplicateById()` anywhere, §3 if any test asserts on the error a delete-by-unknown-id produces. Everything else is a fix you inherit |
36
+
37
+ All of it lives in the file module, the GridFS helper, `CoreS3Service`'s upload path and the Hub's DB
38
+ service. If your project never calls the duplicate methods, the only changes you can observe are the
39
+ fixes — all of which turn a wrong answer into a right one.
40
+
41
+ ---
42
+
43
+ ## Quick Migration
44
+
45
+ ```bash
46
+ pnpm update @lenne.tech/nest-server@11.34.0
47
+ pnpm run build
48
+ pnpm test
49
+ ```
50
+
51
+ **Vendor-mode projects:** eight modified files under `src/core/`, in two independent groups. No
52
+ moved files and no new files, so a partial sync has no atomic file-set hazard — but the Hub group
53
+ must land together, because the panel, its data contract and its action message changed as one.
54
+
55
+ | Group | Files |
56
+ |-------|-------|
57
+ | File module (§1–§5) | `common/helpers/gridfs.helper.ts`, `common/services/core-s3.service.ts`, `modules/file/core-file.service.ts` |
58
+ | Hub Files panel (§6) | `modules/hub/services/core-hub-db.service.ts`, `modules/hub/services/core-hub-actions.service.ts`, `modules/hub/interfaces/hub-panels.interface.ts`, `modules/hub/hub-action-messages.ts`, `modules/hub/helpers/hub-client-js.helper.ts` |
59
+
60
+ The reference implementation in `src/server/modules/file/file.service.ts` also changed (§1), but
61
+ vendor projects do not receive `src/server/` — if you copied its old `duplicate()` body, see §1.
62
+
63
+ ---
64
+
65
+ ## 1. Security: duplication bypassed `checkRights()` on GridFS
66
+
67
+ ### What was wrong
68
+
69
+ Neither `duplicateByName()` nor `duplicateById()` accepted `serviceOptions` at all. They still called
70
+ `getFileInfo()`, `getFileStream()` and `createFile()` — all of which run `checkRights()` — but with an
71
+ **empty context**. Under the fail-closed `checkRights()` that this framework's README, its reference
72
+ server and `nest-server-starter` all teach, that produced three different wrong answers depending on
73
+ the storage driver:
74
+
75
+ | Call | `gridfs` | `filesystem` | `s3` |
76
+ |------|----------|--------------|------|
77
+ | `duplicateById(id)` | `TypeError: Cannot read properties of null (reading 'filename')` | same `TypeError` | same `TypeError` |
78
+ | `duplicateByName(a, b)` | **succeeded — the file was copied with no rights check at all** | `Error: FilesystemFileHelper.writeFile requires either 'buffer' or 'body'` | `TypeError: … (reading 'Symbol(Symbol.asyncIterator)')` |
79
+
80
+ The GridFS cell is the severe one, and it is the reason this release exists: that branch went
81
+ straight to the bucket and never consulted `checkRights()`, so a per-file ownership rule simply did
82
+ not apply to duplication. On the default storage driver, any caller who could reach a duplicate
83
+ endpoint could copy **any** file — and the copy, being a new file, was then reachable under whatever
84
+ rule the copy itself satisfied.
85
+
86
+ The other two cells are the same defect failing loudly instead of silently. That asymmetry is why it
87
+ survived: the drivers that would have made the bug obvious were the ones nobody ran.
88
+
89
+ ### What it does now
90
+
91
+ A duplicate is treated as what it is — a **READ of the source plus a WRITE of the copy** — and each
92
+ half is authorized through the ordinary public method:
93
+
94
+ | Half | Method | `checkInputType` |
95
+ |------|--------|------------------|
96
+ | read the source | `getFileInfoByName()` / `getFileInfo()` + `getFileStreamByName()` / `getFileStream()` | `'filename'` / `'id'` |
97
+ | write the copy | `createFile()` | `'file'` |
98
+
99
+ ```typescript
100
+ // Forward the caller's context, so the duplicate is COVERED by your rule
101
+ await this.fileService.duplicateById(id, { currentUser });
102
+ await this.fileService.duplicateByName(name, newName, { currentUser });
103
+
104
+ // …or say explicitly that a role decorator already decided
105
+ await this.fileService.duplicateById(id, { force: true });
106
+
107
+ // …and give the COPY its own metadata (see below)
108
+ await this.fileService.duplicateById(id, { currentUser, metadata: { ownerId: currentUser.id } });
109
+ ```
110
+
111
+ A missing source, or a refused read, now answers `NotFoundException` — the same 404 the download
112
+ routes answer, so a refusal never confirms that an id or a filename is real. A refused **write**
113
+ answers through `accessDeniedException()` (403 authenticated / 401 anonymous), because at that point
114
+ the caller has already been shown the source and the question is no longer whether the file exists.
115
+
116
+ ### Break 1 — a fail-closed `checkRights()` now applies to duplication
117
+
118
+ This is the security fix seen from the consumer's side: if your project overrides `checkRights()` and
119
+ duplicates files **without** passing a context, the GridFS driver used to copy anyway. It now refuses.
120
+
121
+ **Symptom:** `NotFoundException: File not found with filename …` (or `… with id …`) from a code path
122
+ that worked before, and no copy in the bucket.
123
+
124
+ **Fix:** pass `{ currentUser }` where a user is in scope, or `{ force: true }` where a role decorator
125
+ has already decided. Do **not** add `if (!options.currentUser) return true` to `checkRights()` — that
126
+ reads "system-internal call", but it is also exactly what an anonymous request looks like.
127
+
128
+ ### Break 2 — the return value of `duplicateByName()`
129
+
130
+ ```typescript
131
+ // 11.33.x and earlier, GridFS driver: the raw write stream
132
+ const stream = await fileService.duplicateByName('a.txt', 'b.txt');
133
+ stream.id; // ObjectId
134
+
135
+ // 11.34.0: the copy's file info, under every driver
136
+ const copy = await fileService.duplicateByName('a.txt', 'b.txt');
137
+ copy.id; // string
138
+ copy.filename; // 'b.txt'
139
+ ```
140
+
141
+ `.id` and `.filename` still answer (as a string and the new name), so the common uses keep working —
142
+ `String(result.id)` and `getStringIds(result.id)` are unaffected. Anything that treated the result as
143
+ a **stream** has to change. The declared return type moved from `Promise<any>` to
144
+ `Promise<CoreFileInfo>`, so TypeScript points at the sites for you. **This is the change that makes
145
+ the release a MINOR.**
146
+
147
+ This break is not avoidable: the GridFS branch could only be authorized by routing it through
148
+ `createFile()` like the other two drivers, and that is what produces a file info. The S3 and
149
+ filesystem branches already returned a `CoreFileInfo`, so this also removes a driver-conditional
150
+ return type that was never documented as one.
151
+
152
+ `duplicateById()` still returns the copy's id as a `string`. Unchanged.
153
+
154
+ ### The copy does not inherit the source's metadata
155
+
156
+ It never did, and it deliberately still does not: silently copying `metadata` would hand the
157
+ duplicate the **source's owner**, which is the one thing an ownership rule must not do behind the
158
+ caller's back. State the copy's own metadata instead:
159
+
160
+ ```typescript
161
+ await this.fileService.duplicateById(id, { currentUser, metadata: { copiedFrom: id, ownerId: currentUser.id } });
162
+ ```
163
+
164
+ Under a fail-closed rule a copy made without metadata is reachable by ADMIN only — that is
165
+ fail-closed, not lost data, and `getFileInfo(copyId, { force: true })` still finds it.
166
+
167
+ ### If you copied `FileService.duplicate()` out of `src/server`
168
+
169
+ The reference implementation was a direct GridFS pipe — and because it is a reference
170
+ implementation, this defect was **designed to be copied**:
171
+
172
+ ```typescript
173
+ // WRONG — and it was in the reference implementation until 11.34.0
174
+ async duplicate(fileName: string, newName: string): Promise<any> {
175
+ return this.files.openDownloadStreamByName(fileName).pipe(this.files.openUploadStream(newName));
176
+ }
177
+ ```
178
+
179
+ It only worked on the GridFS driver, bypassed `checkRights()` entirely, returned before the copy was
180
+ written, and carried no error handler on either stream (an unhandled stream `'error'` takes the
181
+ process down — the same defect as §2). It now delegates:
182
+
183
+ ```typescript
184
+ async duplicate(fileName: string, newName: string, serviceOptions?: FileServiceOptions): Promise<CoreFileInfo> {
185
+ return this.duplicateByName(fileName, newName, serviceOptions);
186
+ }
187
+ ```
188
+
189
+ **If you copied the old body into your own `FileService`, replace it with the delegation above.** The
190
+ framework fix does not reach a copy living in your project.
191
+
192
+ ---
193
+
194
+ ## 2. Crash: an aborted upload no longer takes the process down (GridFS **and** S3)
195
+
196
+ Two write paths handed a body stream to something that did not listen on the SOURCE. `pipe()` does
197
+ not forward source errors, and an unhandled `'error'` event is an uncaught exception in Node — which
198
+ ends the process. An aborted upload could take the API down.
199
+
200
+ | Write path | Reached by | Before | After |
201
+ |------------|-----------|--------|-------|
202
+ | `GridFSHelper.writeFileFromStream()` | every GridFS upload | **process crash** | rejects, partial upload aborted |
203
+ | `CoreS3Service.putObject()` with a body stream + `contentLength` | **tus finalization into S3** | **process crash** | rejects with the real cause |
204
+ | `CoreS3Service.putObject()` without a length | `createFile()` on the S3 driver | rejects (`streamToBuffer` throws) | unchanged |
205
+ | `FilesystemFileHelper.writeFile()` | every filesystem upload | rejects (`pipeline()` forwards both ends) | unchanged |
206
+
207
+ So the same aborted upload behaved in three different ways depending on `file.storage` — which is
208
+ the driver asymmetry this release keeps finding, in its most expensive form.
209
+
210
+ **GridFS.** The failed write is now aborted as well, so its chunks do not linger in `fs.chunks` with
211
+ no `fs.files` document naming them.
212
+
213
+ **S3.** The AWS SDK pipes the request body into its HTTP request without a source listener. Adding a
214
+ listener alone is *not* enough: the SDK's own pipeline still observes a stream that errored and
215
+ rejects a promise nothing awaits — an unhandled rejection, which Node also treats as fatal by
216
+ default. The body is therefore relayed through a `PassThrough` that simply **ends early** on source
217
+ failure, which the SDK reports as an ordinary short-body request error; the captured source error
218
+ then replaces the SDK's `socket hang up`, so the log names the cause rather than the symptom.
219
+
220
+ Nothing to do — but if you have been seeing unexplained restarts on a pod that accepts uploads (or
221
+ finalizes tus uploads into S3), this is the fix.
222
+
223
+ ---
224
+
225
+ ## 3. `deleteFile()` answers 404 for an unknown id, not 500
226
+
227
+ `deleteFileByName()` threw `NotFoundException` for a filename that does not exist. `deleteFile()` fell
228
+ through to the GridFS driver, which threw its own `MongoRuntimeError: File not found for id …` — an
229
+ HTTP **500** for the very same condition, under all three storage drivers.
230
+
231
+ ```typescript
232
+ await fileService.deleteFile(unknownId);
233
+ // 11.33.x: MongoRuntimeError → 500
234
+ // 11.34.0: NotFoundException → 404
235
+ ```
236
+
237
+ A **refusal** still answers `null` rather than throwing, which is unchanged and is the module-wide
238
+ contract: a refusal must be indistinguishable from a file that is not there.
239
+
240
+ **What to check:** any test or client code that asserts on the error of a delete-by-unknown-id. If
241
+ you catch `MongoRuntimeError` there, catch `NotFoundException` instead — or, better, treat 404 as the
242
+ idempotent-delete case.
243
+
244
+ ---
245
+
246
+ ## 4. `findFileInfo()` sorts correctly on a dotted path across stores
247
+
248
+ `SortInput.field` is a free string, and MongoDB reads `metadata.ownerId` as a path into the document.
249
+ Each store therefore returned its own page correctly ordered — and the merge that follows compared
250
+ `doc['metadata.ownerId']`, which is `undefined` for every row. Every comparison tied, and the merged
251
+ page came back **grouped by store**.
252
+
253
+ That is not a cosmetic ordering bug: `skip` / `limit` applied to a wrongly ordered merge returns
254
+ **different rows**, so a paginated listing sorted on a nested field silently showed some files twice
255
+ and others never.
256
+
257
+ Only the multi-store read path was affected — i.e. any `file.storage` other than `gridfs`, or a
258
+ GridFS deployment that also has files in `s3-files` / `filesystem-files` from a previous driver.
259
+
260
+ ---
261
+
262
+ ## 5. `findFileInfo()` can filter and sort on `contentType` under every driver
263
+
264
+ `s3-files` and `filesystem-files` carry `contentType` at the root of the metadata document. GridFS
265
+ keeps it inside `metadata` (the MongoDB driver dropped the top-level option in v7). So a filter on
266
+ `contentType` matched every S3 / filesystem file and **no** GridFS file:
267
+
268
+ | Driver | `findFileInfo({ filter: contentType == 'application/pdf' })` before | after |
269
+ |--------|--------------------------------------------------------------------|-------|
270
+ | `gridfs` | always `[]` | matches |
271
+ | `filesystem` / `s3` | matches new files, **silently drops every pre-switch GridFS file** | matches both |
272
+
273
+ `GridFSHelper.findFiles()` now rewrites a `contentType` key to `metadata.contentType` in both the
274
+ filter and the sort — the mirror image of the normalisation it already did on the way out. The
275
+ rewrite is deliberately narrow: only that one key, only where it names a field, recursing only
276
+ through `$and` / `$or` / `$nor`, and an already-qualified `metadata.contentType` is left alone.
277
+
278
+ **What you may notice:** a listing filtered on `contentType` that used to return nothing now returns
279
+ rows. If you depended on the empty result, filter on something else.
280
+
281
+ ---
282
+
283
+ ## 6. The Hub's Files panel covers all three storage drivers
284
+
285
+ `CoreHubDbService.getFiles()` / `deleteFile()` read GridFS directly, so under `file.storage: 's3'` or
286
+ `'filesystem'` the Hub's Files panel reported **0 files** and could not delete anything.
287
+
288
+ "0 files" is not "this panel does not cover your driver" — in an operator cockpit a confidently wrong
289
+ answer is worse than an honest gap, because it is the answer an operator acts on. Somebody checking
290
+ whether an upload landed would have concluded it had not.
291
+
292
+ The panel now reads the same three metadata sources `CoreFileService.findFileInfo()` does
293
+ (`fs.files`, `s3-files`, `filesystem-files`), merges them newest-first, and dispatches a delete to
294
+ whichever store actually holds the file. Each row carries a **Store** column, and the panel's tiles
295
+ name the stores consulted with a per-store count.
296
+
297
+ | Behaviour | Before | After |
298
+ |-----------|--------|-------|
299
+ | Listing under `file.storage: 'gridfs'` | GridFS files | unchanged, plus any files left in the other stores by a previous driver |
300
+ | Listing under `'s3'` / `'filesystem'` | **always empty** | the files that are actually there |
301
+ | Deleting an S3- or filesystem-backed file | **impossible** (`File not found`) | deletes the bytes AND the metadata document |
302
+ | Deleting a GridFS file | GridFS delete | unchanged |
303
+
304
+ Nothing to configure. Two operational notes:
305
+
306
+ - **S3 deletion needs `CoreS3Service` to be usable.** When `s3` is not configured, the Hub refuses an
307
+ S3-backed delete with `S3 storage is not available.` rather than deleting the metadata document and
308
+ orphaning the object in the bucket. A half-delete is unrecoverable in a way a refusal is not.
309
+ - **Reading never creates a collection.** A GridFS-only deployment does not grow an empty `s3-files`
310
+ / `filesystem-files` from the Hub looking at them — the same rule
311
+ `ensureFilenameIndex()` follows on the write path.
312
+
313
+ `HubFilesData` gained a `stores` summary and a `store` field per file. If you override
314
+ `CoreHubDbService` or consume `files.json` directly, both are additive.
315
+
316
+ ---
317
+
318
+ ## Compatibility Notes
319
+
320
+ | Pattern | Status | Notes |
321
+ |---------|--------|-------|
322
+ | Project that never calls `duplicateByName()` / `duplicateById()` | **Unchanged** | |
323
+ | `duplicateById(id)` with the framework's default `checkRights()` | **Unchanged** | The default returns `true`, so an omitted context behaves exactly as before |
324
+ | `duplicateByName(a, b)` reading `.id` / `.filename` off the result | **Compatible** | `.id` is a `string` now instead of an `ObjectId`; `String(...)` / `getStringIds(...)` are unaffected |
325
+ | `duplicateByName(a, b)` treating the result as a stream | **Action required** | §1 — it is a `CoreFileInfo` now |
326
+ | Overridden fail-closed `checkRights()` + duplication without a context | **Action required** | §1 — pass `{ currentUser }` or `{ force: true }` |
327
+ | `FileService.duplicate()` copied out of `src/server` before 11.34.0 | **Action required** | §1 — the copy still bypasses `checkRights()`; replace the body with the delegation |
328
+ | Test asserting `MongoRuntimeError` on delete-by-unknown-id | **Action required** | §3 — it is `NotFoundException` now |
329
+ | `findFileInfo()` sorted on a nested field, `file.storage` other than `gridfs` | **Rows changed (fixed)** | §4 |
330
+ | `findFileInfo()` filtered on `contentType` | **Rows changed (fixed)** | §5 |
331
+ | Hub Files panel under `file.storage: 's3'` / `'filesystem'` | **Rows changed (fixed)** | §6 — it showed nothing before |
332
+ | Project overriding `CoreFileService.deleteFile()` | **Review** | If you call `super.deleteFile()`, it may now throw where it previously returned |
333
+ | Project overriding `GridFSHelper` behaviour | **Review** | `findFiles()` rewrites one filter key; `writeFileFromStream()` gained source-error handling |
334
+ | Project overriding `CoreS3Service.putObject()` | **Review** | A body STREAM is now relayed through a `PassThrough` before it reaches the SDK (§2); Buffer and string bodies are untouched |
335
+ | Project overriding `CoreHubDbService.getFiles()` / `deleteFile()` | **Review** | Both are multi-store now; `HubFilesData` gained `stores` + per-file `store` (§6) |
336
+
337
+ ---
338
+
339
+ ## Troubleshooting
340
+
341
+ | Symptom | Cause | Fix |
342
+ |---------|-------|-----|
343
+ | `NotFoundException: File not found with filename …` from a duplicate that used to work | Fail-closed `checkRights()` now applies to duplication (§1) | Pass `{ currentUser }` or `{ force: true }` |
344
+ | `Property 'pipe' does not exist on type 'CoreFileInfo'` | §1 — the return type changed | Use `copy.id` / `copy.filename`, or `getFileStream(copy.id)` if you really need bytes |
345
+ | A delete that returned 500 now returns 404 | §3 — that is the fix | Treat 404 as the idempotent-delete case |
346
+ | A copied file is suddenly invisible to its owner | The copy records no owner unless you say so (§1) | `duplicateById(id, { currentUser, metadata: { ownerId: currentUser.id } })` |
347
+ | Hub Files panel says `S3 storage is not available.` on delete | §6 — the file's bytes are in S3, but `s3` is not configured in this process | Configure `s3` (and install `@aws-sdk/client-s3`), or delete through the file API |
348
+
349
+ ---
350
+
351
+ ## Verification
352
+
353
+ ```bash
354
+ pnpm run build
355
+ pnpm test
356
+ ```
357
+
358
+ Framework suites covering this release:
359
+
360
+ | Suite | Covers |
361
+ |-------|--------|
362
+ | `tests/file-duplicate.e2e-spec.ts` | Duplication under all three drivers: default context, owner context, stranger, missing context, content type, copy metadata (§1) |
363
+ | `tests/file-upload-stream-error.e2e-spec.ts` | An aborted upload rejects instead of crashing, on GridFS and on the S3 streaming path (§2) |
364
+ | `tests/file-missing-answers.e2e-spec.ts` | By-id vs by-name answers for a file that is not there (§3) |
365
+ | `tests/file-find-info.e2e-spec.ts` | Merged multi-store ordering, dotted sort paths, paging, `contentType` filtering, and that unused metadata collections stay absent (§4, §5) |
366
+ | `tests/tus-finalization.e2e-spec.ts` | A finished tus upload lands in the active driver, takes the S3→S3 copy path, and clears the staging bucket |
367
+ | `tests/unit/core-hub-db.service.spec.ts` | The Hub Files panel across all three metadata stores, and its delete dispatch (§6) |
368
+
369
+ Every defect above is additionally backed by a **registered mutation** in
370
+ `tests/regression-mutations.json`: `pnpm run check:mutations` restores each bug on purpose and
371
+ requires the suites that claim to catch it to go red. A regression test that was never seen failing
372
+ is a claim, not a check — this release is the one that stopped taking those on trust.
373
+
374
+ ---
375
+
376
+ ## Module Documentation
377
+
378
+ ### File
379
+
380
+ - [README](../src/core/modules/file/README.md) — § Access control, § Storage drivers
381
+ - [`CoreFileService`](../src/core/modules/file/core-file.service.ts)
382
+ - Reference implementation: `src/server/modules/file/`
383
+
384
+ ### Hub
385
+
386
+ - [README](../src/core/modules/hub/README.md) — § Files panel
387
+ - [`CoreHubDbService`](../src/core/modules/hub/services/core-hub-db.service.ts)
388
+
389
+ ---
390
+
391
+ ## References
392
+
393
+ - [11.32.x → 11.33.x](./11.32.x-to-11.33.x.md) — the release that introduced the three storage drivers and closed the file endpoints
394
+ - [11.32.3 → 11.32.4](./11.32.3-to-11.32.4.md) — the earlier round of GridFS upload/download hardening
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lenne.tech/nest-server",
3
- "version": "11.33.0",
3
+ "version": "11.34.0",
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",
@@ -24,8 +24,10 @@
24
24
  "c": "pnpm run check",
25
25
  "cf": "pnpm run check:fix",
26
26
  "check": "node scripts/check.mjs",
27
+ "check:consumer": "node scripts/check-consumer.mjs",
27
28
  "check:fix": "pnpm install && pnpm run spectaql:sync && pnpm audit --fix && pnpm run format && pnpm run lint:fix && pnpm run typecheck:tests && pnpm run check:swc-tdz && pnpm test && pnpm run build && pnpm run check:manifest && bash scripts/check-server-start.sh",
28
29
  "check:manifest": "node scripts/check-package-manifest.mjs",
30
+ "check:mutations": "node scripts/check-mutations.mjs",
29
31
  "check:naf": "pnpm install && pnpm run spectaql:sync && pnpm run format && pnpm run lint:fix && pnpm run typecheck:tests && pnpm run check:swc-tdz && pnpm test && pnpm run build && pnpm run check:manifest && bash scripts/check-server-start.sh",
30
32
  "check:raw": "pnpm install --frozen-lockfile && pnpm run spectaql:sync && pnpm audit && pnpm run format:check && pnpm run lint && pnpm run typecheck:tests && pnpm run check:swc-tdz && pnpm test && pnpm run build && pnpm run check:manifest && bash scripts/check-server-start.sh",
31
33
  "check:swc-tdz": "nest build -b swc -p tsconfig.swc-tdz.json && node scripts/check-swc-tdz.mjs",
package/src/config.env.ts CHANGED
@@ -2,6 +2,7 @@ import { CronExpression } from '@nestjs/schedule';
2
2
  import * as dotenv from 'dotenv';
3
3
  import { join } from 'path';
4
4
 
5
+ import { RoleEnum } from './core/common/enums/role.enum';
5
6
  import { getEnvironmentConfig } from './core/common/helpers/config.helper';
6
7
  import { IServerOptions } from './core/common/interfaces/server-options.interface';
7
8
 
@@ -100,6 +101,36 @@ const config: { [env: string]: IServerOptions } = {
100
101
  autoRegister: false,
101
102
  },
102
103
  execAfterInit: 'pnpm run docs:bootstrap',
104
+ // File access — the COARSE gate on the two inherited download routes
105
+ // (`GET /files/id/:id`, `GET /files/:filename`), which `FileController` inherits
106
+ // from `CoreFileController` unchanged so this config can reach them.
107
+ //
108
+ // Since 11.33.0 those routes are role-gated and default to `[ADMIN]`. That default
109
+ // is the safe one for a project that has not thought about file access yet, but it
110
+ // is the WRONG one for this reference server: it ships an avatar upload every
111
+ // signed-in user may use, so an admin-only download would make `user.avatar`
112
+ // unreadable by the very user it belongs to.
113
+ //
114
+ // So the gate is widened to "signed in" and the real decision is made per file in
115
+ // `FileService.checkRights()` (own file, or ADMIN). Roles can only answer "may this
116
+ // caller reach the route"; they can never answer "may this caller have THIS file".
117
+ // Widening here is therefore not a relaxation — it MOVES the decision to the layer
118
+ // that can actually make it. It is also what makes the pairing testable at all: under
119
+ // `[ADMIN]` the guard answers first and an ownership rule could never fire, which is
120
+ // how the two halves drifted apart and shipped a bug. See
121
+ // `migration-guides/11.32.x-to-11.33.x.md` § A.3.
122
+ //
123
+ // `uploadRoles` / `deleteRoles` stay at their `[ADMIN]` default. They govern
124
+ // `CoreFileResolver` members only, and this server registers its own `FileResolver`
125
+ // and `FileController` write endpoints, each carrying `@Roles(RoleEnum.ADMIN)`
126
+ // explicitly — so the two knobs are inert here, and leaving them unset keeps the
127
+ // framework default visible rather than restating it.
128
+ //
129
+ // NOTE for markup-driven downloads: anything stricter than `S_EVERYONE` only works
130
+ // from an `<img>` / `<a download>` when the session travels as a COOKIE on a
131
+ // same-site request. A Bearer-only frontend has to fetch such URLs programmatically
132
+ // and build an object URL.
133
+ file: { downloadRoles: [RoleEnum.S_USER] },
103
134
  filter: {
104
135
  maxLimit: null,
105
136
  },
@@ -224,6 +255,9 @@ const config: { [env: string]: IServerOptions } = {
224
255
  },
225
256
  env: 'development',
226
257
  execAfterInit: 'pnpm run docs:bootstrap',
258
+ // Coarse download gate widened to any signed-in user; the per-file rule lives in
259
+ // `FileService.checkRights()`. Full rationale in the `ci` block above.
260
+ file: { downloadRoles: [RoleEnum.S_USER] },
227
261
  filter: {
228
262
  maxLimit: null,
229
263
  },
@@ -376,6 +410,9 @@ const config: { [env: string]: IServerOptions } = {
376
410
  autoRegister: false,
377
411
  },
378
412
  execAfterInit: 'pnpm run docs:bootstrap',
413
+ // Coarse download gate widened to any signed-in user; the per-file rule lives in
414
+ // `FileService.checkRights()`. Full rationale in the `ci` block above.
415
+ file: { downloadRoles: [RoleEnum.S_USER] },
379
416
  filter: {
380
417
  maxLimit: null,
381
418
  },
@@ -534,6 +571,9 @@ const config: { [env: string]: IServerOptions } = {
534
571
  autoRegister: false,
535
572
  },
536
573
  execAfterInit: 'pnpm run docs:bootstrap',
574
+ // Coarse download gate widened to any signed-in user; the per-file rule lives in
575
+ // `FileService.checkRights()`. Full rationale in the `ci` block above.
576
+ file: { downloadRoles: [RoleEnum.S_USER] },
537
577
  filter: {
538
578
  maxLimit: null,
539
579
  },
@@ -670,6 +710,11 @@ const config: { [env: string]: IServerOptions } = {
670
710
  },
671
711
  env: 'production',
672
712
  execAfterInit: 'pnpm run docs:bootstrap',
713
+ // Coarse download gate widened to any signed-in user; the per-file rule lives in
714
+ // `FileService.checkRights()`. Full rationale in the `ci` block above. Deliberately the
715
+ // SAME value as every other environment: a per-file rule that only runs outside
716
+ // production is a rule nobody has actually tested where it matters.
717
+ file: { downloadRoles: [RoleEnum.S_USER] },
673
718
  filter: {
674
719
  maxLimit: null,
675
720
  },