@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
@@ -1051,6 +1051,53 @@ export interface IBetterAuthUserField {
1051
1051
  type: BetterAuthFieldType;
1052
1052
  }
1053
1053
 
1054
+ /**
1055
+ * Size limits for the two body parsers NestJS registers globally (`application/json` and
1056
+ * `application/x-www-form-urlencoded`).
1057
+ *
1058
+ * @see IServerOptions.bodyParser
1059
+ * @since 11.42.4
1060
+ */
1061
+ export interface IBodyParserConfig {
1062
+ /**
1063
+ * Limit for `application/json` bodies — REST and GraphQL alike, since Apollo reads the body the
1064
+ * global parser produced.
1065
+ *
1066
+ * A number is bytes; a string is a size such as `'500kb'` or `'2mb'` (1024-based, units
1067
+ * `b` / `kb` / `mb` / `gb`, case-insensitive).
1068
+ *
1069
+ * @default '100kb' (body-parser's own default — unset leaves the parser untouched)
1070
+ */
1071
+ json?: IBodyParserTypeConfig;
1072
+
1073
+ /**
1074
+ * Limit for `application/x-www-form-urlencoded` bodies. Same value format as `json`.
1075
+ *
1076
+ * Configured separately on purpose: form posts rarely need more, and raising one limit should not
1077
+ * silently raise the other.
1078
+ *
1079
+ * @default '100kb' (body-parser's own default — unset leaves the parser untouched)
1080
+ */
1081
+ urlencoded?: IBodyParserTypeConfig;
1082
+ }
1083
+
1084
+ /**
1085
+ * Settings for one body parser.
1086
+ *
1087
+ * @see IBodyParserConfig
1088
+ * @since 11.42.4
1089
+ */
1090
+ export interface IBodyParserTypeConfig {
1091
+ /**
1092
+ * Maximum body size: bytes as a number, or a size string such as `'2mb'`.
1093
+ *
1094
+ * A value that is not a positive size (`0`, negative, `NaN`, `'lots'`) is REJECTED with an error
1095
+ * log and the parser keeps its default — it never becomes "no limit". The parser runs before
1096
+ * authentication, so an unbounded limit would be an unbounded pre-auth allocation.
1097
+ */
1098
+ limit?: number | string;
1099
+ }
1100
+
1054
1101
  /**
1055
1102
  * Interface for Error Code module configuration
1056
1103
  *
@@ -1490,8 +1537,9 @@ export interface IMultiTenancy {
1490
1537
  * - tenant API tokens: **403**
1491
1538
  * - public (`S_EVERYONE`) routes: not blocked, but no tenant context is set
1492
1539
  * - no header: the tenant is dropped from the user's tenant list, so its data stays out of reach
1493
- * - GraphQL over WebSocket: no tenant is resolved for a NEW subscription; one already open keeps
1494
- * its tenant until the client reconnects (as it does after a removed membership)
1540
+ * - GraphQL over WebSocket: no tenant is resolved for a NEW subscription; one already open is
1541
+ * closed (4403) once `invalidateTenant()` is called, or at its next event after
1542
+ * `graphQl.subscriptionRevalidationMs` without it (since 11.42.3)
1495
1543
  *
1496
1544
  * A platform administrator under `adminBypass` still reaches the tenant — somebody has to be able to
1497
1545
  * look into it and switch it back on.
@@ -2263,6 +2311,48 @@ export interface IServerOptions {
2263
2311
  */
2264
2312
  betterAuth?: boolean | IBetterAuth;
2265
2313
 
