@lenne.tech/nest-server 11.42.16 → 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 (51) hide show
  1. package/.claude/rules/better-auth.md +22 -0
  2. package/.claude/rules/configurable-features.md +3 -3
  3. package/.claude/rules/testing.md +4 -4
  4. package/FRAMEWORK-API.md +2 -2
  5. package/dist/core/common/interfaces/server-options.interface.d.ts +3 -0
  6. package/dist/core/modules/ai/core-ai-mcp-oauth.initializer.d.ts +19 -0
  7. package/dist/core/modules/ai/core-ai-mcp-oauth.initializer.js +145 -0
  8. package/dist/core/modules/ai/core-ai-mcp-oauth.initializer.js.map +1 -0
  9. package/dist/core/modules/ai/core-ai-mcp.controller.js +6 -5
  10. package/dist/core/modules/ai/core-ai-mcp.controller.js.map +1 -1
  11. package/dist/core/modules/ai/core-ai.module.d.ts +2 -0
  12. package/dist/core/modules/ai/core-ai.module.js +6 -1
  13. package/dist/core/modules/ai/core-ai.module.js.map +1 -1
  14. package/dist/core/modules/ai/helpers/ai-mcp-oauth-consent.helper.d.ts +33 -0
  15. package/dist/core/modules/ai/helpers/ai-mcp-oauth-consent.helper.js +282 -0
  16. package/dist/core/modules/ai/helpers/ai-mcp-oauth-consent.helper.js.map +1 -0
  17. package/dist/core/modules/ai/helpers/ai-mcp-oauth.helper.d.ts +15 -2
  18. package/dist/core/modules/ai/helpers/ai-mcp-oauth.helper.js +73 -19
  19. package/dist/core/modules/ai/helpers/ai-mcp-oauth.helper.js.map +1 -1
  20. package/dist/core/modules/ai/helpers/ai-mcp-oauth.registry.d.ts +5 -0
  21. package/dist/core/modules/ai/helpers/ai-mcp-oauth.registry.js.map +1 -1
  22. package/dist/core/modules/ai/index.d.ts +1 -0
  23. package/dist/core/modules/ai/index.js +1 -0
  24. package/dist/core/modules/ai/index.js.map +1 -1
  25. package/dist/core/modules/ai/services/core-ai-mcp-oauth.service.d.ts +30 -2
  26. package/dist/core/modules/ai/services/core-ai-mcp-oauth.service.js +222 -15
  27. package/dist/core/modules/ai/services/core-ai-mcp-oauth.service.js.map +1 -1
  28. package/dist/core/modules/better-auth/better-auth.config.d.ts +4 -0
  29. package/dist/core/modules/better-auth/better-auth.config.js +29 -2
  30. package/dist/core/modules/better-auth/better-auth.config.js.map +1 -1
  31. package/dist/core/modules/better-auth/core-better-auth.controller.d.ts +2 -0
  32. package/dist/core/modules/better-auth/core-better-auth.controller.js +36 -1
  33. package/dist/core/modules/better-auth/core-better-auth.controller.js.map +1 -1
  34. package/dist/tsconfig.build.tsbuildinfo +1 -1
  35. package/docs/REQUEST-LIFECYCLE.md +1 -1
  36. package/migration-guides/11.42.x-to-11.43.x.md +195 -0
  37. package/package.json +4 -4
  38. package/src/core/common/helpers/swagger.helper.ts +4 -4
  39. package/src/core/common/interfaces/server-options.interface.ts +37 -9
  40. package/src/core/modules/ai/INTEGRATION-CHECKLIST.md +50 -24
  41. package/src/core/modules/ai/README.md +98 -13
  42. package/src/core/modules/ai/core-ai-mcp-oauth.initializer.ts +199 -0
  43. package/src/core/modules/ai/core-ai-mcp.controller.ts +13 -6
  44. package/src/core/modules/ai/core-ai.module.ts +14 -2
  45. package/src/core/modules/ai/helpers/ai-mcp-oauth-consent.helper.ts +369 -0
  46. package/src/core/modules/ai/helpers/ai-mcp-oauth.helper.ts +149 -43
  47. package/src/core/modules/ai/helpers/ai-mcp-oauth.registry.ts +18 -5
  48. package/src/core/modules/ai/index.ts +1 -0
  49. package/src/core/modules/ai/services/core-ai-mcp-oauth.service.ts +385 -28
  50. package/src/core/modules/better-auth/better-auth.config.ts +49 -5
  51. package/src/core/modules/better-auth/core-better-auth.controller.ts +75 -1
