@lenne.tech/nest-server 11.42.14 → 11.42.16

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 (37) hide show
  1. package/.claude/rules/configurable-features.md +1 -0
  2. package/.claude/rules/testing.md +32 -13
  3. package/FRAMEWORK-API.md +1 -1
  4. package/dist/core/common/filters/mongoose-cast-error.filter.d.ts +17 -0
  5. package/dist/core/common/filters/mongoose-cast-error.filter.js +171 -0
  6. package/dist/core/common/filters/mongoose-cast-error.filter.js.map +1 -0
  7. package/dist/core/common/interfaces/server-options.interface.d.ts +1 -0
  8. package/dist/core/modules/ai/models/core-ai-budget-limit.model.d.ts +5 -1
  9. package/dist/core/modules/ai/models/core-ai-connection-preference.model.d.ts +5 -1
  10. package/dist/core/modules/ai/models/core-ai-connection.model.d.ts +5 -1
  11. package/dist/core/modules/ai/models/core-ai-conversation.model.d.ts +5 -1
  12. package/dist/core/modules/ai/models/core-ai-interaction.model.d.ts +5 -1
  13. package/dist/core/modules/ai/models/core-ai-mode.model.d.ts +5 -1
  14. package/dist/core/modules/ai/models/core-ai-prompt-hint.model.d.ts +5 -1
  15. package/dist/core/modules/ai/models/core-ai-prompt.model.d.ts +5 -1
  16. package/dist/core/modules/ai/models/core-ai-slot.model.d.ts +5 -1
  17. package/dist/core/modules/ai/models/core-ai-tool-grant.model.d.ts +5 -1
  18. package/dist/core/modules/ai/models/core-ai-tool-policy.model.d.ts +5 -1
  19. package/dist/core.module.js +7 -0
  20. package/dist/core.module.js.map +1 -1
  21. package/dist/index.d.ts +1 -0
  22. package/dist/index.js +1 -0
  23. package/dist/index.js.map +1 -1
  24. package/dist/server/modules/file/file-info.model.d.ts +5 -1
  25. package/dist/server/modules/user/user.model.d.ts +5 -1
  26. package/dist/tsconfig.build.tsbuildinfo +1 -1
  27. package/docs/REQUEST-LIFECYCLE.md +29 -0
  28. package/docs/security-overrides.md +3 -2
  29. package/migration-guides/11.42.14-to-11.42.15.md +117 -0
  30. package/migration-guides/11.42.15-to-11.42.16.md +114 -0
  31. package/package.json +15 -15
  32. package/src/core/common/filters/mongoose-cast-error.filter.ts +310 -0
  33. package/src/core/common/interfaces/server-options.interface.ts +22 -0
  34. package/src/core/modules/better-auth/README.md +1 -1
  35. package/src/core/modules/hub/README.md +1 -1
  36. package/src/core.module.ts +13 -1
  37. package/src/index.ts +1 -0
