@lenne.tech/nest-server 11.42.7 → 11.42.9

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 (70) 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 +324 -15
  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.controller.js +1 -0
  26. package/dist/core/modules/better-auth/core-better-auth.controller.js.map +1 -1
  27. package/dist/core/modules/better-auth/core-better-auth.service.d.ts +10 -2
  28. package/dist/core/modules/better-auth/core-better-auth.service.js +89 -0
  29. package/dist/core/modules/better-auth/core-better-auth.service.js.map +1 -1
  30. package/dist/core/modules/tus/core-tus.controller.js +3 -0
  31. package/dist/core/modules/tus/core-tus.controller.js.map +1 -1
  32. package/dist/core/modules/tus/core-tus.service.d.ts +3 -0
  33. package/dist/core/modules/tus/core-tus.service.js +58 -25
  34. package/dist/core/modules/tus/core-tus.service.js.map +1 -1
  35. package/dist/core/modules/tus/interfaces/tus-config.interface.js +1 -0
  36. package/dist/core/modules/tus/interfaces/tus-config.interface.js.map +1 -1
  37. package/dist/core/modules/tus/tus.module.d.ts +2 -0
  38. package/dist/core/modules/tus/tus.module.js +23 -1
  39. package/dist/core/modules/tus/tus.module.js.map +1 -1
  40. package/dist/core.module.js +1 -1
  41. package/dist/core.module.js.map +1 -1
  42. package/dist/index.d.ts +1 -0
  43. package/dist/index.js +1 -0
  44. package/dist/index.js.map +1 -1
  45. package/dist/tsconfig.build.tsbuildinfo +1 -1
  46. package/docs/REQUEST-LIFECYCLE.md +9 -1
  47. package/migration-guides/11.42.7-to-11.42.8.md +74 -0
  48. package/migration-guides/11.42.8-to-11.42.9.md +186 -0
  49. package/package.json +2 -2
  50. package/src/core/common/decorators/api-methods.decorator.ts +25 -0
  51. package/src/core/common/helpers/swagger.helper.ts +479 -19
  52. package/src/core/common/interfaces/server-options.interface.ts +46 -8
  53. package/src/core/common/interfaces/swagger-setup-options.interface.ts +5 -2
  54. package/src/core/modules/ai/README.md +7 -0
  55. package/src/core/modules/ai/core-ai-mcp.controller.ts +19 -2
  56. package/src/core/modules/ai/helpers/ai-mcp-oauth.helper.ts +12 -2
  57. package/src/core/modules/ai/helpers/ai-mcp-oauth.registry.ts +29 -0
  58. package/src/core/modules/better-auth/README.md +34 -0
  59. package/src/core/modules/better-auth/better-auth.config.ts +27 -1
  60. package/src/core/modules/better-auth/core-better-auth-api.middleware.ts +4 -17
  61. package/src/core/modules/better-auth/core-better-auth-openapi.helper.ts +219 -0
  62. package/src/core/modules/better-auth/core-better-auth.controller.ts +1 -0
  63. package/src/core/modules/better-auth/core-better-auth.service.ts +166 -2
  64. package/src/core/modules/tus/README.md +50 -14
  65. package/src/core/modules/tus/core-tus.controller.ts +6 -0
  66. package/src/core/modules/tus/core-tus.service.ts +97 -26
  67. package/src/core/modules/tus/interfaces/tus-config.interface.ts +1 -0
  68. package/src/core/modules/tus/tus.module.ts +43 -1
  69. package/src/core.module.ts +2 -1
  70. 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>`),
@@ -55,9 +85,12 @@ export const SWAGGER_TENANT_HEADER_PARAMETER = 'TenantHeader';
55
85
  /** Vendor extension listing the scopes an API token needs for an operation */
56
86
  export const SWAGGER_API_TOKEN_SCOPES_EXTENSION = 'x-api-token-scopes';
57
87
 
58
- const HTTP_METHODS = ['delete', 'get', 'head', 'options', 'patch', 'post', 'put', 'trace'] as const;
59
-
60
- type HttpMethod = (typeof HTTP_METHODS)[number];
88
+ /**
89
+ * Fields of an OpenAPI path item that are not operations. Every other key is an operation: they are read
90
+ * from the path item rather than from a list of HTTP methods, because `@All()` makes @nestjs/swagger emit
91
+ * a `search` operation too, which no OpenAPI version lists — a fixed list left it unenriched.
92
+ */
93
+ const PATH_ITEM_FIELDS = new Set(['$ref', 'description', 'parameters', 'servers', 'summary']);
61
94
 
62
95
  /** An operation with the vendor extension this helper writes */
63
96
  type EnrichedOperation = OperationObject & { [SWAGGER_API_TOKEN_SCOPES_EXTENSION]?: string[] };
@@ -74,6 +107,25 @@ interface IOperationAccess {
74
107
  skipsTenantHeader: boolean;
75
108
  }
76
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
+
77
129
  interface IResolvedApiTokenView {
78
130
  description?: string;
79
131
  enabled: boolean;
@@ -176,15 +228,36 @@ export function buildSwaggerDocument(
176
228
  }
177
229
 
178
230
  const handlers = indexHandlers(app);
231
+ const betterAuthRoutes = resolveBetterAuthRoutes(app, handlers, config);
232
+ const availability = resolveAvailability(app, config, betterAuthRoutes);
233
+ const switchedOff: [string, string][] = [];
179
234
  forEachOperation(document, (path, method, operation) => {
180
235
  const reference = resolveHandler(operation.operationId, handlers);
181
236
  if (!reference) {
182
237
  logger.warn(`No handler found for operation ${operation.operationId ?? `${method.toUpperCase()} ${path}`}`);
183
238
  return;
184
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
+ }
185
256
  const access = describeAccess(reference, multiTenancy);
186
257
 
187
- 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)) {
188
261
  operation.security = [];
189
262
  }
190
263
  if (apiTokens.enabled) {
@@ -215,10 +288,393 @@ export function buildSwaggerDocument(
215
288
  }
216
289
  });
217
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
+
218
315
  document.tags = orderTags(document, options.tags);
219
316
  return document;
220
317
  }
221
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
+
222
678
  /**
223
679
  * The document an API-token caller needs: only the operations that release a scope, authenticated with
224
680
  * the bearer scheme only, without the tenant header (a tenant token carries its tenant), and without the
@@ -243,10 +699,9 @@ export function buildApiTokenSwaggerDocument(
243
699
 
244
700
  const paths: OpenAPIObject['paths'] = {};
245
701
  for (const [path, item] of Object.entries(view.paths)) {
246
- const kept: PathItemObject = {};
247
- for (const method of HTTP_METHODS) {
248
- const operation = item[method] as EnrichedOperation | undefined;
249
- if (!operation?.[SWAGGER_API_TOKEN_SCOPES_EXTENSION]?.length) {
702
+ const kept: Record<string, EnrichedOperation> = {};
703
+ for (const [method, operation] of operationsOf(item)) {
704
+ if (!operation[SWAGGER_API_TOKEN_SCOPES_EXTENSION]?.length) {
250
705
  continue;
251
706
  }
252
707
  operation.security = [{ [SWAGGER_BEARER_SCHEME]: [] }];
@@ -440,25 +895,30 @@ function isRef(value: unknown): value is ReferenceObject {
440
895
 
441
896
  function forEachOperation(
442
897
  document: OpenAPIObject,
443
- visit: (path: string, method: HttpMethod, operation: EnrichedOperation) => void,
898
+ visit: (path: string, method: string, operation: EnrichedOperation) => void,
444
899
  ): void {
445
900
  for (const [path, item] of Object.entries(document.paths ?? {})) {
446
- for (const method of HTTP_METHODS) {
447
- const operation = item[method];
448
- if (operation) {
449
- visit(path, method, operation);
450
- }
901
+ for (const [method, operation] of operationsOf(item)) {
902
+ visit(path, method, operation);
451
903
  }
452
904
  }
453
905
  }
454
906
 
907
+ /** A path-item key that is not an operation: a field OpenAPI defines there, or a vendor extension. */
908
+ function isPathItemField(key: string): boolean {
909
+ return PATH_ITEM_FIELDS.has(key) || key.startsWith('x-');
910
+ }
911
+
912
+ /** The operations of a path item, keyed by their lower-case HTTP method (`get`, ..., `search`). */
913
+ function operationsOf(item: PathItemObject): [string, EnrichedOperation][] {
914
+ return Object.entries(item).filter(
915
+ ([key, value]) => !isPathItemField(key) && typeof value === 'object' && value !== null && !Array.isArray(value),
916
+ ) as [string, EnrichedOperation][];
917
+ }
918
+
455
919
  /** Fields of a path item that are not operations (summary, description, servers, shared parameters). */
456
920
  function pathLevelFields(item: PathItemObject): PathItemObject {
457
- const fields: PathItemObject = { ...item };
458
- for (const method of HTTP_METHODS) {
459
- delete fields[method];
460
- }
461
- return fields;
921
+ return Object.fromEntries(Object.entries(item).filter(([key]) => isPathItemField(key))) as PathItemObject;
462
922
  }
463
923
 
464
924
  /**