@lenne.tech/nest-server 11.42.2 → 11.42.4

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 (62) hide show
  1. package/.claude/rules/configurable-features.md +3 -1
  2. package/.claude/rules/package-management.md +1 -1
  3. package/.claude/rules/role-system.md +31 -3
  4. package/.claude/rules/testing.md +4 -4
  5. package/CLAUDE.md +7 -1
  6. package/FRAMEWORK-API.md +2 -1
  7. package/dist/core/common/helpers/graphql-ws-connection.helper.d.ts +43 -0
  8. package/dist/core/common/helpers/graphql-ws-connection.helper.js +219 -0
  9. package/dist/core/common/helpers/graphql-ws-connection.helper.js.map +1 -0
  10. package/dist/core/common/helpers/graphql-ws-context.helper.d.ts +2 -1
  11. package/dist/core/common/helpers/graphql-ws-context.helper.js +54 -18
  12. package/dist/core/common/helpers/graphql-ws-context.helper.js.map +1 -1
  13. package/dist/core/common/interfaces/server-options.interface.d.ts +9 -0
  14. package/dist/core/common/services/core-body-parser.initializer.d.ts +26 -0
  15. package/dist/core/common/services/core-body-parser.initializer.js +136 -0
  16. package/dist/core/common/services/core-body-parser.initializer.js.map +1 -0
  17. package/dist/core/modules/auth/services/core-auth.service.d.ts +1 -0
  18. package/dist/core/modules/auth/services/core-auth.service.js +19 -0
  19. package/dist/core/modules/auth/services/core-auth.service.js.map +1 -1
  20. package/dist/core/modules/better-auth/better-auth.config.d.ts +2 -1
  21. package/dist/core/modules/better-auth/better-auth.config.js +27 -1
  22. package/dist/core/modules/better-auth/better-auth.config.js.map +1 -1
  23. package/dist/core/modules/better-auth/core-better-auth.module.js +7 -3
  24. package/dist/core/modules/better-auth/core-better-auth.module.js.map +1 -1
  25. package/dist/core/modules/better-auth/core-better-auth.service.js +4 -0
  26. package/dist/core/modules/better-auth/core-better-auth.service.js.map +1 -1
  27. package/dist/core/modules/error-code/error-codes.d.ts +9 -0
  28. package/dist/core/modules/error-code/error-codes.js +8 -0
  29. package/dist/core/modules/error-code/error-codes.js.map +1 -1
  30. package/dist/core/modules/tenant/core-tenant.guard.d.ts +2 -0
  31. package/dist/core/modules/tenant/core-tenant.guard.js +20 -8
  32. package/dist/core/modules/tenant/core-tenant.guard.js.map +1 -1
  33. package/dist/core/modules/user/core-user.service.js +11 -6
  34. package/dist/core/modules/user/core-user.service.js.map +1 -1
  35. package/dist/core.module.d.ts +3 -0
  36. package/dist/core.module.js +102 -42
  37. package/dist/core.module.js.map +1 -1
  38. package/dist/index.d.ts +1 -0
  39. package/dist/index.js +1 -0
  40. package/dist/index.js.map +1 -1
  41. package/dist/server/modules/error-code/error-codes.d.ts +1 -0
  42. package/dist/tsconfig.build.tsbuildinfo +1 -1
  43. package/docs/REQUEST-LIFECYCLE.md +29 -0
  44. package/docs/error-codes.md +16 -0
  45. package/docs/security-overrides.md +18 -2
  46. package/migration-guides/11.42.2-to-11.42.3.md +145 -0
  47. package/migration-guides/11.42.3-to-11.42.4.md +185 -0
  48. package/package.json +3 -3
  49. package/src/core/common/helpers/graphql-ws-connection.helper.ts +382 -0
  50. package/src/core/common/helpers/graphql-ws-context.helper.ts +91 -18
  51. package/src/core/common/interfaces/server-options.interface.ts +121 -2
  52. package/src/core/common/services/core-body-parser.initializer.ts +232 -0
  53. package/src/core/modules/auth/services/core-auth.service.ts +37 -0
  54. package/src/core/modules/better-auth/better-auth.config.ts +46 -1
  55. package/src/core/modules/better-auth/core-better-auth.module.ts +24 -4
  56. package/src/core/modules/better-auth/core-better-auth.service.ts +9 -1
  57. package/src/core/modules/error-code/error-codes.ts +9 -0
  58. package/src/core/modules/tenant/README.md +8 -8
  59. package/src/core/modules/tenant/core-tenant.guard.ts +41 -12
  60. package/src/core/modules/user/core-user.service.ts +19 -6
  61. package/src/core.module.ts +170 -55
  62. package/src/index.ts +1 -0
@@ -45,6 +45,7 @@ The `CoreModule` is a dynamic module that bootstraps the entire framework:
45
45
  | **Central S3 (optional)** | `CoreS3Service` — globally provided **and exported** by `CoreModule`, inert unless `s3` is configured *and* names a `bucket` (a bucket-less block is ignored with a warning). Backs `file.storage: 's3'` and TUS staging (`tus.s3Staging`). Requires the OPTIONAL peers `@aws-sdk/client-s3` (+ `@aws-sdk/s3-request-presigner` for `presignedDownloads`) — configured but missing **fails the boot**. `@aws-sdk/lib-storage` (optional) uploads streams of unknown length in parts; without it they are buffered in memory, with one boot warning |
