@lenne.tech/nest-server 11.41.7 → 11.41.9

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 (44) hide show
  1. package/.claude/rules/better-auth.md +26 -22
  2. package/.claude/rules/configurable-features.md +1 -1
  3. package/.claude/rules/testing.md +4 -4
  4. package/CLAUDE.md +5 -3
  5. package/FRAMEWORK-API.md +1 -1
  6. package/dist/core/modules/better-auth/core-better-auth-account-issuer.helper.d.ts +2 -0
  7. package/dist/core/modules/better-auth/core-better-auth-account-issuer.helper.js +13 -0
  8. package/dist/core/modules/better-auth/core-better-auth-account-issuer.helper.js.map +1 -0
  9. package/dist/core/modules/better-auth/core-better-auth-user.mapper.js +3 -2
  10. package/dist/core/modules/better-auth/core-better-auth-user.mapper.js.map +1 -1
  11. package/dist/core/modules/better-auth/core-better-auth.constants.d.ts +1 -0
  12. package/dist/core/modules/better-auth/core-better-auth.constants.js +2 -1
  13. package/dist/core/modules/better-auth/core-better-auth.constants.js.map +1 -1
  14. package/dist/core/modules/better-auth/core-better-auth.service.d.ts +1 -0
  15. package/dist/core/modules/better-auth/core-better-auth.service.js +46 -3
  16. package/dist/core/modules/better-auth/core-better-auth.service.js.map +1 -1
  17. package/dist/core/modules/system-setup/core-system-setup.service.js +3 -2
  18. package/dist/core/modules/system-setup/core-system-setup.service.js.map +1 -1
  19. package/dist/core/modules/tus/core-tus.service.d.ts +11 -0
  20. package/dist/core/modules/tus/core-tus.service.js +54 -14
  21. package/dist/core/modules/tus/core-tus.service.js.map +1 -1
  22. package/dist/core/modules/tus/tus.constants.d.ts +1 -0
  23. package/dist/core/modules/tus/tus.constants.js +2 -1
  24. package/dist/core/modules/tus/tus.constants.js.map +1 -1
  25. package/dist/core/modules/tus/tus.module.d.ts +2 -0
  26. package/dist/core/modules/tus/tus.module.js +14 -8
  27. package/dist/core/modules/tus/tus.module.js.map +1 -1
  28. package/dist/tsconfig.build.tsbuildinfo +1 -1
  29. package/docs/REQUEST-LIFECYCLE.md +1 -1
  30. package/migration-guides/11.41.7-to-11.41.8.md +135 -0
  31. package/migration-guides/11.41.8-to-11.41.9.md +111 -0
  32. package/package.json +7 -9
  33. package/src/core/modules/better-auth/INTEGRATION-CHECKLIST.md +2 -2
  34. package/src/core/modules/better-auth/README.md +20 -6
  35. package/src/core/modules/better-auth/core-better-auth-account-issuer.helper.ts +25 -0
  36. package/src/core/modules/better-auth/core-better-auth-user.mapper.ts +7 -7
  37. package/src/core/modules/better-auth/core-better-auth.constants.ts +20 -11
  38. package/src/core/modules/better-auth/core-better-auth.service.ts +87 -3
  39. package/src/core/modules/system-setup/core-system-setup.service.ts +12 -8
  40. package/src/core/modules/tus/INTEGRATION-CHECKLIST.md +36 -11
  41. package/src/core/modules/tus/README.md +79 -14
  42. package/src/core/modules/tus/core-tus.service.ts +125 -30
  43. package/src/core/modules/tus/tus.constants.ts +12 -0
  44. package/src/core/modules/tus/tus.module.ts +41 -8
@@ -1,10 +1,10 @@
1
- import { createLocalAccountIssuer } from '@better-auth/core/db';
2
1
  import { ForbiddenException, Injectable, Logger, OnApplicationBootstrap } from '@nestjs/common';
3
2
  import { InjectConnection } from '@nestjs/mongoose';
4
3
  import { isEmail } from 'class-validator';
5
4
  import { Connection } from 'mongoose';
6
5
 
7
6
  import { ConfigService } from '../../common/services/config.service';
7
+ import { legacyCredentialAccountIssuer } from '../better-auth/core-better-auth-account-issuer.helper';
8
8
  import { CoreBetterAuthUserMapper } from '../better-auth/core-better-auth-user.mapper';