2314
+ /**
2315
+ * Size limits for request bodies.
2316
+ *
2317
+ * NestJS registers a JSON and a URL-encoded body parser on every application, and neither has a
2318
+ * limit anybody chose: body-parser's default of **100 kB** applies. A request over it is answered
2319
+ * with 413 in middleware — before authentication, before routing — so the whole request fails,
2320
+ * every retry fails identically, and a document that grew past the limit can no longer be saved.
2321
+ *
2322
+ * Unset, both parsers stay exactly as NestJS registered them. Set a limit only where the data
2323
+ * model needs it, and keep it as small as that need allows: the parser runs before
2324
+ * authentication, and `JSON.parse` blocks the event loop for the whole body — measured on a
2325
+ * realistic document shape, about 0.5 ms for 100 kB, 16 ms for 1 MB and 55 ms for 2 MB per
2326
+ * request. That is why the default is not raised for everybody.
2327
+ *
2328
+ * Applied by `CoreModule` during module init, which replaces the global parsers NestJS registered
2329
+ * IN PLACE — same position in the middleware chain, same options otherwise (`extended: true` for
2330
+ * URL-encoded bodies, `req.rawBody` when the app was created with `NestFactory.create(…, {
2331
+ * rawBody: true })`). Because this happens in `app.init()`, it holds under
2332
+ * `Test.createTestingModule(...).createNestApplication()` too, which never runs `main.ts` — a
2333
+ * `useBodyParser()` call in `main.ts` does not reach the e2e suite, this does. A configured value
2334
+ * also replaces a global parser registered in `main.ts`; a path-scoped one
2335
+ * (`server.use('/upload', json({ limit }))`) is left alone, and stays the way to give ONE path a
2336
+ * different limit, since the parser runs before routing and GraphQL shares a single path anyway.
2337
+ *
2338
+ * One caveat: under the testing module the options passed to `createNestApplication()` are not
2339
+ * visible to providers, so a test app created with `{ rawBody: true }` gets no `req.rawBody` from
2340
+ * a CONFIGURED parser. Applications created with `NestFactory.create()` are not affected.
2341
+ *
2342
+ * Independent of this setting, a 413 from either parser is answered with
2343
+ * `#LTNS_0304: Request body too large [<size>, limit <limit>]` instead of a bare
2344
+ * "request entity too large", and logged as a warning naming the route and the config key.
2345
+ *
2346
+ * @since 11.42.4
2347
+ *
2348
+ * @example
2349
+ * ```typescript
2350
+ * // Documents carry HTML: allow 2 MB of JSON, keep form posts at the default
2351
+ * bodyParser: { json: { limit: '2mb' } },
2352
+ * ```
2353
+ */
2354
+ bodyParser?: IBodyParserConfig;
2355
+
2266
2356
  /**
2267
2357
  * Configuration for Brevo
2268
2358
  * See: https://developers.brevo.com/
@@ -2567,6 +2657,35 @@ export interface IServerOptions {
2567
2657
  * Module options (forRootAsync)
2568
2658
  */
2569
2659
  options?: GqlModuleAsyncOptions;
2660
+
2661
+ /**
2662
+ * How long an open GraphQL WebSocket connection may go without re-checking its authorization,
2663
+ * in milliseconds.
2664
+ *
2665
+ * A WebSocket is authorized at the handshake. Before 11.42.3 that was also the last check: a
2666
+ * removed membership, a deactivated tenant, a withdrawn role or a revoked session took effect
2667
+ * on every new request and on none of the sockets already open. Now a connection is re-checked
2668
+ * at once when an invalidation names it (`CoreTenantGuard.invalidateUser()` /
2669
+ * `invalidateTenant()` / `invalidateAll()`, a role change through `CoreUserService`, a
2670
+ * password reset), and before an event is delivered or an operation runs once its last check
2671
+ * is older than this value — the bound for a change nobody invalidated, such as a sign-out or a
2672
+ * membership edited directly in the database. A connection whose rights changed is closed with
2673
+ * 4403, which the graphql-ws client retries: it reconnects and continues with the current
2674
+ * rights, or is refused at the handshake.
2675
+ *
2676
+ * - `undefined` → 30000
2677
+ * - `0` → re-check on every operation and every event (one session lookup each)
2678
+ * - `false` → no time-based re-check; invalidations still apply
2679
+ * - anything else that is not a non-negative number → 30000, never "off"
2680
+ *
2681
+ * Applies to connections registered by `CoreModule`'s own `onConnect`. A project that replaces
2682
+ * `onConnect` through `graphQl.driver.subscriptions` registers its connections with
2683
+ * `registerWsConnection()` to keep this.
2684
+ *
2685
+ * @default 30000
2686
+ * @since 11.42.3
2687
+ */
2688
+ subscriptionRevalidationMs?: false | number;
2570
2689
  };
