@lenne.tech/nest-server 11.42.15 → 11.43.0

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 (64) hide show
  1. package/.claude/rules/better-auth.md +22 -0
  2. package/.claude/rules/configurable-features.md +4 -3
  3. package/.claude/rules/testing.md +4 -4
  4. package/FRAMEWORK-API.md +2 -2
  5. package/dist/core/common/filters/mongoose-cast-error.filter.d.ts +17 -0
  6. package/dist/core/common/filters/mongoose-cast-error.filter.js +171 -0
  7. package/dist/core/common/filters/mongoose-cast-error.filter.js.map +1 -0
  8. package/dist/core/common/interfaces/server-options.interface.d.ts +4 -0
  9. package/dist/core/modules/ai/core-ai-mcp-oauth.initializer.d.ts +19 -0
  10. package/dist/core/modules/ai/core-ai-mcp-oauth.initializer.js +145 -0
  11. package/dist/core/modules/ai/core-ai-mcp-oauth.initializer.js.map +1 -0
  12. package/dist/core/modules/ai/core-ai-mcp.controller.js +6 -5
  13. package/dist/core/modules/ai/core-ai-mcp.controller.js.map +1 -1
  14. package/dist/core/modules/ai/core-ai.module.d.ts +2 -0
  15. package/dist/core/modules/ai/core-ai.module.js +6 -1
  16. package/dist/core/modules/ai/core-ai.module.js.map +1 -1
  17. package/dist/core/modules/ai/helpers/ai-mcp-oauth-consent.helper.d.ts +33 -0
  18. package/dist/core/modules/ai/helpers/ai-mcp-oauth-consent.helper.js +282 -0
  19. package/dist/core/modules/ai/helpers/ai-mcp-oauth-consent.helper.js.map +1 -0
  20. package/dist/core/modules/ai/helpers/ai-mcp-oauth.helper.d.ts +15 -2
  21. package/dist/core/modules/ai/helpers/ai-mcp-oauth.helper.js +73 -19
  22. package/dist/core/modules/ai/helpers/ai-mcp-oauth.helper.js.map +1 -1
  23. package/dist/core/modules/ai/helpers/ai-mcp-oauth.registry.d.ts +5 -0
  24. package/dist/core/modules/ai/helpers/ai-mcp-oauth.registry.js.map +1 -1
  25. package/dist/core/modules/ai/index.d.ts +1 -0
  26. package/dist/core/modules/ai/index.js +1 -0
  27. package/dist/core/modules/ai/index.js.map +1 -1
  28. package/dist/core/modules/ai/services/core-ai-mcp-oauth.service.d.ts +30 -2
  29. package/dist/core/modules/ai/services/core-ai-mcp-oauth.service.js +222 -15
  30. package/dist/core/modules/ai/services/core-ai-mcp-oauth.service.js.map +1 -1
  31. package/dist/core/modules/better-auth/better-auth.config.d.ts +4 -0
  32. package/dist/core/modules/better-auth/better-auth.config.js +29 -2
  33. package/dist/core/modules/better-auth/better-auth.config.js.map +1 -1
  34. package/dist/core/modules/better-auth/core-better-auth.controller.d.ts +2 -0
  35. package/dist/core/modules/better-auth/core-better-auth.controller.js +36 -1
  36. package/dist/core/modules/better-auth/core-better-auth.controller.js.map +1 -1
  37. package/dist/core.module.js +7 -0
  38. package/dist/core.module.js.map +1 -1
  39. package/dist/index.d.ts +1 -0
  40. package/dist/index.js +1 -0
  41. package/dist/index.js.map +1 -1
  42. package/dist/tsconfig.build.tsbuildinfo +1 -1
  43. package/docs/REQUEST-LIFECYCLE.md +30 -1
  44. package/docs/security-overrides.md +3 -2
  45. package/migration-guides/11.42.15-to-11.42.16.md +114 -0
  46. package/migration-guides/11.42.x-to-11.43.x.md +195 -0
  47. package/package.json +7 -7
  48. package/src/core/common/filters/mongoose-cast-error.filter.ts +310 -0
  49. package/src/core/common/helpers/swagger.helper.ts +4 -4
  50. package/src/core/common/interfaces/server-options.interface.ts +59 -9
  51. package/src/core/modules/ai/INTEGRATION-CHECKLIST.md +50 -24
  52. package/src/core/modules/ai/README.md +98 -13
  53. package/src/core/modules/ai/core-ai-mcp-oauth.initializer.ts +199 -0
  54. package/src/core/modules/ai/core-ai-mcp.controller.ts +13 -6
  55. package/src/core/modules/ai/core-ai.module.ts +14 -2
  56. package/src/core/modules/ai/helpers/ai-mcp-oauth-consent.helper.ts +369 -0
  57. package/src/core/modules/ai/helpers/ai-mcp-oauth.helper.ts +149 -43
  58. package/src/core/modules/ai/helpers/ai-mcp-oauth.registry.ts +18 -5
  59. package/src/core/modules/ai/index.ts +1 -0
  60. package/src/core/modules/ai/services/core-ai-mcp-oauth.service.ts +385 -28
  61. package/src/core/modules/better-auth/better-auth.config.ts +49 -5
  62. package/src/core/modules/better-auth/core-better-auth.controller.ts +75 -1
  63. package/src/core.module.ts +13 -1
  64. 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
