@lenne.tech/nest-server 11.42.11 → 11.42.13
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude/rules/configurable-features.md +1 -1
- package/.claude/rules/package-management.md +12 -5
- package/.claude/rules/testing.md +4 -4
- package/FRAMEWORK-API.md +1 -1
- package/dist/core/common/helpers/cookies.helper.d.ts +3 -2
- package/dist/core/common/helpers/cookies.helper.js +13 -4
- package/dist/core/common/helpers/cookies.helper.js.map +1 -1
- package/dist/core/common/helpers/outbound-url.helper.js +2 -5
- package/dist/core/common/helpers/outbound-url.helper.js.map +1 -1
- package/dist/core/modules/auth/core-auth.controller.js +2 -1
- package/dist/core/modules/auth/core-auth.controller.js.map +1 -1
- package/dist/core/modules/auth/core-auth.resolver.js +2 -1
- package/dist/core/modules/auth/core-auth.resolver.js.map +1 -1
- package/dist/core/modules/better-auth/core-better-auth-api.middleware.js +1 -0
- package/dist/core/modules/better-auth/core-better-auth-api.middleware.js.map +1 -1
- package/dist/core/modules/better-auth/core-better-auth-cookie.helper.d.ts +2 -0
- package/dist/core/modules/better-auth/core-better-auth-cookie.helper.js +2 -1
- package/dist/core/modules/better-auth/core-better-auth-cookie.helper.js.map +1 -1
- package/dist/core/modules/better-auth/core-better-auth-email-verification.service.js +7 -2
- package/dist/core/modules/better-auth/core-better-auth-email-verification.service.js.map +1 -1
- package/dist/core/modules/better-auth/core-better-auth.controller.js +1 -0
- package/dist/core/modules/better-auth/core-better-auth.controller.js.map +1 -1
- package/dist/core/modules/better-auth/core-better-auth.service.d.ts +1 -0
- package/dist/core/modules/better-auth/core-better-auth.service.js +4 -0
- package/dist/core/modules/better-auth/core-better-auth.service.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 +1 -1
- package/docs/error-codes.md +70 -0
- package/migration-guides/11.42.10-to-11.42.11.md +18 -2
- package/migration-guides/11.42.11-to-11.42.12.md +142 -0
- package/migration-guides/11.42.12-to-11.42.13.md +154 -0
- package/package.json +2 -2
- package/src/core/common/helpers/cookies.helper.ts +50 -6
- package/src/core/common/helpers/outbound-url.helper.ts +4 -10
- package/src/core/modules/auth/core-auth.controller.ts +2 -1
- package/src/core/modules/auth/core-auth.resolver.ts +2 -1
- package/src/core/modules/better-auth/README.md +1 -0
- package/src/core/modules/better-auth/core-better-auth-api.middleware.ts +1 -0
- package/src/core/modules/better-auth/core-better-auth-cookie.helper.ts +24 -5
- package/src/core/modules/better-auth/core-better-auth-email-verification.service.ts +20 -10
- package/src/core/modules/better-auth/core-better-auth.controller.ts +4 -0
- package/src/core/modules/better-auth/core-better-auth.service.ts +24 -0
- 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
|
@@ -81,6 +81,16 @@ export interface BetterAuthCookieHelperConfig {
|
|
|
81
81
|
* CRITICAL: Required for Passkey/2FA to work correctly.
|
|
82
82
|
*/
|
|
83
83
|
secret?: string;
|
|
84
|
+
/**
|
|
85
|
+
* Whether Better-Auth's own handlers set `Secure` on the cookies they write
|
|
86
|
+
* (`CoreBetterAuthService.usesSecureCookies()`). `true` makes this helper set it too, whatever the
|
|
87
|
+
* environment, so one session cookie never carries two different transport rules.
|
|
88
|
+
*
|
|
89
|
+
* @since 11.42.12 Before, `secure` followed {@link isProductionLikeEnv} alone, so on the deployed
|
|
90
|
+
* `develop` / `test` stages of a starter-based project the cookie this helper wrote
|
|
91
|
+
* lacked `Secure` while Better-Auth's handlers set it on the same https deployment.
|
|
92
|
+
*/
|
|
93
|
+
secureTransport?: boolean;
|
|
84
94
|
}
|
|
85
95
|
|
|
86
96
|
/**
|
|
@@ -146,9 +156,11 @@ export class BetterAuthCookieHelper {
|
|
|
146
156
|
/**
|
|
147
157
|
* Gets the default cookie options for authentication cookies.
|
|
148
158
|
*
|
|
149
|
-
* The `secure` flag is `true` when
|
|
150
|
-
*
|
|
151
|
-
* staging
|
|
159
|
+
* The `secure` flag is `true` when Better-Auth's own cookies are secure (`config.secureTransport`,
|
|
160
|
+
* i.e. an `https://` base URL unless the project opted out), when `config.env` is production-like
|
|
161
|
+
* (`production` / `staging`), or when `NODE_ENV === 'production'`. The first covers every https
|
|
162
|
+
* deployment whatever its environment name; the other two keep `Secure` where real users are
|
|
163
|
+
* certain even if a project switched it off in Better-Auth.
|
|
152
164
|
*
|
|
153
165
|
* @returns Cookie options with httpOnly, sameSite, and secure settings
|
|
154
166
|
*/
|
|
@@ -156,7 +168,7 @@ export class BetterAuthCookieHelper {
|
|
|
156
168
|
const options: AuthCookieOptions = {
|
|
157
169
|
httpOnly: true,
|
|
158
170
|
sameSite: 'lax',
|
|
159
|
-
secure: isProductionLikeEnv(this.config.env),
|
|
171
|
+
secure: this.config.secureTransport === true || isProductionLikeEnv(this.config.env),
|
|
160
172
|
};
|
|
161
173
|
|
|
162
174
|
if (this.config.domain) {
|
|
@@ -403,7 +415,13 @@ export class BetterAuthCookieHelper {
|
|
|
403
415
|
*/
|
|
404
416
|
export function createCookieHelper(
|
|
405
417
|
basePath: string,
|
|
406
|
-
options?: {
|
|
418
|
+
options?: {
|
|
419
|
+
domain?: string;
|
|
420
|
+
env?: string;
|
|
421
|
+
legacyCookieEnabled?: boolean;
|
|
422
|
+
secret?: string;
|
|
423
|
+
secureTransport?: boolean;
|
|
424
|
+
},
|
|
407
425
|
logger?: Logger,
|
|
408
426
|
): BetterAuthCookieHelper {
|
|
409
427
|
return new BetterAuthCookieHelper({
|
|
@@ -413,5 +431,6 @@ export function createCookieHelper(
|
|
|
413
431
|
legacyCookieEnabled: options?.legacyCookieEnabled ?? false,
|
|
414
432
|
logger,
|
|
415
433
|
secret: options?.secret,
|
|
434
|
+
secureTransport: options?.secureTransport,
|
|
416
435
|
});
|
|
417
436
|
}
|
|
@@ -4,7 +4,7 @@ import ejs = require('ejs');
|
|
|
4
4
|
import * as fs from 'fs';
|
|
5
5
|
import * as path from 'path';
|
|
6
6
|
|
|
7
|
-
import { isProductionLikeEnv, resolveAppUrlFromConfig } from '../../common/helpers/cookies.helper';
|
|
7
|
+
import { isLocalEnv, isProductionLikeEnv, resolveAppUrlFromConfig } from '../../common/helpers/cookies.helper';
|
|
8
8
|
import { maskEmail } from '../../common/helpers/logging.helper';
|
|
9
9
|
import { IBetterAuthEmailVerificationConfig } from '../../common/interfaces/server-options.interface';
|
|
10
10
|
import { BrevoService } from '../../common/services/brevo.service';
|
|
@@ -838,25 +838,35 @@ export class CoreBetterAuthEmailVerificationService {
|
|
|
838
838
|
* Print an auth link (verification / password reset) to stdout for local development.
|
|
839
839
|
*
|
|
840
840
|
* The URL embeds a bearer token: the verification link confirms an address, the reset link
|
|
841
|
-
* changes a password outright. Neither may reach a deployment
|
|
842
|
-
*
|
|
843
|
-
*
|
|
844
|
-
* `
|
|
845
|
-
*
|
|
841
|
+
* changes a password outright. Neither may reach a deployment's logs, so the link is printed only
|
|
842
|
+
* in a LOCAL environment (`isLocalEnv()`: `local`, `ci`, `e2e`) — an allowlist, not "anything
|
|
843
|
+
* that is not production". Until 11.42.12 the gate was `!isProductionLikeEnv()`, which names only
|
|
844
|
+
* `production` and `staging`; the deployed `develop` and `test` stages of a starter-based project
|
|
845
|
+
* passed it and wrote live reset links into their log pipeline, where log access alone was
|
|
846
|
+
* enough to take over an account.
|
|
847
|
+
*
|
|
848
|
+
* `LT_LOG_AUTH_URLS=1` opts in for another environment that runs on a developer machine (this
|
|
849
|
+
* repository's own `development`, for one); `LT_LOG_AUTH_URLS=0` suppresses it everywhere. A
|
|
850
|
+
* production-like environment never prints, whatever the variable says: the opt-in exists for
|
|
851
|
+
* laptops, and a hard floor keeps it from being the thing that reopens a deployment.
|
|
846
852
|
*
|
|
847
853
|
* The address is masked (PII, and the rest of this file already masks it); the URL is printed in
|
|
848
|
-
* full because following it without a mail server is the whole point locally.
|
|
849
|
-
* `LT_LOG_AUTH_URLS=0` to suppress it even outside production.
|
|
854
|
+
* full because following it without a mail server is the whole point locally.
|
|
850
855
|
*
|
|
851
856
|
* `console.log` rather than the NestJS logger, deliberately and for two reasons: the logger
|
|
852
857
|
* buffers, which makes interception unreliable under Vitest, and a logger call would route the
|
|
853
858
|
* token into `HubLogBufferService`'s ring buffer, which is readable over HTTP by any ADMIN.
|
|
854
859
|
*/
|
|
855
860
|
protected logAuthUrlForDevelopment(label: string, email: string, url: string): void {
|
|
856
|
-
|
|
861
|
+
const optIn = process.env.LT_LOG_AUTH_URLS;
|
|
862
|
+
if (optIn === '0') {
|
|
863
|
+
return;
|
|
864
|
+
}
|
|
865
|
+
const env = this.configService?.getFastButReadOnly<string>('env') ?? process.env.NODE_ENV;
|
|
866
|
+
if (isProductionLikeEnv(env)) {
|
|
857
867
|
return;
|
|
858
868
|
}
|
|
859
|
-
if (
|
|
869
|
+
if (!isLocalEnv(env) && optIn !== '1') {
|
|
860
870
|
return;
|
|
861
871
|
}
|
|
862
872
|
// oxlint-disable-next-line no-console
|
|
@@ -268,6 +268,10 @@ export class CoreBetterAuthController {
|
|
|
268
268
|
env: this.configService.getFastButReadOnly<string>('env'),
|
|
269
269
|
legacyCookieEnabled: legacyAuthEnabled,
|
|
270
270
|
secret: betterAuthConfig?.secret,
|
|
271
|
+
// Mirror Better-Auth's own `Secure` decision, so the cookie this controller writes and the
|
|
272
|
+
// one Better-Auth's handlers write follow the same transport rule on every https deployment.
|
|
273
|
+
// Optional call: a project's test stub of the service may predate the method.
|
|
274
|
+
secureTransport: this.betterAuthService.usesSecureCookies?.() === true,
|
|
271
275
|
},
|
|
272
276
|
this.logger,
|
|
273
277
|
);
|
|
@@ -703,6 +703,30 @@ export class CoreBetterAuthService implements OnApplicationBootstrap, OnModuleIn
|
|
|
703
703
|
return this.config.baseUrl || 'http://localhost:3000';
|
|
704
704
|
}
|
|
705
705
|
|
|
706
|
+
/**
|
|
707
|
+
* Whether Better-Auth's own handlers set `Secure` on the cookies they write, read off the live
|
|
708
|
+
* instance.
|
|
709
|
+
*
|
|
710
|
+
* `createBetterAuthInstance()` pins `useSecureCookies: false` (for the cookie NAME) and restores
|
|
711
|
+
* the transport flag through `advanced.defaultCookieAttributes.secure` on an `https://` base URL;
|
|
712
|
+
* a project may override either. Reading the instance rather than re-deriving from a URL gives
|
|
713
|
+
* exactly the answer Better-Auth applies — including the base URL it actually uses, which
|
|
714
|
+
* {@link getBaseUrl} does not know when only the server's `baseUrl` is set.
|
|
715
|
+
*
|
|
716
|
+
* The framework's own cookie helper mirrors this, so a session cookie carries `Secure` no matter
|
|
717
|
+
* which path wrote it.
|
|
718
|
+
*
|
|
719
|
+
* @since 11.42.12
|
|
720
|
+
*/
|
|
721
|
+
usesSecureCookies(): boolean {
|
|
722
|
+
const advanced = (
|
|
723
|
+
this.getInstance() as unknown as {
|
|
724
|
+
options?: { advanced?: { defaultCookieAttributes?: { secure?: unknown }; useSecureCookies?: unknown } };
|
|
725
|
+
} | null
|
|
726
|
+
)?.options?.advanced;
|
|
727
|
+
return advanced?.useSecureCookies === true || advanced?.defaultCookieAttributes?.secure === true;
|
|
728
|
+
}
|
|
729
|
+
|
|
706
730
|
/**
|
|
707
731
|
* Gets the resolved cookie domain for cross-subdomain cookie sharing.
|
|
708
732
|
*
|
|
@@ -373,6 +373,55 @@ export const LtnsErrors = {
|
|
|
373
373
|
},
|
|
374
374
|
},
|
|
375
375
|
|
|
376
|
+
// CoreTenantGuard refusals (LTNS_0103-LTNS_0107). Each `message` is the guard's original
|
|
377
|
+
// plain-string wording, kept verbatim: consumers match on it, and the `#CODE: ` prefix keeps a
|
|
378
|
+
// substring match working. The code is what makes the refusal translatable at all.
|
|
379
|
+
|
|
380
|
+
NOT_TENANT_MEMBER: {
|
|
381
|
+
code: 'LTNS_0103',
|
|
382
|
+
message: 'Not a member of this tenant',
|
|
383
|
+
translations: {
|
|
384
|
+
de: 'Sie sind kein Mitglied dieses Mandanten.',
|
|
385
|
+
en: 'You are not a member of this tenant.',
|
|
386
|
+
},
|
|
387
|
+
},
|
|
388
|
+
|
|
389
|
+
INSUFFICIENT_TENANT_ROLE: {
|
|
390
|
+
code: 'LTNS_0104',
|
|
391
|
+
message: 'Insufficient tenant role',
|
|
392
|
+
translations: {
|
|
393
|
+
de: 'Ihre Rolle in diesem Mandanten reicht für diese Aktion nicht aus.',
|
|
394
|
+
en: 'Your role in this tenant does not allow this action.',
|
|
395
|
+
},
|
|
396
|
+
},
|
|
397
|
+
|
|
398
|
+
INSUFFICIENT_ROLE: {
|
|
399
|
+
code: 'LTNS_0105',
|
|
400
|
+
message: 'Insufficient role',
|
|
401
|
+
translations: {
|
|
402
|
+
de: 'Ihre Rolle reicht für diese Aktion nicht aus.',
|
|
403
|
+
en: 'Your role does not allow this action.',
|
|
404
|
+
},
|
|
405
|
+
},
|
|
406
|
+
|
|
407
|
+
TENANT_INACTIVE: {
|
|
408
|
+
code: 'LTNS_0106',
|
|
409
|
+
message: 'Tenant is inactive',
|
|
410
|
+
translations: {
|
|
411
|
+
de: 'Dieser Mandant ist deaktiviert.',
|
|
412
|
+
en: 'This tenant is deactivated.',
|
|
413
|
+
},
|
|
414
|
+
},
|
|
415
|
+
|
|
416
|
+
VERIFICATION_REQUIRED: {
|
|
417
|
+
code: 'LTNS_0107',
|
|
418
|
+
message: 'Verification required',
|
|
419
|
+
translations: {
|
|
420
|
+
de: 'Für diese Aktion muss Ihr Konto verifiziert sein.',
|
|
421
|
+
en: 'Your account must be verified for this action.',
|
|
422
|
+
},
|
|
423
|
+
},
|
|
424
|
+
|
|
376
425
|
// =====================================================
|
|
377
426
|
// User Errors (LTNS_0200-LTNS_0299)
|
|
378
427
|
// =====================================================
|
|
@@ -458,6 +458,7 @@ export class FileController extends CoreFileController {
|
|
|
458
458
|
| Situation | Status | Body |
|
|
459
459
|
| -------------------------------------------------------------------------------------- | ------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
|
460
460
|
| Unknown id / filename, or `checkRights()` refused | `404` | `NotFoundException` with `ErrorCode.FILE_NOT_FOUND` |
|
|
461
|
+
| An id that is not 24 hex characters (e.g. a TUS upload id taken for the file id) | `404` | Same as an unknown id — the service answers `null` / `NotFoundException` before any lookup. Up to 11.42.12 `new ObjectId()` threw and the route answered `500` |
|
|
461
462
|
| `deleteFile()` / `deleteFileByName()` / `duplicate*()` on a file that is not there | `404` | `NotFoundException`. Both halves of each id/name pair answer identically — up to 11.33.1 the by-id delete surfaced the driver's `MongoRuntimeError` as a `500` |
|
|
462
463
|
| Missing id / filename in the route | `400` | `BadRequestException` with `ErrorCode.REQUIRED_FIELD_MISSING` |
|
|
463
464
|
| An upload's SOURCE stream fails (client aborts, staged read drops) | `500` | The promise rejects with the cause — under **every** driver. It used to be an uncaught exception (i.e. a process exit) on GridFS, and on the S3 streaming path tus finalization uses |
|
|
@@ -139,6 +139,21 @@ export abstract class CoreFileService {
|
|
|
139
139
|
return this.storageDriver === 'filesystem';
|
|
140
140
|
}
|
|
141
141
|
|
|
142
|
+
/**
|
|
143
|
+
* Whether `id` is a string that cannot name a stored file at all.
|
|
144
|
+
*
|
|
145
|
+
* Every store keys files by an ObjectId, so a string that is not 24 hex characters — a TUS upload
|
|
146
|
+
* id (which clients took for the file id), a filename, a typo in a URL — names no file. It is
|
|
147
|
+
* answered exactly like an unknown id. Handed to `new ObjectId()` it threw a BSONError instead,
|
|
148
|
+
* and `GET /files/id/<tus-upload-id>` answered 500 where an unknown id gets 404.
|
|
149
|
+
*
|
|
150
|
+
* Only strings are judged: anything else keeps its previous path, so an ObjectId from a second
|
|
151
|
+
* copy of `bson` (where `instanceof` fails) is not suddenly reported as missing.
|
|
152
|
+
*/
|
|
153
|
+
protected isMalformedFileId(id: unknown): boolean {
|
|
154
|
+
return typeof id === 'string' && !(id.length === 24 && Types.ObjectId.isValid(id));
|
|
155
|
+
}
|
|
156
|
+
|
|
142
157
|
/**
|
|
143
158
|
* Find the filesystem metadata of a file by ID (null when it is stored elsewhere)
|
|
144
159
|
*/
|
|
@@ -264,6 +279,9 @@ export abstract class CoreFileService {
|
|
|
264
279
|
* @returns the id of the copy
|
|
265
280
|
*/
|
|
266
281
|
async duplicateById(id: string | Types.ObjectId, serviceOptions?: FileServiceOptions): Promise<string> {
|
|
282
|
+
if (this.isMalformedFileId(id)) {
|
|
283
|
+
throw new NotFoundException(`File not found with id ${id}`);
|
|
284
|
+
}
|
|
267
285
|
const objectId = getObjectIds(id);
|
|
268
286
|
const source = await this.getFileInfo(objectId, serviceOptions);
|
|
269
287
|
if (!source) {
|
|
@@ -425,6 +443,9 @@ export abstract class CoreFileService {
|
|
|
425
443
|
* Get info about file via file ID
|
|
426
444
|
*/
|
|
427
445
|
async getFileInfo(id: string | Types.ObjectId, serviceOptions?: FileServiceOptions): Promise<CoreFileInfo> {
|
|
446
|
+
if (this.isMalformedFileId(id)) {
|
|
447
|
+
return null;
|
|
448
|
+
}
|
|
428
449
|
if (!(await this.checkRights(id, { ...serviceOptions, checkInputType: 'id' }))) {
|
|
429
450
|
return null;
|
|
430
451
|
}
|
|
@@ -459,6 +480,9 @@ export abstract class CoreFileService {
|
|
|
459
480
|
serviceOptions?: FileServiceOptions,
|
|
460
481
|
knownStore?: FileStorageDriver,
|
|
461
482
|
): Promise<Readable> {
|
|
483
|
+
if (this.isMalformedFileId(id)) {
|
|
484
|
+
return null;
|
|
485
|
+
}
|
|
462
486
|
if (!(await this.checkRights(id, { ...serviceOptions, checkInputType: 'id' }))) {
|
|
463
487
|
return null;
|
|
464
488
|
}
|
|
@@ -515,6 +539,9 @@ export abstract class CoreFileService {
|
|
|
515
539
|
id: string | Types.ObjectId,
|
|
516
540
|
serviceOptions?: FileServiceOptions,
|
|
517
541
|
): Promise<{ info: CoreFileInfo; store: FileStorageDriver } | null> {
|
|
542
|
+
if (this.isMalformedFileId(id)) {
|
|
543
|
+
return null;
|
|
544
|
+
}
|
|
518
545
|
if (!(await this.checkRights(id, { ...serviceOptions, checkInputType: 'id' }))) {
|
|
519
546
|
return null;
|
|
520
547
|
}
|
|
@@ -552,6 +579,9 @@ export abstract class CoreFileService {
|
|
|
552
579
|
* disabled — callers then fall back to streaming the file themselves.
|
|
553
580
|
*/
|
|
554
581
|
async getDownloadUrl(id: string | Types.ObjectId, serviceOptions?: FileServiceOptions): Promise<string | undefined> {
|
|
582
|
+
if (this.isMalformedFileId(id)) {
|
|
583
|
+
return undefined;
|
|
584
|
+
}
|
|
555
585
|
if (!(await this.checkRights(id, { ...serviceOptions, checkInputType: 'id' }))) {
|
|
556
586
|
return undefined;
|
|
557
587
|
}
|
|
@@ -600,6 +630,9 @@ export abstract class CoreFileService {
|
|
|
600
630
|
* Get file buffer (for small files) via file ID
|
|
601
631
|
*/
|
|
602
632
|
async getBuffer(id: string | Types.ObjectId, serviceOptions?: FileServiceOptions): Promise<Buffer> {
|
|
633
|
+
if (this.isMalformedFileId(id)) {
|
|
634
|
+
return null;
|
|
635
|
+
}
|
|
603
636
|
if (!(await this.checkRights(id, { ...serviceOptions, checkInputType: 'id' }))) {
|
|
604
637
|
return null;
|
|
605
638
|
}
|
|
@@ -652,6 +685,9 @@ export abstract class CoreFileService {
|
|
|
652
685
|
* exact input, so a `null` here is genuinely a missing file.
|
|
653
686
|
*/
|
|
654
687
|
async deleteFile(id: string | Types.ObjectId, serviceOptions?: FileServiceOptions): Promise<CoreFileInfo> {
|
|
688
|
+
if (this.isMalformedFileId(id)) {
|
|
689
|
+
throw new NotFoundException(`File not found with id ${id}`);
|
|
690
|
+
}
|
|
655
691
|
if (!(await this.checkRights(id, { ...serviceOptions, checkInputType: 'id' }))) {
|
|
656
692
|
return null;
|
|
657
693
|
}
|
|
@@ -748,6 +784,9 @@ export abstract class CoreFileService {
|
|
|
748
784
|
* @returns the raw document, or null when no file has that id
|
|
749
785
|
*/
|
|
750
786
|
protected async getRawFileInfo(id: string | Types.ObjectId): Promise<null | Record<string, any>> {
|
|
787
|
+
if (this.isMalformedFileId(id)) {
|
|
788
|
+
return null;
|
|
789
|
+
}
|
|
751
790
|
const fileInfo =
|
|
752
791
|
(await this.findS3FileById(id)) ||
|
|
753
792
|
(await this.findFilesystemFileById(id)) ||
|
|
@@ -182,8 +182,8 @@ See the module README: "Deactivated Tenants" and "Service-Level Role Checks".
|
|
|
182
182
|
- [ ] Request with `X-Tenant-Id` header only returns tenant data
|
|
183
183
|
- [ ] Request without header + hierarchy role checks user.roles and filters tenantIds
|
|
184
184
|
- [ ] Admin user can access any tenant (if adminBypass: true)
|
|
185
|
-
- [ ] Non-member gets 403
|
|
186
|
-
- [ ] Unauthenticated + header →
|
|
185
|
+
- [ ] Non-member gets 403 `#LTNS_0103: Not a member of this tenant`
|
|
186
|
+
- [ ] Unauthenticated + header → 401 `#LTNS_0100` (re-authentication is the remedy, so it is not a 403)
|
|
187
187
|
- [ ] Public endpoint accessing tenantId-schema without context throws 403 (Safety Net)
|
|
188
188
|
- [ ] IAM endpoints work without `X-Tenant-Id` header when `skipTenantCheck: true` (default)
|
|
189
189
|
|
|
@@ -193,6 +193,22 @@ class UserController {
|
|
|
193
193
|
// @Roles(DefaultHR.MANAGER) without header → 200 (user.roles manager(2) >= manager(2))
|
|
194
194
|
```
|
|
195
195
|
|
|
196
|
+
### Refusal Codes
|
|
197
|
+
|
|
198
|
+
Every 403 of the guard carries a translatable error code; the text behind the code is the guard's
|
|
199
|
+
original wording, so a substring match on it keeps working:
|
|
200
|
+
|
|
201
|
+
| Code | Message | Refused because |
|
|
202
|
+
| ----------- | ----------------------------- | --------------------------------------------------------------------------------------- |
|
|
203
|
+
| `LTNS_0103` | `Not a member of this tenant` | the header names a tenant the user has no active membership in |
|
|
204
|
+
| `LTNS_0104` | `Insufficient tenant role` | the membership role does not satisfy the required tenant role |
|
|
205
|
+
| `LTNS_0105` | `Insufficient role` | `user.roles` does not meet the required role (no header, or a `@SkipTenantCheck` route) |
|
|
206
|
+
| `LTNS_0106` | `Tenant is inactive` | `multiTenancy.isTenantActive` reports the header's tenant as switched off |
|
|
207
|
+
| `LTNS_0107` | `Verification required` | an `S_VERIFIED` endpoint, and the user is not verified |
|
|
208
|
+
|
|
209
|
+
A project that calls tenants something else overrides the translations in its own error registry
|
|
210
|
+
(`errorCode.additionalErrorRegistry`, registered after the core and therefore winning).
|
|
211
|
+
|
|
196
212
|
### Skipping Tenant Checks
|
|
197
213
|
|
|
198
214
|
Use `@SkipTenantCheck()` for endpoints that intentionally work without tenant context:
|
|
@@ -238,7 +254,7 @@ multiTenancy: {
|
|
|
238
254
|
|
|
239
255
|
| Path | Deactivated tenant |
|
|
240
256
|
| -------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
|
241
|
-
| Header + tenant role, or `S_USER` / `S_VERIFIED` with a header | **403**
|
|
257
|
+
| Header + tenant role, or `S_USER` / `S_VERIFIED` with a header | **403** `#LTNS_0106: Tenant is inactive` |
|
|
242
258
|
| Tenant API token | **403** |
|
|
243
259
|
| `S_EVERYONE` with a header | not blocked, but no tenant context is set |
|
|
244
260
|
| No header | dropped from the user's tenant list — its data stays out of reach |
|
|
@@ -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
|