@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.
- package/.claude/rules/better-auth.md +22 -0
- package/.claude/rules/configurable-features.md +4 -3
- package/.claude/rules/testing.md +4 -4
- package/FRAMEWORK-API.md +2 -2
- package/dist/core/common/filters/mongoose-cast-error.filter.d.ts +17 -0
- package/dist/core/common/filters/mongoose-cast-error.filter.js +171 -0
- package/dist/core/common/filters/mongoose-cast-error.filter.js.map +1 -0
- package/dist/core/common/interfaces/server-options.interface.d.ts +4 -0
- package/dist/core/modules/ai/core-ai-mcp-oauth.initializer.d.ts +19 -0
- package/dist/core/modules/ai/core-ai-mcp-oauth.initializer.js +145 -0
- package/dist/core/modules/ai/core-ai-mcp-oauth.initializer.js.map +1 -0
- package/dist/core/modules/ai/core-ai-mcp.controller.js +6 -5
- package/dist/core/modules/ai/core-ai-mcp.controller.js.map +1 -1
- package/dist/core/modules/ai/core-ai.module.d.ts +2 -0
- package/dist/core/modules/ai/core-ai.module.js +6 -1
- package/dist/core/modules/ai/core-ai.module.js.map +1 -1
- package/dist/core/modules/ai/helpers/ai-mcp-oauth-consent.helper.d.ts +33 -0
- package/dist/core/modules/ai/helpers/ai-mcp-oauth-consent.helper.js +282 -0
- package/dist/core/modules/ai/helpers/ai-mcp-oauth-consent.helper.js.map +1 -0
- package/dist/core/modules/ai/helpers/ai-mcp-oauth.helper.d.ts +15 -2
- package/dist/core/modules/ai/helpers/ai-mcp-oauth.helper.js +73 -19
- package/dist/core/modules/ai/helpers/ai-mcp-oauth.helper.js.map +1 -1
- package/dist/core/modules/ai/helpers/ai-mcp-oauth.registry.d.ts +5 -0
- package/dist/core/modules/ai/helpers/ai-mcp-oauth.registry.js.map +1 -1
- package/dist/core/modules/ai/index.d.ts +1 -0
- package/dist/core/modules/ai/index.js +1 -0
- package/dist/core/modules/ai/index.js.map +1 -1
- package/dist/core/modules/ai/services/core-ai-mcp-oauth.service.d.ts +30 -2
- package/dist/core/modules/ai/services/core-ai-mcp-oauth.service.js +222 -15
- package/dist/core/modules/ai/services/core-ai-mcp-oauth.service.js.map +1 -1
- package/dist/core/modules/better-auth/better-auth.config.d.ts +4 -0
- package/dist/core/modules/better-auth/better-auth.config.js +29 -2
- package/dist/core/modules/better-auth/better-auth.config.js.map +1 -1
- package/dist/core/modules/better-auth/core-better-auth.controller.d.ts +2 -0
- package/dist/core/modules/better-auth/core-better-auth.controller.js +36 -1
- package/dist/core/modules/better-auth/core-better-auth.controller.js.map +1 -1
- package/dist/core.module.js +7 -0
- package/dist/core.module.js.map +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/dist/tsconfig.build.tsbuildinfo +1 -1
- package/docs/REQUEST-LIFECYCLE.md +30 -1
- package/docs/security-overrides.md +3 -2
- package/migration-guides/11.42.15-to-11.42.16.md +114 -0
- package/migration-guides/11.42.x-to-11.43.x.md +195 -0
- package/package.json +7 -7
- package/src/core/common/filters/mongoose-cast-error.filter.ts +310 -0
- package/src/core/common/helpers/swagger.helper.ts +4 -4
- package/src/core/common/interfaces/server-options.interface.ts +59 -9
- package/src/core/modules/ai/INTEGRATION-CHECKLIST.md +50 -24
- package/src/core/modules/ai/README.md +98 -13
- package/src/core/modules/ai/core-ai-mcp-oauth.initializer.ts +199 -0
- package/src/core/modules/ai/core-ai-mcp.controller.ts +13 -6
- package/src/core/modules/ai/core-ai.module.ts +14 -2
- package/src/core/modules/ai/helpers/ai-mcp-oauth-consent.helper.ts +369 -0
- package/src/core/modules/ai/helpers/ai-mcp-oauth.helper.ts +149 -43
- package/src/core/modules/ai/helpers/ai-mcp-oauth.registry.ts +18 -5
- package/src/core/modules/ai/index.ts +1 -0
- package/src/core/modules/ai/services/core-ai-mcp-oauth.service.ts +385 -28
- package/src/core/modules/better-auth/better-auth.config.ts +49 -5
- package/src/core/modules/better-auth/core-better-auth.controller.ts +75 -1
- package/src/core.module.ts +13 -1
- 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
|
-
*
|
|
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
|
|
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
|
|
2118
|
-
*
|
|
2119
|
-
*
|
|
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?:
|
|
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
|
|
4586
|
+
* If not set, it is derived (see below) — never "all origins": an origin check has no such mode.
|
|
4541
4587
|
*
|
|
4542
|
-
* Auto-derived
|
|
4543
|
-
*
|
|
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
|
|
4603
|
-
*
|
|
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
|
|
48
|
-
|
|
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
|
-
**
|
|
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
|
-
|
|
211
|
-
|
|
212
|
-
|
|
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
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
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
|
-
|
|
222
|
-
|
|
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
|
|
241
|
-
|
|
|
242
|
-
| No `ai` config block
|
|
243
|
-
| No encryption secret in prod
|
|
244
|
-
| Encryption secret changed after keys stored
|
|
245
|
-
| Tool returns `.lean()`/aggregate data
|
|
246
|
-
| Tool not registered
|
|
247
|
-
| Overridden resolver method missing decorators
|
|
248
|
-
| Storing a real API key in `config.env.ts`
|
|
249
|
-
| `localhost` fallback for the MCP OAuth `baseUrl`
|
|
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()` |
|