9
9
  import { CoreBetterAuthService } from '../better-auth/core-better-auth.service';
10
10
  import { ErrorCode } from '../error-code/error-codes';
@@ -305,18 +305,22 @@ export class CoreSystemSetupService implements OnApplicationBootstrap {
305
305
 
306
306
  // Hash password and create credential account
307
307
  const hashedPassword = await context.password.hash(normalizedPassword);
308
- // better-auth >= 1.7 keys accounts by (issuer, accountId) and requires the
309
- // issuer. Credential accounts have no real issuer, so better-auth derives a
310
- // synthetic one — always via this helper, never a hand-written literal:
311
- // the format is better-auth's to change, and a copy of it would silently
312
- // stop matching the accounts better-auth writes itself.
308
+ // better-auth 1.7.0–1.7.2 key accounts by (issuer, accountId) and require the issuer;
309
+ // credential accounts carry a synthetic one, always derived via the helper, never a
310
+ // hand-written literal. better-auth 1.7.3+ went back to (providerId, accountId) and knows no
311
+ // issuer field, so the helper answers `undefined` there and nothing is written.
312
+ //
313
+ // Cast to the adapter's OWN parameter type: against better-auth 1.7.0–1.7.2 that type requires
314
+ // `issuer`, and a conditional spread reads as optional — a vendored core compiled against
315
+ // those versions would otherwise not build.
316
+ const credentialIssuer = legacyCredentialAccountIssuer();
313
317
  await context.internalAdapter.linkAccount({
314
318
  accountId: iamUser.id,
315
- issuer: createLocalAccountIssuer('credential'),
319
+ ...(credentialIssuer ? { issuer: credentialIssuer } : {}),
316
320
  password: hashedPassword,
317
321
  providerId: 'credential',
318
322
  userId: iamUser.id,
319
- });
323
+ } as Parameters<typeof context.internalAdapter.linkAccount>[0]);
320
324
 
321
325
  // Sync to nest-server users collection