+ }
@@ -60,8 +60,8 @@ import type { ISwaggerApiTokenViewOptions, ISwaggerSetupOptions } from '../inter
60
60
  * - With multi-tenancy on, every operation that reads the tenant header documents it as a parameter.
61
61
  * - The routes Better-Auth serves itself (two-factor, passkey, sessions, password change, … — whatever the
62
62
  * configured plugins add) are documented from Better-Auth's own OpenAPI generator, under the project's
63
- * Better-Auth controller (since 11.42.9); so are `/ai/mcp` and the MCP OAuth endpoints
64
- * `mountAiMcpOAuth()` mounted.
63
+ * Better-Auth controller (since 11.42.9); so are `/ai/mcp` and the MCP OAuth endpoints that are
64
+ * actually mounted (by `CoreModule` when `ai.mcp.oauth` is enabled).
65
65
  * - Only what can succeed under the running configuration is documented (since 11.42.9): an `@All()`
66
66
  * handler keeps the methods `@ApiMethods()` names; legacy `/auth/*` disappears while
67
67
  * `auth.legacyEndpoints` keeps REST closed; Better-Auth routes disappear when the configuration or the
@@ -437,8 +437,8 @@ function declaresSecurity(reference: IHandlerReference): boolean {
437
437
  }
438
438
 
439
439
  /**
440
- * Documents the MCP OAuth 2.1 endpoints `mountAiMcpOAuth()` mounted on Express — the ones it mounted,
441
- * nothing else. They authenticate the client themselves, so they carry no security requirement.
440
+ * Documents the MCP OAuth 2.1 endpoints mounted on Express (`CoreAiMcpOAuthInitializer`, or a manual
441
+ * `mountAiMcpOAuth()`) — the ones mounted, nothing else. They authenticate the client themselves, so they carry no security requirement.
442
442
  */
