@lenne.tech/nest-server 11.42.0 → 11.42.2

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.
@@ -0,0 +1,66 @@
1
+ # Migration Guide: 11.42.0 → 11.42.1
2
+
3
+ ## Overview
4
+
5
+ | Category | Details |
6
+ | -------------------- | --------------------------------------------------------------------------------------------- |
7
+ | **Bugfix** | Browser uploads through TUS work with cookie authentication and separate app/API origins (§1) |
8
+ | **New option** | `tus.allowedOrigins` overrides the derived origins (§1) |
9
+ | **Migration Effort** | None — `pnpm update` |
10
+
11
+ ## Quick Migration
12
+
13
+ ```bash
14
+ pnpm update @lenne.tech/nest-server
15
+ ```
16
+
17
+ Vendor-mode projects pick up the core update; nothing else to change.
18
+
19
+ ## 1. TUS answered a credentialed request with `Access-Control-Allow-Origin: *`
20
+
21
+ The tus handler writes its responses itself, past the API's CORS layer, and `@tus/server` answers
22
+ `Access-Control-Allow-Origin: *` unless it is given `allowedOrigins`. The core gave it none. With cookie
23
+ authentication and separate app and API origins (`lt dev up`, or `app.example.com` /
24
+ `api.example.com`), the request is credentialed and the API adds
25
+ `Access-Control-Allow-Credentials: true` — a combination every browser refuses:
26
+
27
+ > The value of the 'Access-Control-Allow-Origin' header in the response must not be the wildcard '*'
28
+ > when the request's credentials mode is 'include'.
29
+
30
+ So every browser upload failed, while the server answered `201`. Preflights and unauthenticated
31
+ requests were fine, which is why no test noticed — only an **authenticated** request with an `Origin`
32
+ reaches the header.
33
+
34
+ Since 11.42.1 the tus server takes its origins from `buildCorsConfig()`, the decision REST, GraphQL and
35
+ Better-Auth already share (`appUrl`, `baseUrl`, `cors.allowedOrigins`, `cors.allowAll`), and mirrors an
36
+ allowed `Origin`. A foreign origin gets no `Access-Control-Allow-Origin` at all. To use different
37
+ origins for uploads:
38
+
39
+ ```typescript
40
+ tus: {
41
+ allowedOrigins: ['https://uploads.example.com'];
42
+ }
43
+ ```
44
+
45
+ Without credentialed CORS configured — cookies off, or no origin resolvable — nothing changes; the tus
46
+ default `*` stays.
47
+
48
+ ## 2. Dependencies
49
+
50
+ Updated within their majors: `@modelcontextprotocol/sdk` 1.32.0, `mongoose` 9.10.4, `supertest` 7.3.1, and
51
+ the `@aws-sdk/*` S3 packages 3.1146.0 (dev). NestJS stays on 11.
52
+
53
+ `@modelcontextprotocol/sdk` 1.32.0 lets its CLIENT transports follow redirects only within the same
54
+ origin. The framework uses the server side only, so this matters only to a project that builds its own MCP
55
+ client transport and relied on a cross-origin redirect.
56
+
57
+ ## Troubleshooting
58
+
59
+ | Symptom | Cause | Fix |
60
+ | -------------------------------------------------------------------------------------------------- | ---------------------------------------------- | ------------------------------------------------------------ |
61
+ | Browser: "must not be the wildcard '*' when the request's credentials mode is 'include'" on `/tus` | 11.42.0 or older | Update to 11.42.1 |
62
+ | Upload from an origin that is not the app refused by CORS | Origin not in `appUrl` / `cors.allowedOrigins` | Add it to `cors.allowedOrigins`, or set `tus.allowedOrigins` |
63
+
64
+ ## Module Documentation
65
+
66
+ - [TUS README](../src/core/modules/tus/README.md) — CORS Headers
@@ -0,0 +1,53 @@
1
+ # Migration Guide: 11.42.1 → 11.42.2
2
+
3
+ ## Overview
4
+
5
+ | Category | Details |
6
+ | -------------------- | ------------------------------------------------------------------------ |
7
+ | **Bugfix** | Finished TUS uploads answer 204 behind the `compression` middleware (§1) |
8
+ | **Migration Effort** | None — `pnpm update` |
9
+
10
+ ## Quick Migration
11
+
12
+ ```bash
13
+ pnpm update @lenne.tech/nest-server
14
+ ```
15
+
16
+ Vendor-mode projects pick up the core update; nothing else to change.
17
+
18
+ ## 1. A finished TUS upload never got an answer although the file was stored
19
+
20
+ `@tus/server` finishes its responses with `res.end(callback)`, a documented Node signature. The
21
+ `compression` middleware patches `res.end(chunk, encoding)` and treats the first argument as a chunk,
22
+ so the callback reached `Buffer.byteLength()` and threw:
23
+
24
+ ```
25
+ TypeError [ERR_INVALID_ARG_TYPE]: The first argument must be of type string or an instance of Buffer … Received function
26
+ ```
27
+
28
+ The starter's `main.ts` — and therefore every generated project — registers `compression` with
29
+ `filter: () => true` and `threshold: 0`, which removes the two checks that would otherwise skip a
30
+ bodyless 204. The failure hit the final `PATCH`, AFTER the upload had been migrated into the file
31
+ store: the request hung — the headers were already sent, so no error response could follow — the
32
+ client never saw a 2xx, `tus-js-client` never fired `onSuccess`, and no log named `compression`.
33
+
34
+ Since 11.42.2 `CoreTusService` normalises `res.end(callback)` before the tus server writes
35
+ (`normalizeEndCallback()`, wrapped around `getServer().handle()`), so it holds for the core controller
36
+ and for any project controller that calls the tus server. No change to `main.ts` is needed; a project
37
+ that worked around it by excluding `/tus` from the compression filter can keep or drop that filter.
38
+
39
+ ## 2. TUS request errors are logged with their stack
40
+
41
+ `CoreTusController` logged only `error.message` for a failed TUS request. A `TypeError` thrown by a
42
+ middleware's patched `res.end` then read like a tus failure, with nothing pointing at the middleware —
43
+ the defect in §1 took several rounds to find for exactly that reason. The stack is now logged too.
44
+
45
+ ## Troubleshooting
46
+
47
+ | Symptom | Cause | Fix |
48
+ | -------------------------------------------------------------------------------------- | ------------------------------------- | ----------------- |
49
+ | File stored, final PATCH never answers (request hangs), `Received function` in the log | 11.42.1 or older behind `compression` | Update to 11.42.2 |
50
+
51
+ ## Module Documentation
52
+
53
+ - [TUS README](../src/core/modules/tus/README.md) — Troubleshooting
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lenne.tech/nest-server",
3
- "version": "11.42.0",
3
+ "version": "11.42.2",
4
4
  "description": "Modern, fast, powerful Node.js web framework in TypeScript based on Nest with a GraphQL API and a connection to MongoDB (or other databases).",