@@ -0,0 +1,199 @@
1
+ import {
2
+ Injectable,
3
+ Logger,
4
+ OnApplicationBootstrap,
5
+ OnApplicationShutdown,
6
+ OnModuleInit,
7
+ Optional,
8
+ RequestMethod,
9
+ } from '@nestjs/common';
10
+ import { MODULE_PATH, PATH_METADATA, VERSION_METADATA } from '@nestjs/common/constants';
11
+ import { addLeadingSlash } from '@nestjs/common/utils/shared.utils';
12
+ import { ApplicationConfig, HttpAdapterHost, ModuleRef, ModulesContainer } from '@nestjs/core';
13
+ import { RoutePathFactory } from '@nestjs/core/router/route-path-factory';
14
+
15
+ import { ConfigService } from '../../common/services/config.service';
16
+ import { getBetterAuthTokenService } from '../better-auth/core-better-auth.registry';
17
+ import { CoreAiMcpController } from './core-ai-mcp.controller';
18
+ import { mountAiMcpOAuthRouter } from './helpers/ai-mcp-oauth.helper';
19
+ import { getMountedMcpOAuth, IMountedMcpOAuth, setMountedMcpOAuth } from './helpers/ai-mcp-oauth.registry';
20
+ import { CoreAiMcpOAuthService } from './services/core-ai-mcp-oauth.service';
21
+
22
+ /**
23
+ * Mounts the MCP OAuth 2.1 endpoints (`/.well-known/oauth-*`, `/authorize`, `/token`, `/register`)
24
+ * when `ai.mcp.oauth` is enabled — no `main.ts` code, and the same under `Test.createTestingModule()`.
25
+ *
26
+ * WHERE in the lifecycle is the whole point. The endpoints are an Express router, not Nest routes,
27
+ * and Nest registers its not-found handler inside `app.init()`, after `onModuleInit`. A router added
28
+ * after `app.init()` — what the documentation used to prescribe — sits behind that handler and is
29
+ * never reached: every OAuth endpoint answered 404. Mounted here, it sits behind every Nest
30
+ * middleware (security headers, request context, CORS) and the Nest routes, and ahead of the
31
+ * not-found handler.
32
+ *
33
+ * The MCP resource path is computed the way Nest computes the controller's route (global prefix and
34
+ * its exclusions, URI versioning, a `RouterModule` path), so the protected resource metadata names
35
+ * the URL the MCP endpoint actually answers on. The issuer is `ai.mcp.oauthBaseUrl`, else the
36
+ * server's `baseUrl`; a deployed environment without either fails the boot rather than advertising
37
+ * a guessed URL.
38
+ *
39
+ * Registered by `CoreAiModule` whenever the MCP endpoint is; consumers never interact with it.
40
+ */
41
+ @Injectable()
42
+ export class CoreAiMcpOAuthInitializer implements OnApplicationBootstrap, OnApplicationShutdown, OnModuleInit {
43
+ protected readonly logger = new Logger(CoreAiMcpOAuthInitializer.name);
44
+
45
+ /** What this instance mounted, so shutdown clears only its own record. */
46
+ private mounted?: IMountedMcpOAuth;
47
+
48
+ constructor(
49
+ protected readonly oauthService: CoreAiMcpOAuthService,
50
+ @Optional() protected readonly httpAdapterHost?: HttpAdapterHost,
51
+ @Optional() protected readonly moduleRef?: ModuleRef,
52
+ @Optional() protected readonly modulesContainer?: ModulesContainer,
53
+ ) {}
54
+
55
+ async onModuleInit(): Promise<void> {
56
+ if (!this.oauthService.isOAuthEnabled()) {
57
+ return;
58
+ }
59
+ const adapter = this.httpAdapterHost?.httpAdapter;
60
+ if (!adapter) {
61
+ // An application context without an HTTP server (CLI, migration script): nothing to serve.
62
+ return;
63
+ }
64
+ // Repeated here, in framework code, because a subclass may override onModuleInit() without super.
65
+ this.oauthService.assertProductionSafe();
66
+ if (adapter.getType() !== 'express') {
67
+ throw new Error(
68
+ `ai.mcp.oauth requires the Express adapter, but the application runs on "${adapter.getType()}": ` +
69
+ 'the MCP OAuth endpoints are an Express router.',
70
+ );
71
+ }
72
+
73
+ this.mounted = await mountAiMcpOAuthRouter(
74
+ adapter.getInstance(),
75
+ (router) => adapter.use(router),
76
+ this.oauthService,
77
+ {
78
+ baseUrl: ConfigService.get<unknown>('ai.mcp.oauthBaseUrl'),
79
+ authBasePath: this.resolveAuthBasePath(),
80
+ baseUrlSource: 'ai.mcp.oauthBaseUrl',
81
+ mcpPath: this.resolveMcpPath(),
82
+ },
83
+ );
84
+ }
85
+
86
+ /**
87
+ * The built-in consent page signs users in through Better-Auth. Checked at bootstrap, when every
88
+ * module — Better-Auth's included — has initialized.
89
+ */
90
+ onApplicationBootstrap(): void {
91
+ if (!this.mounted || !this.oauthService.usesBuiltInConsent() || getBetterAuthTokenService()) {
92
+ return;
93
+ }
94
+ this.logger.warn(
95
+ 'ai.mcp.oauth is enabled, but Better-Auth is not active: the built-in consent page has no way to sign a user ' +
96
+ 'in, so no MCP client can be authorized. Enable betterAuth, or register a CoreAiMcpOAuthService subclass ' +
97
+ 'with an authorizeConsent() of your own via CoreModule.forRoot(env, { ai: { mcpOAuthService } }).',
98
+ );
99
+ }
100
+
101
+ onApplicationShutdown(): void {
102
+ if (this.mounted && getMountedMcpOAuth() === this.mounted) {
103
+ setMountedMcpOAuth(undefined);
104
+ }
105
+ }
106
+
107
+ /**
108
+ * The path the MCP endpoint (`POST` on `CoreAiMcpController`) is registered at, computed with
109
+ * Nest's own `RoutePathFactory` from the inputs Nest passes it (`NestApplication.registerRouter`,
110
+ * `RoutesResolver`). Re-implementing the rules instead would drift silently the first time Nest
111
+ * changes one; this breaks loudly, and `tests/unit/ai-mcp-oauth-lifecycle.spec.ts` pins the result
112
+ * against the route the controller actually answers on.
113
+ */
114
+ protected resolveMcpPath(): string {
115
+ const container = (this.moduleRef as unknown as { container?: { applicationConfig?: ApplicationConfig } })
116
+ ?.container;
117
+ const applicationConfig = container?.applicationConfig;
118
+ const ctrlPath = this.controllerPath();
119
+ if (!applicationConfig) {
120
+ return ctrlPath;
121
+ }
122
+
123
+ const versioningOptions = applicationConfig.getVersioning();
124
+ const controllerVersion = versioningOptions
125
+ ? (Reflect.getMetadata(VERSION_METADATA, CoreAiMcpController) ?? versioningOptions.defaultVersion)
126
+ : undefined;
127
+ const paths = new RoutePathFactory(applicationConfig).create(
128
+ {
129
+ controllerVersion,
130
+ ctrlPath,
131
+ globalPrefix: addLeadingSlash(applicationConfig.getGlobalPrefix()),
132
+ methodPath: '/',
133
+ modulePath: this.modulePath(),
134
+ versioningOptions,
135
+ },
136
+ RequestMethod.POST,
137
+ );
138
+
139
+ if (paths.length > 1) {
140
+ this.logger.warn(
141
+ `The MCP endpoint is registered at ${paths.join(', ')}; the OAuth metadata describes the first one only.`,
142
+ );
143
+ }
144
+ return paths[0];
145
+ }
146
+
147
+ /**
148
+ * Where the IAM endpoints the consent page signs in through answer: Better-Auth's `basePath` below
149
+ * the global prefix (and URI version) Nest registers the IAM controller under. The OAuth router
150
+ * itself sits at the root, so a page served there must not assume `/iam`.
151
+ */
152
+ protected resolveAuthBasePath(): string {
153
+ const basePath = `/${String(ConfigService.get<string>('betterAuth.basePath') || 'iam').replace(/^\//, '')}`;
154
+ const applicationConfig = (this.moduleRef as unknown as { container?: { applicationConfig?: ApplicationConfig } })
155
+ ?.container?.applicationConfig;
156
+ if (!applicationConfig) {
157
+ return basePath;
158
+ }
159
+ const versioningOptions = applicationConfig.getVersioning();
160
+ const [signIn] = new RoutePathFactory(applicationConfig).create(
161
+ {
162
+ controllerVersion: versioningOptions?.defaultVersion,
163
+ ctrlPath: basePath,
164
+ globalPrefix: addLeadingSlash(applicationConfig.getGlobalPrefix()),
165
+ methodPath: '/sign-in/email',
166
+ versioningOptions,
167
+ },
168
+ RequestMethod.POST,
169
+ );
170
+ return signIn.replace(/\/sign-in\/email$/, '');
171
+ }
172
+
173
+ /** The controller's declared route, with a leading slash (`/ai/mcp`). */
174
+ private controllerPath(): string {
175
+ const declared = Reflect.getMetadata(PATH_METADATA, CoreAiMcpController);
176
+ const path = Array.isArray(declared) ? declared[0] : declared;
177
+ return `/${String(path ?? 'ai/mcp').replace(/^\//, '')}`;
178
+ }
179
+
180
+ /**
181
+ * A `RouterModule` path in front of the module that registered the MCP controller, read the way
182
+ * `RoutesResolver` reads it. The module is looked up rather than imported: importing `CoreAiModule`
183
+ * here would put this file and the module on an import cycle.
184
+ */
185
+ private modulePath(): string | undefined {
186
+ if (!this.modulesContainer) {
187
+ return undefined;
188
+ }
189
+ for (const module of this.modulesContainer.values()) {
190
+ if (module.controllers.has(CoreAiMcpController)) {
191
+ return (
192
+ Reflect.getMetadata(MODULE_PATH + this.modulesContainer.applicationId, module.metatype) ??
193
+ Reflect.getMetadata(MODULE_PATH, module.metatype)
194
+ );
195
+ }
196
+ }
197
+ return undefined;
198
+ }
199
+ }
@@ -9,6 +9,7 @@ import { ConfigService } from '../../common/services/config.service';
9
9
  import { CoreRedisService } from '../../common/services/core-redis.service';
