@lenne.tech/nest-server 11.42.3 → 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 (28) hide show
  1. package/.claude/rules/configurable-features.md +1 -0
  2. package/.claude/rules/package-management.md +1 -1
  3. package/.claude/rules/testing.md +4 -4
  4. package/CLAUDE.md +2 -1
  5. package/FRAMEWORK-API.md +2 -1
  6. package/dist/core/common/interfaces/server-options.interface.d.ts +8 -0
  7. package/dist/core/common/services/core-body-parser.initializer.d.ts +26 -0
  8. package/dist/core/common/services/core-body-parser.initializer.js +136 -0
  9. package/dist/core/common/services/core-body-parser.initializer.js.map +1 -0
  10. package/dist/core/modules/better-auth/core-better-auth.module.js +7 -3
  11. package/dist/core/modules/better-auth/core-better-auth.module.js.map +1 -1
  12. package/dist/core/modules/error-code/error-codes.d.ts +9 -0
  13. package/dist/core/modules/error-code/error-codes.js +8 -0
  14. package/dist/core/modules/error-code/error-codes.js.map +1 -1
  15. package/dist/core.module.js +2 -0
  16. package/dist/core.module.js.map +1 -1
  17. package/dist/server/modules/error-code/error-codes.d.ts +1 -0
  18. package/dist/tsconfig.build.tsbuildinfo +1 -1
  19. package/docs/REQUEST-LIFECYCLE.md +28 -0
  20. package/docs/error-codes.md +16 -0
  21. package/docs/security-overrides.md +18 -2
  22. package/migration-guides/11.42.3-to-11.42.4.md +185 -0
  23. package/package.json +3 -3
  24. package/src/core/common/interfaces/server-options.interface.ts +89 -0
  25. package/src/core/common/services/core-body-parser.initializer.ts +232 -0
  26. package/src/core/modules/better-auth/core-better-auth.module.ts +24 -4
  27. package/src/core/modules/error-code/error-codes.ts +9 -0
  28. package/src/core.module.ts +4 -0
@@ -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
+ }
@@ -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
 
@@ -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
  // =====================================================
@@ -48,6 +48,7 @@ import { mongooseRoleGuardPlugin } from './core/common/plugins/mongoose-role-gua
48
48
  import { mongooseSystemRolePlugin } from './core/common/plugins/mongoose-system-role.plugin';
49
49
  import { mongooseTenantPlugin } from './core/common/plugins/mongoose-tenant.plugin';
50
50
  import { ConfigService } from './core/common/services/config.service';
51
+ import { CoreBodyParserInitializer } from './core/common/services/core-body-parser.initializer';
51
52
  import { CoreCronJobsInitializer } from './core/common/services/core-cron-jobs.initializer';
52
53
  import { CoreRedisService } from './core/common/services/core-redis.service';
53
54
  import { CoreS3Service } from './core/common/services/core-s3.service';
@@ -324,6 +325,9 @@ export class CoreModule implements NestModule {
324
325
  },
325
326
 
326
327
  // Core Services
328
+ // Applies `bodyParser` limits and answers a body-parser 413 with an error code. Swaps the
329
+ // parser layers NestJS registered in place, during module init — see the initializer.
330
+ CoreBodyParserInitializer,
327
331
  CoreCronJobsInitializer,
328
332
  // Audits the file endpoints the project actually REGISTERED. applyFileRoles() above can only
329
333
  // reach the base-class members; a subclass that re-declares one keeps its own @Roles() and the