5
5
  "keywords": [
6
6
  "node",
@@ -92,7 +92,7 @@
92
92
  "@apollo/server": "5.5.1",
93
93
  "@as-integrations/express5": "1.1.2",
94
94
  "@getbrevo/brevo": "6.0.3",
95
- "@modelcontextprotocol/sdk": "1.31.0",
95
+ "@modelcontextprotocol/sdk": "1.32.0",
96
96
  "@nestjs/apollo": "13.4.5",
97
97
  "@nestjs/common": "11.2.7",
98
98
  "@nestjs/core": "11.2.7",
@@ -126,7 +126,7 @@
126
126
  "json-to-graphql-query": "2.3.0",
127
127
  "lodash": "4.18.1",
128
128
  "mongodb": "7.6.0",
129
- "mongoose": "9.10.3",
129
+ "mongoose": "9.10.4",
130
130
  "multer": "2.4.0",
131
131
  "node-mailjet": "6.0.11",
132
132
  "nodemailer": "10.0.13",
@@ -135,7 +135,7 @@
135
135
  "reflect-metadata": "0.2.2",
136
136
  "rfdc": "1.4.1",
137
137
  "rxjs": "7.8.2",
138
- "supertest": "7.3.0",
138
+ "supertest": "7.3.1",
139
139
  "ts-morph": "28.0.0",
140
140
  "undici": "7.30.0",
141
141
  "ws": "8.21.3",
@@ -182,9 +182,9 @@
182
182
  }
183
183
  },