10
10
  import { CoreBetterAuthModule } from '../better-auth/core-better-auth.module';
11
11
  import { ErrorCode } from '../error-code/error-codes';
12
+ import { getMountedMcpOAuth } from './helpers/ai-mcp-oauth.registry';
12
13
  import { CoreAiMcpOAuthService } from './services/core-ai-mcp-oauth.service';
13
14
  import { CoreAiMcpService } from './services/core-ai-mcp.service';
14
15
  import { AiToolUser } from './tools/ai-tool.registry';
@@ -34,7 +35,8 @@ import { AiToolUser } from './tools/ai-tool.registry';
34
35
  * `WWW-Authenticate` header as the protocol expects.
35
36
  *
36
37
  * When `ai.mcp.oauth` is enabled, the handler additionally accepts OAuth 2.1 access
37
- * tokens (see `mountAiMcpOAuth`); otherwise it authenticates via Bearer/session token.
38
+ * tokens (endpoints mounted by `CoreAiMcpOAuthInitializer`); otherwise it authenticates via
39
+ * Bearer/session token.
38
40
  *
39
41
  * ## Multi-replica
40
42
  *
@@ -121,7 +123,7 @@ export class CoreAiMcpController implements OnModuleDestroy {
121
123
  async handlePost(@Req() req: Request, @Res() res: Response): Promise<void> {
122
124
  const user = await this.resolveUser(req);
123
125
  if (!user?.id) {
124
- this.unauthorized(req, res);
126
+ this.unauthorized(res);
125
127
  return;
126
128
  }
127
129
 
@@ -298,7 +300,7 @@ export class CoreAiMcpController implements OnModuleDestroy {
298
300
  private async handleSessionRequest(req: Request, res: Response): Promise<void> {
299
301
  const user = await this.resolveUser(req);
300
302
  if (!user?.id) {
301
- this.unauthorized(req, res);
303
+ this.unauthorized(res);
302
304
  return;
303
305
  }
304
306
  const sessionId = req.headers['mcp-session-id'] as string | undefined;
@@ -470,12 +472,17 @@ export class CoreAiMcpController implements OnModuleDestroy {
470
472
 
471
473
  /**
472
474
  * MCP-style 401 with a `WWW-Authenticate` header for OAuth discovery.
475
+ *
476
+ * `resource_metadata` is the URL of the protected resource METADATA (RFC 9728 §5.1), exactly as
477
+ * the mounted OAuth router serves it — not the endpoint itself, and not a URL built from the
478
+ * request's `Host` header, which the caller controls. Without mounted OAuth endpoints there is
479
+ * no metadata to point at, so the challenge is a plain `Bearer`.
473
480
  */
474
- private unauthorized(req: Request, res: Response): void {
475
- const baseUrl = `${req.protocol}://${req.get('host')}`;
481
+ private unauthorized(res: Response): void {
482
+ const oauth = getMountedMcpOAuth();
476
483
  res
477
484
  .status(401)
478
- .set({ 'WWW-Authenticate': `Bearer resource_metadata="${baseUrl}/ai/mcp"` })
485
+ .set({ 'WWW-Authenticate': oauth ? `Bearer resource_metadata="${oauth.resourceMetadataUrl}"` : 'Bearer' })
479
486
  .json({ error: 'Unauthorized: a valid Bearer token is required' });
480
487
  }
481
488
 
@@ -1,6 +1,7 @@
1
1
  import { DynamicModule, Global, Module, Type } from '@nestjs/common';
2
2
  import { MongooseModule } from '@nestjs/mongoose';
3
3
 
4
+ import { CoreAiMcpOAuthInitializer } from './core-ai-mcp-oauth.initializer';
4
5
  import { CoreAiMcpController } from './core-ai-mcp.controller';
5
6
  import { CoreAiController } from './core-ai.controller';
6
7
  import { CoreAiResolver } from './core-ai.resolver';
@@ -95,6 +96,12 @@ export interface CoreAiModuleOptions {
95
96
  /** Whether to register the MCP server controller at /ai/mcp. */
96
97
  mcpEnabled?: boolean;
97
98
 
99
+ /**
100
+ * Custom MCP OAuth service (extends CoreAiMcpOAuthService) — where `authorizeConsent()` wires the
101
+ * login/consent UI. Bound to the base token, so the MCP controller and the OAuth endpoints use it.
102
+ */
103
+ mcpOAuthService?: Type<CoreAiMcpOAuthService>;
104
+
98
105
  /** Custom connection-preference service (extends CoreAiConnectionPreferenceService). */
99
106
  preferenceService?: Type<CoreAiConnectionPreferenceService>;
100
107
 
@@ -174,11 +181,15 @@ export class CoreAiModule {
174
181
  const ToolPolicyServiceClass = options.toolPolicyService || CoreAiToolPolicyService;
175
182
  const ModeServiceClass = options.modeService || CoreAiModeService;
176
183
  const McpClientServiceClass = options.mcpClientService || CoreAiMcpClientService;
184
+ const McpOAuthServiceClass = options.mcpOAuthService || CoreAiMcpOAuthService;
177
185
 
178
- // Register the MCP controller only when enabled (it lazy-loads the MCP SDK).
186
+ // Register the MCP controller only when enabled (it lazy-loads the MCP SDK), together with the
187
+ // initializer that mounts its OAuth endpoints when `ai.mcp.oauth` is enabled.
179
188
  const controllers: Type<any>[] = [ControllerClass];
189
+ const mcpProviders: Type<any>[] = [];
180
190
  if (options.mcpEnabled) {
181
191
  controllers.push(CoreAiMcpController);
192
+ mcpProviders.push(CoreAiMcpOAuthInitializer);
182
193
  }
183
194
 
184
195
  return {
@@ -246,7 +257,7 @@ export class CoreAiModule {
246
257
  { provide: CoreAiConversationService, useClass: ConversationServiceClass },
247
258
  { provide: CoreAiInteractionService, useClass: InteractionServiceClass },
248
259
  CoreAiMcpService,
249
- CoreAiMcpOAuthService,
260
+ ...mcpProviders,
250
261
  // Bind base-class tokens to the (possibly overridden) implementations so
251
262
  // injections by base type resolve to the project's subclass.
252
263
  { provide: CoreAiConnectionPreferenceService, useClass: PreferenceServiceClass },
@@ -262,6 +273,7 @@ export class CoreAiModule {
262
273
  { provide: CoreAiToolPolicyService, useClass: ToolPolicyServiceClass },
263
274
  { provide: CoreAiModeService, useClass: ModeServiceClass },
264
275
  { provide: CoreAiMcpClientService, useClass: McpClientServiceClass },
276
+ { provide: CoreAiMcpOAuthService, useClass: McpOAuthServiceClass },
265
277
  ResolverClass,
266
278
  ],
267
279
  };