2571
2690
 
2572
2691
  /**
@@ -0,0 +1,232 @@
1
+ import { Injectable, Logger, OnModuleInit, Optional, PayloadTooLargeException } from '@nestjs/common';
2
+ import { HttpAdapterHost, ModuleRef } from '@nestjs/core';
3
+ import { json, urlencoded } from 'express';
4
+
5
+ import { ErrorCode } from '../../modules/error-code/error-codes';
6
+ import { IBodyParserConfig } from '../interfaces/server-options.interface';
7
+ import { ConfigService } from './config.service';
8
+
9
+ import type { NextFunction, Request, RequestHandler, Response } from 'express';
10
+
11
+ /** The two parsers NestJS registers on every Express application */
12
+ export type CoreBodyParserType = 'json' | 'urlencoded';
13
+
14
+ /**
15
+ * The function names body-parser gives its middleware. NestJS identifies its own parser layers by
16
+ * exactly these names (`ExpressAdapter.isMiddlewareApplied()`), which is why a wrapper must keep them.
17
+ */
18
+ const PARSER_NAMES: Record<CoreBodyParserType, string> = { json: 'jsonParser', urlencoded: 'urlencodedParser' };
19
+
20
+ /** 1024-based, like the `bytes` package body-parser itself parses limits with */
21
+ const UNIT_FACTORS: Record<string, number> = { b: 1, gb: 1024 ** 3, kb: 1024, mb: 1024 ** 2 };
22
+
23
+ /** The router layer fields this initializer reads and writes (Express 5 / `router` 2.x) */
24
+ interface ExpressLayer {
25
+ handle: RequestHandler;
26
+ slash?: boolean;
27
+ }
28
+
29
+ /**
30
+ * Convert a configured body limit into bytes.
31
+ *
32
+ * Returns `undefined` for anything that is not a positive size, so the caller can refuse it. The
33
+ * value is turned into a number HERE rather than handed to body-parser as a string, so the limit that
34
+ * is logged and the limit that is enforced cannot differ.
35
+ *
36
+ * @example parseBodyLimit('2mb') // 2097152
37
+ * @example parseBodyLimit(500000) // 500000
38
+ * @example parseBodyLimit('lots') // undefined
39
+ */
40
+ export function parseBodyLimit(value: unknown): number | undefined {
41
+ if (typeof value === 'number') {
42
+ return Number.isFinite(value) && value >= 1 ? Math.floor(value) : undefined;
43
+ }
44
+ if (typeof value !== 'string') {
45
+ return undefined;
46
+ }
47
+ const match = /^(\d+(?:\.\d+)?)\s*(b|kb|mb|gb)?$/i.exec(value.trim());
48
+ if (!match) {
49
+ return undefined;
50
+ }
51
+ const bytes = Math.floor(Number(match[1]) * UNIT_FACTORS[(match[2] || 'b').toLowerCase()]);
52
+ return bytes >= 1 ? bytes : undefined;
53
+ }
54
+
55
+ /**
56
+ * Applies `ServerOptions.bodyParser` to the body parsers NestJS registered, and turns their 413 into
57
+ * an answer a caller can act on.
58
+ *
59
+ * WHY it swaps layers instead of calling `useBodyParser()`: NestJS registers its parsers inside
60
+ * `app.init()`, BEFORE any `onModuleInit` runs, and a parser appended afterwards would sit behind
61
+ * the routes. So the layers NestJS created are replaced in place — same position in the middleware
62
+ * chain, same options otherwise. Doing it during module init (rather than in `main.ts`) is what
63
+ * makes the limit hold under `Test.createTestingModule()`, which never runs `main.ts`.
64
+ *
65
+ * Unconfigured, the parsers are not replaced, only wrapped: their own limit and options stay, and a
66
+ * 413 they raise is answered with `ErrorCode.REQUEST_BODY_TOO_LARGE` plus size and limit instead of
67
+ * a bare "request entity too large", and logged as a warning that names the route and the config
68
+ * key. Without that line the first symptom of a too-small limit is a user whose save keeps failing.
69
+ *
70
+ * Registered as a CoreModule provider; consumers never interact with it.
71
+ */
72
+ @Injectable()
73
+ export class CoreBodyParserInitializer implements OnModuleInit {
74
+ protected readonly logger = new Logger(CoreBodyParserInitializer.name);
75
+
76
+ constructor(
77
+ @Optional() protected readonly httpAdapterHost?: HttpAdapterHost,
78
+ @Optional() protected readonly moduleRef?: ModuleRef,
79
+ ) {}
80
+
81
+ onModuleInit(): void {
82
+ const stack = this.getRouterStack();
83
+ if (!stack) {
84
+ // Fastify and non-HTTP contexts have no body-parser layers; there is nothing to adjust.
85
+ return;
86
+ }
87
+
88
+ const config = this.getConfig();
89
+ for (const type of ['json', 'urlencoded'] as const) {
90
+ const limit = this.resolveLimit(type, config?.[type]?.limit);
91
+ const layers = stack.filter((layer) => this.isGlobalParserLayer(layer, type));
92
+
93
+ if (limit !== undefined && !layers.length) {
94
+ this.logger.warn(
95
+ `bodyParser.${type}.limit is configured, but no global ${type} body parser is registered ` +
96
+ '(was the app created with `bodyParser: false`?) — the limit has no effect.',
97
+ );
98
+ continue;
99
+ }
100
+
101
+ for (const layer of layers) {
102
+ const parser = limit === undefined ? layer.handle : this.createParser(type, limit);
103
+ layer.handle = this.wrapParser(type, parser);
104
+ }
105
+
106
+ if (limit !== undefined) {
107
+ this.logger.log(`Body parser: ${type} limit set to ${limit} bytes`);
108
+ }
109
+ }
110
+ }
111
+
112
+ /**
113
+ * A parser equivalent to the one NestJS registers, with the configured limit.
114
+ *
115
+ * `extended: true` mirrors NestJS's URL-encoded default (nested objects via `qs`). `req.rawBody`
116
+ * is kept when the app was created with `rawBody: true`, because webhook signature checks read it
117
+ * and would otherwise fail on every request once a limit is configured.
118
+ */
119
+ protected createParser(type: CoreBodyParserType, limit: number): RequestHandler {
120
+ const verify = this.isRawBodyEnabled() ? captureRawBody : undefined;
121
+ return type === 'json' ? json({ limit, verify }) : urlencoded({ extended: true, limit, verify });
122
+ }
123
+
124
+ /** The configured `bodyParser` block, or `undefined` when the project did not set one */
125
+ protected getConfig(): IBodyParserConfig | undefined {
126
+ return ConfigService.getFastButReadOnly<IBodyParserConfig>('bodyParser');
127
+ }
128
+
129
+ /** The Express router stack, or `undefined` for a non-Express adapter */
130
+ protected getRouterStack(): ExpressLayer[] | undefined {
131
+ const app = this.httpAdapterHost?.httpAdapter?.getInstance?.();
132
+ if (typeof app?.use !== 'function' || typeof app?.set !== 'function') {
133
+ return undefined;
134
+ }
135
+ const stack = app.router?.stack;
136
+ return Array.isArray(stack) ? stack : undefined;
137
+ }
138
+
139
+ /**
140
+ * Whether a layer is a GLOBAL parser of this type.
141
+ *
142
+ * `slash` is how the Express 5 router marks `app.use(fn)` — mounted on `/` without an end. A
143
+ * path-scoped `app.use('/upload', json({ limit }))` is a project's deliberate exception for one
144
+ * path and is left alone, including its 413.
145
+ */
146
+ protected isGlobalParserLayer(layer: ExpressLayer | undefined, type: CoreBodyParserType): boolean {
147
+ return layer?.slash === true && typeof layer.handle === 'function' && layer.handle.name === PARSER_NAMES[type];
148
+ }
149
+
150
+ /**
151
+ * Whether the application was created with `rawBody: true`.
152
+ *
153
+ * `NestFactory.create()` hands its options to the container and nothing public exposes them, so
154
+ * they are read from the container `ModuleRef` carries. Under `Test.createTestingModule()` the
155
+ * options passed to `createNestApplication()` never reach the container — that is the caveat
156
+ * documented on `IServerOptions.bodyParser`.
157
+ */
158
+ protected isRawBodyEnabled(): boolean {
159
+ const container = (this.moduleRef as unknown as { container?: { contextOptions?: { rawBody?: boolean } } })
160
+ ?.container;
161
+ return container?.contextOptions?.rawBody === true;
162
+ }
163
+
164
+ /**
165
+ * The configured limit in bytes, or `undefined` when unset or invalid.
166
+ *
167
+ * An invalid value is refused rather than passed on: body-parser would throw at boot for some
168
+ * inputs and apply nonsense (a negative limit rejects every body) for others. The parser keeps the
169
+ * limit it has — never "no limit", since it runs before authentication.
170
+ */
171
+ protected resolveLimit(type: CoreBodyParserType, value: unknown): number | undefined {
172
+ if (value === undefined || value === null) {
173
+ return undefined;
174
+ }
175
+ const limit = parseBodyLimit(value);
176
+ if (limit === undefined) {
177
+ this.logger.error(
178
+ `bodyParser.${type}.limit ${JSON.stringify(value)} is not a positive size (bytes, or e.g. '500kb' / '2mb') ` +
179
+ `— ignored, the ${type} body parser keeps the limit it has (100 kB unless set elsewhere).`,
180
+ );
181
+ }
182
+ return limit;
183
+ }
184
+
185
+ /**
186
+ * Answer a body-parser 413 with an error code, the size and the limit, and log it.
187
+ *
188
+ * Every other error (malformed JSON, unsupported charset, too many parameters) passes through
189
+ * unchanged, so NestJS answers it exactly as before.
190
+ */
191
+ protected translateError(type: CoreBodyParserType, error: unknown, req: Request): unknown {
192
+ const details = error as undefined | { length?: unknown; limit?: unknown; type?: unknown };
193
+ if (details?.type !== 'entity.too.large') {
194
+ return error;
195
+ }
196
+
197
+ // `length` is the declared Content-Length; a chunked body has none, and the byte count at which
198
+ // body-parser stopped reading says nothing about how large the body really was.
199
+ const length = typeof details.length === 'number' ? details.length : undefined;
200
+ const sizeText = length === undefined ? '' : `${length} bytes, `;
201
+ const path = String(req.originalUrl ?? req.url ?? '').split('?')[0];
202
+
203
+ this.logger.warn(
204
+ `413 ${req.method} ${path}: ${type} body ${length === undefined ? '' : `of ${length} bytes `}` +
205
+ `exceeds the limit of ${details.limit} bytes. If payloads of this size are legitimate here, ` +
206
+ `raise \`bodyParser.${type}.limit\` in the server options.`,
207
+ );
208
+ return new PayloadTooLargeException(
209
+ `${ErrorCode.REQUEST_BODY_TOO_LARGE} [${sizeText}limit ${details.limit} bytes]`,
210
+ );
211
+ }
212
+
213
+ /**
214
+ * Wrap a parser so its 413 is translated, keeping the name NestJS identifies the layer by.
215
+ *
216
+ * Wrapping an already wrapped parser is harmless: the outer wrapper receives the translated
217
+ * `PayloadTooLargeException`, which carries no body-parser `type`, and passes it on unchanged.
218
+ */
219
+ protected wrapParser(type: CoreBodyParserType, parser: RequestHandler): RequestHandler {
220
+ const wrapped = (req: Request, res: Response, next: NextFunction) =>
221
+ parser(req, res, (error?: unknown) => (error ? next(this.translateError(type, error, req)) : next()));
222
+ Object.defineProperty(wrapped, 'name', { value: PARSER_NAMES[type] });
223
+ return wrapped;
224
+ }
225
+ }
226
+
227
+ /** Same behaviour as the `verify` NestJS installs for `rawBody: true` */
228
+ function captureRawBody(req: Request & { rawBody?: Buffer }, _res: Response, buffer: Buffer): void {
229
+ if (Buffer.isBuffer(buffer)) {
230
+ req.rawBody = buffer;
231
+ }
232
+ }
@@ -69,11 +69,48 @@ export class CoreAuthService {
69
69
 
70
70
  /**
71
71
  * Decode JWT
72
+ *
73
+ * Reads the payload WITHOUT checking signature or expiry — never decide access on its result.
74
+ * Use `verifyJwt()` for that.
72
75
  */
73
76
  decodeJwt(token: string): JwtPayload {
74
77
  return this.jwtService.decode(token) as JwtPayload;
75
78
  }
76
79
 
80
+ /**
81
+ * Verify an access token's signature and expiry and answer its payload, or `null` when it does not
82
+ * verify.
83
+ *
84
+ * The same check passport-jwt applies on the HTTP path (`JwtStrategy`), for transports that have no
85
+ * passport in front of them — today the GraphQL WebSocket. The key is resolved in passport's order:
86
+ * `jwt.secretOrKeyProvider` when configured, else `jwt.secretOrPrivateKey || jwt.secret`. Until
87
+ * 11.42.3 the WebSocket handshake used `decodeJwt()`, so an expired token, or a copy re-signed with
88
+ * any key, was accepted there as long as its `tokenId` still matched the device.
89
+ *
90
+ * @since 11.42.3
91
+ */
92
+ async verifyJwt(token: string): Promise<JwtPayload | null> {
93
+ if (!token || typeof token !== 'string') {
94
+ return null;
95
+ }
96
+ try {
97
+ const provider = this.configService.getFastButReadOnly('jwt.secretOrKeyProvider');
98
+ const secret =
99
+ typeof provider === 'function'
100
+ ? await new Promise<string>((resolve, reject) =>
101
+ provider({}, token, (error: unknown, key: string) => (error ? reject(error) : resolve(key))),
102
+ )
103
+ : this.configService.getFastButReadOnly('jwt.secretOrPrivateKey') ||
104
+ this.configService.getFastButReadOnly('jwt.secret');
105
+ if (!secret) {
106
+ return null;
107
+ }
108
+ return (await this.jwtService.verifyAsync(token, { secret })) as JwtPayload;
109
+ } catch {
110
+ return null;
111
+ }
112
+ }
113
+
77
114
  /**
78
115
  * Logout user (from device)
79
116
  */
@@ -8,6 +8,7 @@ import * as fs from 'fs';
8
8
  import * as path from 'path';
9
9
 
10
10
  import { resolveServerUrls, strippedApiHostname } from '../../common/helpers/cookies.helper';
11
+ import { revalidateWsConnectionsOf } from '../../common/helpers/graphql-ws-connection.helper';
11
12
  import { IBetterAuth, ICorsConfig } from '../../common/interfaces/server-options.interface';
12
13
  import { detectCookiePrefixDrift, resolveBetterAuthCookiePrefix } from './better-auth-cookie-prefix.helper';
13
14
 
@@ -753,6 +754,48 @@ export function createBetterAuthInstance(options: CreateBetterAuthOptions): Crea
753
754
  };