443
443
  function addMcpOAuthRoutes(
444
444
  document: OpenAPIObject,
@@ -2114,12 +2114,36 @@ export interface IAi {
2114
2114
  * external MCP clients. Requires `@modelcontextprotocol/sdk`. Boolean shorthand.
2115
2115
  *
2116
2116
  * `oauth: true` additionally enables the OAuth 2.1 layer (dynamic client
2117
- * registration + PKCE) so generic MCP clients can authenticate; mount the OAuth
2118
- * router in `main.ts` via `mountAiMcpOAuth(app)` (see INTEGRATION-CHECKLIST).
2119
- * Without it, MCP authenticates via the existing Bearer/session token.
2117
+ * registration + PKCE) so generic MCP clients can authenticate. `CoreModule` mounts
2118
+ * its endpoints (`/.well-known/oauth-*`, `/authorize`, `/token`, `/register`) during
2119
+ * `app.init()` — no `main.ts` code (11.43.0+). The issuer is `oauthBaseUrl`, else
2120
+ * the server's `baseUrl`; a deployed environment with neither fails the boot. `/authorize`
2121
+ * answers with a built-in consent page: sign-in through Better-Auth (password + 2FA),
2122
+ * then Allow / Deny. Replace it via `CoreModule.forRoot(env, { ai: { mcpOAuthService } })`.
2123
+ * Without `oauth`, MCP authenticates via the existing Bearer/session token.
2120
2124
  * @default false
2121
2125
  */
2122
- mcp?: boolean | { enabled?: boolean; oauth?: boolean; oauthSecret?: string };
2126
+ mcp?:
2127
+ | boolean
2128
+ | {
2129
+ enabled?: boolean;
2130
+ oauth?: boolean;
2131
+ /**
2132
+ * Public base URL of this server for the OAuth issuer and the MCP resource, when MCP
2133
+ * clients reach it under a different URL than `baseUrl`. Blank counts as unset.
2134
+ * @default the server's `baseUrl` (`http://localhost:3000` in local / ci / e2e)
2135
+ */
2136
+ oauthBaseUrl?: string;
2137
+ /**
2138
+ * The app's login page, linked from the consent page for sign-in methods it does not
2139
+ * offer itself (passkey, social login). A `{returnTo}` in it is replaced by the URL of the
2140
+ * pending `/authorize` request, for a login page that sends the user back by itself;
2141
+ * without it the link opens next to the consent page, which then offers "continue".
2142
+ * @default `<appUrl>/auth/login` when an app URL can be resolved
2143
+ */
2144
+ oauthLoginUrl?: string;
2145
+ oauthSecret?: string;
2146
+ };
2123
2147
 
2124
2148
  /** Rate limiting for prompts. */
2125
2149
  rateLimit?: IAiRateLimit;
@@ -3154,6 +3178,28 @@ export interface IServerOptions {
3154
3178
  nonWhitelistedFields?: 'strip' | 'error' | false;
3155
3179
  };
3156
3180
 
3181
+ /**
3182
+ * Answer a Mongoose `CastError` caused by the client as a client error instead of a 500.
3183
+ * See @lenne.tech/nest-server/src/core/common/filters/mongoose-cast-error.filter.ts
3184
+ *
3185
+ * A malformed id in a route parameter (`GET /users/not-an-id`) answers 404
3186
+ * (`ErrorCode.RESOURCE_NOT_FOUND`), one in the query string or the body 400
3187
+ * (`ErrorCode.INVALID_FIELD_FORMAT`); GraphQL likewise for a top-level argument and a nested input.
3188
+ * A failed cast on a value the request does not contain (stored data, server code) stays a 500.
3189
+ *
3190
+ * Covers casts Mongoose performs while running a query or an update (`findById`, filters,
3191
+ * `findByIdAndUpdate`). A malformed id in a document being created (`save()`, `create()`,
3192
+ * `insertMany()`) is a Mongoose `ValidationError` instead and stays a 500 — validate such ids in the
3193
+ * input (`validator: () => [IsMongoId()]`).
3194
+ *
3195
+ * Registered by `CoreModule` as `APP_FILTER`. `false` restores the previous behaviour, in which every
3196
+ * `CastError` answered 500.
3197
+ *
3198
+ * @default true
3199
+ * @since 11.42.16
3200
+ */
3201
+ mongooseCastErrorFilter?: boolean;
3202
+
3157
3203
  /**
3158
3204
  * Mongoose password hashing plugin - automatically hashes passwords on save/update
3159
3205
  * (save, findOneAndUpdate, updateOne, updateMany).
@@ -4537,10 +4583,11 @@ interface IBetterAuthWithoutPasskey extends IBetterAuthBase {
4537
4583
  * Trusted origins for CORS configuration.
4538
4584
  *
4539
4585
  * Optional when Passkey is disabled.
4540
- * If not set, all origins are allowed (CORS `*`).
4586
+ * If not set, it is derived (see below) — never "all origins": an origin check has no such mode.
4541
4587
  *
4542
- * Auto-derived from `baseUrl` and `appUrl` when these are configured.
4543
- * Explicitly listed origins are merged with the auto-derived ones.
4588
+ * Auto-derived when not set: `appUrl`, `baseUrl` and `cors.allowedOrigins` (plus the passkey
4589
+ * origins and, with `ai.mcp.oauth`, the origin of `ai.mcp.oauthBaseUrl`). An explicit list
4590
+ * REPLACES the derived one — include `appUrl` and every frontend origin, or their sign-ins answer 403.
4544
4591
  *
4545
4592
  * **Important for cross-subdomain setups:** When using `crossSubDomainCookies`,
4546
4593
  * cookies are shared across subdomains, but the server also needs to accept requests
@@ -4599,8 +4646,9 @@ interface IBetterAuthWithPasskey extends IBetterAuthBase {
4599
4646
  * Passkey uses `credentials: 'include'` which requires explicit CORS origins.
4600
4647
  * Browsers don't allow wildcard `*` with credentials.
4601
4648
  *
4602
- * Auto-derived from `baseUrl` and `appUrl` when these are configured.
4603
- * Explicitly listed origins are merged with the auto-derived ones.
4649
+ * Auto-derived when not set: `appUrl`, `baseUrl` and `cors.allowedOrigins` (plus the passkey
4650
+ * origins and, with `ai.mcp.oauth`, the origin of `ai.mcp.oauthBaseUrl`). An explicit list
4651
+ * REPLACES the derived one — include `appUrl` and every frontend origin, or their sign-ins answer 403.
4604
4652
  *
4605
4653
  * **Important for cross-subdomain setups:** When using `crossSubDomainCookies`,
4606
4654
  * cookies are shared across subdomains, but the server also needs to accept requests
@@ -4681,6 +4729,7 @@ export interface ICoreModuleOverrides {
4681
4729
  * - `conversationService` must extend `CoreAiConversationService`
4682
4730
  * - `interactionService` must extend `CoreAiInteractionService`
4683
4731
  * - `mcpClientService` must extend `CoreAiMcpClientService`
4732
+ * - `mcpOAuthService` must extend `CoreAiMcpOAuthService` (implements `authorizeConsent()`)
4684
4733
  * - `modeService` must extend `CoreAiModeService`
4685
4734
  * - `placeholderRegistry` must extend `CoreAiPlaceholderRegistry`
4686
4735
  * - `preferenceService` must extend `CoreAiConnectionPreferenceService`
@@ -4713,6 +4762,7 @@ export interface ICoreModuleOverrides {
4713
4762
  conversationService?: Type<any>;
4714
4763
  interactionService?: Type<any>;
4715
4764
  mcpClientService?: Type<any>;
4765
+ mcpOAuthService?: Type<any>;
4716
4766
  modeService?: Type<any>;
4717
4767
  placeholderRegistry?: Type<any>;
4718
4768
  preferenceService?: Type<any>;
@@ -44,8 +44,10 @@ rest. Without `ai.encryptionSecret` / `NSC__AI__ENCRYPTION_SECRET` /
44
44
  `SECRETS_ENCRYPTION_KEY`, an insecure development default is used (logged as a
45
45
  warning) in non-production environments. In **production/staging the app refuses to
46
46
  boot** when AI is enabled but no secret is set (mirrors the email/cookie production
47
- guards) — so a missing secret can never silently ship. The same applies to the MCP
48
- OAuth signing secret when `ai.mcp.oauth` is enabled.
47
+ guards) — so a missing secret can never silently ship. The MCP OAuth signing secret
48
+ (`ai.mcp.oauth`) is stricter: it is required everywhere except `local`, `ci` and `e2e`,
49
+ deployed `develop` / `test` stages included — a public key there would let anybody forge
50
+ an MCP access token for any user id.
49
51
 
50
52
  ### 2. Set the LLM API key (environment)
51
53
 
@@ -205,21 +207,42 @@ The response body carries the stable `#LTNS_0901` code, never the raw error.
205
207
 
206
208
  ### MCP OAuth 2.1 (only when `ai.mcp.oauth` is enabled)
207
209
 
208
- **Edit `main.ts`** (after `app.init()`):
210
+ **No `main.ts` step.** `CoreModule` mounts the OAuth endpoints itself during `app.init()`
211
+ (11.43.0+). Remove a `mountAiMcpOAuth(app)` call from `main.ts` — it is a no-op now.
209
212
 
210
- ```typescript
211
- import { mountAiMcpOAuth } from '@lenne.tech/nest-server';
212
- await mountAiMcpOAuth(app);
213
- ```
213
+ **WHY not in `main.ts` any more:** the endpoints are an Express router, and Nest registers its
214
+ not-found handler inside `app.init()`. Mounted after `app.init()`, as this checklist used to say,
215
+ the router sat behind that handler and every OAuth endpoint answered 404.
216
+
217
+ **Check `NSC__BASE_URL` in every deployed environment.** The issuer is every URL in the discovery
218
+ metadata, and MCP clients follow them. It is the server's `baseUrl`, and a deployed environment
219
+ without one fails the boot rather than advertising `localhost` — which sends clients to the user's
220
+ own machine. Set `ai.mcp.oauthBaseUrl` only when MCP clients reach the server under another URL;
221
+ never add a `localhost` fallback of your own.
222
+
223
+ **Nothing to create for consent.** `/authorize` answers with a built-in consent page: sign-in through
224
+ Better-Auth (password + TOTP / backup code), then Allow / Deny with a CSRF token bound to user, client,
225
+ redirect URI and PKCE challenge. It needs Better-Auth active (the boot warns otherwise).
226
+
227
+ **Optional — `ai.mcp.oauthLoginUrl`:** the link for passkey / social login, default `<appUrl>/auth/login`.
228
+ Put `{returnTo}` into it only if that login page can send the user back to the API's `/authorize` URL
229
+ (the nuxt-base-starter login accepts same-origin targets only, so leave it out there).
230
+
231
+ **Optional — your own consent UI:** a subclass of `CoreAiMcpOAuthService` overriding
232
+ `renderConsentPage(view)` (restyle) or `authorizeConsent(client, params, res)` (replace), registered via
233
+ `CoreModule.forRoot(envConfig, { ai: { mcpOAuthService: McpOAuthService } })`.
214
234
 
215
- **WHY no `baseUrl` argument:** the issuer is every URL in the discovery metadata, and MCP clients
216
- follow them. The helper takes it from the server's `baseUrl` (`NSC__BASE_URL` when deployed) and
217
- fails the boot in a deployed environment that has none, rather than advertising `localhost` —
218
- which sends clients to the user's own machine. Pass `{ baseUrl }` only for an issuer that must
219
- differ from the server's `baseUrl`; never add a `localhost` fallback of your own.
235
+ **WHY a POST, never "signed in, so issue":** anybody can register a client with their own redirect
236
+ URI and start the flow — PKCE is then theirs too. A GET to `/authorize` is what any link produces,
237
+ and it carries the session cookie, so issuing on GET hands a code for whoever clicks to the
238
+ attacker. The framework refuses a code saved while answering a GET to `/authorize` (the client gets
239
+ `error=server_error`); an own consent UI must show WHICH client is approved and protect the POST
240
+ with a CSRF token.
220
241
 
221
- Override `CoreAiMcpOAuthService.authorizeConsent()` with your login/consent UI (the only
222
- browser-interactive step). All other OAuth pieces (tokens, PKCE, stores) are built in.
242
+ **WHY refuse with `this.loadOAuthErrors()`:** the SDK maps an error to its OAuth code only when it
243
+ is one of its own `OAuthError` classes, and turns anything else into `server_error`. A user who
244
+ declines consent should send the client back with `access_denied` (`AccessDeniedError`), not with
245
+ a server error.
223
246
 
224
247
  ## Verification Checklist
225
248
 
@@ -237,13 +260,16 @@ browser-interactive step). All other OAuth pieces (tokens, PKCE, stores) are bui
237
260
 
238
261
  ## Common Mistakes
239
262
 
240
- | Mistake | Symptom | Fix |
241
- | ------------------------------------------------ | -------------------------------------------------------------------------------------- | ------------------------------------------------------------------ |
242
- | No `ai` config block | Module not loaded, `aiPrompt` missing from schema | Add an `ai` block (presence implies enabled) |
243
- | No encryption secret in prod | **App refuses to boot** (throws); dev/local only warns | Set `NSC__AI__ENCRYPTION_SECRET` (32+ chars) |
244
- | Encryption secret changed after keys stored | Boot logs "key(s) could not be decrypted"; those prompts fail | Re-enter the API key for the listed connections |
245
- | Tool returns `.lean()`/aggregate data | `@Restricted` fields leak into the LLM context | Route through `CrudService` with `context.serviceOptions` |
246
- | Tool not registered | Tool never offered to the LLM | Declare it as a provider in a module (extends `AiTool`) |
247
- | Overridden resolver method missing decorators | Method absent from GraphQL schema | Re-declare `@Mutation`/`@Query`/`@Roles` in the override |
248
- | Storing a real API key in `config.env.ts` | Secret committed to the repo | Use `apiKeyEnv` or the runtime connection CRUD |
249
- | `localhost` fallback for the MCP OAuth `baseUrl` | MCP client fails to register with `ECONNREFUSED`; discovery metadata names `localhost` | Call `mountAiMcpOAuth(app)` without `baseUrl`, set `NSC__BASE_URL` |
263
+ | Mistake | Symptom | Fix |
264
+ | ---------------------------------------------------------- | -------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------- |
265
+ | No `ai` config block | Module not loaded, `aiPrompt` missing from schema | Add an `ai` block (presence implies enabled) |
266
+ | No encryption secret in prod | **App refuses to boot** (throws); dev/local only warns | Set `NSC__AI__ENCRYPTION_SECRET` (32+ chars) |
267
+ | Encryption secret changed after keys stored | Boot logs "key(s) could not be decrypted"; those prompts fail | Re-enter the API key for the listed connections |
268
+ | Tool returns `.lean()`/aggregate data | `@Restricted` fields leak into the LLM context | Route through `CrudService` with `context.serviceOptions` |
269
+ | Tool not registered | Tool never offered to the LLM | Declare it as a provider in a module (extends `AiTool`) |
270
+ | Overridden resolver method missing decorators | Method absent from GraphQL schema | Re-declare `@Mutation`/`@Query`/`@Roles` in the override |
271
+ | Storing a real API key in `config.env.ts` | Secret committed to the repo | Use `apiKeyEnv` or the runtime connection CRUD |
272
+ | `localhost` fallback for the MCP OAuth `baseUrl` | MCP client fails to register with `ECONNREFUSED`; discovery metadata names `localhost` | Set `NSC__BASE_URL`; no `ai.mcp.oauthBaseUrl` pointing at `localhost` |
273
+ | `mountAiMcpOAuth(app)` after `app.init()` (pre-11.43.0) | `/.well-known/oauth-*`, `/token`, `/register` answer Nest's 404 | Upgrade; remove the call — `CoreModule` mounts the endpoints during `app.init()` |
274
+ | Own consent issues the code on GET ("signed in = consent") | `/authorize` redirects back with `error=server_error`; the refusal is logged | Show the consent page on GET, issue only on the approving POST with a CSRF token |
275
+ | `ai.mcp.oauth` without Better-Auth | Boot warning; the consent page offers no sign-in | Enable `betterAuth`, or register an own `authorizeConsent()` |