@lenne.tech/nest-server 11.42.8 → 11.42.10

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 (68) hide show
  1. package/.claude/rules/better-auth.md +24 -0
  2. package/.claude/rules/configurable-features.md +5 -3
  3. package/.claude/rules/testing.md +4 -4
  4. package/FRAMEWORK-API.md +3 -2
  5. package/dist/core/common/decorators/api-methods.decorator.d.ts +2 -0
  6. package/dist/core/common/decorators/api-methods.decorator.js +8 -0
  7. package/dist/core/common/decorators/api-methods.decorator.js.map +1 -0
  8. package/dist/core/common/helpers/swagger.helper.js +312 -1
  9. package/dist/core/common/helpers/swagger.helper.js.map +1 -1
  10. package/dist/core/common/interfaces/server-options.interface.d.ts +1 -0
  11. package/dist/core/modules/ai/core-ai-mcp.controller.js +15 -1
  12. package/dist/core/modules/ai/core-ai-mcp.controller.js.map +1 -1
  13. package/dist/core/modules/ai/helpers/ai-mcp-oauth.helper.js +8 -1
  14. package/dist/core/modules/ai/helpers/ai-mcp-oauth.helper.js.map +1 -1
  15. package/dist/core/modules/ai/helpers/ai-mcp-oauth.registry.d.ts +7 -0
  16. package/dist/core/modules/ai/helpers/ai-mcp-oauth.registry.js +12 -0
  17. package/dist/core/modules/ai/helpers/ai-mcp-oauth.registry.js.map +1 -0
  18. package/dist/core/modules/better-auth/better-auth.config.js +12 -0
  19. package/dist/core/modules/better-auth/better-auth.config.js.map +1 -1
  20. package/dist/core/modules/better-auth/core-better-auth-api.middleware.js +2 -2
  21. package/dist/core/modules/better-auth/core-better-auth-api.middleware.js.map +1 -1
  22. package/dist/core/modules/better-auth/core-better-auth-openapi.helper.d.ts +62 -0
  23. package/dist/core/modules/better-auth/core-better-auth-openapi.helper.js +95 -0
  24. package/dist/core/modules/better-auth/core-better-auth-openapi.helper.js.map +1 -0
  25. package/dist/core/modules/better-auth/core-better-auth.service.d.ts +10 -2
  26. package/dist/core/modules/better-auth/core-better-auth.service.js +89 -0
  27. package/dist/core/modules/better-auth/core-better-auth.service.js.map +1 -1
  28. package/dist/core/modules/tus/core-tus.controller.js +3 -0
  29. package/dist/core/modules/tus/core-tus.controller.js.map +1 -1
  30. package/dist/core/modules/tus/core-tus.service.d.ts +3 -0
  31. package/dist/core/modules/tus/core-tus.service.js +58 -25
  32. package/dist/core/modules/tus/core-tus.service.js.map +1 -1
  33. package/dist/core/modules/tus/interfaces/tus-config.interface.js +1 -0
  34. package/dist/core/modules/tus/interfaces/tus-config.interface.js.map +1 -1
  35. package/dist/core/modules/tus/tus.module.d.ts +2 -0
  36. package/dist/core/modules/tus/tus.module.js +23 -1
  37. package/dist/core/modules/tus/tus.module.js.map +1 -1
  38. package/dist/core.module.js +1 -1
  39. package/dist/core.module.js.map +1 -1
  40. package/dist/index.d.ts +1 -0
  41. package/dist/index.js +1 -0
  42. package/dist/index.js.map +1 -1
  43. package/dist/tsconfig.build.tsbuildinfo +1 -1
  44. package/docs/REQUEST-LIFECYCLE.md +9 -1
  45. package/docs/security-overrides.md +6 -5
  46. package/migration-guides/11.42.8-to-11.42.9.md +186 -0
  47. package/migration-guides/11.42.9-to-11.42.10.md +111 -0
  48. package/package.json +7 -8
  49. package/src/core/common/decorators/api-methods.decorator.ts +25 -0
  50. package/src/core/common/helpers/swagger.helper.ts +454 -1
  51. package/src/core/common/interfaces/server-options.interface.ts +46 -8
  52. package/src/core/common/interfaces/swagger-setup-options.interface.ts +5 -2
  53. package/src/core/modules/ai/README.md +7 -0
  54. package/src/core/modules/ai/core-ai-mcp.controller.ts +19 -2
  55. package/src/core/modules/ai/helpers/ai-mcp-oauth.helper.ts +12 -2
  56. package/src/core/modules/ai/helpers/ai-mcp-oauth.registry.ts +29 -0
  57. package/src/core/modules/better-auth/README.md +34 -0
  58. package/src/core/modules/better-auth/better-auth.config.ts +27 -1
  59. package/src/core/modules/better-auth/core-better-auth-api.middleware.ts +4 -17
  60. package/src/core/modules/better-auth/core-better-auth-openapi.helper.ts +219 -0
  61. package/src/core/modules/better-auth/core-better-auth.service.ts +166 -2
  62. package/src/core/modules/tus/README.md +50 -14
  63. package/src/core/modules/tus/core-tus.controller.ts +6 -0
  64. package/src/core/modules/tus/core-tus.service.ts +97 -26
  65. package/src/core/modules/tus/interfaces/tus-config.interface.ts +1 -0
  66. package/src/core/modules/tus/tus.module.ts +43 -1
  67. package/src/core.module.ts +2 -1
  68. package/src/index.ts +1 -0