184
184
  "devDependencies": {
185
- "@aws-sdk/client-s3": "3.1145.0",
186
- "@aws-sdk/lib-storage": "3.1145.0",
187
- "@aws-sdk/s3-request-presigner": "3.1145.0",
185
+ "@aws-sdk/client-s3": "3.1146.0",
186
+ "@aws-sdk/lib-storage": "3.1146.0",
187
+ "@aws-sdk/s3-request-presigner": "3.1146.0",
188
188
  "@better-auth/core": "1.7.7",
189
189
  "@better-auth/passkey": "1.7.7",
190
190
  "@compodoc/compodoc": "2.0.0",
@@ -3606,6 +3606,23 @@ export interface ITusConfig {
3606
3606
  */
3607
3607
  allowedHeaders?: string[];
3608
3608
 
3609
+ /**
3610
+ * Origins the tus handler may answer a cross-origin request with (`Access-Control-Allow-Origin`).
3611
+ *
3612
+ * Unset, they come from the API's own CORS configuration — `buildCorsConfig()`, the function REST,
3613
+ * GraphQL and Better-Auth share (`appUrl`, `baseUrl`, `cors.allowedOrigins`, `cors.allowAll`).
3614
+ * Without credentialed CORS configured (cookies off, or no origin resolvable) the tus server keeps
3615
+ * its own default, `*`.
3616
+ *
3617
+ * The wildcard is what broke browser uploads with cookie authentication and separate app/API
3618
+ * origins before 11.42.1: a credentialed response with `Access-Control-Allow-Origin: *` is refused
3619
+ * by every browser, although the server answered 201.
3620
+ *
3621
+ * @default undefined (derived from the API CORS configuration)
3622
+ * @since 11.42.1
3623
+ */
3624
+ allowedOrigins?: string[];
3625
+
3609
3626
  /**
3610
3627
  * Allowed MIME types for uploads.
3611
3628
  * If undefined, all types are allowed.
@@ -107,6 +107,17 @@ The TUS server automatically handles CORS headers for browser-based clients:
107
107
  - `Upload-Offset`
108
108
  - `Upload-Metadata`
109
109
 
110
+ **`Access-Control-Allow-Origin` follows the API's CORS configuration (11.42.1+).** The tus handler
111
+ writes its responses itself, and without `allowedOrigins` `@tus/server` answers `*`. With cookie
112
+ authentication and separate app and API origins the request is credentialed, and every browser
113
+ refuses `*` there — uploads failed in the browser although the server answered 201. The origins now
114
+ come from `buildCorsConfig()` (`appUrl`, `baseUrl`, `cors.allowedOrigins`, `cors.allowAll`), the same
115
+ decision REST, GraphQL and Better-Auth make; `tus.allowedOrigins` overrides. Without credentialed CORS
116
+ (cookies off, or no origin resolvable) the tus default `*` stays.
117
+
118
+ The preflight (`OPTIONS`) is answered by Nest's CORS layer and was never affected — test an
119
+ **authenticated** request with an `Origin` header when checking this.
120
+
110
121
  ---
111
122
 
112
123
  ## Configuration
@@ -515,6 +526,21 @@ from elsewhere (an API key, a signed form token), or to let a support role resum
515
526
  2. Verify MongoDB connection is established
516
527
  3. Check server logs for initialization errors
517
528
 
529
+ ### Upload is stored but the client never sees success (fixed in 11.42.2)
530
+
531
+ **Symptom:** the file appears in the file store (`Upload … migrated to …` in the log), but the final
532
+ `PATCH` never answers (the request hangs: the headers are already sent, so no error response can
533
+ follow), `tus-js-client` never fires `onSuccess`, and the log shows
534
+ `ERR_INVALID_ARG_TYPE … Received function`.
535
+
536
+ **Cause:** `@tus/server` finishes its responses with `res.end(callback)`, and the `compression`
537
+ middleware patches `res.end(chunk, encoding)` and reads the callback as a chunk. The starter registers
538
+ `compression` with `filter: () => true` and `threshold: 0`, so it also handles the bodyless 204.
539
+
540
+ **Solution:** update to 11.42.2 — `CoreTusService` normalises `res.end(callback)` before the tus
541
+ server writes (`normalizeEndCallback()`), for the core controller and any project controller that
542
+ calls `getServer().handle()`. No change to `main.ts` is needed.
543
+
518
544
  ### Upload stalls or fails to resume
519
545
 
520
546
  **Cause:** Upload expired or server restarted
@@ -105,7 +105,9 @@ export class CoreTusController {
105
105
  try {
106
106
  await server.handle(req, res);
107
107
  } catch (error) {
108
- this.logger.error(`TUS request error: ${error.message}`);
108
+ // The stack, not just the message: a TypeError from a middleware's patched `res.end` reads
109
+ // like a tus failure without it, and that cost a project several rounds of diagnosis (11.42.2).
110
+ this.logger.error(`TUS request error: ${error.message}`, error?.stack);
109
111
  if (!res.headersSent) {
110
112
  res.status(500).json({ message: 'Upload error' });
111
113
  }
@@ -131,7 +133,7 @@ export class CoreTusController {
131
133
  try {
132
134
  await server.handle(req, res);
133
135
  } catch (error) {
134
- this.logger.error(`TUS request error for upload ${req.params.id}: ${error.message}`);
136
+ this.logger.error(`TUS request error for upload ${req.params.id}: ${error.message}`, error?.stack);
135
137
  if (!res.headersSent) {
136
138
  res.status(500).json({ message: 'Upload error' });
137
139
  }
@@ -9,6 +9,7 @@ import { Readable } from 'stream';
9
9
 
10
10
  import { GridFSHelper } from '../../common/helpers/gridfs.helper';
11
11
  import { ITusConfig } from '../../common/interfaces/server-options.interface';
12
+ import { buildCorsConfig } from '../../common/helpers/cookies.helper';
12
13
  import { ConfigService } from '../../common/services/config.service';
13
14
  import { CoreRedisService } from '../../common/services/core-redis.service';
14
15
  import { CoreS3Service } from '../../common/services/core-s3.service';
@@ -53,6 +54,9 @@ export interface CoreTusServiceOptions {
53
54
  s3Service?: CoreS3Service;
54
55
  }
55
56
 
57
+ /** Marks a response whose `end()` was already normalised (see `normalizeEndCallback()`). */
58
+ const END_CALLBACK_NORMALIZED = Symbol('tusEndCallbackNormalized');
59
+
56
60
  /**
57
61
  * Core TUS Service
58
62
  *
@@ -638,6 +642,46 @@ export class CoreTusService implements OnModuleDestroy, OnModuleInit {
638
642
  return { metadata };
639
643
  }
640
644
 
645
+ /**
646
+ * The origins the tus handler may answer a cross-origin request with — or `undefined` to keep the
647
+ * tus server's own default (`*`).
648
+ *
649
+ * The tus handler writes its responses itself, past the API's CORS layer, and without
650
+ * `allowedOrigins` @tus/server sets `Access-Control-Allow-Origin: *`. On a credentialed request —
651
+ * cookie authentication with separate app and API origins — every browser refuses that, so no
652
+ * browser upload could succeed although the server answered 201. Preflights and 401s were fine
653
+ * (Nest answers those), which is why nothing noticed: only an AUTHENTICATED request reaches it.
654
+ *
655
+ * Taken from `tus.allowedOrigins` when set, else from `buildCorsConfig()` — the same decision REST,
656
+ * GraphQL and Better-Auth make. Without credentialed CORS configured the previous default stays.
657
+ */
658
+ protected resolveCorsOrigins(): ((origin: string) => boolean) | undefined {
659
+ const toOrigin = (value: string): string => {
660
+ try {
661
+ return new URL(value).origin;
662
+ } catch {
663
+ return value;
664
+ }
665
+ };
666
+ const fromList = (list: string[]) => {
667
+ const allowed = new Set(list.filter((entry) => typeof entry === 'string').map(toOrigin));
668
+ return (origin: string) => allowed.has(toOrigin(origin));
669
+ };
670
+
671
+ if (Array.isArray(this.config.allowedOrigins) && this.config.allowedOrigins.length) {
672
+ return fromList(this.config.allowedOrigins);
673
+ }
674
+ const options = this.options?.configService?.configFastButReadOnly ?? ConfigService.configFastButReadOnly ?? {};
675
+ const cors = buildCorsConfig(options);
676
+ if (cors.credentials !== true) {
677
+ return undefined;
678
+ }
679
+ if (cors.origin === true) {
680
+ return () => true;
681
+ }
682
+ return Array.isArray(cors.origin) ? fromList(cors.origin as string[]) : undefined;
683
+ }
684
+
641
685
  /**
642
686
  * The VALIDATED tenant a new upload belongs to, or `undefined`.
643
687
  *
@@ -716,8 +760,12 @@ export class CoreTusService implements OnModuleDestroy, OnModuleInit {
716
760
  // correct for the single-replica deployment that configuration describes.
717
761
  const locker = this.createLocker();
718
762
 
763
+ const cors = this.resolveCorsOrigins();
719
764
  const server = new Server({
720
765
  allowedHeaders: this.config.allowedHeaders || DEFAULT_TUS_ALLOWED_HEADERS,
766
+ // Without these, @tus/server answers every request with `Access-Control-Allow-Origin: *`,
767
+ // which a browser refuses on a credentialed (cookie) request — see resolveCorsOrigins().
768
+ ...(cors ? { allowedCredentials: true, allowedOrigins: cors } : {}),
721
769
  datastore,
722
770
  ...(locker ? { locker } : {}),
723
771
  maxSize: this.config.maxSize,
@@ -749,9 +797,66 @@ export class CoreTusService implements OnModuleDestroy, OnModuleInit {
749
797
  respectForwardedHeaders: true,
750
798
  });
751
799
 
800
+ // Every caller of getServer().handle() — the core controller and any project controller — gets
801
+ // the response normalised before @tus/server writes to it. See normalizeEndCallback().
802
+ const handle = server.handle.bind(server);
803
+ server.handle = (req, res) => {
804
+ this.normalizeEndCallback(res);
805
+ return handle(req, res);
806
+ };
807
+
752
808
  return server;
753
809
  }
754
810
 
811
+ /**
812
+ * Makes `res.end(callback)` safe for middlewares that patch `res.end` with the old two-argument form.
813
+ *
814
+ * @tus/server finishes its responses through srvx with `res.end(callback)` — a documented Node
815
+ * signature. `compression` (and any middleware written like it) patches `res.end(chunk, encoding)`
816
+ * and treats the first argument as a chunk unconditionally, so the callback reaches
817
+ * `Buffer.byteLength()` and throws `ERR_INVALID_ARG_TYPE ... Received function`. The starter
818
+ * registers `compression` with `filter: () => true, threshold: 0`, which removes the checks that
819
+ * would otherwise skip a bodyless 204, so every finished upload failed AFTER the file had been
820
+ * stored: the client never saw a 2xx and retried an upload that was already complete.
821
+ *
822
+ * The wrapper moves a function argument into the callback slot and runs it on `finish`, which is
823
+ * what Node itself does with it — or on `close`, so a connection dropped after `end()` resolves srvx
824
+ * too. A response destroyed before `end()` stays unresolved, exactly as with plain Node.
825
+ * Idempotent per response. Protected so a project can widen it.
826
+ */
827
+ protected normalizeEndCallback(res: any): void {
828
+ if (!res || typeof res.end !== 'function' || res[END_CALLBACK_NORMALIZED]) {
829
+ return;
830
+ }
831
+ const end = res.end;
832
+ res.end = function normalizedEnd(this: any, chunk?: unknown, encoding?: unknown, callback?: unknown) {
833
+ if (typeof chunk === 'function') {
834
+ callback = chunk;
835
+ chunk = undefined;
836
+ encoding = undefined;
837
+ } else if (typeof encoding === 'function') {
838
+ callback = encoding;
839
+ encoding = undefined;
840
+ }
841
+ if (typeof callback === 'function') {
842
+ let called = false;
843
+ const done = () => {
844
+ if (!called) {
845
+ called = true;
846
+ (callback as () => void)();
847
+ }
848
+ };
849
+ this.once('finish', done);
850
+ this.once('close', done);
851
+ }
852
+ if (chunk === undefined) {
853
+ return end.call(this);
854
+ }
855
+ return encoding === undefined ? end.call(this, chunk) : end.call(this, chunk, encoding);
856
+ };
857
+ res[END_CALLBACK_NORMALIZED] = true;
858
+ }
859
+
755
860
  /**
756
861
  * Parse TUS metadata into object
757
862
  * @tus/server v2 already parses metadata into an object
@@ -31,13 +31,16 @@ export const DEFAULT_TUS_ALLOWED_HEADERS: string[] = [];
31
31
  * Default TUS configuration
32
32
  */
33
33
  export const DEFAULT_TUS_CONFIG: Required<
34
- Omit<ITusConfig, 'allowedTypes' | 'creation' | 'expiration'> & {
34
+ Omit<ITusConfig, 'allowedOrigins' | 'allowedTypes' | 'creation' | 'expiration'> & {
35
+ allowedOrigins: undefined;
35
36
  allowedTypes: undefined;
36
37
  creation: boolean;
37
38
  expiration: ITusExpirationConfig;
38
39
  }
39
40
  > = {
40
41
  allowedHeaders: DEFAULT_TUS_ALLOWED_HEADERS,
42
+ // Derived from the API CORS configuration (CoreTusService.resolveCorsOrigins())
43
+ allowedOrigins: undefined,
41
44
  allowedTypes: undefined,
42
45
  checksum: true,
43
46
  concatenation: true,