322
326
  const syncedUser = await this.userMapper.linkOrCreateUser({
@@ -15,7 +15,7 @@
15
15
  | Use TUS with defaults (signed-in users may upload) | No - works automatically |
16
16
  | **Allow anonymous uploads** (public form) | **Yes - Step 0** (the default no longer allows this) |
17
17
  | Restrict uploads to specific roles | Yes - Step 0 |
18
- | Custom upload handling (notifications, etc.) | Yes - Step 2 |
18
+ | Custom upload handling (quota, notifications, ...) | Yes - Step 2 |
19
19
  | Hard-code a policy that config must NOT change | Yes - Step 1 |
20
20
  | Disable TUS completely | No - just use `TusModule.forRoot({ config: false })` |
21
21
 
@@ -134,25 +134,47 @@ export class ServerModule {}
134
134
  **Create:** `src/server/modules/tus/tus.service.ts`
135
135
 
136
136
  ```typescript
137
- import { Injectable } from '@nestjs/common';
138
137
  import { CoreTusService } from '@lenne.tech/nest-server';
139
138
  import { Upload } from '@tus/server';
140
139
 
141
- @Injectable()
142
140
  export class TusService extends CoreTusService {
143
- protected override async onUploadComplete(upload: Upload): Promise<void> {
144
- // Call parent to handle GridFS migration
145
- await super.onUploadComplete(upload);
141
+ // Before the first byte: the declared Upload-Length is known, nothing is stored yet.
142
+ protected override async onUploadCreate(req: any, upload: Upload) {
143
+ const quota = this.options?.moduleRef?.get(QuotaService, { strict: false });
144
+ if (!(await quota?.hasRoomFor(req, upload.size))) {
145
+ const error = new Error('Storage quota exceeded');
146
+ (error as any).status_code = 413;
147
+ throw error;
148
+ }
149
+ return super.onUploadCreate(req, upload); // records owner + tenant, enforces allowedTypes
150
+ }
146
151
 
147
- // Add custom logic
148
- const metadata = upload.metadata;
149
- console.log(`Upload complete: ${metadata.filename}`);
150
- // await this.notificationService.sendUploadComplete(...);
152
+ // After the last byte.
153
+ protected override async onUploadComplete(upload: Upload): Promise<void> {
154
+ await super.onUploadComplete(upload); // migrates the file into the configured storage
155
+ // custom logic
151
156
  }
152
157
  }
153
158
  ```
154
159
 
155
- **Note:** To use a custom service, you'll need to create a custom TusModule that provides your service instead of CoreTusService.
160
+ **Register it** (11.41.9+):
161
+
162
+ ```typescript
163
+ // server.module.ts
164
+ TusModule.forRoot({ controller: TusController, service: TusService });
165
+ ```
166
+
167
+ **WHY no constructor dependencies?** The module constructs the service itself, with exactly
168
+ `(connection, options)`. A constructor of your own would receive the wrong arguments. Resolve
169
+ project providers at call time via `this.options?.moduleRef` instead.
170
+
171
+ **WHY always return `super.onUploadCreate()`?** It writes the framework-owned owner and tenant keys
172
+ and checks `allowedTypes`. An override that skips it produces owner-less uploads — reachable by
173
+ every caller allowed to use TUS — and accepts every file type.
174
+
175
+ **Multi-tenancy:** with `multiTenancy` configured, the core validates the tenant header against the
176
+ caller's memberships and stores `metadata.tenantId` on the finished file; a non-member gets 403.
177
+ Without `multiTenancy` nothing changes. Nothing to do in the project either way.
156
178
 
157
179
  ---
158
180
 
@@ -216,6 +238,9 @@ TusModule.forRoot({ config: false });
216
238
  | Gating `OPTIONS` in a custom controller | Every browser upload fails before the first byte | Leave `handleTusOptions*` on `@Roles(RoleEnum.S_EVERYONE)` — the preflight sends no credentials |
217
239
  | Expecting a subclass `@Roles()` to win over config | Roles look ignored | `applyRoles()` overwrites them — Step 1 table |
218
240
  | Forgot to register custom controller | Custom handler logic never runs (core controller is used) | Add `controller: TusController` to `forRoot()` |
241
+ | Forgot to register custom service | `onUploadCreate` / `onUploadComplete` overrides never run | Add `service: TusService` to `forRoot()` (11.41.9+) — Step 2 |
242
+ | Constructor dependencies on the service subclass | `undefined` dependencies, or a crash at boot | Resolve them at call time via `this.options?.moduleRef` — Step 2 |
243
+ | Override of `onUploadCreate` without `super` | Uploads have no owner or tenant; `allowedTypes` ignored | `return super.onUploadCreate(req, upload)` — Step 2 |
219
244
  | 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()` |
220
245
  | Using wrong endpoint path | 404 on upload | Ensure client uses same path as config |
221
246
 
@@ -309,30 +309,94 @@ TusModule.forRoot({
309
309
  });
310
310
  ```
311
311
 
312
+ ### Custom Service (11.41.9+)
313
+
314
+ Register a subclass of `CoreTusService` through `TusModule.forRoot({ service })`. Before 11.41.9
315
+ both provider factories constructed `CoreTusService` directly, so a subclass could not be plugged in
316
+ at all.
317
+
318
+ ```typescript
319
+ // server.module.ts
320
+ TusModule.forRoot({ controller: TusController, service: TusService });
321
+ ```
322
+
323
+ The module constructs the service itself, with exactly `(connection, options)` — keep that
324
+ constructor signature, and do not declare constructor dependencies of your own. Reach project
325
+ providers at call time through `this.options?.moduleRef` instead:
326
+
327
+ ```typescript
328
+ const quota = this.options?.moduleRef?.get(QuotaService, { strict: false });
329
+ ```
330
+
331
+ ### Validate an Upload Before It Starts
332
+
333
+ `onUploadCreate(req, upload)` runs once per upload, after the client declared `Upload-Length` and
334
+ before a single byte is written — the only point at which a quota can refuse an upload without the
335
+ store having grown first. Throw an `Error` carrying `status_code` to refuse:
336
+
337
+ ```typescript
338
+ import { CoreTusService } from '@lenne.tech/nest-server';
339
+ import { Upload } from '@tus/server';
340
+
341
+ export class TusService extends CoreTusService {
342
+ protected override async onUploadCreate(req: any, upload: Upload) {
343
+ const quota = this.options?.moduleRef?.get(QuotaService, { strict: false });
344
+ if (!(await quota?.hasRoomFor(req, upload.size))) {
345
+ const error = new Error('Storage quota exceeded');
346
+ (error as any).status_code = 413;
347
+ throw error;
348
+ }
349
+ return super.onUploadCreate(req, upload);
350
+ }
351
+ }
352
+ ```
353
+
354
+ Always return `super.onUploadCreate()`: it records the owner and the tenant and enforces
355
+ `allowedTypes`. Skipping it leaves the upload owner-less (reachable by every caller who may use TUS)
356
+ and lets any file type through.
357
+
312
358
  ### Custom Upload Handler
313
359
 
314
360
  Override `onUploadComplete` to customize what happens after upload:
315
361
 
316
362
  ```typescript
317
363
  // src/server/modules/tus/tus.service.ts
318
- import { Injectable } from '@nestjs/common';
319
364
  import { CoreTusService } from '@lenne.tech/nest-server';
320
365
  import { Upload } from '@tus/server';
321
366
 
322
- @Injectable()
323
367
  export class TusService extends CoreTusService {
324
368
  protected override async onUploadComplete(upload: Upload): Promise<void> {
325
- // Call parent to migrate to GridFS
369
+ // Call parent to migrate to the configured file storage
326
370
  await super.onUploadComplete(upload);
327
371
 
328
372
  // Custom logic after upload
329
- const metadata = upload.metadata;
330
- await this.notificationService.sendUploadComplete(metadata.filename);
331
- await this.analyticsService.trackUpload(upload.id, upload.size);
373
+ const notifications = this.options?.moduleRef?.get(NotificationService, { strict: false });
374
+ await notifications?.sendUploadComplete(upload.metadata?.filename);
332
375
  }
333
376
  }
334
377
  ```
335
378
 
379
+ ### Tenant Scoping (11.41.9+)
380
+
381
+ **Only with `multiTenancy` configured.** Without it nothing changes: no header is read, no field is
382
+ written.
383
+
384
+ With multi-tenancy active, an upload carrying the tenant header (`multiTenancy.headerName`, default
385
+ `x-tenant-id`) is validated against the caller's active memberships — the same check GraphQL
386
+ subscriptions use. The TUS routes carry `@SkipTenantCheck()`, so the tenant guard does not do this
387
+ for them.
388
+
389
+ | Request | Result |
390
+ | ---------------------------------------------------- | ------------------------------------------ |
391
+ | No tenant header | upload stays tenant-less, as before |
392
+ | Header naming a tenant the caller belongs to | the finished file gets `metadata.tenantId` |
393
+ | Header naming a tenant the caller does NOT belong to | **403**, nothing is stored |
394
+
395
+ The tenant is recorded under `TUS_TENANT_METADATA_KEY` (`ltTenantId`), next to the owner under
396
+ `TUS_OWNER_METADATA_KEY`. Both keys are framework-owned and always overwritten, so a client cannot
397
+ put either of them into its own `Upload-Metadata`. `metadata.tenantId` on the finished file is what
398
+ the file module's `'tenant'` access preset and any per-file `checkRights()` rule read.
399
+
336
400
  ---
337
401
 
338
402
  ## Integration with FileModule
@@ -380,14 +444,15 @@ query {
380
444
 
381
445
  The following metadata is stored with each GridFS file:
382
446
 
383
- | Field | Source |
384
- | ------------------ | ------------------------------------- |
385
- | `filename` | From TUS `Upload-Metadata` header |
386
- | `contentType` | From TUS `filetype` metadata |
387
- | `tusUploadId` | Original TUS upload ID |
388
- | `originalMetadata` | All TUS metadata |
389
- | `ownerId` | The authenticated uploader (11.35.0+) |
390
- | `uploadedAt` | Completion timestamp |
447
+ | Field | Source |
448
+ | ------------------ | ------------------------------------------------------------------------------------------------------- |
449
+ | `filename` | From TUS `Upload-Metadata` header |
450
+ | `contentType` | From TUS `filetype` metadata |
451
+ | `tusUploadId` | Original TUS upload ID |
452
+ | `originalMetadata` | All TUS metadata |
453
+ | `ownerId` | The authenticated uploader (11.35.0+) |
454
+ | `tenantId` | The validated tenant, only with `multiTenancy` (11.41.9+) — see [Tenant Scoping](#tenant-scoping-11419) |
455
+ | `uploadedAt` | Completion timestamp |
391
456
 
392
457
  ### Upload ownership (11.35.0+)
393
458
 
@@ -1,4 +1,5 @@
1
1
  import { Injectable, Logger, OnModuleDestroy, OnModuleInit } from '@nestjs/common';
2
+ import { ModuleRef } from '@nestjs/core';
2
3
  import { FileStore } from '@tus/file-store';
3
4
  import { Server, Upload } from '@tus/server';
4
5
  import * as fs from 'fs';
@@ -11,6 +12,7 @@ import { ITusConfig } from '../../common/interfaces/server-options.interface';
11
12
  import { ConfigService } from '../../common/services/config.service';
12
13
  import { CoreRedisService } from '../../common/services/core-redis.service';
13
14
  import { CoreS3Service } from '../../common/services/core-s3.service';
15
+ import { getTenantContextResolver } from '../../common/services/core-tenant-context.registry';
14
16
  import { resolveFileStorage } from '../file/file-storage.helper';
15
17
  import {
16
18
  DEFAULT_FILESYSTEM_DIR,
@@ -18,7 +20,7 @@ import {
18
20
  FilesystemFileHelper,
19
21
  } from '../file/filesystem-file.helper';
20
22
  import { S3_FILES_COLLECTION, S3FileHelper } from '../file/s3-file.helper';
21
- import { TUS_OWNER_METADATA_KEY } from './tus.constants';
23
+ import { TUS_OWNER_METADATA_KEY, TUS_TENANT_METADATA_KEY } from './tus.constants';
22
24
  import { TusRedisLocker } from './tus-redis-locker';
23
25
  import {
24
26
  DEFAULT_TUS_ALLOWED_HEADERS,
@@ -37,6 +39,14 @@ import {
37
39
  export interface CoreTusServiceOptions {
38
40
  configService?: ConfigService;
39
41
 
42
+ /**
43
+ * How a subclass reaches project providers. `TusModule` constructs the service itself, with
44
+ * exactly `(connection, options)`, so a subclass cannot declare constructor dependencies of its
45
+ * own — resolve them at call time instead:
46
+ * `this.options?.moduleRef?.get(QuotaService, { strict: false })`.
47
+ */
48
+ moduleRef?: ModuleRef;
49
+
40
50
  /** Central Redis; when present, upload locks are shared across replicas */
41
51
  redisService?: CoreRedisService;
42
52
 
@@ -187,6 +197,10 @@ export class CoreTusService implements OnModuleDestroy, OnModuleInit {
187
197
  // ADMIN, so a project following the documented pattern ended up with undownloadable files.
188
198
  // Written from the SERVER-recorded owner, never from the client's own metadata.
189
199
  ...(metadata[TUS_OWNER_METADATA_KEY] ? { ownerId: metadata[TUS_OWNER_METADATA_KEY] } : {}),
200
+ // The tenant the upload was created in — validated against an active membership in
201
+ // onUploadCreate(), never taken from the client. Present only with multi-tenancy active; it
202
+ // is what the file module's `'tenant'` access preset decides on.
203
+ ...(metadata[TUS_TENANT_METADATA_KEY] ? { tenantId: metadata[TUS_TENANT_METADATA_KEY] } : {}),
190
204
  tusUploadId: upload.id,
191
205
  uploadedAt: new Date(),
192
206
  };
@@ -579,6 +593,115 @@ export class CoreTusService implements OnModuleDestroy, OnModuleInit {
579
593
  return false;
580
594
  }
581
595
 
596
+ /**
597
+ * Runs when a client CREATES an upload — the one point where the declared `Upload-Length` is known
598
+ * and no byte has been written yet. Returning `{ metadata }` is what PERSISTS it: the tus server
599
+ * writes the returned value onto the upload (returning `{}` would keep the client's metadata
600
+ * verbatim). Throw an error carrying `status_code` to refuse the upload.
601
+ *
602
+ * Records two framework-owned keys, both OVERWRITTEN rather than merged, because the client's
603
+ * `Upload-Metadata` header must never be able to set them:
604
+ * - {@link TUS_OWNER_METADATA_KEY} — the authenticated creator (see `assertUploadOwnership()`).
605
+ * - {@link TUS_TENANT_METADATA_KEY} — the validated tenant, only while multi-tenancy is active
606
+ * (see `resolveUploadTenantId()`); `null` otherwise.
607
+ *
608
+ * Override to add project rules — a quota, a per-tenant type policy — and call `super` to keep the
609
+ * owner, the tenant and `allowedTypes`. Register the subclass via `TusModule.forRoot({ service })`.
610
+ */
611
+ protected async onUploadCreate(req: any, upload: Upload): Promise<{ metadata: Record<string, null | string> }> {
612
+ // Record the creator and the tenant FIRST, so the checks below and later requests have something
613
+ // to compare against.
614
+ const metadata: Record<string, null | string> = {
615
+ ...upload.metadata,
616
+ [TUS_OWNER_METADATA_KEY]: this.readRequestUserId(req) ?? null,
617
+ [TUS_TENANT_METADATA_KEY]: (await this.resolveUploadTenantId(req)) ?? null,
618
+ };
619
+
620
+ // Validate file type if allowedTypes is configured
621
+ if (this.config.allowedTypes && this.config.allowedTypes.length > 0) {
622
+ const filetype = this.parseMetadata(upload.metadata).filetype;
623
+
624
+ if (!this.validateFileType(filetype)) {
625
+ const allowedList = this.config.allowedTypes.join(', ');
626
+ this.logger.warn(
627
+ `Upload rejected: file type '${filetype || 'unknown'}' not allowed. Allowed types: ${allowedList}`,
628
+ );
629
+
630
+ // Throw error to reject the upload
631
+ // @tus/server v2 expects throwing an error with status_code
632
+ const error = new Error(`File type '${filetype || 'unknown'}' is not allowed. Allowed types: ${allowedList}`);
633
+ (error as any).status_code = 415; // Unsupported Media Type
634
+ throw error;
635
+ }
636
+ }
637
+
638
+ return { metadata };
639
+ }
640
+
641
+ /**
642
+ * The VALIDATED tenant a new upload belongs to, or `undefined`.
643
+ *
644
+ * Only while multi-tenancy is active AND the tenant guard is registered — without it nothing can
645
+ * validate a tenant, and a project without multi-tenancy sees no change at all. The tus routes
646
+ * carry `@SkipTenantCheck()`, so the guard did NOT validate the request's tenant header; this asks
647
+ * the same membership logic directly (`core-tenant-context.registry.ts`, the path GraphQL
648
+ * subscriptions use).
649
+ *
650
+ * - No tenant header: `undefined` — the upload stays tenant-less, as before.
651
+ * - A header naming a tenant the caller is an active member of (or any tenant, for an admin under
652
+ * `adminBypass`): that tenant.
653
+ * - A header naming a tenant the caller is NOT a member of: the upload is REFUSED with 403. Taking
654
+ * the header verbatim would let anybody place a file in somebody else's tenant, and silently
655
+ * dropping it would store the file somewhere the caller did not ask for.
656
+ *
657
+ * Override to derive the tenant from somewhere else (a subdomain, an API key).
658
+ */
659
+ protected async resolveUploadTenantId(req: any): Promise<string | undefined> {
660
+ const multiTenancy = ConfigService.configFastButReadOnly?.multiTenancy;
661
+ const resolver = getTenantContextResolver();
662
+ if (!multiTenancy || multiTenancy.enabled === false || !resolver) {
663
+ return undefined;
664
+ }
665
+
666
+ const headerName = (multiTenancy.headerName ?? 'x-tenant-id').toLowerCase();
667
+ const header = this.readRequestHeader(req, headerName)?.trim();
668
+ if (!header) {
669
+ return undefined;
670
+ }
671
+
672
+ const resolved = await resolver.resolve(this.readRequestUser(req), header);
673
+ if (!resolved?.tenantId) {
674
+ const error = new Error('Not a member of the requested tenant');
675
+ (error as any).status_code = 403;
676
+ throw error;
677
+ }
678
+ return resolved.tenantId;
679
+ }
680
+
681
+ /**
682
+ * The authenticated caller with roles, read the same way as {@link readRequestUserId} — the roles
683
+ * decide the admin bypass of the tenant resolution.
684
+ */
685
+ protected readRequestUser(req: any): undefined | { id: string; roles?: string[] } {
686
+ const id = this.readRequestUserId(req);
687
+ if (!id) {
688
+ return undefined;
689
+ }
690
+ const user = req?.user ?? req?.runtime?.node?.req?.user ?? req?.context?.user;
691
+ return { id, roles: Array.isArray(user?.roles) ? user.roles : undefined };
692
+ }
693
+
694
+ /**
695
+ * A request header, from either request shape `@tus/server` v2 hands its hooks: the WHATWG
696
+ * `ServerRequest` (a `Headers` object) or the original Node request behind `runtime.node.req`.
697
+ */
698
+ protected readRequestHeader(req: any, name: string): string | undefined {
699
+ const fromHeaders = typeof req?.headers?.get === 'function' ? req.headers.get(name) : req?.headers?.[name];
700
+ const value = fromHeaders ?? req?.runtime?.node?.req?.headers?.[name];
701
+ const single = Array.isArray(value) ? value[0] : value;
702
+ return typeof single === 'string' && single.length > 0 ? single : undefined;
703
+ }
704
+
582
705
  /**
583
706
  * Create the TUS server instance with configured extensions
584
707
  */
@@ -604,35 +727,7 @@ export class CoreTusService implements OnModuleDestroy, OnModuleInit {
604
727
  // protocol is built around a per-upload URL. Appending is the sharp end — bytes PATCHed into
605
728
  // somebody else's upload are migrated into the file store under THEIR filename.
606
729
  onIncomingRequest: async (req, uploadId) => this.assertUploadOwnership(req, uploadId),
607
- onUploadCreate: async (req, upload) => {
608
- // Record the creator FIRST, so the ownership check below has something to compare against.
609
- // Overwrites rather than merges: see TUS_OWNER_METADATA_KEY.
610
- const metadata = { ...upload.metadata, [TUS_OWNER_METADATA_KEY]: this.readRequestUserId(req) ?? null };
611
-
612
- // Validate file type if allowedTypes is configured
613
- if (this.config.allowedTypes && this.config.allowedTypes.length > 0) {
614
- const filetype = this.parseMetadata(upload.metadata).filetype;
615
-
616
- if (!this.validateFileType(filetype)) {
617
- const allowedList = this.config.allowedTypes.join(', ');
618
- this.logger.warn(
619
- `Upload rejected: file type '${filetype || 'unknown'}' not allowed. Allowed types: ${allowedList}`,
620
- );
621
-
622
- // Throw error to reject the upload
623
- // @tus/server v2 expects throwing an error with status_code
624
- const error = new Error(
625
- `File type '${filetype || 'unknown'}' is not allowed. Allowed types: ${allowedList}`,
626
- );
627
- (error as any).status_code = 415; // Unsupported Media Type
628
- throw error;
629
- }
630
- }
631
-
632
- // Returning the metadata is what PERSISTS the owner — the tus server writes the returned
633
- // value onto the upload. Returning `{}` here would keep the client's own metadata verbatim.
634
- return { metadata };
635
- },
730
+ onUploadCreate: async (req, upload) => this.onUploadCreate(req, upload),
636
731
  onUploadFinish: async (_req, upload) => {
637
732
  try {
638
733
  await this.onUploadComplete(upload);
@@ -39,3 +39,15 @@ export const TUS_CONFIG = 'TUS_CONFIG';
39
39
  * `tests/unit/import-cycle-invariants.spec.ts` enforces exactly that placement.
40
40
  */
41
41
  export const TUS_OWNER_METADATA_KEY = 'ltOwnerId';
42
+
43
+ /**
44
+ * Metadata key recording the VALIDATED tenant a tus upload was created in, in the upload's own
45
+ * (staged) metadata. On completion it becomes the finished file's `metadata.tenantId` — the field the
46
+ * file module's `'tenant'` access preset decides on.
47
+ *
48
+ * Framework-owned and ALWAYS overwritten in `onUploadCreate`, exactly like
49
+ * {@link TUS_OWNER_METADATA_KEY}: the client's `Upload-Metadata` header must never be able to place
50
+ * a file in a tenant. It is set only while multi-tenancy is active and the caller's tenant header
51
+ * resolves to a tenant they are an active member of; otherwise it is `null`.
52
+ */
53
+ export const TUS_TENANT_METADATA_KEY = 'ltTenantId';
@@ -1,4 +1,5 @@
1
1
  import { DynamicModule, Global, Logger, Module, OnModuleInit, Type } from '@nestjs/common';
2
+ import { ModuleRef } from '@nestjs/core';
2
3
  import { getConnectionToken } from '@nestjs/mongoose';
3
4
  import { Connection } from 'mongoose';
4
5
 
@@ -54,6 +55,32 @@ export interface TusModuleOptions {
54
55
  * ```
55
56
  */
56
57
  controller?: Type<CoreTusController>;
58
+
59
+ /**
60
+ * Custom service class to use instead of CoreTusService.
61
+ * The class must extend CoreTusService and keep its constructor signature
62
+ * `(connection, options?)`: the module constructs it with exactly those arguments, so it cannot
63
+ * declare constructor dependencies of its own. Project providers are reached at call time through
64
+ * `this.options?.moduleRef`.
65
+ *
66
+ * The seam for project-specific upload rules — e.g. override `onUploadCreate()` to enforce a quota
67
+ * from the declared `Upload-Length` before a single byte is written, or `onUploadComplete()` to
68
+ * post-process the finished file.
69
+ *
70
+ * @example
71
+ * ```typescript
72
+ * export class TusService extends CoreTusService {
73
+ * protected override async onUploadCreate(req: any, upload: Upload) {
74
+ * const quota = this.options?.moduleRef?.get(QuotaService, { strict: false });
75
+ * await quota?.assertRoomFor(req, upload.size); // throw an Error with status_code 413 to refuse
76
+ * return super.onUploadCreate(req, upload);
77
+ * }
78
+ * }
79
+ *
80
+ * TusModule.forRoot({ service: TusService })
81
+ * ```
82
+ */
83
+ service?: Type<CoreTusService>;
57
84
  }
58
85
 
59
86
  /**
@@ -98,6 +125,7 @@ export class TusModule implements OnModuleInit {
98
125
  private static tusEnabled = false;
99
126
  private static currentConfig: ITusConfig | null = null;
100
127
  private static customController: null | Type<CoreTusController> = null;
128
+ private static customService: null | Type<CoreTusService> = null;
101
129
 
102
130
  constructor(private readonly tusService?: CoreTusService) {}
103
131
 
@@ -157,15 +185,17 @@ export class TusModule implements OnModuleInit {
157
185
  * @returns Dynamic module configuration
158
186
  */
159
187
  static forRoot(options: TusModuleOptions = {}): DynamicModule {
160
- const { config: rawConfig, controller } = options;
188
+ const { config: rawConfig, controller, service } = options;
161
189
 
162
190
  // Normalize config: undefined/true → enabled with defaults, false → disabled
163
191
  const config = normalizeTusConfig(rawConfig);
164
192
 
165
193
  // Store config for service configuration
166
194
  this.currentConfig = config;
167
- // Store custom controller if provided
195
+ // Store custom controller and service if provided
168
196
  this.customController = controller || null;
197
+ this.customService = service || null;
198
+ const ServiceClass = this.customService || CoreTusService;
169
199
 
170
200
  // If TUS is disabled, return minimal module
171
201
  if (config === null) {
@@ -183,9 +213,9 @@ export class TusModule implements OnModuleInit {
183
213
  inject: [getConnectionToken()],
184
214
  provide: CoreTusService,
185
215
  useFactory: (connection: Connection) => {
186
- const service = new CoreTusService(connection);
187
- service.configure(false);
188
- return service;
216
+ const tusService = new ServiceClass(connection);
217
+ tusService.configure(false);
218
+ return tusService;
189
219
  },
190
220
  },
191
221
  ],
@@ -230,6 +260,7 @@ export class TusModule implements OnModuleInit {
230
260
  ConfigService,
231
261
  { optional: true, token: CoreS3Service },
232
262
  { optional: true, token: CoreRedisService },
263
+ ModuleRef,
233
264
  ],
234
265
  provide: CoreTusService,
235
266
  useFactory: async (
@@ -238,14 +269,15 @@ export class TusModule implements OnModuleInit {
238
269
  configService: ConfigService,
239
270
  s3Service?: CoreS3Service,
240
271
  redisService?: CoreRedisService,
272
+ moduleRef?: ModuleRef,
241
273
  ) => {
242
- const service = new CoreTusService(connection, { configService, redisService, s3Service });
243
- service.configure(tusConfig);
274
+ const tusService = new ServiceClass(connection, { configService, moduleRef, redisService, s3Service });
275
+ tusService.configure(tusConfig);
244
276
  // NestJS DOES call onModuleInit on a factory-provided instance — its hook iterates
245
277
  // every non-alias provider, however it was constructed. Calling it here as well ran
246
278
  // init TWICE per boot: two TUS servers, two S3 stores, and two hourly expiration
247
279
  // intervals of which onModuleDestroy clears only the second.
248
- return service;
280
+ return tusService;
249
281
  },
250
282
  },
251
283
  ],
@@ -260,5 +292,6 @@ export class TusModule implements OnModuleInit {
260
292
  this.tusEnabled = false;
261
293
  this.currentConfig = null;
262
294
  this.customController = null;
295
+ this.customService = null;
263
296
  }
264
297
  }