@@ -0,0 +1,310 @@
1
+ import {
2
+ ArgumentsHost,
3
+ BadRequestException,
4
+ Catch,
5
+ ExceptionFilter,
6
+ HttpException,
7
+ HttpStatus,
8
+ Logger,
9
+ NotFoundException,
10
+ } from '@nestjs/common';
11
+ import { Error as MongooseError } from 'mongoose';
12
+
13
+ import { ErrorCode } from '../../modules/error-code/error-codes';
14
+
15
+ import type { GqlContextType } from '@nestjs/graphql';
16
+ import type { Request, Response } from 'express';
17
+
18
+ /**
19
+ * Where the value of a failed Mongoose cast came from — and therefore who is at fault.
20
+ *
21
+ * - `identifier`: it names the resource the request addresses — a route parameter, or a top-level
22
+ * GraphQL argument. A malformed identifier names nothing, so it is answered like an unknown one: 404.
23
+ * - `input`: the client sent it as data — query string, body, or nested inside a GraphQL input: 400.
24
+ * - `server`: the request does not contain it. It came from stored data or from server code, which is
25
+ * a data or code defect and stays a 500, so monitoring keeps seeing it.
26
+ */
27
+ export type CastErrorOrigin = 'identifier' | 'input' | 'server';
28
+
29
+ /**
30
+ * Bounds for walking request data. They keep error handling cheap and terminating for a deeply nested,
31
+ * very wide or self-referencing structure — the body parser's size limit alone does not: a project may
32
+ * raise it, and a few megabytes of `[0,0,0,…]` are a million nodes. What the budget cannot reach is not
33
+ * matched, which answers `server` (a 500), the status quo — never a client error.
34
+ */
35
+ const MAX_WALK_DEPTH = 32;
36
+ const MAX_WALK_NODES = 10_000;
37
+
38
+ type Primitive = bigint | boolean | number | string;
39
+
40
+ function isPrimitive(value: unknown): value is Primitive {
41
+ return (
42
+ typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean' || typeof value === 'bigint'
43
+ );
44
+ }
45
+
46
+ /**
47
+ * The strings a CastError's `value` stands for.
48
+ *
49
+ * A primitive stands for itself, an array (the `$in` shape) for each of its primitive elements.
50
+ * Anything else yields nothing: the strings inside an object cast as a whole (a subdocument, a whole
51
+ * array of them) are not what failed, and matching them against the request would relabel a server
52
+ * defect as a client error whenever one of them happens to appear in the body.
53
+ */
54
+ export function castErrorCandidates(value: unknown): string[] {
55
+ if (isPrimitive(value)) {
56
+ return [String(value)];
57
+ }
58
+ if (Array.isArray(value)) {
59
+ return value.filter(isPrimitive).map((item) => String(item));
60
+ }
61
+ return [];
62
+ }
63
+
64
+ /**
65
+ * The children of an array or object, or `undefined` when there are more than `room` of them.
66
+ *
67
+ * Never collects more than `room` entries: an object is read key by key rather than through
68
+ * `Object.values()`, which would first copy every value of a million-key object.
69
+ */
70
+ function boundedChildren(value: object, room: number): readonly unknown[] | undefined {
71
+ if (Array.isArray(value)) {
72
+ return value.length > room ? undefined : value;
73
+ }
74
+ const children: unknown[] = [];
75
+ // Own enumerable keys only, as Object.values — also for the null-prototype objects Express's query
76
+ // parser and graphql-js produce, which is why hasOwnProperty is borrowed rather than called
77
+ for (const key in value) {
78
+ if (!Object.prototype.hasOwnProperty.call(value, key)) {
79
+ continue;
80
+ }
81
+ if (children.length >= room) {
82
+ return undefined;
83
+ }
84
+ children.push((value as Record<string, unknown>)[key]);
85
+ }
86
+ return children;
87
+ }
88
+
89
+ /**
90
+ * Whether one of `candidates` occurs as a primitive anywhere in `source` (compared as strings, so a
91
+ * number in a JSON body matches the number Mongoose failed to cast). Depth- and size-bounded, and
92
+ * safe against cycles.
93
+ *
94
+ * The node budget is checked BEFORE a container's children are queued, not when they are visited: a
95
+ * container the remaining budget cannot hold is skipped as a whole. Queuing first and counting later
96
+ * let `{ ids: ['bad'], pad: [<a million zeros>] }` allocate a million stack entries and then give up
97
+ * before reaching `ids` — a 400 answered as a 500 after half a second of blocked event loop. Skipping
98
+ * (rather than queuing the first few) also leaves the budget to the oversized container's siblings.
99
+ */
100
+ export function containsCastValue(source: unknown, candidates: readonly string[]): boolean {
101
+ if (!candidates.length) {
102
+ return false;
103
+ }
104
+ const wanted = new Set(candidates);
105
+ const seen = new Set<object>();
106
+ const stack: { depth: number; value: unknown }[] = [{ depth: 0, value: source }];
107
+ let nodes = 0;
108
+ for (let item = stack.pop(); item; item = stack.pop()) {
109
+ const { depth, value } = item;
110
+ // Unreachable while boundedChildren() keeps the invariant below; kept so that a regression there
111
+ // degrades to a 500 instead of an unbounded walk
112
+ if (++nodes > MAX_WALK_NODES) {
113
+ return false;
114
+ }
115
+ if (isPrimitive(value)) {
116
+ if (wanted.has(String(value))) {
117
+ return true;
118
+ }
119
+ continue;
120
+ }
121
+ if (!value || typeof value !== 'object' || depth >= MAX_WALK_DEPTH || ArrayBuffer.isView(value)) {
122
+ continue;
123
+ }
124
+ if (seen.has(value)) {
125
+ continue;
126
+ }
127
+ seen.add(value);
128
+ // nodes + stack.length never exceeds MAX_WALK_NODES: only what the budget can still hold is queued
129
+ const children = boundedChildren(value, MAX_WALK_NODES - nodes - stack.length);
130
+ for (const child of children ?? []) {
131
+ stack.push({ depth: depth + 1, value: child });
132
+ }
133
+ }
134
+ return false;
135
+ }
136
+
137
+ /**
138
+ * Whether one of `candidates` is one of `values` itself, or an element of an array among them — the
139
+ * shape of route parameters (Express 5 wildcards are arrays) and of top-level GraphQL arguments.
140
+ */
141
+ function isDirectValue(values: readonly unknown[], candidates: readonly string[]): boolean {
142
+ const wanted = new Set(candidates);
143
+ const matches = (value: unknown) => isPrimitive(value) && wanted.has(String(value));
144
+ // A list beyond the walk budget is not scanned here either — a GraphQL list argument is client-sized
145
+ return values.some((value) =>
146
+ Array.isArray(value) ? value.length <= MAX_WALK_NODES && value.some(matches) : matches(value),
147
+ );
148
+ }
149
+
150
+ /**
151
+ * Classify a CastError raised while handling a REST request.
152
+ *
153
+ * A value that is a route parameter is an `identifier`; one found anywhere in the query string or the
154
+ * body is `input`; anything else is `server`. The parameter check runs first, so `PATCH /x/:id` with the
155
+ * same malformed id in its body is still a 404.
156
+ */
157
+ export function classifyHttpCastError(
158
+ value: unknown,
159
+ request: Partial<Pick<Request, 'body' | 'params' | 'query'>> | undefined,
160
+ ): CastErrorOrigin {
161
+ const candidates = castErrorCandidates(value);
162
+ if (!candidates.length || !request) {
163
+ return 'server';
164
+ }
165
+ if (isDirectValue(Object.values(request.params ?? {}), candidates)) {
166
+ return 'identifier';
167
+ }
168
+ if (containsCastValue(request.query, candidates) || containsCastValue(request.body, candidates)) {
169
+ return 'input';
170
+ }
171
+ return 'server';
172
+ }
173
+
174
+ /**
175
+ * Classify a CastError raised while executing a GraphQL field.
176
+ *
177
+ * The GraphQL counterpart of a route parameter is a top-level scalar argument (`getUser(id: …)`), and
178
+ * of a body a value nested inside an input object (`findUsers(filter: …)`, `updateUser(input: …)`).
179
+ */
180
+ export function classifyGraphQlCastError(value: unknown, args: unknown): CastErrorOrigin {
181
+ const candidates = castErrorCandidates(value);
182
+ if (!candidates.length || !args || typeof args !== 'object') {
183
+ return 'server';
184
+ }
185
+ if (isDirectValue(Object.values(args), candidates)) {
186
+ return 'identifier';
187
+ }
188
+ if (containsCastValue(args, candidates)) {
189
+ return 'input';
190
+ }
191
+ return 'server';
192
+ }
193
+
194
+ /**
195
+ * Answers a Mongoose `CastError` caused by the CLIENT as a client error instead of a 500.
196
+ *
197
+ * A malformed id in a route parameter (`GET /users/not-an-id`) reaches Mongoose, which throws
198
+ * `CastError`. That is not an `HttpException`, so it escaped every `@Catch(HttpException)` filter and Nest
199
+ * answered **500 Internal server error** for what is simply "this resource does not exist".
200
+ *
201
+ * | Value of the failed cast | REST | GraphQL |
202
+ * |--------------------------|------|---------|
203
+ * | a route parameter / a top-level argument | 404 `ErrorCode.RESOURCE_NOT_FOUND` | same |
204
+ * | in the query string or the body / nested in an input | 400 `ErrorCode.INVALID_FIELD_FORMAT [path]` | same |
205
+ * | not in the request (stored data, server code) | 500, exactly as before, logged as an error | unchanged |
206
+ *
207
+ * The third row is deliberate: a broken id in stored data or a cast on a server-computed value is a
208
+ * defect, and relabelling it as a client error would hide it from monitoring.
209
+ *
210
+ * The Mongoose message is never sent: it names the model. Registered by `CoreModule` as `APP_FILTER`
211
+ * (opt out with `security.mongooseCastErrorFilter: false`), so `main.ts` and test setups need no change.
212
+ * A project's own catch-all filter registered via `app.useGlobalFilters()` is consulted first and wins.
213
+ *
214
+ * Only `CastError` is handled, and Mongoose raises it only while casting a QUERY or an UPDATE
215
+ * (filters, `findById`, `findByIdAndUpdate` / `updateOne` — the path `CrudService.update()` takes). Two
216
+ * neighbours stay a 500:
217
+ * - a malformed id in a document being CREATED or SAVED (`new Model().save()`, `Model.create()`,
218
+ * `insertMany()` — the path `CrudService.create()` takes) arrives as a `ValidationError` wrapping the
219
+ * CastError. Validate reference ids in the input instead (`validator: () => [IsMongoId()]` on the
220
+ * `@UnifiedField`);
221
+ * - a `BSONError` from `new Types.ObjectId(bad)` in service code carries no value to classify; such code
222
+ * should check the id itself (see `CoreFileService.isMalformedFileId()`).
223
+ */
224
+ @Catch(MongooseError.CastError)
225
+ export class MongooseCastErrorFilter implements ExceptionFilter {
226
+ protected static readonly logger = new Logger('MongooseCastErrorFilter');
227
+
228
+ /**
229
+ * The context Nest's own handler logs an unknown error under — so a cast failure that stays a 500 is
230
+ * logged exactly as before this filter existed, and monitoring matching that line keeps matching.
231
+ */
232
+ protected static readonly unknownErrorLogger = new Logger('ExceptionsHandler');
233
+
234
+ catch(exception: MongooseError.CastError, host: ArgumentsHost): HttpException | undefined {
235
+ const type = host.getType<GqlContextType>();
236
+ if (type === 'graphql') {
237
+ return this.catchGraphQl(exception, host);
238
+ }
239
+ if (type === 'http') {
240
+ this.catchHttp(exception, host);
241
+ }
242
+ // Any other transport: returning nothing hands the error back to its own exception handler
243
+ return undefined;
244
+ }
245
+
246
+ /**
247
+ * GraphQL: the returned exception becomes the field error. Returning nothing hands the error back to
248
+ * Nest's external exception handler, which logs and rethrows it — what happened before this filter.
249
+ */
250
+ protected catchGraphQl(exception: MongooseError.CastError, host: ArgumentsHost): HttpException | undefined {
251
+ const origin = classifyGraphQlCastError(exception.value, host.getArgByIndex(1));
252
+ if (origin === 'server') {
253
+ return undefined;
254
+ }
255
+ return this.toHttpException(exception, origin);
256
+ }
257
+
258
+ /**
259
+ * REST: a filter that matched must answer the request itself — Nest does not fall back to its own
260
+ * handler afterwards.
261
+ */
262
+ protected catchHttp(exception: MongooseError.CastError, host: ArgumentsHost): void {
263
+ const ctx = host.switchToHttp();
264
+ const response = ctx.getResponse<Response>();
265
+ const origin = classifyHttpCastError(exception.value, ctx.getRequest<Request>());
266
+ if (origin === 'server') {
267
+ this.replyAsUnknownError(exception, response);
268
+ return;
269
+ }
270
+ const httpException = this.toHttpException(exception, origin);
271
+ if (response.headersSent) {
272
+ response.end();
273
+ return;
274
+ }
275
+ // Same wire shape as HttpExceptionLogFilter, so a client sees one error format for every 4xx
276
+ response.status(httpException.getStatus()).json({ ...httpException });
277
+ }
278
+
279
+ /**
280
+ * The client error a request-supplied cast failure is answered with. The Mongoose message stays in
281
+ * the server log (debug level): it names the model, which is none of the client's business.
282
+ */
283
+ protected toHttpException(exception: MongooseError.CastError, origin: 'identifier' | 'input'): HttpException {
284
+ MongooseCastErrorFilter.logger.debug(
285
+ `Cast failed on a value from the request (${origin === 'identifier' ? 404 : 400}): ${exception.message}`,
286
+ );
287
+ if (origin === 'identifier') {
288
+ return new NotFoundException(ErrorCode.RESOURCE_NOT_FOUND);
289
+ }
290
+ return new BadRequestException(
291
+ exception.path ? `${ErrorCode.INVALID_FIELD_FORMAT} [${exception.path}]` : ErrorCode.INVALID_FIELD_FORMAT,
292
+ );
293
+ }
294
+
295
+ /**
296
+ * What Nest's `BaseExceptionFilter` does with an error it does not know: the generic 500 body, and
297
+ * the error object itself in the log — `logger.error(exception)`, the same call Nest 11 makes, so the
298
+ * line (and its JSON form under a JSON logger) is the one monitoring already sees.
299
+ */
300
+ protected replyAsUnknownError(exception: Error, response: Response): void {
301
+ if (response.headersSent) {
302
+ response.end();
303
+ } else {
304
+ response
305
+ .status(HttpStatus.INTERNAL_SERVER_ERROR)
306
+ .json({ statusCode: HttpStatus.INTERNAL_SERVER_ERROR, message: 'Internal server error' });
307
+ }
308
+ MongooseCastErrorFilter.unknownErrorLogger.error(exception);
309
+ }
310
+ }
@@ -3154,6 +3154,28 @@ export interface IServerOptions {
3154
3154
  nonWhitelistedFields?: 'strip' | 'error' | false;
3155
3155
  };