@@ -1,11 +1,29 @@
1
1
  import { Logger } from '@nestjs/common';
2
+ import { PATH_METADATA } from '@nestjs/common/constants';
2
3
  import { ModulesContainer } from '@nestjs/core';
4
+ import { SchedulerRegistry } from '@nestjs/schedule';
3
5
  import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger';
4
6
 
7
+ import { getMountedMcpOAuth } from '../../modules/ai/helpers/ai-mcp-oauth.registry';
5
8
  import { resolveApiTokenConfig } from '../../modules/api-token/core-api-token.helpers';
6
9
  import { API_TOKEN_SCOPES_KEY } from '../../modules/api-token/core-api-token.constants';
10
+ import { CoreAuthController } from '../../modules/auth/core-auth.controller';
11
+ import { isLegacyEndpointEnabled } from '../../modules/auth/helpers/legacy-endpoints.helper';
7
12
  import { resolveBetterAuthSessionCookieName } from '../../modules/better-auth/better-auth-cookie-prefix.helper';
13
+ import {
14
+ isBetterAuthOpenApiPath,
15
+ isControllerHandledPath,
16
+ isUnavailableOperation,
17
+ switchedOffBetterAuthPaths,
18
+ } from '../../modules/better-auth/core-better-auth-openapi.helper';
19
+ import { CoreBetterAuthUserMapper } from '../../modules/better-auth/core-better-auth-user.mapper';
20
+ import { CoreBetterAuthController } from '../../modules/better-auth/core-better-auth.controller';
21
+ import { CoreBetterAuthService } from '../../modules/better-auth/core-better-auth.service';
22
+ import { CoreHubActionsController } from '../../modules/hub/core-hub-actions.controller';
23
+ import { CoreHubController } from '../../modules/hub/core-hub.controller';
24
+ import { isHubEnabled, normalizeHubConfig } from '../../modules/hub/hub-config.helper';
8
25
  import { SKIP_TENANT_CHECK_KEY } from '../../modules/tenant/core-tenant.decorators';
26
+ import { API_METHODS_KEY } from '../decorators/api-methods.decorator';
9
27
  import { mergeRolesMetadata } from '../../modules/tenant/core-tenant.helpers';
10
28
  import { RoleEnum } from '../enums/role.enum';
11
29
  import { ConfigService } from '../services/config.service';
@@ -20,6 +38,9 @@ import type {
20
38
  ReferenceObject,
21
39
  TagObject,
22
40
  } from '@nestjs/swagger';
41
+ import type { IMountedMcpOAuth } from '../../modules/ai/helpers/ai-mcp-oauth.registry';
42
+ import type { IBetterAuthOpenApiDocument } from '../../modules/better-auth/core-better-auth-openapi.helper';
43
+ import type { ResolvedHubConfig } from '../../modules/hub/interfaces/hub-config.interface';
23
44
  import type { IServerOptions } from '../interfaces/server-options.interface';
24
45
  import type { ISwaggerApiTokenViewOptions, ISwaggerSetupOptions } from '../interfaces/swagger-setup-options.interface';
25
46
 
@@ -37,6 +58,15 @@ import type { ISwaggerApiTokenViewOptions, ISwaggerSetupOptions } from '../inter
37
58
  * (`x-api-token-scopes` plus a line in the description). `@ApiTokenScopes()` on the method replaces
38
59
  * the class-level declaration, exactly as in `assertApiTokenScopes()`.
39
60
  * - With multi-tenancy on, every operation that reads the tenant header documents it as a parameter.