46
46
  | **Configuration System** | `config.env.ts` with ENV variables, `NEST_SERVER_CONFIG` JSON, `NSC__*` prefixes |
47
47
  | **Cookie Handling** | Enabled by default (`cookies: true`), configurable via `ICookiesConfig` with `exposeTokenInBody` option |
48
+ | **Body Parser Limits** | `bodyParser.json.limit` / `bodyParser.urlencoded.limit` (default 100 kB each, body-parser's own). Applied in place to the parsers NestJS registers, so they hold under `Test.createTestingModule()` too. Every 413 is answered with `#LTNS_0304` plus size and limit, and logged with the route and the config key — see [0d. Body parsers](#0d-body-parsers) |
48
49
  | **Unified CORS** | Single `cors` config propagates to GraphQL, REST, and BetterAuth layers |
49
50
  | **Dual Auth Modes** | IAM-Only (BetterAuth) or Legacy+IAM for migration periods |
50
51
 
@@ -208,6 +209,7 @@ JWT-based authentication for existing projects:
208
209
  | **Custom Scalars** | `Date`, `DateTime` (timestamp), `JSON`, `Any` |
209
210
  | **Subscriptions** | WebSocket support via `graphql-ws` with auth. The `PUB_SUB` provider is built from a factory: `CoreRedisPubSub` when `redis` is configured (delivery is then cluster-wide), the in-memory `PubSub` otherwise (delivery only to clients connected to the publishing replica). **Constraint once Redis is in play: every published payload must be JSON-serializable** — it crosses the wire as JSON, so `Date`, class instances, `Map`/`Set` and `undefined` do not survive the round trip. An in-process `PubSub` never had this constraint, so a payload that worked on one replica can silently lose fields on a cluster. Publish plain objects and ISO strings |
210
211
  | **Subscription request context** | Since 11.35.0 `CoreModule` installs a context-aware `execute` / `subscribe` pair (inside `subscriptions`, where `ApolloDriver` forwards it) on all three GraphQL driver builders, so every WebSocket operation runs inside a `RequestContext`. Before that a WS operation had **none** — no Express middleware runs on an upgrade, and `CoreTenantGuard.getRequest()` finds no `req` on a subscription context — and `mongooseTenantPlugin` reads "no context" as "system operation, no filter", so a tenant-scoped read while delivering a subscription message returned EVERY tenant's rows. The tenant comes from the handshake's tenant header **validated against an active membership**, else from the subscriber's memberships; an unresolvable tenant leaves the safety net to refuse the read. The async iterator is wrapped too, not only the subscribe call: graphql-js runs `resolve` / `filter` / field resolvers inside its `next()`. See `.claude/rules/role-system.md` → "Tenant context on non-HTTP transports" |
212
+ | **Subscription re-check** | Since 11.42.3 that context is not trusted for the life of the socket. Every connection a `CoreModule` `onConnect` accepts is registered (`registerWsConnection()`) and checked again — at once on an invalidation (tenant guard, role change, password reset, Better-Auth deleting the session), otherwise before the next operation or event once `graphQl.subscriptionRevalidationMs` (30 s) has passed. The check re-runs the handshake's authentication and the tenant resolution; a connection whose rights changed is closed with 4403 (retried by the graphql-ws client, which then reconnects with its current rights) and the event that triggered the check is dropped. Before, a removed membership, a deactivated tenant, a withdrawn role or a revoked session kept receiving events on an open socket until the client reconnected. See `.claude/rules/role-system.md` → "Open WebSockets" |
211
213
  | **Complexity Analysis** | Query cost calculation to prevent DoS attacks |
212
214
  | **Enum Registration** | `registerEnum()` helper for GraphQL enum types |
213
215
  | **Upload Support** | `graphqlUploadExpress()` for multipart file uploads |
@@ -325,6 +327,12 @@ The following diagram shows the exact order of execution from HTTP request to re
325
327
  | - credentials: true when cookies enabled |
326
328
  | - Origins from appUrl/baseUrl/cors.allowedOrigins |
327
329
  | - Propagated to BetterAuth trustedOrigins |
330
+ | |
331
+ | 0d. Body parsers: json + urlencoded [always] |
332
+ | - Registered by NestJS in app.init(), limits from |
333
+ | bodyParser.*.limit (default 100 kB each) |
334
+ | - Over the limit: 413 #LTNS_0304 BEFORE auth and |
335
+ | routing, REST and GraphQL alike |
328
336
  +----------------------------+----------------------------+
329
337
  |
330
338
  +----------------------------v----------------------------+
@@ -550,6 +558,27 @@ if (!isCorsDisabled(envConfig.cors)) {
550
558
 
551
559
  > **Security:** steps 2 and 4 grant the derived origin credentialed CORS. If the apex domain is not trusted (e.g. a third-party-hosted marketing site whose XSS surface you do not control), set `cors.deriveAppUrl: false` and list the frontend origin explicitly via `appUrl` or `cors.allowedOrigins`. The derivation never yields a bare TLD (`https://api.dev` stays unchanged) and never emits the opaque `null` origin. With `cors.deriveAppUrl: false`, a host-split localhost `baseUrl` falls back to the `http://localhost:3001` default.
552
560
 
561
+ #### 0d. Body parsers
562
+
563
+ NestJS registers two global body parsers on every Express application — `application/json` and `application/x-www-form-urlencoded` (`extended: true`) — inside `app.init()`, i.e. after everything `main.ts` registers and before the NestJS middleware chain. GraphQL has no parser of its own: Apollo reads the body the global JSON parser produced, so one limit governs REST and GraphQL.
564
+
565
+ Both use body-parser's default limit of **100 kB** unless `bodyParser` says otherwise (since 11.42.4):
566
+
567
+ ```typescript
568
+ // config.env.ts — documents carry HTML: allow 2 MB of JSON, keep form posts at the default
569
+ bodyParser: { json: { limit: '2mb' } },
570
+ ```
571
+
572
+ `CoreBodyParserInitializer` applies it during module init by replacing the handle of the global parser layers NestJS created — in place, because NestJS registers them before any init hook runs and a parser appended later would sit behind the routes. Running during module init is what makes the limit hold under `Test.createTestingModule()`, which never runs `main.ts`. A path-scoped parser (`server.use('/upload', json({ limit }))` in `main.ts`) runs before the global one and is left untouched — that remains the way to give one path a different limit.
573
+
574
+ The parser runs **before authentication and routing**. Over the limit, the whole request is refused:
575
+
576
+ ```json
577
+ { "statusCode": 413, "message": "#LTNS_0304: Request body too large [137270 bytes, limit 102400 bytes]", "error": "Payload Too Large" }
578
+ ```
579
+
580
+ and the server logs a warning with method, path (query string stripped) and the config key to raise. Other parser errors (malformed JSON → 400) pass through unchanged. The default is deliberately not raised for everybody: `JSON.parse` is synchronous, and a larger pre-auth limit is a larger pre-auth event-loop block for every route, including unauthenticated ones.
581
+
553
582
  ### NestJS Middleware Chain (CoreModule)
554
583
 
555
584
  NestJS middleware runs for every request after Express-level middleware. Registration happens in `CoreModule.configure()`:
@@ -262,6 +262,22 @@ All error codes follow the format: `#PREFIX_XXXX: Description`
262
262
 
263
263
  ---
264
264
 
265
+ ### LTNS_0304: requestBodyTooLarge
266
+
267
+ **Message:** Request body too large
268
+
269
+ **Description:** Answered with HTTP 413 when a JSON or URL-encoded request body exceeds the body parser's limit. The parser runs before authentication and routing, so the whole request is rejected. The message carries the size and the limit in brackets, e.g. `#LTNS_0304: Request body too large [137270 bytes, limit 102400 bytes]` (the size is missing when the client sent no `Content-Length`). The server logs a warning with the route and the config key.
270
+
271
+ **Solution:** Send a smaller body (split the operation, or upload large content through the file endpoints). If payloads of this size are legitimate for the route, raise the limit via `bodyParser.json.limit` / `bodyParser.urlencoded.limit` in the server options.
272
+
273
+ **Parameters:** none (the size details are part of the developer message, not of the translation)
274
+
275
+ **Translations:**
276
+ - DE: Die gesendeten Daten sind zu groß und wurden nicht verarbeitet.
277
+ - EN: The submitted data is too large and was not processed.
278
+
279
+ ---
280
+
265
281
  ## Resource Errors (LTNS_0400-LTNS_0499)
266
282
 
267
283
  ### LTNS_0400: resourceNotFound
@@ -11,15 +11,23 @@ A green `pnpm audit` inside the framework repo says nothing about your tree.
11
11
 
12
12
  ## What this concretely means for you
13
13
 
14
- The framework pulls in three transitive packages that its `@nestjs/*` dependencies used to
15
- **exact-pin** to a vulnerable version:
14
+ The framework pulls in four transitive packages that its `@nestjs/*` dependencies exact-pin, or
15
+ used to **exact-pin**, to a vulnerable version:
16
16
 
17
17
  | Package | Advisory | Why it cannot resolve forward on its own |
18
18
  |---------|----------|------------------------------------------|
19
19
  | `ws` | [GHSA-96hv-2xvq-fx4p](https://github.com/advisories/GHSA-96hv-2xvq-fx4p) — high: memory-exhaustion DoS + uninitialized memory disclosure. Patched `>=8.21.0` | Older `@nestjs/graphql` releases declare `"ws": "8.20.1"` — an **exact pin**, not a caret. 13.4.5, the version this framework declares, pins `8.21.3` |
20
20
  | `js-yaml` | [GHSA-pm4m-ph32-ghv5](https://github.com/advisories/GHSA-pm4m-ph32-ghv5) — high: exponential parsing time in flow collections (DoS), patched `>=5.2.2`; [GHSA-r3ph-w7gj-g6xm](https://github.com/advisories/GHSA-r3ph-w7gj-g6xm) — moderate: `maxTotalMergeKeys` does not bound CPU use for empty merge sources, covers `<=5.4.0`, patched `>=5.4.1` (published 2026-09-29) | `@nestjs/swagger` declares js-yaml as an **exact pin**, the same shape as the `ws` case: older releases `5.2.1`, 11.4.7 — the version this framework declares, and the newest 11.x — `5.3.0`. No swagger update reaches the fix, so **this entry is load-bearing again** |
21
+ | `@graphql-tools/utils` | [GHSA-7mx3-vvmw-hjmv](https://github.com/advisories/GHSA-7mx3-vvmw-hjmv) — high: prototype pollution in `mergeDeep`, which runs while the GraphQL schema is assembled. Covers `<=12.0.0`, patched `>=12.0.1` (added 2026-10-06) | `@nestjs/graphql` 13.4.5 — the version this framework declares, and the newest 13.x — declares `"@graphql-tools/utils": "12.0.0"`, an **exact pin**. Only `@nestjs/graphql` 14.x moves it, so **this entry is load-bearing**. `@apollo/server` reaches it too, through `@graphql-tools/schema` / `merge` — and in a lockfile that still holds schema 10.0.x (resolved before 2026-08-12), as **utils 11.x**: vulnerable too, with no 11.x fix, and outside the override key. Lift it with `pnpm update --depth Infinity @graphql-tools/schema @graphql-tools/merge`; the current releases request utils `^12.0.3`, which needs Node >=22.15 |
21
22
  | `multer` | [GHSA-wc9g-mqfw-jrwm](https://github.com/advisories/GHSA-wc9g-mqfw-jrwm), [GHSA-535w-7cp7-47q4](https://github.com/advisories/GHSA-535w-7cp7-47q4), [GHSA-qfvm-cv95-jqjf](https://github.com/advisories/GHSA-qfvm-cv95-jqjf) — high: DoS via crafted multipart input; [GHSA-qvfw-j98x-7q72](https://github.com/advisories/GHSA-qvfw-j98x-7q72) — low: file size limit bypass. Patched `>=2.3.0` | `@nestjs/platform-express` up to 11.2.5 declares `"multer": "2.2.0"` — an **exact pin**. A direct `multer` dependency does not move it: you get both copies, and FileInterceptor uses the vulnerable one. 11.2.6, which this framework declared from 11.41.4, pins `2.4.0`, and so does 11.2.7, declared since 11.41.7 |
22
23
 
24
+ **Status since 11.42.4:** `@graphql-tools/utils` joins `js-yaml` as load-bearing — no `@nestjs/graphql`
25
+ 13.x resolves past the exact pin. The 11.42.4 release also covers `proxy-addr` (GHSA-jqcg-44mw-7w3h,
26
+ critical: IP spoofing through an IPv4-mapped IPv6 address in a trusted subnet — it is what
27
+ `trustProxy` hands to Express). That one needs **no override**: `express` requests `^2.0.7` and the
28
+ fix `2.0.8` is in range, but an existing lockfile keeps `2.0.7` until you refresh it:
29
+ `pnpm update --depth Infinity proxy-addr`.
30
+
23
31
  **Status since 11.41.5:** `ws` and `multer` resolve to a patched version on their own with the
24
32
  `@nestjs/*` versions this framework declares; their entries below are insurance for a project whose
25
33
  own `package.json` pins an older `@nestjs/graphql` or `@nestjs/platform-express`. **`js-yaml` does
@@ -54,6 +62,14 @@ overrides:
54
62
  # Keep the target in LOCKSTEP with the multer version @lenne.tech/nest-server declares (2.4.0 since 11.41.4).
55
63
  # Inert on @nestjs/platform-express 11.2.6+, which pins 2.4.0 itself.
56
64
  'multer@>=2.0.0 <2.4.0': '2.4.0'
65
+
66
+ # @nestjs/graphql 13.4.5 exact-pins @graphql-tools/utils@12.0.0 — GHSA-7mx3-vvmw-hjmv (high, patched >=12.0.1).
67
+ # Target 12.0.1 rather than the newest 12.x: 12.0.2+ requires Node >=22.15 via @whatwg-node/promise-helpers@2.
68
+ # If your engines floor is already >=22.15, prefer the newest 12.x, so the caret requesters are not held back.
69
+ # Does NOT reach a utils 11.x pulled in by an older @graphql-tools/schema 10.0.x — refresh that one instead:
70
+ # pnpm update --depth Infinity @graphql-tools/schema @graphql-tools/merge (current releases need Node >=22.15)
71
+ # Remove once @nestjs/graphql pins >=12.0.1 in the major you use.
72
+ '@graphql-tools/utils@>=12.0.0 <12.0.1': '12.0.1'
57
73
  ```
58
74
 
59
75
  ### Retired: `@hono/node-server` (removed 2026-08-22, nest-server 11.36.1)
@@ -0,0 +1,145 @@
1
+ # Migration Guide: 11.42.2 → 11.42.3
2
+
3
+ ## Overview
4
+
5
+ | Category | Details |
6
+ | -------------------- | ------------------------------------------------------------------------------------------------------------------------------- |
7
+ | **Security** | An open GraphQL WebSocket loses what its user lost — membership, tenant, role, session — instead of keeping it until reconnect (§1) |
8
+ | **Security** | IAM sign-out ends the session on the server, not only in the browser (§2) |
9
+ | **Bugfix** | The IAM WebSocket handshake accepts a valid bearer token (§3) |
10
+ | **Security** | The legacy WebSocket handshake verifies the token's signature and expiry, as HTTP does (§4) |
11
+ | **New option** | `graphQl.subscriptionRevalidationMs` (default 30000) (§1) |
12
+ | **Migration Effort** | None for most projects — `pnpm update`. Check §1 if you replaced `onConnect` yourself |
13
+
14
+ ## Quick Migration
15
+
16
+ ```bash
17
+ pnpm update @lenne.tech/nest-server
18
+ ```
19
+
20
+ Vendor-mode projects pick up the core update. No configuration change is required.
21
+
22
+ ## 1. Open WebSockets are re-checked, and closed when the rights behind them changed
23
+
24
+ A GraphQL WebSocket was authorized once: `onConnect` validated the token and recorded the user, the
25
+ subscribe wrapper resolved the tenant, and every event and operation on the socket ran on that
26
+ snapshot until the client reconnected. Removing someone from a tenant, switching a tenant off,
27
+ withdrawing a role or ending a session took effect on every new request and on none of the sockets
28
+ that were already open — for a browser tab, often hours.
29
+
30
+ Since 11.42.3 every connection `CoreModule`'s `onConnect` accepts is registered with the rights it was
31
+ authorized with, and checked again:
32
+
33
+ | Trigger | When |
34
+ | ------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------- |
35
+ | `CoreTenantGuard.invalidateUser()` / `invalidateTenant()` / `invalidateAll()`, `CoreUserService.setRoles()`, an `update()` of `roles` / `verified` / `emailVerified`, the legacy password reset | at once |
36
+ | Better-Auth deleting a session: sign-out, a revoked session, a reset with `revokeSessionsOnPasswordReset` | at once |
37
+ | A change nobody invalidated (a membership edited directly in the database, an expired session) | before the next event or operation, once `graphQl.subscriptionRevalidationMs` has passed |
38
+
39
+ A connection whose rights changed is closed with **4403**. The graphql-ws client retries that code: it
40
+ reconnects, resubscribes and continues with the rights the user holds now, or is refused at the
41
+ handshake when the session is gone. Nothing changes for a connection whose rights did not change.
42
+
43
+ ```typescript
44
+ // config.env.ts — optional, the default is 30000
45
+ graphQl: {
46
+ subscriptionRevalidationMs: 30000, // 0 = re-check on every event and operation, false = only on invalidation
47
+ },
48
+ ```
49
+
50
+ **If you replaced `onConnect`** through `graphQl.driver.subscriptions`, register what you accept, or
51
+ your connections keep the old "checked once" behaviour:
52
+
53
+ ```typescript
54
+ import { registerWsConnection } from '@lenne.tech/nest-server';
55
+
56
+ onConnect: async (context) => {
57
+ const user = await authenticate(context); // your own handshake
58
+ context.extra.user = user;
59
+ registerWsConnection({
60
+ carrier: context.extra,
61
+ reauthenticate: () => authenticate(context), // the user the token belongs to NOW, or null
62
+ socket: context.extra.socket,
63
+ user,
64
+ });
65
+ return context.extra;
66
+ },
67
+ ```
68
+
69
+ **If your client does not use graphql-ws** (or turned its retries off), make sure it reconnects after a
70
+ 4403 close; otherwise the user's live updates stop until the page is reloaded.
71
+
72
+ A dropped event is checked after graphql-js executed it and before it is sent: the reads made while
73
+ computing it happened, but nothing of it reaches the client. Subscription resolvers should not write.
74
+
75
+ ## 2. IAM sign-out ends the session on the server
76
+
77
+ `CoreBetterAuthService.revokeSession()` — used by `POST /iam/sign-out` and the `betterAuthSignOut`
78
+ mutation — passed the session token to Better-Auth's `signOut` only as an `Authorization` header.
79
+ Better-Auth reads that header only with its `bearer` plugin, which the framework does not install. The
80
+ sign-out therefore answered `{ success: true }`, cleared the cookies, and deleted nothing: the session
81
+ stayed valid server-side, and the same token kept authenticating requests until it expired.
82
+
83
+ The token is now passed as the signed session cookie Better-Auth reads, and the header is kept for
84
+ projects that added the bearer plugin themselves. **No action is needed.** Sessions signed out before
85
+ the update are still valid until they expire; if that matters for your deployment, delete them from the
86
+ `session` collection.
87
+
88
+ ## 3. The IAM WebSocket handshake accepts a valid bearer token
89
+
90
+ Both IAM driver builders authenticated the handshake through `getSession()` with an `Authorization`
91
+ header — the same header Better-Auth ignores without its `bearer` plugin. Every IAM WebSocket handshake
92
+ with a bearer token was refused as `Invalid or expired session`, so authenticated subscriptions did not
93
+ work in IAM-only projects at all.
94
+
95
+ The handshake now resolves the token the way the HTTP middleware does for the same header: a Better-Auth
96
+ JWT first, then the session store, and `getSession()` as the last resort. If you built a workaround
97
+ (for example a custom `onConnect`), you can remove it — and register the connection as shown in §1 if
98
+ you keep it.
99
+
100
+ ## 4. The legacy WebSocket handshake verifies the token
101
+
102
+ Legacy access tokens are verified on HTTP by passport-jwt: signature and expiry. The legacy WebSocket
103
+ handshake read them with `decodeJwt()` instead, which checks neither — only the payload's `tokenId` had
104
+ to match the device. An expired token, or a copy of one re-signed with any key, therefore kept opening
105
+ sockets for as long as the device's refresh token was not rotated.
106
+
107
+ The handshake, and the re-check from §1, now use `CoreAuthService.verifyJwt()`, which applies passport's
108
+ rule: `jwt.secretOrKeyProvider` when configured, else `jwt.secretOrPrivateKey || jwt.secret`. A token
109
+ that is not a JWT at all is refused as unauthorized instead of closing the socket with 4500 and a
110
+ TypeError as the reason.
111
+
112
+ **What this changes for a legacy WebSocket client:** a connection now ends when its token does — at
113
+ expiry, or when the client refreshes its tokens (a refresh rotates the device's `tokenId`, which already
114
+ invalidated the old token on HTTP). The socket is closed with 4403 and graphql-ws reconnects, so pass
115
+ the CURRENT token on every connect rather than the one the client had at start-up:
116
+
117
+ ```typescript
118
+ createClient({
119
+ url,
120
+ connectionParams: () => ({ Authorization: `Bearer ${getCurrentAccessToken()}` }), // a function, not an object
121
+ });
122
+ ```
123
+
124
+ The same holds for an IAM client that authenticates its socket with a Better-Auth JWT (§3): the JWT
125
+ expires after `betterAuth.jwt.expiresIn` (15 minutes by default). A session token lives as long as its
126
+ session.
127
+
128
+ An AuthService passed to `CoreModule.forRoot()` that does not extend `CoreAuthService` may lack
129
+ `verifyJwt()`; it keeps the unverified read and logs a warning once. Extend `CoreAuthService` to close it.
130
+
131
+ ## Troubleshooting
132
+
133
+ | Symptom | Cause | Fix |
134
+ | -------------------------------------------------------------------------- | -------------------------------------------------------------- | ---------------------------------------------------- |
135
+ | A client's WebSocket closes with 4403 after a role or membership change | Intended: the rights behind the connection changed | Let the client reconnect (graphql-ws does by default) |
136
+ | Live updates stop after a role change and never resume | The client does not reconnect after a 4403 close | Enable reconnects (graphql-ws `retryAttempts`) |
137
+ | Revoked access still receives events on a project with its own `onConnect` | The custom `onConnect` does not register the connection | Call `registerWsConnection()` (§1) |
138
+ | IAM subscriptions are refused with `Invalid or expired session` | 11.42.2 or older | Update to 11.42.3 |
139
+ | A WebSocket reconnects and is refused shortly after a token refresh or expiry | The client reconnects with the token it had at start-up | Pass `connectionParams` as a function (§4) |
140
+ | `has no verifyJwt()` warning at the first legacy WebSocket | The AuthService does not extend `CoreAuthService` | Extend `CoreAuthService` (§4) |
141
+
142
+ ## Module Documentation
143
+
144
+ - [Tenant README](../src/core/modules/tenant/README.md) — Deactivated tenants
145
+ - [Better-Auth README](../src/core/modules/better-auth/README.md)
@@ -0,0 +1,185 @@
1
+ # Migration Guide: 11.42.3 → 11.42.4
2
+
3
+ ## Overview
4
+
5
+ | Category | Details |
6
+ | -------------------- | ---------------------------------------------------------------------------------------------------------------- |
7
+ | **New option** | `bodyParser.json.limit` / `bodyParser.urlencoded.limit` — the request body limit, configurable for the first time (§1) |
8
+ | **Improvement** | A request over the body limit is answered with `#LTNS_0304` plus size and limit, and logged with the route (§2) |
9
+ | **Security** | `proxy-addr` 2.0.8 (critical) and `@graphql-tools/utils` 12.0.1 (high) — your project needs a lockfile refresh and one override (§3) |
10
+ | **Bugfix** | Without `betterAuth.secret` and with JWT secrets shorter than 32 characters, native Better-Auth endpoints rejected the session right after sign-in (§4) |
11
+ | **Dependencies** | `nodemailer` 10.0.14 (patch: address-parser and DKIM header fixes) — no action needed |
12
+ | **Migration Effort** | Small — `pnpm update` plus one override in `pnpm-workspace.yaml` (§3). Projects that raise the body limit in `main.ts` can move it into the config (§1) |
13
+
14
+ ## Quick Migration
15
+
16
+ ```bash
17
+ pnpm update @lenne.tech/nest-server
18
+ pnpm update --depth Infinity proxy-addr
19
+ ```
20
+
21
+ Then add the `@graphql-tools/utils` override from §3 to `pnpm-workspace.yaml`, run `pnpm install`,
22
+ and check §3 for an older `@graphql-tools/utils` 11.x copy, which the override does not reach.
23
+ Vendor-mode projects pick up the core update and need the same two dependency steps. The body limit
24
+ needs no configuration: unset, both parsers keep the 100 kB they had.
25
+
26
+ ## 1. The request body limit is configurable
27
+
28
+ NestJS registers a JSON and a URL-encoded body parser on every application, and until now neither had
29
+ a limit anybody chose: body-parser's default of **100 kB** applied, and nothing in the framework or the
30
+ starter could change it. A request over it is refused in middleware — before authentication, before
31
+ routing, GraphQL included — so a document that grew past 100 kB could no longer be saved, and every
32
+ retry failed the same way.
33
+
34
+ ```typescript
35
+ // config.env.ts — documents carry HTML: allow 2 MB of JSON, keep form posts at the default
36
+ bodyParser: {
37
+ json: { limit: '2mb' },
38
+ },
39
+ ```
40
+
41
+ | Value | Meaning |
42
+ | ---------------------- | -------------------------------------------------------------------- |
43
+ | unset | 100 kB, exactly as before |
44
+ | `2097152` | bytes |
45
+ | `'500kb'`, `'2mb'` | 1024-based size; units `b` / `kb` / `mb` / `gb`, case-insensitive |
46
+ | `0`, negative, `'lots'` | refused with an error log at boot; the parser keeps its limit |
47
+
48
+ The JSON and URL-encoded limits are separate on purpose: raising one does not raise the other.
49
+
50
+ **Raise it only where the data model needs it, and only as far.** The parser runs before
51
+ authentication, and `JSON.parse` blocks the event loop for the whole body — measured on a realistic
52
+ document shape, about 0.5 ms for 100 kB, 16 ms for 1 MB and 55 ms for 2 MB per request. That is why
53
+ the default stays at 100 kB.
54
+
55
+ **If you raise the limit in `main.ts` today**, move it into the config:
56
+
57
+ ```typescript
58
+ // main.ts — before: not applied in the e2e suite
59
+ server.useBodyParser('json', { limit: '2mb' });
60
+
61
+ // config.env.ts — after: applied in production AND under Test.createTestingModule()
62
+ bodyParser: { json: { limit: '2mb' } },
63
+ ```
64
+
65
+ The config is applied during module init, so it holds in every test that builds the app with
66
+ `Test.createTestingModule(...).createNestApplication()` — which never runs `main.ts`, so a
67
+ `useBodyParser()` call there never reached the e2e suite. Once configured, the value also replaces a
68
+ global parser your `main.ts` registered; keeping both is harmless, but the config wins.
69
+
70
+ A **path-scoped** parser is left alone: `server.use('/upload', json({ limit: '20mb' }))` in `main.ts`
71
+ still gives that one path its own limit. The parser runs before routing, so a per-route limit can only
72
+ key on the path — and all GraphQL operations share one.
73
+
74
+ **`rawBody: true`:** `req.rawBody` is kept for apps created with `NestFactory.create(…, { rawBody:
75
+ true })`. Under `Test.createTestingModule()` the options passed to `createNestApplication()` are not
76
+ visible to providers, so a test app created with `{ rawBody: true }` gets no `req.rawBody` once a limit
77
+ is configured.
78
+
79
+ ## 2. An oversized request says what happened
80
+
81
+ Before, the caller received:
82
+
83
+ ```json
84
+ { "statusCode": 413, "message": "request entity too large" }
85
+ ```
86
+
87
+ and the server logged the same text as an error with a stack trace. Now, configured or not:
88
+
89
+ ```json
90
+ { "statusCode": 413, "message": "#LTNS_0304: Request body too large [137270 bytes, limit 102400 bytes]", "error": "Payload Too Large" }
91
+ ```
92
+
93
+ The size is present when the client sent a `Content-Length`. Frontends using the error translation of
94
+ `@lenne.tech/nuxt-extensions` show the translated `LTNS_0304` text. The server logs one warning per
95
+ refused request, with method, path (without query string) and the config key to raise:
96
+
97
+ ```
98
+ 413 PATCH /offers/6650…: json body of 137270 bytes exceeds the limit of 102400 bytes. If payloads of this size are legitimate here, raise `bodyParser.json.limit` in the server options.
99
+ ```
100
+
101
+ The status code is unchanged. Only clients that compare the message text against
102
+ `'request entity too large'` need adjusting — branch on the status or on `LTNS_0304` instead.
103
+
104
+ ## 3. Two dependency advisories your project must fix itself
105
+
106
+ `pnpm` overrides apply only to the project that runs the install, so the framework's own fixes do not
107
+ reach your tree — see [docs/security-overrides.md](../docs/security-overrides.md).
108
+
109
+ | Package | Advisory | What your project needs |
110
+ | ------- | -------- | ----------------------- |
111
+ | `proxy-addr` | [GHSA-jqcg-44mw-7w3h](https://github.com/advisories/GHSA-jqcg-44mw-7w3h) — **critical**: IP spoofing through an IPv4-mapped IPv6 address in a trusted subnet. Patched `>=2.0.8` | A lockfile refresh. `express` requests `^2.0.7`, so `2.0.8` is in range, but an existing lockfile stays on `2.0.7` |
112
+ | `@graphql-tools/utils` | [GHSA-7mx3-vvmw-hjmv](https://github.com/advisories/GHSA-7mx3-vvmw-hjmv) — high: prototype pollution in `mergeDeep` (schema assembly). Covers `<=12.0.0` — **11.x included, with no 11.x fix**. Patched `>=12.0.1` | An override for the copy `@nestjs/graphql` 13.4.5 exact-pins (`12.0.0`), and — in an older lockfile — a refresh of `@graphql-tools/schema` / `merge`, which can still pull in 11.x (see below) |
113
+
114
+ `proxy-addr` is what Express uses to compute `request.ip` from `X-Forwarded-For` as far as
115
+ `trustProxy` allows. With `trustProxy` set, every IP-keyed rate limit — the framework's own and any of
116
+ yours — depends on it.
117
+
118
+ ```bash
119
+ pnpm update --depth Infinity proxy-addr
120
+ ```
121
+
122
+ ```yaml
123
+ # pnpm-workspace.yaml
124
+ overrides:
125
+ # @nestjs/graphql 13.4.5 exact-pins @graphql-tools/utils@12.0.0 — GHSA-7mx3-vvmw-hjmv (high, patched >=12.0.1).
126
+ # 12.0.2+ requires Node >=22.15; take the newest 12.x instead if your engines floor allows it.
127
+ '@graphql-tools/utils@>=12.0.0 <12.0.1': '12.0.1'
128
+ ```
129
+
130
+ **Check for a second, older copy.** `@apollo/server` 5.x requests `@graphql-tools/schema` `^10.0.0`. A
131
+ lockfile resolved before schema 10.1.0 (published 2026-08-12) holds a 10.0.x, which requests utils
132
+ `^11.1.0` — vulnerable, outside the override key above, and with no fix in the 11.x line. Look for it:
133
+
134
+ ```bash
135
+ grep -n "'@graphql-tools/utils@11" pnpm-lock.yaml
136
+ ```
137
+
138
+ If it is there, lift the requesters into their current lines, which ask for utils 12:
139
+
140
+ ```bash
141
+ pnpm update --depth Infinity @graphql-tools/schema @graphql-tools/merge
142
+ ```
143
+
144
+ The current releases (schema 10.1.3, merge 9.2.6) request utils `^12.0.3`, which depends on
145
+ `@whatwg-node/promise-helpers` 2 and therefore needs **Node >=22.15**. Current Node 22 LTS images
146
+ (22.22) and Node 24 satisfy that; if your `engines.node` still declares a lower floor, raise it — the
147
+ override above cannot hold this copy back, because `^12.0.3` does not intersect its key.
148
+
149
+ Then `pnpm install` and `pnpm audit` — both advisories must be gone.
150
+
151
+ ## 4. Native Better-Auth endpoints accept the session when the secret is auto-generated
152
+
153
+ **Affects only** projects that configure no `betterAuth.secret` and whose fallback secrets
154
+ (`jwt.secret`, `jwt.refresh.secret`) are shorter than 32 characters — typically a local `.env`.
155
+ Better-Auth then generates a secret at startup and signs with it, while the NestJS layer (cookie
156
+ helper, API middleware, `CoreBetterAuthService`) still signed with the configured value or none. The
157
+ session cookie went out unsigned, so every endpoint Better-Auth serves itself and that reads the
158
+ cookie — `/iam/token`, the MCP `/iam/mcp/authorize`, passkey, 2FA — answered **401 right after a
159
+ successful sign-in**, and an MCP OAuth login looped back to the login page.
160
+
161
+ Since 11.42.4 the NestJS layer signs with exactly the secret Better-Auth uses. No action is needed.
162
+ It is still the wrong setup for anything but local development: a generated secret changes on every
163
+ restart and differs per replica, so sessions do not survive either — set `betterAuth.secret` (at
164
+ least 32 characters), as the boot warning says.
165
+
166
+ ## Troubleshooting
167
+
168
+ | Symptom | Cause | Fix |
169
+ | ---------------------------------------------------------------- | ------------------------------------------------------------ | ---------------------------------------------------------- |
170
+ | `413 … exceeds the limit` warnings for a route that should accept it | The 100 kB default is too small for that data | Set `bodyParser.json.limit` (§1) |
171
+ | `bodyParser.json.limit … is not a positive size` at boot | A typo or unit the parser does not know (`'2 megabytes'`) | Use bytes or `'2mb'` |
172
+ | `no global json body parser is registered` at boot | The app was created with `bodyParser: false` | Remove the option, or register your own parser |
173
+ | A webhook test loses `req.rawBody` after configuring a limit | Test app created via the testing module with `rawBody: true` | See the `rawBody` note in §1 |
174
+ | A path still answers a bare `request entity too large` | A path-scoped parser of your own refused it | Raise that parser's limit in `main.ts` |
175
+ | `pnpm audit` still reports `@graphql-tools/utils` 12.0.0 | The override is missing, or its key was copied without the range | Add the entry from §3 verbatim |
176
+ | `pnpm audit` still reports `@graphql-tools/utils` 11.x | An older `@graphql-tools/schema` 10.0.x requests `^11.1.0`, outside the override key | `pnpm update --depth Infinity @graphql-tools/schema @graphql-tools/merge` (§3) — needs Node >=22.15 |
177
+ | `pnpm audit` still reports `proxy-addr` 2.0.7 | The lockfile was not refreshed | `pnpm update --depth Infinity proxy-addr` (§3) |
178
+ | `/iam/token` or MCP authorize answers 401 right after sign-in | 11.42.3 or older without `betterAuth.secret` and with short JWT secrets | Update to 11.42.4 (§4); for anything beyond local development set `betterAuth.secret` |
179
+
180
+ ## Module Documentation
181
+
182
+ - [Request lifecycle — 0d. Body parsers](../docs/REQUEST-LIFECYCLE.md#0d-body-parsers)
183
+ - [Error codes — LTNS_0304](../docs/error-codes.md#ltns_0304-requestbodytoolarge)
184
+ - [Security overrides — why yours are not inherited](../docs/security-overrides.md)
185
+ - [Better-Auth README](../src/core/modules/better-auth/README.md) — secret resolution and fallback chain
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lenne.tech/nest-server",
3
- "version": "11.42.2",
3
+ "version": "11.42.4",
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",
@@ -129,7 +129,7 @@
129
129
  "mongoose": "9.10.4",
130
130
  "multer": "2.4.0",
131
131
  "node-mailjet": "6.0.11",
132
- "nodemailer": "10.0.13",
132
+ "nodemailer": "10.0.14",
133
133
  "passport": "0.7.0",
134
134
  "passport-jwt": "4.0.1",
135
135
  "reflect-metadata": "0.2.2",
@@ -214,7 +214,7 @@
214
214
  "oxfmt": "0.71.0",
215
215
  "oxlint": "1.86.0",
216
216
  "rimraf": "6.1.3",
217
- "smtp-server": "3.19.16",
217
+ "smtp-server": "3.19.17",
218
218
  "ts-node": "10.9.2",
219
219
  "tsconfig-paths": "4.2.0",
220
220
  "tsx": "4.23.15",