3156
3156
 
3157
+ /**
3158
+ * Answer a Mongoose `CastError` caused by the client as a client error instead of a 500.
3159
+ * See @lenne.tech/nest-server/src/core/common/filters/mongoose-cast-error.filter.ts
3160
+ *
3161
+ * A malformed id in a route parameter (`GET /users/not-an-id`) answers 404
3162
+ * (`ErrorCode.RESOURCE_NOT_FOUND`), one in the query string or the body 400
3163
+ * (`ErrorCode.INVALID_FIELD_FORMAT`); GraphQL likewise for a top-level argument and a nested input.
3164
+ * A failed cast on a value the request does not contain (stored data, server code) stays a 500.
3165
+ *
3166
+ * Covers casts Mongoose performs while running a query or an update (`findById`, filters,
3167
+ * `findByIdAndUpdate`). A malformed id in a document being created (`save()`, `create()`,
3168
+ * `insertMany()`) is a Mongoose `ValidationError` instead and stays a 500 — validate such ids in the
3169
+ * input (`validator: () => [IsMongoId()]`).
3170
+ *
3171
+ * Registered by `CoreModule` as `APP_FILTER`. `false` restores the previous behaviour, in which every
3172
+ * `CastError` answered 500.
3173
+ *
3174
+ * @default true
3175
+ * @since 11.42.16
3176
+ */
3177
+ mongooseCastErrorFilter?: boolean;
3178
+
3157
3179
  /**
3158
3180
  * Mongoose password hashing plugin - automatically hashes passwords on save/update
3159
3181
  * (save, findOneAndUpdate, updateOne, updateMany).
@@ -1040,7 +1040,7 @@ better-auth 1.7.0–1.7.2 keyed accounts by `(issuer, accountId)`; 1.7.3 restore
1040
1040
  recognised by shape, so a renamed collection or field is covered. Existing `issuer` values stay;
1041
1041
  they are inert. The backfill marker is deleted, so a rollback to a better-auth that needs the
1042
1042
  issuer runs the backfill again. Log line: `Dropped the unique index "…" on account(issuer,
1043
- accountId)`. A failure is logged at `error` and the boot continues.
1043
+ accountId)`. A failure is logged at `error` and the boot continues.
1044
1044
  - **better-auth 1.7.0–1.7.2: `backfillAccountIssuers()`, described below.** Kept for a project that
1045
1045
  has not raised its better-auth pin yet.
1046
1046
 
@@ -121,7 +121,7 @@ Every action writes an audit line: `[HUB-ACTION] <action> by user <id>`.
121
121
  whose `env` is not `local`/`development`/`test`/`ci`/`e2e`) — it suppresses outgoing mail.
122
122
  - **Public-access guard**: `roles: false` (no auth check — public config viewer, logs and destructive
123
123
  actions) throws at startup in any **reachable** environment unless `hub.allowPublicAccessInProduction:
124
- true` is set explicitly. This closes the single most dangerous Hub misconfiguration (a `roles: false`
124
+ true` is set explicitly. This closes the single most dangerous Hub misconfiguration (a `roles: false`
125
125
  copied from a local config into a reachable environment). The reachable check is fail-safe: it treats
126
126
  every env name except the known local/test set as reachable, so a custom name (`prod`, `preprod`,
127
127
  `staging-2`, …) cannot bypass it. Only acknowledge public access behind a fully-controlled network
@@ -8,13 +8,14 @@ import {
8
8
  NestModule,
9
9
  UnauthorizedException,
10
10
  } from '@nestjs/common';
11
- import { APP_INTERCEPTOR, APP_PIPE, DiscoveryModule } from '@nestjs/core';
11
+ import { APP_FILTER, APP_INTERCEPTOR, APP_PIPE, DiscoveryModule } from '@nestjs/core';
12
12
  import { GraphQLModule } from '@nestjs/graphql';
13
13
  import { MongooseModule } from '@nestjs/mongoose';
14
14
  import type { Context } from 'graphql-ws';
15
15
  import graphqlUploadExpress = require('graphql-upload/graphqlUploadExpress.js');
16
16
  import mongoose from 'mongoose';
17
17
 
18
+ import { MongooseCastErrorFilter } from './core/common/filters/mongoose-cast-error.filter';
18
19
  import { merge } from './core/common/helpers/config.helper';
19
20
  import { registerWsConnection } from './core/common/helpers/graphql-ws-connection.helper';
20
21
  import {
@@ -391,6 +392,17 @@ export class CoreModule implements NestModule {
391
392
  });
392
393
  }
393
394
 
395
+ // MongooseCastErrorFilter: a malformed id from the request answers 404 (route parameter) or 400
396
+ // (query/body) instead of escaping every @Catch(HttpException) filter as a 500. A cast failure on a
397
+ // value the request does not contain stays a 500. Registered here rather than in main.ts so that
398
+ // test setups built with Test.createTestingModule() get it too.
399
+ if (config.security?.mongooseCastErrorFilter !== false) {
400
+ providers.push({
401
+ provide: APP_FILTER,
402
+ useClass: MongooseCastErrorFilter,
403
+ });
404
+ }
405
+
394
406
  if (config.mongoose?.modelDocumentation) {
395
407
  providers.push(ModelDocService);
396
408
  }
package/src/index.ts CHANGED
@@ -29,6 +29,7 @@ export * from './core/common/enums/role.enum';
29
29
  export * from './core/common/enums/sort-order.emum';
30
30
  export * from './core/common/exceptions/access-denied.exception';
31
31
  export * from './core/common/filters/http-exception-log.filter';
32
+ export * from './core/common/filters/mongoose-cast-error.filter';
32
33
  export * from './core/common/helpers/common.helper';
33
34
  export * from './core/common/helpers/config.helper';
34
35
  export * from './core/common/helpers/content-disposition.helper';