61
+ * - The routes Better-Auth serves itself (two-factor, passkey, sessions, password change, … — whatever the
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.
65
+ * - Only what can succeed under the running configuration is documented (since 11.42.9): an `@All()`
66
+ * handler keeps the methods `@ApiMethods()` names; legacy `/auth/*` disappears while
67
+ * `auth.legacyEndpoints` keeps REST closed; Better-Auth routes disappear when the configuration or the
68
+ * live instance refuses them; Hub endpoints disappear with their switched-off panel. Every rule reads
69
+ * the switch the handler itself reads — project modules are documented by the same scan, automatically.
40
70
  * - Optionally, a second document lists only the routes an API token may call.
41
71
  *
42
72
  * Each operation is mapped to its handler through the default operation id (`<Controller>_<method>`),
@@ -77,6 +107,25 @@ interface IOperationAccess {
77
107
  skipsTenantHeader: boolean;
78
108
  }
79
109
 
110
+ /** What the document needs to know about the Better-Auth routes of the application. */
111
+ interface IBetterAuthRoutes {
112
+ /** Base path every Better-Auth route lives under (`/iam`) */
113
+ basePath: string;
114
+ /** The project's Better-Auth controller, under which the generated routes are documented */
115
+ controller: Type<unknown>;
116
+ /** The controller's routes are not where its metadata puts them (global prefix, URI versioning) */
117
+ relocated?: boolean;
118
+ /** Better-Auth's own description of the routes it serves itself */
119
+ schema?: IBetterAuthOpenApiDocument;
120
+ /**
121
+ * Operations (relative to the base path) that answer an error for every caller: what the configuration
122
+ * switches off plus what the live Better-Auth instance refuses (`isUnavailableOperation()` keys)
123
+ */
124
+ switchedOff: Set<string>;
125
+ /** Tags of the controller's explicit routes, as Swagger built them (before `operationTags`) */
126
+ tags?: string[];
127
+ }
128
+
80
129
  interface IResolvedApiTokenView {
81
130
  description?: string;
82
131
  enabled: boolean;
@@ -179,15 +228,36 @@ export function buildSwaggerDocument(
179
228
  }
180
229
 
181
230
  const handlers = indexHandlers(app);
231
+ const betterAuthRoutes = resolveBetterAuthRoutes(app, handlers, config);
232
+ const availability = resolveAvailability(app, config, betterAuthRoutes);
233
+ const switchedOff: [string, string][] = [];
182
234
  forEachOperation(document, (path, method, operation) => {
183
235
  const reference = resolveHandler(operation.operationId, handlers);
184
236
  if (!reference) {
185
237
  logger.warn(`No handler found for operation ${operation.operationId ?? `${method.toUpperCase()} ${path}`}`);
186
238
  return;
187
239
  }
240
+ if (betterAuthRoutes && isBetterAuthController(reference.controller)) {
241
+ betterAuthRoutes.tags ??= [...(operation.tags ?? [])];
242
+ // A global prefix or URI versioning puts the controller's routes below another base than its
243
+ // metadata says — and then Better-Auth's own routes are not reachable at all (see
244
+ // addBetterAuthRoutes). Compared by the controller's base only: a handler path is written in Nest's
245
+ // syntax (`:id`, `*path`), the document's in OpenAPI's (`{id}`), so a full comparison would read
246
+ // every parametrized route as relocated.
247
+ const base = declaredControllerBase(reference.controller);
248
+ if (base && path !== base && !path.startsWith(`${base}/`)) {
249
+ betterAuthRoutes.relocated = true;
250
+ }
251
+ }
252
+ if (isSwitchedOffOperation(reference, method, availability)) {
253
+ switchedOff.push([path, method]);
254
+ return;
255
+ }
188
256
  const access = describeAccess(reference, multiTenancy);
189
257
 
190
- if (access.isPublic) {
258
+ // An explicit @ApiBearerAuth()/@ApiSecurity() wins over "public": a route the guards admit to
259
+ // everybody may still authenticate the caller itself (the MCP endpoint does).
260
+ if (access.isPublic && !declaresSecurity(reference)) {
191
261
  operation.security = [];
192
262
  }
193
263
  if (apiTokens.enabled) {
@@ -218,10 +288,393 @@ export function buildSwaggerDocument(
218
288
  }
219
289
  });
220
290
 
291
+ for (const [path, method] of switchedOff) {
292
+ const item = document.paths[path] as Record<string, unknown>;
293
+ delete item[method];
294
+ if (!operationsOf(document.paths[path]).length) {
295
+ delete document.paths[path];
296
+ }
297
+ }
298
+
299
+ const added = { apiTokensEnabled: apiTokens.enabled, operationTags: options.operationTags };
300
+ if (betterAuthRoutes?.schema) {
301
+ if (betterAuthRoutes.relocated) {
302
+ logger.warn(
303
+ "Better-Auth's own routes are not documented: under a global prefix or URI versioning the API " +
304
+ 'middleware does not forward them, so none of them would be reachable at the documented path.',
305
+ );
306
+ } else {
307
+ addBetterAuthRoutes(document, betterAuthRoutes, added);
308
+ }
309
+ }
310
+ const mcpOAuth = getMountedMcpOAuth();
311
+ if (mcpOAuth) {
312
+ addMcpOAuthRoutes(document, mcpOAuth, added);
313
+ }
314
+
221
315
  document.tags = orderTags(document, options.tags);
222
316
  return document;
223
317
  }
224
318
 
319
+ /** What decides whether a registered route can succeed under the running configuration. */
320
+ interface IAvailability {
321
+ /** AI module registered (the Hub's AI panel reads it) */
322
+ ai: boolean;
323
+ betterAuth?: IBetterAuthRoutes;
324
+ /** Better-Auth's user mapper registered (the Hub's auth-migration panel reads it) */
325
+ betterAuthMapper: boolean;
326
+ config: Partial<IServerOptions>;
327
+ /** The Hub's resolved configuration, when the Hub is enabled */
328
+ hub?: ResolvedHubConfig;
329
+ /** `ScheduleModule` registered (the Hub's cron panel and actions read it) */
330
+ scheduler: boolean;
331
+ }
332
+
333
+ /** Legacy-auth REST handlers that answer 410 while `auth.legacyEndpoints` keeps REST closed */
334
+ const LEGACY_REST_HANDLERS = new Set(['logout', 'refreshToken', 'signIn', 'signUp']);
335
+
336
+ /**
337
+ * Hub endpoints that answer `{ available: false }` (or refuse) unless their source is present — keyed by
338
+ * handler name, read the way the Hub's own services decide it.
339
+ */
340
+ const HUB_AVAILABILITY: Record<string, (availability: IAvailability, hub: ResolvedHubConfig) => boolean> = {
341
+ aiJson: (availability) => availability.ai,
342
+ authMigrationJson: (availability) => availability.betterAuthMapper,
343
+ cron: (availability) => availability.scheduler,
344
+ cronJson: (availability) => availability.scheduler,
345
+ dbJson: (_availability, hub) => hub.db !== false,
346
+ emailPreview: (_availability, hub) => hub.emailPreview,
347
+ emailsJson: (_availability, hub) => hub.emailPreview,
348
+ logsJson: (_availability, hub) => hub.collectors.logs !== false,
349
+ mailboxHtml: (_availability, hub) => hub.mailbox !== false,
350
+ mailboxJson: (_availability, hub) => hub.mailbox !== false,
351
+ migrationsDown: (_availability, hub) => hub.migrations !== false,
352
+ migrationsJson: (_availability, hub) => hub.migrations !== false,
353
+ migrationsRun: (_availability, hub) => hub.migrations !== false,
354
+ queriesJson: (_availability, hub) => hub.collectors.queries !== false,
355
+ tracesJson: (_availability, hub) => hub.collectors.traces !== false,
356
+ };
357
+
358
+ function resolveAvailability(
359
+ app: INestApplication,
360
+ config: Partial<IServerOptions>,
361
+ betterAuth: IBetterAuthRoutes | undefined,
362
+ ): IAvailability {
363
+ const ai = config.ai;
364
+ return {
365
+ ai: ai === true || (typeof ai === 'object' && ai !== null && (ai as { enabled?: boolean }).enabled !== false),
366
+ betterAuth,
367
+ betterAuthMapper: isProvided(app, CoreBetterAuthUserMapper),
368
+ config,
369
+ hub: isHubEnabled(config.hub) ? normalizeHubConfig(config.hub!, { env: config.env ?? '', version: '' }) : undefined,
370
+ scheduler: isProvided(app, SchedulerRegistry),
371
+ };
372
+ }
373
+
374
+ /** Whether a provider is registered anywhere in the application */
375
+ function isProvided(app: INestApplication, token: Type<unknown>): boolean {
376
+ try {
377
+ return !!app.get(token, { strict: false });
378
+ } catch {
379
+ return false;
380
+ }
381
+ }
382
+
383
+ /**
384
+ * Whether an operation of a registered handler answers an error for every caller under the running
385
+ * configuration — documented, it could never succeed. Each rule reads the switch the handler itself reads.
386
+ */
387
+ function isSwitchedOffOperation(reference: IHandlerReference, method: string, availability: IAvailability): boolean {
388
+ const methods = Reflect.getMetadata(API_METHODS_KEY, reference.handler) as string[] | undefined;
389
+ if (methods && !methods.includes(method)) {
390
+ return true;
391
+ }
392
+ const { controller, handlerName } = reference;
393
+ if (extendsClass(controller, CoreAuthController) && LEGACY_REST_HANDLERS.has(handlerName)) {
394
+ return !isLegacyEndpointEnabled(availability.config.auth?.legacyEndpoints, 'rest');
395
+ }
396
+ if (availability.betterAuth && isBetterAuthController(controller)) {
397
+ const relativePath = declaredHandlerPath(reference.handler);
398
+ return (
399
+ relativePath !== undefined && isUnavailableOperation(availability.betterAuth.switchedOff, method, relativePath)
400
+ );
401
+ }
402
+ if (
403
+ availability.hub &&
404
+ (extendsClass(controller, CoreHubController) || extendsClass(controller, CoreHubActionsController)) &&
405
+ HUB_AVAILABILITY[handlerName]
406
+ ) {
407
+ return !HUB_AVAILABILITY[handlerName](availability, availability.hub);
408
+ }
409
+ return false;
410
+ }
411
+
412
+ /** `base` itself or a class extending it */
413
+ function extendsClass(controller: Type<unknown>, base: Type<unknown>): boolean {
414
+ return controller === base || controller.prototype instanceof base;
415
+ }
416
+
417
+ /** The handler's own route path (`sign-up/email` → `/sign-up/email`), or `undefined` for several paths */
418
+ function declaredHandlerPath(handler: IHandlerReference['handler']): string | undefined {
419
+ const path = Reflect.getMetadata(PATH_METADATA, handler) as string | string[] | undefined;
420
+ return typeof path === 'string' ? `/${path.replace(/^\/+/, '')}`.replace(/\/$/, '') || '/' : undefined;
421
+ }
422
+
423
+ /** The path controller + handler metadata declare, without global prefix or version */
424
+ /** The path a controller declares (`/iam`), or `undefined` when it declares none or several. */
425
+ function declaredControllerBase(controller: IHandlerReference['controller']): string | undefined {
426
+ const controllerPath = Reflect.getMetadata(PATH_METADATA, controller) as string | string[] | undefined;
427
+ const trimmed = typeof controllerPath === 'string' ? controllerPath.replace(/^\/+|\/+$/g, '') : '';
428
+ return trimmed ? `/${trimmed}` : undefined;
429
+ }
430
+
431
+ /** Whether the handler or its controller declares its security itself (`@ApiBearerAuth()`, `@ApiSecurity()`) */
432
+ function declaresSecurity(reference: IHandlerReference): boolean {
433
+ return !!(
434
+ Reflect.getMetadata('swagger/apiSecurity', reference.handler) ??
435
+ Reflect.getMetadata('swagger/apiSecurity', reference.controller)
436
+ );
437
+ }
438
+
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.
442
+ */
443
+ function addMcpOAuthRoutes(
444
+ document: OpenAPIObject,
445
+ mounted: IMountedMcpOAuth,
446
+ context: { apiTokensEnabled: boolean; operationTags: ISwaggerSetupOptions['operationTags'] },
447
+ ): void {
448
+ const routes: [string, string, string, string][] = [
449
+ [
450
+ 'get',
451
+ '/.well-known/oauth-authorization-server',
452
+ 'authorizationServerMetadata',
453
+ 'OAuth 2.1 authorization server metadata (RFC 8414) for MCP clients.',
454
+ ],
455
+ [
456
+ 'get',
457
+ `/.well-known/oauth-protected-resource${mounted.mcpPath}`,
458
+ 'protectedResourceMetadata',
459
+ `Protected resource metadata (RFC 9728) of the MCP endpoint \`${mounted.mcpPath}\`.`,
460
+ ],
461
+ ['get', '/authorize', 'authorize', 'Authorization endpoint (authorization code with PKCE S256).'],
462
+ ['post', '/authorize', 'authorizePost', 'Authorization endpoint, form post variant.'],
463
+ ['post', '/token', 'token', 'Token endpoint: exchanges an authorization code or refresh token.'],
464
+ ];
465
+ if (mounted.registration) {
466
+ routes.push(['post', '/register', 'register', 'Dynamic client registration (RFC 7591).']);
467
+ }
468
+ if (mounted.revocation) {
469
+ routes.push(['post', '/revoke', 'revoke', 'Token revocation (RFC 7009).']);
470
+ }
471
+ for (const [method, path, name, description] of routes) {
472
+ const operation: EnrichedOperation = {
473
+ description: context.apiTokensEnabled ? appendLine(description, apiTokenLine(undefined)) : description,
474
+ operationId: `McpOAuth_${name}`,
475
+ responses: { default: { description: 'See the MCP authorization specification' } },
476
+ security: [],
477
+ summary: description.split(':')[0].replace(/\.$/, ''),
478
+ tags: ['MCP OAuth'],
479
+ };
480
+ if (context.operationTags) {
481
+ const tags = context.operationTags({
482
+ controller: 'McpOAuth',
483
+ handler: name,
484
+ isPublic: true,
485
+ method,
486
+ path,
487
+ scopes: undefined,
488
+ tags: [...(operation.tags ?? [])],
489
+ });
490
+ if (tags) {
491
+ operation.tags = tags;
492
+ }
493
+ }
494
+ document.paths[path] = { ...document.paths[path], [method]: operation };
495
+ }
496
+ }
497
+
498
+ /** `CoreBetterAuthController` or a project controller extending it */
499
+ function isBetterAuthController(controller: Type<unknown>): boolean {
500
+ return controller === CoreBetterAuthController || controller.prototype instanceof CoreBetterAuthController;
501
+ }
502
+
503
+ /**
504
+ * The Better-Auth routes of the application, or `undefined` when no Better-Auth controller is registered.
505
+ * The base path and Better-Auth's own route description come from `CoreBetterAuthService`; what the
506
+ * configuration switches off is read from `config`, like everything else this helper documents.
507
+ */
508
+ function resolveBetterAuthRoutes(
509
+ app: INestApplication,
510
+ handlers: Map<string, Type<unknown>[]>,
511
+ config: Partial<IServerOptions>,
512
+ ): IBetterAuthRoutes | undefined {
513
+ const controller = [...handlers.values()].flat().find(isBetterAuthController);
514
+ if (!controller) {
515
+ return undefined;
516
+ }
517
+ let service: CoreBetterAuthService | undefined;
518
+ try {
519
+ service = app.get(CoreBetterAuthService, { strict: false });
520
+ } catch {
521
+ service = undefined;
522
+ }
523
+ const betterAuth = typeof config.betterAuth === 'object' && config.betterAuth !== null ? config.betterAuth : {};
524
+ return {
525
+ basePath: service?.getBasePath?.() || betterAuth.basePath || '/iam',
526
+ controller,
527
+ schema: service?.getOpenApiSchema?.(),
528
+ switchedOff: new Set([...switchedOffBetterAuthPaths(betterAuth), ...(service?.getUnavailableOperations?.() ?? [])]),
529
+ };
530
+ }
531
+
532
+ /**
533
+ * Adds the routes Better-Auth serves itself, documented like the routes of the project's Better-Auth
534
+ * controller: its tags and `operationTags`, the global security requirement, the API-token line. Leaves
535
+ * out what the controller handles (an explicit route always wins), what the configuration switches off,
536
+ * and the `openAPI()` plugin's own routes. Their schemas are added with a `BetterAuth` prefix — `User`
537
+ * and `Session` would otherwise collide with the project's models — and converted to OpenAPI 3.0.
538
+ */
539
+ function addBetterAuthRoutes(
540
+ document: OpenAPIObject,
541
+ routes: IBetterAuthRoutes,
542
+ context: { apiTokensEnabled: boolean; operationTags: ISwaggerSetupOptions['operationTags'] },
543
+ ): void {
544
+ const renamed = new Map<string, string>();
545
+ const controllerTags = routes.tags ?? defaultControllerTags(routes.controller);
546
+ for (const [relativePath, item] of Object.entries(routes.schema?.paths ?? {})) {
547
+ if (isControllerHandledPath(relativePath) || isBetterAuthOpenApiPath(relativePath)) {
548
+ continue;
549
+ }
550
+ const path = `${routes.basePath}${relativePath}`;
551
+ for (const [method, source] of operationsOf(item as PathItemObject)) {
552
+ const existing = document.paths[path] as Record<string, unknown> | undefined;
553
+ if (existing?.[method] || isUnavailableOperation(routes.switchedOff, method, relativePath)) {
554
+ continue;
555
+ }
556
+ const operation = toOpenApi30(renameSchemaRefs(structuredClone(source), renamed)) as EnrichedOperation;
557
+ const betterAuthId = operation.operationId ?? `${method}${relativePath}`;
558
+ operation.operationId = `${routes.controller.name}_betterAuth_${betterAuthId}`;
559
+ // Better-Auth's generator marks every route bearer-only, public ones included.
560
+ // CoreBetterAuthService.prepareOpenApiSchema() replaces that with what Better-Auth enforces:
561
+ // `security: []` on a public route. Anything else gets the global requirement, so "Authorize"
562
+ // sends the session along — the safe reading of a description that did not say.
563
+ const isPublic = Array.isArray(operation.security) && operation.security.length === 0;
564
+ if (!isPublic) {
565
+ delete operation.security;
566
+ }
567
+ operation.tags = [...controllerTags];
568
+ if (context.apiTokensEnabled) {
569
+ // No route here releases a scope via @ApiTokenScopes(), so an API token is refused on all of them.
570
+ operation.description = appendLine(operation.description, apiTokenLine(undefined));
571
+ }
572
+ if (context.operationTags) {
573
+ const tags = context.operationTags({
574
+ controller: routes.controller.name,
575
+ handler: betterAuthId,
576
+ isPublic,
577
+ method,
578
+ path,
579
+ scopes: undefined,
580
+ tags: [...operation.tags],
581
+ });
582
+ if (tags) {
583
+ operation.tags = tags;
584
+ }
585
+ }
586
+ document.paths[path] = { ...document.paths[path], [method]: operation };
587
+ }
588
+ }
589
+ addRenamedSchemas(document, routes.schema?.components?.schemas ?? {}, renamed);
590
+ }
591
+
592
+ /** Tags `@ApiTags()` gives a controller, or the one `autoTagControllers` derives from its name */
593
+ function defaultControllerTags(controller: Type<unknown>): string[] {
594
+ const tags = Reflect.getMetadata('swagger/apiUseTags', controller) as string[] | undefined;
595
+ return tags?.length ? [...tags] : [controller.name.replace(/Controller$/, '')];
596
+ }
597
+
598
+ /** Rewrites `#/components/schemas/<Name>` to `BetterAuth<Name>`, recording each rename. */
599
+ function renameSchemaRefs<T>(value: T, renamed: Map<string, string>): T {
600
+ const prefix = '#/components/schemas/';
601
+ const visit = (node: unknown): void => {
602
+ if (Array.isArray(node)) {
603
+ node.forEach(visit);
604
+ } else if (typeof node === 'object' && node !== null) {
605
+ const record = node as Record<string, unknown>;
606
+ for (const [key, child] of Object.entries(record)) {
607
+ if (key === '$ref' && typeof child === 'string' && child.startsWith(prefix)) {
608
+ const name = child.slice(prefix.length);
609
+ const target = renamed.get(name) ?? `BetterAuth${name}`;
610
+ renamed.set(name, target);
611
+ record.$ref = `${prefix}${target}`;
612
+ } else {
613
+ visit(child);
614
+ }
615
+ }
616
+ }
617
+ };
618
+ visit(value);
619
+ return value;
620
+ }
621
+
622
+ /** Copies every renamed schema — and the schemas those refer to — into the document. */
623
+ function addRenamedSchemas(
624
+ document: OpenAPIObject,
625
+ schemas: Record<string, unknown>,
626
+ renamed: Map<string, string>,
627
+ ): void {
628
+ const added = new Set<string>();
629
+ let pending = [...renamed.keys()];
630
+ while (pending.length) {
631
+ for (const name of pending) {
632
+ added.add(name);
633
+ if (!schemas[name]) {
634
+ continue;
635
+ }
636
+ document.components = document.components ?? {};
637
+ document.components.schemas = document.components.schemas ?? {};
638
+ document.components.schemas[renamed.get(name)!] ??= toOpenApi30(
639
+ renameSchemaRefs(structuredClone(schemas[name]), renamed),
640
+ ) as Record<string, unknown>;
641
+ }
642
+ pending = [...renamed.keys()].filter((name) => !added.has(name));
643
+ }
644
+ }
645
+
646
+ /**
647
+ * OpenAPI 3.1 → 3.0 for what Better-Auth's generator emits: a `type` array (`['string', 'null']`) becomes
648
+ * `type` plus `nullable`, or `anyOf` for several non-null types. The document itself is OpenAPI 3.0.
649
+ */
650
+ function toOpenApi30<T>(value: T): T {
651
+ const visit = (node: unknown): void => {
652
+ if (Array.isArray(node)) {
653
+ node.forEach(visit);
654
+ } else if (typeof node === 'object' && node !== null) {
655
+ const record = node as Record<string, unknown>;
656
+ if (Array.isArray(record.type)) {
657
+ const types = record.type as string[];
658
+ const nonNull = types.filter((type) => type !== 'null');
659
+ if (nonNull.length > 1) {
660
+ record.anyOf = nonNull.map((type) => ({ type }));
661
+ delete record.type;
662
+ } else if (nonNull.length === 1) {
663
+ record.type = nonNull[0];
664
+ } else {
665
+ delete record.type;
666
+ }
667
+ if (types.includes('null')) {
668
+ record.nullable = true;
669
+ }
670
+ }
671
+ Object.values(record).forEach(visit);
672
+ }
673
+ };
674
+ visit(value);
675
+ return value;
676
+ }
677
+
225
678
  /**
226
679
  * The document an API-token caller needs: only the operations that release a scope, authenticated with
227
680
  * the bearer scheme only, without the tenant header (a tenant token carries its tenant), and without the
@@ -2801,7 +2801,11 @@ export interface IServerOptions {
2801
2801
  };
2802
2802
 
2803
2803
  /**
2804
- * Whether health check is enabled
2804
+ * Whether the health check endpoints are registered. The block's presence enables them; `false`
2805
+ * leaves them out (GET /health-check and the `healthCheck` query answer 404 / are absent) while
2806
+ * keeping the configured checks for later. Honoured since 11.42.9 — before, any `healthCheck`
2807
+ * object registered the endpoints, `enabled: false` included.
2808
+ * @default true (when `healthCheck` is set)
2805
2809
  */
2806
2810
  enabled?: boolean;
2807
2811
  };
@@ -3455,6 +3459,10 @@ export interface IServerOptions {
3455
3459
  * Follows the "Enabled by Default" pattern - tus is automatically enabled
3456
3460
  * without any configuration. Set `tus: false` to explicitly disable.
3457
3461
  *
3462
+ * Read by `TusModule.forRoot()` when it gets no `config` of its own; an explicit `config` wins.
3463
+ * Before 11.42.9 this key was never read — `tus: false`, `allowedTypes`, `maxSize` and `roles` here
3464
+ * did nothing.
3465
+ *
3458
3466
  * Accepts:
3459
3467
  * - `true` or `undefined`: Enable with defaults (enabled by default)
3460
3468
  * - `false`: Disable TUS uploads
@@ -3756,28 +3764,29 @@ export interface ITusConfig {
3756
3764
 
3757
3765
  /**
3758
3766
  * Checksum extension configuration.
3759
- * Enables data integrity verification.
3767
+ * Has no effect with the built-in stores (file system, S3): neither supports the extension.
3760
3768
  * @default true
3761
3769
  */
3762
3770
  checksum?: boolean;
3763
3771
 
3764
3772
  /**
3765
3773
  * Concatenation extension configuration.
3766
- * Allows parallel uploads that are merged.
3774
+ * Has no effect with the built-in stores (file system, S3): neither supports the extension.
3767
3775
  * @default true
3768
3776
  */
3769
3777
  concatenation?: boolean;
3770
3778
 
3771
3779
  /**
3772
3780
  * Creation extension configuration.
3773
- * Allows creating new uploads via POST.
3781
+ * Allows creating new uploads via POST. `false`: POST answers 501 and OPTIONS no longer advertises it
3782
+ * (enforced since 11.42.9 — `@tus/server` itself ignores the flag).
3774
3783
  * @default true
3775
3784
  */
3776
3785
  creation?: boolean | ITusCreationConfig;
3777
3786
 
3778
3787
  /**
3779
3788
  * Creation With Upload extension configuration.
3780
- * Allows sending data in the initial POST request.
3789
+ * Allows sending data in the initial POST request. `false`: a POST carrying data answers 501 (11.42.9+).
3781
3790
  * @default true
3782
3791
  */
3783
3792
  creationWithUpload?: boolean;
@@ -3802,11 +3811,30 @@ export interface ITusConfig {
3802
3811
  maxSize?: number;
3803
3812
 
3804
3813
  /**
3805
- * Base path for tus endpoints
3814
+ * Base path for tus endpoints (11.42.9+):
3815
+ * - a new route (`'/uploads'`): the controller is mounted there — a project controller too, as with
3816
+ * `roles`. Like any controller route it sits BELOW a global prefix and a URI version.
3817
+ * - a path ending with the controller's own route (`'/api/tus'` for `tus`): the URL the route is
3818
+ * publicly reachable under — a global prefix, or a proxy that strips a prefix. The controller stays
3819
+ * and upload URLs use this path. That is how `path` had to be set before 11.42.9, and it still works.
3820
+ * - unset: follows the controller's own route.
3821
+ *
3822
+ * Otherwise the URL handed to a client for a new upload is built from the path the upload was created
3823
+ * at, so it is correct below a global prefix or a URI version without any `path`.
3806
3824
  * @default '/tus'
3807
3825
  */
3808
3826
  path?: string;
3809
3827
 
3828
+ /**
3829
+ * Hand out upload URLs (`Location`) without scheme and host — `/tus/<id>` instead of
3830
+ * `https://api.example.com/tus/<id>`. For a frontend that reaches TUS through a same-origin proxy:
3831
+ * an absolute URL names the origin the API saw, which the browser cannot reach. A project passing
3832
+ * @tus/server's own `relativeLocation` is honoured too.
3833
+ * @default false
3834
+ * @since 11.42.9
3835
+ */
3836
+ relativeLocation?: boolean;
3837
+
3810
3838
  /**
3811
3839
  * Roles allowed to use the tus endpoints (create, write, read offset, terminate).
3812
3840
  *
@@ -3849,7 +3877,8 @@ export interface ITusConfig {
3849
3877
 
3850
3878
  /**
3851
3879
  * Termination extension configuration.
3852
- * Allows deleting uploads via DELETE.
3880
+ * Allows deleting uploads via DELETE. `false`: DELETE answers 501 and OPTIONS no longer advertises it
3881
+ * (enforced since 11.42.9 — before, the flag was only logged).
3853
3882
  * @default true
3854
3883
  */
3855
3884
  termination?: boolean;
@@ -4071,6 +4100,8 @@ interface IBetterAuthBase {
4071
4100
  * Disable user registration (sign-up) via BetterAuth.
4072
4101
  * Passed through to better-auth's native emailAndPassword.disableSignUp.
4073
4102
  * Custom endpoints (GraphQL + REST) also check this flag early.
4103
+ * The Swagger document built by `setupSwagger()` then leaves out `POST /iam/sign-up/email`,
4104
+ * which would answer `SIGNUP_DISABLED` to every caller.
4074
4105
  * @default false
4075
4106
  */
4076
4107
  disableSignUp?: boolean;
@@ -4091,6 +4122,8 @@ interface IBetterAuthBase {
4091
4122
  * Set `false` to withhold the hook, which makes that route answer `RESET_PASSWORD_DISABLED`
4092
4123
  * again. For deployments whose reset policy is support-mediated or SSO-primary, and which
4093
4124
  * therefore do not want an unauthenticated, token-minting, mail-sending endpoint at all.
4125
+ * The Swagger document built by `setupSwagger()` then leaves out `/iam/request-password-reset`,
4126
+ * `/iam/reset-password` and `/iam/reset-password/{token}`.
4094
4127
  *
4095
4128
  * @default true
4096
4129
  * @since 11.36.1
@@ -4243,7 +4276,12 @@ interface IBetterAuthBase {
4243
4276
 
4244
4277
  /**
4245
4278
  * Additional Better-Auth plugins to include.
4246
- * These will be merged with the built-in plugins (jwt, twoFactor, passkey).
4279
+ * These will be merged with the built-in plugins (jwt, twoFactor, passkey). Their routes appear in the
4280
+ * Swagger document built by `setupSwagger()` automatically.
4281
+ *
4282
+ * Better-Auth's `openAPI()` plugin is registered internally (it feeds `setupSwagger()`), with its routes
4283
+ * kept off the router. Add `openAPI()` here only if the project wants those routes public; it then
4284
+ * replaces the internal registration.
4247
4285
  * @see https://www.better-auth.com/docs/plugins
4248
4286
  * @example
4249
4287
  * ```typescript
@@ -6,10 +6,13 @@ import type { DocumentBuilder, SwaggerCustomOptions, SwaggerDocumentOptions } fr
6
6
  * @since 11.42.7
7
7
  */
8
8
  export interface ISwaggerOperationInfo {
9
- /** Class name of the controller that serves the operation */
9
+ /**
10
+ * Class name of the controller that serves the operation. For a route Better-Auth serves itself, the
11
+ * project's Better-Auth controller (`BetterAuthController` / `CoreBetterAuthController`).
12
+ */
10
13
  controller: string;
11
14
 
12
- /** Name of the handler method */
15
+ /** Name of the handler method; for a route Better-Auth serves itself, Better-Auth's operation id */
13
16
  handler: string;
14
17
 
15
18
  /** True when neither the role guard nor the tenant guard requires a sign-in */
@@ -693,6 +693,13 @@ import { mountAiMcpOAuth } from '@lenne.tech/nest-server';
693
693
  await mountAiMcpOAuth(app);
694
694
  ```
695
695
 
696
+ **Swagger (11.42.9):** `setupSwagger()` documents `/ai/mcp` (POST/GET/DELETE, with the bearer
697
+ requirement the endpoint enforces itself) whenever the MCP server is registered, and — because the
698
+ OAuth endpoints are an Express router, invisible to `@nestjs/swagger` — exactly the OAuth endpoints
699
+ `mountAiMcpOAuth()` mounted (`/.well-known/oauth-authorization-server`,
700
+ `/.well-known/oauth-protected-resource/ai/mcp`, `/authorize`, `/token`, `/register`; `/revoke` only for a
701
+ provider with `revokeToken`). Without the call they are not documented, because they do not exist.
702
+
696
703
  The issuer — and with it every endpoint URL in the discovery metadata, which MCP clients follow —
697
704
  is the server's `baseUrl` (`NSC__BASE_URL` when deployed), resolved the same way BetterAuth and
698
705
  CORS resolve it. `local` / `ci` / `e2e` fall back to `http://localhost:3000`; any other environment