@lenne.tech/nest-server 11.42.12 → 11.42.14
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/testing.md +4 -4
- package/FRAMEWORK-API.md +1 -1
- package/dist/core/modules/auth/guards/auth.guard.js +14 -0
- package/dist/core/modules/auth/guards/auth.guard.js.map +1 -1
- package/dist/core/modules/error-code/error-codes.d.ts +45 -0
- package/dist/core/modules/error-code/error-codes.js +40 -0
- package/dist/core/modules/error-code/error-codes.js.map +1 -1
- package/dist/core/modules/file/core-file.service.d.ts +1 -0
- package/dist/core/modules/file/core-file.service.js +27 -0
- package/dist/core/modules/file/core-file.service.js.map +1 -1
- package/dist/core/modules/tenant/core-tenant.guard.js +7 -7
- package/dist/core/modules/tenant/core-tenant.guard.js.map +1 -1
- package/dist/core/modules/tus/core-tus.service.d.ts +1 -0
- package/dist/core/modules/tus/core-tus.service.js +10 -4
- package/dist/core/modules/tus/core-tus.service.js.map +1 -1
- package/dist/core/modules/tus/tus.constants.d.ts +1 -0
- package/dist/core/modules/tus/tus.constants.js +2 -1
- package/dist/core/modules/tus/tus.constants.js.map +1 -1
- package/dist/server/modules/error-code/error-codes.d.ts +5 -0
- package/dist/tsconfig.build.tsbuildinfo +1 -1
- package/docs/REQUEST-LIFECYCLE.md +9 -1
- package/docs/error-codes.md +70 -0
- package/migration-guides/11.42.12-to-11.42.13.md +154 -0
- package/migration-guides/11.42.13-to-11.42.14.md +74 -0
- package/package.json +3 -3
- package/src/core/modules/api-token/README.md +5 -0
- package/src/core/modules/auth/guards/auth.guard.ts +31 -1
- package/src/core/modules/error-code/error-codes.ts +49 -0
- package/src/core/modules/file/README.md +1 -0
- package/src/core/modules/file/core-file.service.ts +39 -0
- package/src/core/modules/tenant/INTEGRATION-CHECKLIST.md +2 -2
- package/src/core/modules/tenant/README.md +17 -1
- package/src/core/modules/tenant/core-tenant.guard.ts +7 -7
- package/src/core/modules/tus/INTEGRATION-CHECKLIST.md +2 -1
- package/src/core/modules/tus/README.md +42 -3
- package/src/core/modules/tus/core-tus.service.ts +32 -6
- package/src/core/modules/tus/tus.constants.ts +11 -0
|
@@ -470,7 +470,7 @@ export class CoreTenantGuard implements CanActivate, OnApplicationBootstrap, OnM
|
|
|
470
470
|
}
|
|
471
471
|
const isVerified = !!(user.verified || user.verifiedAt || user.emailVerified);
|
|
472
472
|
if (!isVerified) {
|
|
473
|
-
throw new ForbiddenException(
|
|
473
|
+
throw new ForbiddenException(ErrorCode.VERIFICATION_REQUIRED);
|
|
474
474
|
}
|
|
475
475
|
if (headerTenantId && !hasSkipDecorator) {
|
|
476
476
|
return this.handleSystemRoleWithTenantHeader(user, headerTenantId, request, isAdmin);
|
|
@@ -536,7 +536,7 @@ export class CoreTenantGuard implements CanActivate, OnApplicationBootstrap, OnM
|
|
|
536
536
|
const membership = await this.findMembershipCached(user.id, headerTenantId);
|
|
537
537
|
|
|
538
538
|
if (!membership) {
|
|
539
|
-
throw new ForbiddenException(
|
|
539
|
+
throw new ForbiddenException(ErrorCode.NOT_TENANT_MEMBER);
|
|
540
540
|
}
|
|
541
541
|
await this.assertTenantActive(headerTenantId);
|
|
542
542
|
|
|
@@ -567,7 +567,7 @@ export class CoreTenantGuard implements CanActivate, OnApplicationBootstrap, OnM
|
|
|
567
567
|
tenantRoles.length > 0 && memberRole !== null && checkRoleAccess(tenantRoles, undefined, memberRole);
|
|
568
568
|
|
|
569
569
|
if (!satisfiedGlobally && !satisfiedByTenant) {
|
|
570
|
-
throw new ForbiddenException(
|
|
570
|
+
throw new ForbiddenException(ErrorCode.INSUFFICIENT_TENANT_ROLE);
|
|
571
571
|
}
|
|
572
572
|
}
|
|
573
573
|
|
|
@@ -595,7 +595,7 @@ export class CoreTenantGuard implements CanActivate, OnApplicationBootstrap, OnM
|
|
|
595
595
|
|
|
596
596
|
// Check role access against user.roles (hierarchy: level comparison, normal: exact match)
|
|
597
597
|
if (!checkRoleAccess(checkableRoles, user.roles, undefined)) {
|
|
598
|
-
throw new ForbiddenException(
|
|
598
|
+
throw new ForbiddenException(ErrorCode.INSUFFICIENT_ROLE);
|
|
599
599
|
}
|
|
600
600
|
|
|
601
601
|
// Resolve tenant IDs filtered by minimum required hierarchy level
|
|
@@ -904,7 +904,7 @@ export class CoreTenantGuard implements CanActivate, OnApplicationBootstrap, OnM
|
|
|
904
904
|
/** 403 for a deactivated tenant. */
|
|
905
905
|
private async assertTenantActive(tenantId: string): Promise<void> {
|
|
906
906
|
if (!(await this.isTenantActive(tenantId))) {
|
|
907
|
-
throw new ForbiddenException(
|
|
907
|
+
throw new ForbiddenException(ErrorCode.TENANT_INACTIVE);
|
|
908
908
|
}
|
|
909
909
|
}
|
|
910
910
|
|
|
@@ -946,7 +946,7 @@ export class CoreTenantGuard implements CanActivate, OnApplicationBootstrap, OnM
|
|
|
946
946
|
|
|
947
947
|
const membership = await this.findMembershipCached(user.id, headerTenantId);
|
|
948
948
|
if (!membership) {
|
|
949
|
-
throw new ForbiddenException(
|
|
949
|
+
throw new ForbiddenException(ErrorCode.NOT_TENANT_MEMBER);
|
|
950
950
|
}
|
|
951
951
|
await this.assertTenantActive(headerTenantId);
|
|
952
952
|
request.tenantId = headerTenantId;
|
|
@@ -965,7 +965,7 @@ export class CoreTenantGuard implements CanActivate, OnApplicationBootstrap, OnM
|
|
|
965
965
|
throw new UnauthorizedException(ErrorCode.UNAUTHORIZED);
|
|
966
966
|
}
|
|
967
967
|
if (!isAdmin && !checkRoleAccess(checkableRoles, user.roles, undefined)) {
|
|
968
|
-
throw new ForbiddenException(
|
|
968
|
+
throw new ForbiddenException(ErrorCode.INSUFFICIENT_ROLE);
|
|
969
969
|
}
|
|
970
970
|
}
|
|
971
971
|
return true;
|
|
@@ -151,7 +151,7 @@ export class TusService extends CoreTusService {
|
|
|
151
151
|
|
|
152
152
|
// After the last byte.
|
|
153
153
|
protected override async onUploadComplete(upload: Upload): Promise<void> {
|
|
154
|
-
await super.onUploadComplete(upload); //
|
|
154
|
+
await super.onUploadComplete(upload); // stores the file and records its ID for X-File-Id (11.42.13+)
|
|
155
155
|
// custom logic
|
|
156
156
|
}
|
|
157
157
|
}
|
|
@@ -243,6 +243,7 @@ TusModule.forRoot({ config: false });
|
|
|
243
243
|
| Forgot to register custom service | `onUploadCreate` / `onUploadComplete` overrides never run | Add `service: TusService` to `forRoot()` (11.41.9+) — Step 2 |
|
|
244
244
|
| Constructor dependencies on the service subclass | `undefined` dependencies, or a crash at boot | Resolve them at call time via `this.options?.moduleRef` — Step 2 |
|
|
245
245
|
| Override of `onUploadCreate` without `super` | Uploads have no owner or tenant; `allowedTypes` ignored | `return super.onUploadCreate(req, upload)` — Step 2 |
|
|
246
|
+
| Override of `onUploadComplete` without `super` | No `X-File-Id`; links built from the TUS URL answer `404` | Call `super.onUploadComplete(upload)` with the upload received (11.42.13+) — Step 2 |
|
|
246
247
|
| Upload succeeds but the file cannot be downloaded | `403` on `GET /files/id/:id` | `file.downloadRoles` defaults to `[ADMIN]` — widen it, or authorize per file in `checkRights()` |
|
|
247
248
|
| Using wrong endpoint path | 404 on upload | Ensure client uses same path as config |
|
|
248
249
|
|
|
@@ -111,6 +111,7 @@ The TUS server automatically handles CORS headers for browser-based clients:
|
|
|
111
111
|
- `Upload-Length`
|
|
112
112
|
- `Upload-Offset`
|
|
113
113
|
- `Upload-Metadata`
|
|
114
|
+
- `X-File-Id` (11.42.13+) — the ID of the stored file, see [The ID of the Stored File](#the-id-of-the-stored-file-114213)
|
|
114
115
|
|
|
115
116
|
**`Access-Control-Allow-Origin` follows the API's CORS configuration (11.42.1+).** The tus handler
|
|
116
117
|
writes its responses itself, and without `allowedOrigins` `@tus/server` answers `*`. With cookie
|
|
@@ -265,11 +266,19 @@ const upload = new Upload(file, {
|
|
|
265
266
|
const percentage = ((bytesUploaded / bytesTotal) * 100).toFixed(2);
|
|
266
267
|
console.log(`${percentage}%`);
|
|
267
268
|
},
|
|
269
|
+
// The response that completes the upload names the stored file (11.42.13+).
|
|
270
|
+
onAfterResponse: (_req, res) => {
|
|
271
|
+
const id = res.getHeader('X-File-Id');
|
|
272
|
+
if (id) {
|
|
273
|
+
fileId = id;
|
|
274
|
+
}
|
|
275
|
+
},
|
|
268
276
|
onSuccess: () => {
|
|
269
|
-
|
|
270
|
-
console.log('
|
|
277
|
+
// `upload.url` is the TUS upload URL — its last segment is NOT the file id.
|
|
278
|
+
console.log('Upload complete, file:', `/files/id/${fileId}`);
|
|
271
279
|
},
|
|
272
280
|
});
|
|
281
|
+
let fileId: string | undefined;
|
|
273
282
|
|
|
274
283
|
// Start or resume upload
|
|
275
284
|
upload.start();
|
|
@@ -423,6 +432,10 @@ export class TusService extends CoreTusService {
|
|
|
423
432
|
}
|
|
424
433
|
```
|
|
425
434
|
|
|
435
|
+
Call `super.onUploadComplete(upload)` with the `upload` you received: it stores the file and records
|
|
436
|
+
its ID for the `X-File-Id` response header (11.42.13+). An override that never calls `super` still
|
|
437
|
+
completes the upload, but the client receives no `X-File-Id`.
|
|
438
|
+
|
|
426
439
|
### Tenant Scoping (11.41.9+)
|
|
427
440
|
|
|
428
441
|
**Only with `multiTenancy` configured.** Without it nothing changes: no header is read, no field is
|
|
@@ -487,6 +500,31 @@ query {
|
|
|
487
500
|
|
|
488
501
|
**Recommendation:** Use the ID-based endpoint (`/files/id/:id`) for TUS uploads as filenames may not be unique.
|
|
489
502
|
|
|
503
|
+
### The ID of the Stored File (11.42.13+)
|
|
504
|
+
|
|
505
|
+
The response that completes an upload — the last `PATCH`, or the `POST` of a creation-with-upload —
|
|
506
|
+
carries the ID of the stored file:
|
|
507
|
+
|
|
508
|
+
```
|
|
509
|
+
HTTP/1.1 204 No Content
|
|
510
|
+
Upload-Offset: 52428800
|
|
511
|
+
X-File-Id: 6720c1f0a2b4c8e9d0f1a2b3
|
|
512
|
+
Access-Control-Expose-Headers: …, X-File-Id
|
|
513
|
+
```
|
|
514
|
+
|
|
515
|
+
Read it from that response and use it for `/files/id/:id`. **Do not take the last segment of the
|
|
516
|
+
TUS URL** (`upload.url`): that is the TUS UPLOAD id, a key of the staging store, and `/files/id/` answers
|
|
517
|
+
it with 404. Until 11.42.13 the header did not exist and the TUS URL was all a client had.
|
|
518
|
+
|
|
519
|
+
The header is sent on the completing request only — creation and partial `PATCH`es store nothing yet.
|
|
520
|
+
It is listed in `Access-Control-Expose-Headers`, so a browser on a separate app origin can read it. The
|
|
521
|
+
name is exported as `TUS_FILE_ID_HEADER`.
|
|
522
|
+
|
|
523
|
+
A project override of `onUploadComplete()` keeps the header as long as it calls `super` — see
|
|
524
|
+
[Custom Upload Handler](#custom-upload-handler). The hook's signature did not change. A client must
|
|
525
|
+
still cope with a missing header (an older server, or an override that stores the file itself): treat
|
|
526
|
+
it as "no file id known", never as a reason to fall back to the TUS URL.
|
|
527
|
+
|
|
490
528
|
> **These download routes are gated.** They require `file.downloadRoles`, which defaults to
|
|
491
529
|
> `[RoleEnum.ADMIN]` — while uploading here only requires `tus.roles` (default `S_USER`). So out of
|
|
492
530
|
> the box a signed-in user can upload but cannot read their own file back.
|
|
@@ -634,7 +672,8 @@ calls `getServer().handle()`. No change to `main.ts` is needed.
|
|
|
634
672
|
3. Client: PATCH /tus/:id (send chunks)
|
|
635
673
|
4. Server: Returns Upload-Offset
|
|
636
674
|
5. Client: Repeat PATCH until complete
|
|
637
|
-
6. Server: Migrate to
|
|
675
|
+
6. Server: Migrate to the configured file storage, cleanup temp file
|
|
676
|
+
7. Server: Answer the completing PATCH with X-File-Id (the stored file's ID)
|
|
638
677
|
```
|
|
639
678
|
|
|
640
679
|
### File Storage
|
|
@@ -21,7 +21,7 @@ import {
|
|
|
21
21
|
FilesystemFileHelper,
|
|
22
22
|
} from '../file/filesystem-file.helper';
|
|
23
23
|
import { S3_FILES_COLLECTION, S3FileHelper } from '../file/s3-file.helper';
|
|
24
|
-
import { TUS_OWNER_METADATA_KEY, TUS_TENANT_METADATA_KEY } from './tus.constants';
|
|
24
|
+
import { TUS_FILE_ID_HEADER, TUS_OWNER_METADATA_KEY, TUS_TENANT_METADATA_KEY } from './tus.constants';
|
|
25
25
|
import { TusRedisLocker } from './tus-redis-locker';
|
|
26
26
|
import {
|
|
27
27
|
DEFAULT_TUS_ALLOWED_HEADERS,
|
|
@@ -86,6 +86,17 @@ export class CoreTusService implements OnModuleDestroy, OnModuleInit {
|
|
|
86
86
|
private files: mongo.GridFSBucket;
|
|
87
87
|
private cleanupInterval: NodeJS.Timeout | null = null;
|
|
88
88
|
|
|
89
|
+
/**
|
|
90
|
+
* The ID of the stored file per finished upload, recorded by {@link CoreTusService.onUploadComplete}
|
|
91
|
+
* and read by `onUploadFinish` for the `X-File-Id` response header.
|
|
92
|
+
*
|
|
93
|
+
* Recorded on the side rather than returned, so the hook keeps its `Promise<void>` signature: a
|
|
94
|
+
* project override in either documented shape — `await super.onUploadComplete(upload)` or
|
|
95
|
+
* `return super.onUploadComplete(upload)` — still compiles AND still yields the header. Keyed by
|
|
96
|
+
* the `Upload` object `onUploadFinish` hands to the hook, so nothing outlives the request.
|
|
97
|
+
*/
|
|
98
|
+
private readonly storedFileIds = new WeakMap<Upload, string>();
|
|
99
|
+
|
|
89
100
|
/** Datastore of the TUS server; set when uploads are staged in S3 */
|
|
90
101
|
protected s3Store: any = null;
|
|
91
102
|
|
|
@@ -183,10 +194,15 @@ export class CoreTusService implements OnModuleDestroy, OnModuleInit {
|
|
|
183
194
|
}
|
|
184
195
|
|
|
185
196
|
/**
|
|
186
|
-
* Handle upload completion - migrate
|
|
197
|
+
* Handle upload completion - migrate the finished upload into the configured file storage
|
|
187
198
|
*
|
|
188
199
|
* This method can be overridden in extending services to customize
|
|
189
200
|
* what happens after an upload completes.
|
|
201
|
+
*
|
|
202
|
+
* Records the ID of the stored file, which the completing response sends to the client as
|
|
203
|
+
* `X-File-Id` (`TUS_FILE_ID_HEADER`). An override must call `super.onUploadComplete(upload)` with
|
|
204
|
+
* the `upload` it received; one that does not stores nothing here and sends no header — the client
|
|
205
|
+
* then knows the TUS URL alone, whose last segment is the upload id, not the file id.
|
|
190
206
|
*/
|
|
191
207
|
protected async onUploadComplete(upload: Upload): Promise<void> {
|
|
192
208
|
try {
|
|
@@ -231,6 +247,7 @@ export class CoreTusService implements OnModuleDestroy, OnModuleInit {
|
|
|
231
247
|
`Upload ${upload.id} copied inside S3 to ${fileInfo._id} (filename: ${filename}) — no bytes through this process`,
|
|
232
248
|
);
|
|
233
249
|
await this.deleteStagedUpload(upload.id);
|
|
250
|
+
this.storedFileIds.set(upload, String(fileInfo._id));
|
|
234
251
|
return;
|
|
235
252
|
}
|
|
236
253
|
|
|
@@ -239,15 +256,16 @@ export class CoreTusService implements OnModuleDestroy, OnModuleInit {
|
|
|
239
256
|
return;
|
|
240
257
|
}
|
|
241
258
|
|
|
259
|
+
let fileInfo: { _id: unknown };
|
|
242
260
|
if (this.fileStorageDriver === 'filesystem') {
|
|
243
|
-
|
|
261
|
+
fileInfo = await FilesystemFileHelper.writeFile(
|
|
244
262
|
this.fileStorageDir,
|
|
245
263
|
this.connection.db.collection(FILESYSTEM_FILES_COLLECTION),
|
|
246
264
|
{ body: readStream, contentType, filename, metadata: fileMetadata },
|
|
247
265
|
);
|
|
248
266
|
this.logger.debug(`Upload ${upload.id} migrated to the filesystem as ${fileInfo._id} (filename: ${filename})`);
|
|
249
267
|
} else if (this.s3FileStorage) {
|
|
250
|
-
|
|
268
|
+
fileInfo = await S3FileHelper.writeFile(
|
|
251
269
|
this.options.s3Service,
|
|
252
270
|
this.connection.db.collection(S3_FILES_COLLECTION),
|
|
253
271
|
// Streamed with the length TUS already knows, so a multi-GB resumable upload is never
|
|
@@ -256,7 +274,7 @@ export class CoreTusService implements OnModuleDestroy, OnModuleInit {
|
|
|
256
274
|
);
|
|
257
275
|
this.logger.debug(`Upload ${upload.id} migrated to S3 as ${fileInfo._id} (filename: ${filename})`);
|
|
258
276
|
} else {
|
|
259
|
-
|
|
277
|
+
fileInfo = await GridFSHelper.writeFileFromStream(this.files, readStream, {
|
|
260
278
|
contentType,
|
|
261
279
|
filename,
|
|
262
280
|
metadata: fileMetadata,
|
|
@@ -266,6 +284,7 @@ export class CoreTusService implements OnModuleDestroy, OnModuleInit {
|
|
|
266
284
|
|
|
267
285
|
// Clean up the staged upload
|
|
268
286
|
await this.deleteStagedUpload(upload.id);
|
|
287
|
+
this.storedFileIds.set(upload, String(fileInfo._id));
|
|
269
288
|
} catch (error) {
|
|
270
289
|
this.logger.error(`Failed to migrate upload ${upload.id}: ${error.message}`);
|
|
271
290
|
throw error;
|
|
@@ -837,6 +856,9 @@ export class CoreTusService implements OnModuleDestroy, OnModuleInit {
|
|
|
837
856
|
// which a browser refuses on a credentialed (cookie) request — see resolveCorsOrigins().
|
|
838
857
|
...(cors ? { allowedCredentials: true, allowedOrigins: cors } : {}),
|
|
839
858
|
datastore,
|
|
859
|
+
// Appended to the tus protocol headers in `Access-Control-Expose-Headers`: a browser on a
|
|
860
|
+
// separate app origin cannot read a response header that is not listed there.
|
|
861
|
+
exposedHeaders: [TUS_FILE_ID_HEADER],
|
|
840
862
|
...(locker ? { locker } : {}),
|
|
841
863
|
maxSize: this.config.maxSize,
|
|
842
864
|
// Every request that names an upload — HEAD (offset), PATCH (append bytes), DELETE
|
|
@@ -864,10 +886,14 @@ export class CoreTusService implements OnModuleDestroy, OnModuleInit {
|
|
|
864
886
|
const relative = this.config.relativeLocation === true || (server as any)?.options?.relativeLocation === true;
|
|
865
887
|
return relative ? url : `${proto}://${host}${url}`;
|
|
866
888
|
},
|
|
889
|
+
// The response that completes the upload names the stored file. Without it the client knows
|
|
890
|
+
// only the TUS URL, whose last segment is the upload id — a staging key `/files/id/` answers
|
|
891
|
+
// with 404. An override of onUploadComplete() that never calls super records nothing: no header.
|
|
867
892
|
onUploadFinish: async (_req, upload) => {
|
|
868
893
|
try {
|
|
869
894
|
await this.onUploadComplete(upload);
|
|
870
|
-
|
|
895
|
+
const fileId = this.storedFileIds.get(upload);
|
|
896
|
+
return fileId ? { headers: { [TUS_FILE_ID_HEADER]: fileId } } : {};
|
|
871
897
|
} catch (error) {
|
|
872
898
|
// Do NOT swallow this. `onUploadComplete` is what moves the finished upload into its
|
|
873
899
|
// permanent store; returning `{}` after it failed answers the client with a 204 that
|
|
@@ -51,3 +51,14 @@ export const TUS_OWNER_METADATA_KEY = 'ltOwnerId';
|
|
|
51
51
|
* resolves to a tenant they are an active member of; otherwise it is `null`.
|
|
52
52
|
*/
|
|
53
53
|
export const TUS_TENANT_METADATA_KEY = 'ltTenantId';
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Response header naming the ID of the stored file, sent on the request that COMPLETES a tus upload
|
|
57
|
+
* (the last PATCH, or the POST of a creation-with-upload).
|
|
58
|
+
*
|
|
59
|
+
* Without it a client knows only the TUS URL, whose last segment is the tus UPLOAD id — a key of the
|
|
60
|
+
* staging store, which `/files/id/<id>` answers with 404. The header is also listed in
|
|
61
|
+
* `Access-Control-Expose-Headers`, because a browser on a separate app origin cannot read it
|
|
62
|
+
* otherwise.
|
|
63
|
+
*/
|
|
64
|
+
export const TUS_FILE_ID_HEADER = 'X-File-Id';
|