@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.
Files changed (67) hide show
  1. package/.claude/rules/configurable-features.md +1 -1
  2. package/.claude/rules/package-management.md +12 -5
  3. package/.claude/rules/testing.md +4 -4
  4. package/FRAMEWORK-API.md +1 -1
  5. package/dist/core/common/helpers/cookies.helper.d.ts +3 -2
  6. package/dist/core/common/helpers/cookies.helper.js +13 -4
  7. package/dist/core/common/helpers/cookies.helper.js.map +1 -1
  8. package/dist/core/common/helpers/outbound-url.helper.js +2 -5
  9. package/dist/core/common/helpers/outbound-url.helper.js.map +1 -1
  10. package/dist/core/modules/auth/core-auth.controller.js +2 -1
  11. package/dist/core/modules/auth/core-auth.controller.js.map +1 -1
  12. package/dist/core/modules/auth/core-auth.resolver.js +2 -1
  13. package/dist/core/modules/auth/core-auth.resolver.js.map +1 -1
  14. package/dist/core/modules/better-auth/core-better-auth-api.middleware.js +1 -0
  15. package/dist/core/modules/better-auth/core-better-auth-api.middleware.js.map +1 -1
  16. package/dist/core/modules/better-auth/core-better-auth-cookie.helper.d.ts +2 -0
  17. package/dist/core/modules/better-auth/core-better-auth-cookie.helper.js +2 -1
  18. package/dist/core/modules/better-auth/core-better-auth-cookie.helper.js.map +1 -1
  19. package/dist/core/modules/better-auth/core-better-auth-email-verification.service.js +7 -2
  20. package/dist/core/modules/better-auth/core-better-auth-email-verification.service.js.map +1 -1
  21. package/dist/core/modules/better-auth/core-better-auth.controller.js +1 -0
  22. package/dist/core/modules/better-auth/core-better-auth.controller.js.map +1 -1
  23. package/dist/core/modules/better-auth/core-better-auth.service.d.ts +1 -0
  24. package/dist/core/modules/better-auth/core-better-auth.service.js +4 -0
  25. package/dist/core/modules/better-auth/core-better-auth.service.js.map +1 -1
  26. package/dist/core/modules/error-code/error-codes.d.ts +45 -0
  27. package/dist/core/modules/error-code/error-codes.js +40 -0
  28. package/dist/core/modules/error-code/error-codes.js.map +1 -1
  29. package/dist/core/modules/file/core-file.service.d.ts +1 -0
  30. package/dist/core/modules/file/core-file.service.js +27 -0
  31. package/dist/core/modules/file/core-file.service.js.map +1 -1
  32. package/dist/core/modules/tenant/core-tenant.guard.js +7 -7
  33. package/dist/core/modules/tenant/core-tenant.guard.js.map +1 -1
  34. package/dist/core/modules/tus/core-tus.service.d.ts +1 -0
  35. package/dist/core/modules/tus/core-tus.service.js +10 -4
  36. package/dist/core/modules/tus/core-tus.service.js.map +1 -1
  37. package/dist/core/modules/tus/tus.constants.d.ts +1 -0
  38. package/dist/core/modules/tus/tus.constants.js +2 -1
  39. package/dist/core/modules/tus/tus.constants.js.map +1 -1
  40. package/dist/server/modules/error-code/error-codes.d.ts +5 -0
  41. package/dist/tsconfig.build.tsbuildinfo +1 -1
  42. package/docs/REQUEST-LIFECYCLE.md +1 -1
  43. package/docs/error-codes.md +70 -0
  44. package/migration-guides/11.42.10-to-11.42.11.md +18 -2
  45. package/migration-guides/11.42.11-to-11.42.12.md +142 -0
  46. package/migration-guides/11.42.12-to-11.42.13.md +154 -0
  47. package/package.json +2 -2
  48. package/src/core/common/helpers/cookies.helper.ts +50 -6
  49. package/src/core/common/helpers/outbound-url.helper.ts +4 -10
  50. package/src/core/modules/auth/core-auth.controller.ts +2 -1
  51. package/src/core/modules/auth/core-auth.resolver.ts +2 -1
  52. package/src/core/modules/better-auth/README.md +1 -0
  53. package/src/core/modules/better-auth/core-better-auth-api.middleware.ts +1 -0
  54. package/src/core/modules/better-auth/core-better-auth-cookie.helper.ts +24 -5
  55. package/src/core/modules/better-auth/core-better-auth-email-verification.service.ts +20 -10
  56. package/src/core/modules/better-auth/core-better-auth.controller.ts +4 -0
  57. package/src/core/modules/better-auth/core-better-auth.service.ts +24 -0
  58. package/src/core/modules/error-code/error-codes.ts +49 -0
  59. package/src/core/modules/file/README.md +1 -0
  60. package/src/core/modules/file/core-file.service.ts +39 -0
  61. package/src/core/modules/tenant/INTEGRATION-CHECKLIST.md +2 -2
  62. package/src/core/modules/tenant/README.md +17 -1
  63. package/src/core/modules/tenant/core-tenant.guard.ts +7 -7
  64. package/src/core/modules/tus/INTEGRATION-CHECKLIST.md +2 -1
  65. package/src/core/modules/tus/README.md +42 -3
  66. package/src/core/modules/tus/core-tus.service.ts +32 -6
  67. 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 either `config.env` is production-like
150
- * (`production` / `staging`) or `NODE_ENV === 'production'`. This covers
151
- * staging deployments where only one of the two signals is set.
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?: { domain?: string; env?: string; legacyCookieEnabled?: boolean; secret?: string },
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 holding real accounts, so the gate
842
- * is `isProductionLikeEnv()` — the same two-layer check `EmailService` and the cookie helpers
843
- * already use. `NODE_ENV !== 'production'` alone is NOT enough: a staging deployment sets
844
- * `NODE_ENV=staging` so `getEnvironmentConfig()` loads its config block, which passes that test
845
- * while holding real users.
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. Set
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
- if (process.env.LT_LOG_AUTH_URLS === '0') {
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 (isProductionLikeEnv(this.configService?.getFastButReadOnly<string>('env'))) {
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 "Not a member of this tenant"
186
- - [ ] Unauthenticated + header → 403 "Authentication required for tenant access"
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** `Tenant is inactive` |
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('Verification required');
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('Not a member of this tenant');
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('Insufficient tenant role');
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('Insufficient role');
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('Tenant is inactive');
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('Not a member of this tenant');
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('Insufficient role');
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); // migrates the file into the configured storage
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
- console.log('Upload complete!');
270
- console.log('File URL:', upload.url);
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 GridFS, cleanup temp file
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 to GridFS
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
- const fileInfo = await FilesystemFileHelper.writeFile(
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
- const fileInfo = await S3FileHelper.writeFile(
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
- const fileInfo = await GridFSHelper.writeFileFromStream(this.files, readStream, {
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
- return {};
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