754
755
  }
755
756
 
757
+ /**
758
+ * Ends the GraphQL WebSocket connections that rode on a session the moment the session is deleted —
759
+ * a sign-out, a revoked session, a password reset with `revokeSessionsOnPasswordReset`, a deleted user.
760
+ *
761
+ * Without it such a connection stays open until `graphQl.subscriptionRevalidationMs` (30 s by
762
+ * default) catches it on its next event. Better-Auth's `onPasswordReset` cannot do this job: all
763
+ * three reset routes call it BEFORE they delete the sessions, so a re-check from there would still
764
+ * find them valid.
765
+ *
766
+ * A plugin rather than `databaseHooks` in the options, because Better-Auth collects a plugin's
767
+ * `init().options.databaseHooks` as a SEPARATE source: a project's own `options.databaseHooks`
768
+ * neither replaces this one nor is replaced by it — unlike everything spread from `options`.
769
+ *
770
+ * It only asks for a re-check of the user's connections. The re-check resolves each connection's
771
+ * own token again, so a connection on a DIFFERENT, still-valid session of the same user stays open.
772
+ * It never throws: a failure here must not fail the sign-out.
773
+ */
774
+ export function wsSessionRevocationPlugin(): BetterAuthPlugin {
775
+ return {
776
+ id: 'lt-ws-session-revocation',
777
+ init: () => ({
778
+ options: {
779
+ databaseHooks: {
780
+ session: {
781
+ delete: {
782
+ after: async (session: { userId?: unknown }) => {
783
+ try {
784
+ if (session?.userId) {
785
+ revalidateWsConnectionsOf({ iamId: String(session.userId) });
786
+ }
787
+ } catch {
788
+ // Never fail the deletion over a re-check; the revalidation interval still applies.
789
+ }
790
+ },
791
+ },
792
+ },
793
+ },
794
+ },
795
+ }),
796
+ } as BetterAuthPlugin;
797
+ }
798
+
756
799
  /**
757
800
  * Formats a package name to a human-readable display name.
758
801
  * Converts kebab-case and snake_case to Title Case.
@@ -873,7 +916,9 @@ function buildPlugins(
873
916
  options: { passkeyNormalization: PasskeyNormalizationResult; serverEnv?: string },
874
917
  ): BetterAuthPlugin[] {
875
918
  const { passkeyNormalization, serverEnv } = options;
876
- const plugins: BetterAuthPlugin[] = [];
919
+ // Always first and always on: it changes nothing about authentication itself, it only ends the
920
+ // WebSocket connections that rode on a session once the session is gone.
921
+ const plugins: BetterAuthPlugin[] = [wsSessionRevocationPlugin()];
877
922
 
878
923
  // JWT Plugin for API client compatibility
879
924
  // JWT is enabled by default unless explicitly disabled (jwt: false or jwt: { enabled: false })
@@ -226,6 +226,27 @@ function normalizeBetterAuthConfig(config: boolean | IBetterAuth | undefined): I
226
226
  return config;
227
227
  }
228
228
 
229
+ /**
230
+ * The secret Better-Auth actually signs with, for the NestJS layer (controller cookie helper,
231
+ * API middleware, CoreBetterAuthService) to sign its cookies with the same value.
232
+ *
233
+ * `createBetterAuthInstance` resolves it as `betterAuth.secret` → first fallback of at least 32
234
+ * characters → auto-generated. Only the instance knows the generated one; without it the cookie
235
+ * went out unsigned and every native Better-Auth endpoint (`/iam/token`, MCP authorize, passkey,
236
+ * 2FA) rejected the session with 401.
237
+ */
238
+ function resolveSigningSecret(
239
+ authInstance: BetterAuthInstance | null,
240
+ config: IBetterAuth,
241
+ fallbackSecrets?: (string | undefined)[],
242
+ ): string | undefined {
243
+ return (
244
+ (authInstance as null | { options?: { secret?: string } })?.options?.secret ||
245
+ config.secret ||
246
+ fallbackSecrets?.find((s) => s && s.length >= 32)
247
+ );
248
+ }
249
+
229
250
  /**
230
251
  * CoreBetterAuthModule provides integration with the better-auth authentication framework.
231
252
  *
@@ -698,8 +719,8 @@ export class CoreBetterAuthModule implements NestModule, OnModuleInit {
698
719
  // Store a config copy with the resolved secret so that consumers
699
720
  // (CoreBetterAuthService, CoreBetterAuthController) can sign cookies.
700
721
  // The original config object may be frozen (from ConfigService), so we
701
- // create a shallow copy with the resolved fallback secret applied.
702
- const resolvedSecret = config.secret || fallbackSecrets?.find((s) => s && s.length >= 32);
722
+ // create a shallow copy with the resolved (fallback or generated) secret applied.
723
+ const resolvedSecret = resolveSigningSecret(this.authInstance, config, fallbackSecrets);
703
724
  this.currentConfig =
704
725
  resolvedSecret && resolvedSecret !== config.secret ? { ...config, secret: resolvedSecret } : config;
705
726
 
@@ -1068,8 +1089,7 @@ export class CoreBetterAuthModule implements NestModule, OnModuleInit {
1068
1089
  this.resolvedCookieDomain = result?.cookieDomain;
1069
1090
 
1070
1091
  // Store a config copy with the resolved secret (same as first forRoot variant)
1071
- const fallbacks = options?.fallbackSecrets;
1072
- const resolvedSecret2 = config.secret || fallbacks?.find((s) => s && s.length >= 32);
1092
+ const resolvedSecret2 = resolveSigningSecret(this.authInstance, config, options?.fallbackSecrets);
1073
1093
  this.currentConfig =
1074
1094
  resolvedSecret2 && resolvedSecret2 !== config.secret ? { ...config, secret: resolvedSecret2 } : config;
1075
1095
 
@@ -804,9 +804,17 @@ export class CoreBetterAuthService implements OnModuleInit {
804
804
  }
805
805
 
806
806
  try {
807
- // Create headers with the session token
807
+ // Better-Auth's signOut finds the session through its SIGNED session cookie. An `Authorization`
808
+ // header is read only by its `bearer` plugin, which the framework does not install — so until
809
+ // 11.42.3 this call deleted nothing: the caller got `true`, the controller cleared the cookies,
810
+ // and the session stayed valid server-side, authenticating the same token until it expired.
811
+ // The header stays for projects that added the bearer plugin themselves.
808
812
  const headers = new Headers();
809
813
  headers.set('Authorization', `Bearer ${sessionToken}`);
814
+ const secret = this.config?.secret;
815
+ if (secret) {
816
+ headers.set('cookie', `${this.getSessionCookieName()}=${signCookieValueIfNeeded(sessionToken, secret, true)}`);
817
+ }
810
818
 
811
819
  // Call Better-Auth's signOut endpoint
812
820
  await api.signOut({ headers });
@@ -435,6 +435,15 @@ export const LtnsErrors = {
435
435
  },
436
436
  },
437
437
 
438
+ REQUEST_BODY_TOO_LARGE: {
439
+ code: 'LTNS_0304',
440
+ message: 'Request body too large',
441
+ translations: {
442
+ de: 'Die gesendeten Daten sind zu groß und wurden nicht verarbeitet.',
443
+ en: 'The submitted data is too large and was not processed.',
444
+ },
445
+ },
446
+
438
447
  // =====================================================
439
448
  // Resource Errors (LTNS_0400-LTNS_0499)
440
449
  // =====================================================
@@ -236,14 +236,14 @@ multiTenancy: {
236
236
  }
237
237
  ```
238
238
 
239
- | Path | Deactivated tenant |
240
- | -------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------- |
241
- | Header + tenant role, or `S_USER` / `S_VERIFIED` with a header | **403** `Tenant is inactive` |
242
- | Tenant API token | **403** |
243
- | `S_EVERYONE` with a header | not blocked, but no tenant context is set |
244
- | No header | dropped from the user's tenant list — its data stays out of reach |
245
- | GraphQL over WebSocket | no tenant resolved for a new subscription — an open one keeps its tenant until it reconnects (as after a removed membership) |
246
- | Admin under `adminBypass` | still allowed — somebody has to be able to switch it back on |
239
+ | Path | Deactivated tenant |
240
+ | -------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
241
+ | Header + tenant role, or `S_USER` / `S_VERIFIED` with a header | **403** `Tenant is inactive` |
242
+ | Tenant API token | **403** |
243
+ | `S_EVERYONE` with a header | not blocked, but no tenant context is set |
244
+ | No header | dropped from the user's tenant list — its data stays out of reach |
245
+ | GraphQL over WebSocket | no tenant resolved for a new subscription; an open one is closed (4403) on `invalidateTenant()`, or at its next event once `graphQl.subscriptionRevalidationMs` has passed (since 11.42.3) |
246
+ | Admin under `adminBypass` | still allowed — somebody has to be able to switch it back on |
247
247
 
248
248
  The hook is asked **after** the membership check, so a non-member learns nothing about a tenant's
249
249
  state. Return `false` for inactive; anything else counts as active. Answers are cached for