@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
@@ -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
@@ -1,5 +1,5 @@
1
1
  import { Controller, Delete, Get, Logger, OnModuleDestroy, Optional, Post, Req, Res } from '@nestjs/common';
2
- import { ApiExcludeController } from '@nestjs/swagger';
2
+ import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
3
3
  import { Request, Response } from 'express';
4
4
  import { hostname } from 'node:os';
5
5
 
@@ -62,7 +62,10 @@ interface McpSessionEntry {
62
62
  user: AiToolUser;
63
63
  }
64
64
 
65
- @ApiExcludeController()
65
+ // The route guard admits everybody because the endpoint authenticates itself (session/JWT bearer, or an
66
+ // MCP OAuth token): the bearer requirement is declared for Swagger, which setupSwagger() keeps.
67
+ @ApiBearerAuth('bearer')
68
+ @ApiTags('AI MCP')
66
69
  @Controller('ai/mcp')
67
70
  @Roles(RoleEnum.S_EVERYONE)
68
71
  export class CoreAiMcpController implements OnModuleDestroy {
@@ -108,6 +111,12 @@ export class CoreAiMcpController implements OnModuleDestroy {
108
111
  @Optional() protected readonly redisService?: CoreRedisService,
109
112
  ) {}
110
113
 
114
+ @ApiOperation({
115
+ description:
116
+ 'Model Context Protocol endpoint (Streamable HTTP) for MCP clients: JSON-RPC 2.0 messages, starting with `initialize`. ' +
117
+ 'The response carries the `mcp-session-id` header that later requests send back. Requires a bearer token (session, JWT or MCP OAuth token).',
118
+ summary: 'MCP request',
119
+ })
111
120
  @Post()
112
121
  async handlePost(@Req() req: Request, @Res() res: Response): Promise<void> {
113
122
  const user = await this.resolveUser(req);
@@ -206,11 +215,19 @@ export class CoreAiMcpController implements OnModuleDestroy {
206
215
  }
207
216
  }
208
217
 
218
+ @ApiOperation({
219
+ description: 'Server-sent event stream of an MCP session (`mcp-session-id` header). Requires a bearer token.',
220
+ summary: 'MCP event stream',
221
+ })
209
222
  @Get()
210
223
  async handleGet(@Req() req: Request, @Res() res: Response): Promise<void> {
211
224
  await this.handleSessionRequest(req, res);
212
225
  }
213
226
 
227
+ @ApiOperation({
228
+ description: 'Ends an MCP session (`mcp-session-id` header). Requires a bearer token.',
229
+ summary: 'End MCP session',
230
+ })
214
231
  @Delete()
215
232
  async handleDelete(@Req() req: Request, @Res() res: Response): Promise<void> {
216
233
  await this.handleSessionRequest(req, res);
@@ -3,6 +3,7 @@ import { Logger } from '@nestjs/common';
3
3
  import { resolveServerUrls } from '../../../common/helpers/cookies.helper';
4
4
  import { ConfigService } from '../../../common/services/config.service';
5
5
  import { CoreAiMcpOAuthService } from '../services/core-ai-mcp-oauth.service';
6
+ import { setMountedMcpOAuth } from './ai-mcp-oauth.registry';
6
7
 
7
8
  /**
8
9
  * Mount the MCP OAuth 2.1 router (`mcpAuthRouter`) on a NestJS application.
@@ -10,7 +11,9 @@ import { CoreAiMcpOAuthService } from '../services/core-ai-mcp-oauth.service';
10
11
  * Call this in `main.ts` AFTER `app.init()` when `ai.mcp.oauth` is enabled. It
11
12
  * lazy-imports `@modelcontextprotocol/sdk` and wires the OAuth provider built from
12
13
  * {@link CoreAiMcpOAuthService}, exposing the standard discovery + token endpoints
13
- * (`/.well-known/oauth-*`, `/authorize`, `/token`, `/register`, `/revoke`).
14
+ * (`/.well-known/oauth-*`, `/authorize`, `/token`, `/register`; `/revoke` only for a provider that
15
+ * implements `revokeToken`, which the built-in one does not). `setupSwagger()` documents exactly the
16
+ * endpoints mounted here.
14
17
  *
15
18
  * The interactive consent step requires `CoreAiMcpOAuthService.authorizeConsent`
16
19
  * to be overridden with your login/consent UI (see INTEGRATION-CHECKLIST).
@@ -46,10 +49,11 @@ export async function mountAiMcpOAuth(
46
49
  const mcpPath = options.mcpPath ?? '/ai/mcp';
47
50
 
48
51
  let router: unknown;
52
+ const provider = oauthService.buildOAuthProvider() as any;
49
53
  try {
50
54
  router = mcpAuthRouter({
51
55
  issuerUrl,
52
- provider: oauthService.buildOAuthProvider() as any,
56
+ provider,
53
57
  resourceServerUrl: new URL(`${baseUrl.replace(/\/$/, '')}${mcpPath}`),
54
58
  });
55
59
  } catch (error) {
@@ -63,6 +67,12 @@ export async function mountAiMcpOAuth(
63
67
  }
64
68
 
65
69
  app.use(router);
70
+ // The same conditions mcpAuthRouter() uses to decide whether /register and /revoke exist.
71
+ setMountedMcpOAuth({
72
+ mcpPath,
73
+ registration: !!provider?.clientsStore?.registerClient,
74
+ revocation: !!provider?.revokeToken,
75
+ });
66
76
  new Logger('mountAiMcpOAuth').log(`MCP OAuth issuer: ${issuerUrl.href} (from ${source})`);
67
77
  }
68
78
 
@@ -0,0 +1,29 @@
1
+ /**
2
+ * What `mountAiMcpOAuth()` mounted, for `setupSwagger()`.
3
+ *
4
+ * The MCP OAuth endpoints are an Express router mounted with `app.use()`, not Nest controllers, so
5
+ * `@nestjs/swagger` cannot see them. The mount records here what it put in place — only then are the
6
+ * endpoints documented, so the document follows the actual call in `main.ts`, not a config flag.
7
+ *
8
+ * Imports nothing on purpose: read by the common Swagger helper without pulling in the AI module.
9
+ */
10
+ export interface IMountedMcpOAuth {
11
+ /** Path of the protected MCP resource (`/ai/mcp`) */
12
+ mcpPath: string;
13
+ /** Whether `/register` (dynamic client registration) is mounted */
14
+ registration: boolean;
15
+ /** Whether `/revoke` is mounted */
16
+ revocation: boolean;
17
+ }
18
+
19
+ let mounted: IMountedMcpOAuth | undefined;
20
+
21
+ /** Records the mounted MCP OAuth endpoints (called by `mountAiMcpOAuth()`). */
22
+ export function setMountedMcpOAuth(value: IMountedMcpOAuth | undefined): void {
23
+ mounted = value;
24
+ }
25
+
26
+ /** The mounted MCP OAuth endpoints, or `undefined` when `mountAiMcpOAuth()` was not called. */
27
+ export function getMountedMcpOAuth(): IMountedMcpOAuth | undefined {
28
+ return mounted;
29
+ }
@@ -1146,6 +1146,40 @@ When enabled, Better-Auth exposes the following endpoints at the configured `bas
1146
1146
  | `/iam/reset-password` | POST | Reset password with token |
1147
1147
  | `/iam/verify-email` | POST | Verify email address |
1148
1148
 
1149
+ ### In the Swagger document (since 11.42.9)
1150
+
1151
+ `setupSwagger()` documents every route this module makes reachable under the base path, as the server
1152
+ is configured:
1153
+
1154
+ - The routes `CoreBetterAuthController` handles itself (`sign-in/email`, `sign-up/email`, `sign-out`,
1155
+ `session`, `features`) come from the controller, as for any Nest controller.
1156
+ - The routes Better-Auth serves itself — everything the configured plugins add (two-factor, passkey,
1157
+ sessions, password change, …) — come from Better-Auth's own OpenAPI generator
1158
+ (`CoreBetterAuthService.getOpenApiSchema()`, prepared at bootstrap). They are documented under the
1159
+ project's Better-Auth controller: its tags, its `operationTags`, operation ids
1160
+ `<Controller>_betterAuth_<betterAuthOperationId>`, schemas prefixed `BetterAuth` (`BetterAuthUser`).
1161
+ - Only what can succeed is documented. `switchedOffBetterAuthPaths()` reads the nest-server switches
1162
+ (sign-in/sign-up under `emailAndPassword.enabled: false`, `sign-up/email` under `disableSignUp`, the
1163
+ reset routes under `passwordReset: false`); `unavailableBetterAuthOperations()` reads the LIVE
1164
+ instance at bootstrap — so `betterAuth.options` and project plugins count — and drops e.g. POST
1165
+ `/get-session` without `session.deferSessionRefresh`, social routes without a provider, two-factor OTP
1166
+ routes without `otpOptions.sendOTP`. Both apply to the controller's own routes as well.
1167
+ - Security says what Better-Auth enforces, not what its generator writes (every route bearer-only): a
1168
+ route guarded by one of Better-Auth's session middlewares — or one of the three social-account routes
1169
+ that check the session in their handler (`HANDLER_SESSION_PATHS`) — carries the document's global
1170
+ requirement; every other route is public (`security: []`): sign-in, password reset, get-session, the
1171
+ two-factor verification steps of a sign-in. Routes of a plugin the module does not register itself
1172
+ (`admin()`, `organization()`, anything in `betterAuth.plugins`) always keep the global requirement:
1173
+ such a plugin may check the session in a middleware of its own, which cannot be recognised from outside.
1174
+ - Under a global prefix or URI versioning the middleware does not forward Better-Auth's own routes, so
1175
+ they are not documented then (a warning says so).
1176
+
1177
+ Better-Auth exposes its generator only through its `openAPI()` plugin, so the module registers that plugin
1178
+ itself (`disableDefaultReference: true`). Its own route, `/iam/open-api/generate-schema`, is not part of
1179
+ the API: the plugin's endpoints are marked server-only, so Better-Auth never routes them (404, however the
1180
+ path is spelled) and the module calls the generator in-process. A project that adds `openAPI()` to
1181
+ `betterAuth.plugins` itself keeps those routes — then it is the project's decision.
1182
+
1149
1183
  ### JWT Token Endpoint
1150
1184
 
1151
1185
  The `/iam/token` endpoint returns a fresh JWT token for the current session. Use this when your JWT has expired but your session is still valid.
@@ -2,7 +2,7 @@ import { passkey } from '@better-auth/passkey';
2
2
  import { Logger } from '@nestjs/common';
3
3
  import { betterAuth, BetterAuthPlugin } from 'better-auth';
4
4
  import { mongodbAdapter } from 'better-auth/adapters/mongodb';
5
- import { jwt, twoFactor } from 'better-auth/plugins';
5
+ import { jwt, openAPI, twoFactor } from 'better-auth/plugins';
6
6
  import * as crypto from 'crypto';
7
7
  import * as fs from 'fs';
8
8
  import * as path from 'path';
@@ -11,6 +11,7 @@ import { resolveServerUrls, strippedApiHostname } from '../../common/helpers/coo
11
11
  import { revalidateWsConnectionsOf } from '../../common/helpers/graphql-ws-connection.helper';
12
12
  import { IBetterAuth, ICorsConfig } from '../../common/interfaces/server-options.interface';
13
13
  import { detectCookiePrefixDrift, resolveBetterAuthCookiePrefix } from './better-auth-cookie-prefix.helper';
14
+ import { projectProvidesOpenApiPlugin } from './core-better-auth-openapi.helper';
14
15
 
15
16
  /**
16
17
  * Type for better-auth instance with plugins
@@ -978,6 +979,16 @@ function buildPlugins(
978
979
  plugins.push(passkey(passkeyOptions));
979
980
  }
980
981
 
982
+ // Better-Auth's OpenAPI generator, so setupSwagger() can document exactly the routes the configured
983
+ // plugins add (CoreBetterAuthService.getOpenApiSchema()). Better-Auth exposes the generator only
984
+ // through this plugin, and the plugin only as HTTP routes — which are not part of the API. They are
985
+ // kept off the router rather than refused by path: a path check sees the request as sent, while
986
+ // Better-Auth resolves `/iam/./open-api/…` or `/iam/x/%2e%2e/open-api/…` to the very same route.
987
+ // A project that registers openAPI() itself keeps its routes.
988
+ if (!projectProvidesOpenApiPlugin(config)) {
989
+ plugins.push(serverOnlyPlugin(openAPI({ disableDefaultReference: true })));
990
+ }
991
+
981
992
  // Merge custom plugins from configuration
982
993
  // This allows projects to add any Better-Auth plugin without modifying this package
983
994
  if (config.plugins?.length) {
@@ -987,6 +998,21 @@ function buildPlugins(
987
998
  return plugins;
988
999
  }
989
1000
 
1001
+ /**
1002
+ * Keeps every endpoint of a plugin off the HTTP router. better-call skips an endpoint whose metadata
1003
+ * carries `SERVER_ONLY` when it builds the router, whatever path a request names, while
1004
+ * `auth.api.<endpoint>()` still calls it in-process. Better-Auth's own generator leaves such endpoints
1005
+ * out of its schema too.
1006
+ */
1007
+ function serverOnlyPlugin<T extends BetterAuthPlugin>(plugin: T): T {
1008
+ for (const endpoint of Object.values(plugin.endpoints ?? {}) as { options?: { metadata?: object } }[]) {
1009
+ if (endpoint?.options) {
1010
+ endpoint.options.metadata = { ...endpoint.options.metadata, SERVER_ONLY: true };
1011
+ }
1012
+ }
1013
+ return plugin;
1014
+ }
1015
+
990
1016
  /**
991
1017
  * Builds the social providers configuration object dynamically.
992
1018
  * Iterates over all configured providers and includes those that are enabled
@@ -6,26 +6,12 @@ import { ConfigService } from '../../common/services/config.service';
6
6
  import { CoreBetterAuthChallengeService } from './core-better-auth-challenge.service';
7
7
  import { BetterAuthCookieHelper, createCookieHelper } from './core-better-auth-cookie.helper';
8
8
  import { wrapBetterAuthErrorResponse } from './core-better-auth-error-codes.helper';
9
+ import { isControllerHandledPath } from './core-better-auth-openapi.helper';
9
10
  import { runWithResetPassword } from './core-better-auth-password-reset.registry';
10
11
  import { CoreBetterAuthUserMapper } from './core-better-auth-user.mapper';
11
12
  import { extractSessionToken, sendWebResponse, signCookieValue, toWebRequest } from './core-better-auth-web.helper';
12
13
  import { CoreBetterAuthService } from './core-better-auth.service';
13
14
 
14
- /**
15
- * List of paths that are handled by CoreBetterAuthController
16
- * These should NOT be forwarded to Better Auth's native handler
17
- *
18
- * Only paths with nest-server-specific logic belong here:
19
- * - sign-in/email: Legacy user migration, password normalization
20
- * - sign-up/email: User linking to own DB, password sync
21
- * - sign-out: Custom cookie clearing
22
- * - session: Custom response format with mapped user
23
- *
24
- * All other paths (Passkey, 2FA, etc.) go directly to Better Auth's
25
- * native handler via this middleware for maximum compatibility.
26
- */
27
- const CONTROLLER_HANDLED_PATHS = ['/features', '/sign-in/email', '/sign-up/email', '/sign-out', '/session'];
28
-
29
15
  /**
30
16
  * Native Better-Auth routes that set a NEW password from a token or OTP, with the body
31
17
  * field each of them carries it in.
@@ -227,8 +213,9 @@ export class CoreBetterAuthApiMiddleware implements NestMiddleware {
227
213
  // Get the path relative to the base path
228
214
  const relativePath = requestPath.slice(basePath.length);
229
215
 
230
- // Skip paths that are handled by CoreBetterAuthController (nest-server-specific logic)
231
- if (CONTROLLER_HANDLED_PATHS.some((path) => relativePath === path || relativePath.startsWith(`${path}/`))) {
216
+ // Skip paths that are handled by CoreBetterAuthController (nest-server-specific logic).
217
+ // All other paths (Passkey, 2FA, etc.) go directly to Better Auth's native handler.
218
+ if (isControllerHandledPath(relativePath)) {
232
219
  return next();
233
220
  }
234
221
 
@@ -0,0 +1,219 @@
1
+ import type { IBetterAuth } from '../../common/interfaces/server-options.interface';
2
+
3
+ /**
4
+ * What the Swagger document needs to know about the routes under the Better-Auth base path.
5
+ *
6
+ * Two kinds of route live there. The few with nest-server-specific logic are handled by
7
+ * `CoreBetterAuthController` (`CONTROLLER_HANDLED_PATHS`); every other request is forwarded by
8
+ * `CoreBetterAuthApiMiddleware` to Better-Auth's own handler, which serves whatever the configured
9
+ * plugins add (two-factor, passkey, sessions, password change, …). Those forwarded routes have no
10
+ * Nest handler, so `@nestjs/swagger` cannot see them; `setupSwagger()` documents them from Better-Auth's
11
+ * OpenAPI generator instead (see `CoreBetterAuthService.getOpenApiSchema()`).
12
+ *
13
+ * This file imports types only, so the middleware, the plugin builder and the Swagger helper can all
14
+ * read the same rules without importing each other.
15
+ */
16
+
17
+ /**
18
+ * Paths (relative to the base path) handled by `CoreBetterAuthController` rather than forwarded.
19
+ *
20
+ * Only paths with nest-server-specific logic belong here:
21
+ * - sign-in/email: legacy user migration, password normalization
22
+ * - sign-up/email: user linking to own DB, password sync
23
+ * - sign-out: custom cookie clearing
24
+ * - session: custom response format with mapped user
25
+ * - features: nest-server's own feature report
26
+ */
27
+ export const CONTROLLER_HANDLED_PATHS = ['/features', '/sign-in/email', '/sign-up/email', '/sign-out', '/session'];
28
+
29
+ /**
30
+ * Routes Better-Auth guards with a session check in the HANDLER instead of a session middleware: the
31
+ * social-account routes resolve their caller through `resolveUserId()` (better-auth `api/routes/account`),
32
+ * which refuses an HTTP caller without a session. Every other route without a session middleware is
33
+ * public. Pinned against better-auth's source by tests/unit/better-auth-openapi-availability.spec.ts.
34
+ */
35
+ export const HANDLER_SESSION_PATHS = ['/account-info', '/get-access-token', '/refresh-token'];
36
+
37
+ /**
38
+ * The plugins `buildPlugins()` registers itself. Their routes — like Better-Auth's core routes — are
39
+ * classified by the session middleware they carry. Any other plugin's routes keep the global security
40
+ * requirement: such a plugin may check the session in a middleware of its own (`admin()` does), which
41
+ * nothing outside it can recognise, and a session route documented as public sends clients without one.
42
+ */
43
+ export const BUILT_IN_BETTER_AUTH_PLUGIN_IDS = ['jwt', 'lt-ws-session-revocation', 'open-api', 'passkey', 'two-factor'];
44
+
45
+ /** Better-Auth's `:param` route syntax as OpenAPI's `{param}`, which its generator writes. */
46
+ export function toOpenApiPath(path: string): string {
47
+ return path.replace(/:([^/]+)/g, '{$1}');
48
+ }
49
+
50
+ /** Base of the routes Better-Auth's `openAPI()` plugin adds (`/open-api/generate-schema`). */
51
+ export const BETTER_AUTH_OPEN_API_PATH = '/open-api';
52
+
53
+ /** Id of Better-Auth's `openAPI()` plugin */
54
+ export const BETTER_AUTH_OPEN_API_PLUGIN_ID = 'open-api';
55
+
56
+ /**
57
+ * Minimal shape of the document Better-Auth's OpenAPI generator returns (OpenAPI 3.1, paths relative to
58
+ * the base path).
59
+ */
60
+ export interface IBetterAuthOpenApiDocument {
61
+ components?: { schemas?: Record<string, unknown> };
62
+ paths: Record<string, Record<string, unknown>>;
63
+ }
64
+
65
+ /** Whether a path relative to the base path is handled by `CoreBetterAuthController`. */
66
+ export function isControllerHandledPath(relativePath: string): boolean {
67
+ return CONTROLLER_HANDLED_PATHS.some((path) => relativePath === path || relativePath.startsWith(`${path}/`));
68
+ }
69
+
70
+ /** Whether a path relative to the base path belongs to Better-Auth's `openAPI()` plugin. */
71
+ export function isBetterAuthOpenApiPath(relativePath: string): boolean {
72
+ return relativePath === BETTER_AUTH_OPEN_API_PATH || relativePath.startsWith(`${BETTER_AUTH_OPEN_API_PATH}/`);
73
+ }
74
+
75
+ /**
76
+ * Whether the project registered `openAPI()` itself in `betterAuth.plugins`. Then its routes are the
77
+ * project's decision and stay reachable; otherwise nest-server registers the plugin only to feed the
78
+ * Swagger document, with its endpoints kept off the router (`serverOnlyPlugin()` in better-auth.config.ts).
79
+ */
80
+ export function projectProvidesOpenApiPlugin(config: IBetterAuth | null | undefined): boolean {
81
+ return !!config?.plugins?.some(
82
+ (plugin) => (plugin as { id?: unknown } | null)?.id === BETTER_AUTH_OPEN_API_PLUGIN_ID,
83
+ );
84
+ }
85
+
86
+ /**
87
+ * The parts of a running Better-Auth instance (`await auth.$context`) that decide whether a route it
88
+ * serves can succeed. Typed loosely on purpose: these are Better-Auth's resolved options, including
89
+ * whatever a project passed through `betterAuth.options` or added as a plugin.
90
+ */
91
+ export interface IBetterAuthRuntime {
92
+ options?: {
93
+ account?: { accountLinking?: { allowUnlinkingAll?: boolean } };
94
+ emailAndPassword?: { disableSignUp?: boolean; enabled?: boolean; sendResetPassword?: unknown };
95
+ emailVerification?: { sendVerificationEmail?: unknown };
96
+ plugins?: {
97
+ id?: string;
98
+ options?: Record<string, any>;
99
+ schema?: { session?: { fields?: Record<string, { input?: boolean }> } };
100
+ }[];
101
+ session?: { additionalFields?: Record<string, { input?: boolean }>; deferSessionRefresh?: boolean };
102
+ user?: { changeEmail?: { enabled?: boolean }; deleteUser?: { enabled?: boolean } };
103
+ };
104
+ socialProviders?: unknown[];
105
+ }
106
+
107
+ /**
108
+ * Operations Better-Auth serves but answers with an error for EVERY caller under the running
109
+ * configuration. Better-Auth's OpenAPI generator lists each registered endpoint regardless of the options
110
+ * it checks at request time, so `CoreBetterAuthService` removes these from the description it hands to
111
+ * `setupSwagger()`. Keys: `/path` (every method) or `<method> /path`.
112
+ *
113
+ * Each row mirrors a check in better-auth's own source (1.7.7, `dist/…`), pinned by
114
+ * `tests/unit/better-auth-openapi-availability.spec.ts`:
115
+ *
116
+ * | Unavailable | Unless | Better-Auth answers |
117
+ * |---|---|---|
118
+ * | `post /get-session` | `session.deferSessionRefresh` | 405 `METHOD_NOT_ALLOWED_DEFER_SESSION_REQUIRED` (api/routes/session.mjs) |
119
+ * | update-session | a session field accepts input (`session.additionalFields` or a plugin's session schema) | "No fields to update" (update-session.mjs) |
120
+ * | social sign-in, callback, link, provider tokens | a social provider is configured | `PROVIDER_NOT_FOUND` / "Provider … is not supported" (sign-in, callback, account.mjs) |
121
+ * | unlink-account | a social provider, or `account.accountLinking.allowUnlinkingAll` | `FAILED_TO_UNLINK_LAST_ACCOUNT` — without a provider a user has one account (account.mjs) |
122
+ * | send-verification-email, verify-email | `emailVerification.sendVerificationEmail` | "Verification email isn't enabled" (email-verification.mjs) |
123
+ * | request + reset password | `emailAndPassword.sendResetPassword` | `RESET_PASSWORD_DISABLED` (password.mjs) |
124
+ * | sign-in + sign-up with email | `emailAndPassword.enabled` | `EMAIL_PASSWORD_DISABLED` (sign-in.mjs, sign-up.mjs). Not change/verify password: Better-Auth does not check `enabled` there |
125
+ * | `/sign-up/email` | not `emailAndPassword.disableSignUp` | "Email and password sign up is not enabled" (sign-up.mjs) |
126
+ * | delete-user (+ callback) | `user.deleteUser.enabled` | error (update-user.mjs) |
127
+ * | change-email | `user.changeEmail.enabled` | `CHANGE_EMAIL_DISABLED` (update-user.mjs) |
128
+ * | two-factor OTP send/verify | the two-factor plugin's `otpOptions.sendOTP` | `OTP_NOT_CONFIGURED` (plugins/two-factor) |
129
+ * | two-factor TOTP uri/verify | not the two-factor plugin's `totpOptions.disable` | `TOTP_NOT_CONFIGURED` (plugins/two-factor) |
130
+ *
131
+ * Add a row when an upgrade of better-auth introduces a new option-dependent refusal.
132
+ */
133
+ export function unavailableBetterAuthOperations(runtime: IBetterAuthRuntime | null | undefined): Set<string> {
134
+ const options = runtime?.options ?? {};
135
+ const unavailable = new Set<string>();
136
+ const add = (...keys: string[]) => keys.forEach((key) => unavailable.add(key));
137
+
138
+ if (!options.session?.deferSessionRefresh) {
139
+ add('post /get-session');
140
+ }
141
+ const acceptsInput = (fields: Record<string, { input?: boolean }> | undefined) =>
142
+ Object.values(fields ?? {}).some((field) => field?.input !== false);
143
+ if (
144
+ !acceptsInput(options.session?.additionalFields) &&
145
+ !options.plugins?.some((plugin) => acceptsInput(plugin?.schema?.session?.fields))
146
+ ) {
147
+ add('/update-session');
148
+ }
149
+ if (!runtime?.socialProviders?.length) {
150
+ add('/sign-in/social', '/callback/{id}', '/link-social', '/refresh-token', '/get-access-token', '/account-info');
151
+ if (!options.account?.accountLinking?.allowUnlinkingAll) {
152
+ add('/unlink-account');
153
+ }
154
+ }
155
+ if (!options.emailVerification?.sendVerificationEmail) {
156
+ add('/send-verification-email', '/verify-email');
157
+ }
158
+ if (!options.emailAndPassword?.sendResetPassword) {
159
+ add('/request-password-reset', '/reset-password', '/reset-password/{token}');
160
+ }
161
+ if (!options.emailAndPassword?.enabled) {
162
+ add('/sign-in/email', '/sign-up/email');
163
+ }
164
+ if (options.emailAndPassword?.disableSignUp) {
165
+ add('/sign-up/email');
166
+ }
167
+ if (!options.user?.deleteUser?.enabled) {
168
+ add('/delete-user', '/delete-user/callback');
169
+ }
170
+ if (!options.user?.changeEmail?.enabled) {
171
+ add('/change-email');
172
+ }
173
+ const twoFactor = options.plugins?.find((plugin) => plugin?.id === 'two-factor');
174
+ if (twoFactor) {
175
+ if (!twoFactor.options?.otpOptions?.sendOTP) {
176
+ add('/two-factor/send-otp', '/two-factor/verify-otp');
177
+ }
178
+ if (twoFactor.options?.totpOptions?.disable) {
179
+ add('/two-factor/get-totp-uri', '/two-factor/verify-totp');
180
+ }
181
+ }
182
+ return unavailable;
183
+ }
184
+
185
+ /** Whether an operation is listed by `unavailableBetterAuthOperations()` (or `switchedOffBetterAuthPaths()`). */
186
+ export function isUnavailableOperation(unavailable: Set<string>, method: string, relativePath: string): boolean {
187
+ return unavailable.has(relativePath) || unavailable.has(`${method.toLowerCase()} ${relativePath}`);
188
+ }
189
+
190
+ /**
191
+ * Paths (relative to the base path) the configuration switches off for every caller. Documented, they
192
+ * could only ever answer an error, so the Swagger document leaves them out — explicit controller routes
193
+ * and generated ones alike. Each rule mirrors the switch it reads:
194
+ *
195
+ * | Configuration | Paths | Why they cannot succeed |
196
+ * |---|---|---|
197
+ * | `emailAndPassword.enabled: false` | sign-in + sign-up with email | `EMAIL_PASSWORD_DISABLED` (the only routes Better-Auth checks it on) |
198
+ * | `emailAndPassword.disableSignUp: true` | `/sign-up/email` | `SIGNUP_DISABLED` (`isSignUpEnabled()`) |
199
+ * | `emailAndPassword.passwordReset: false` | request + reset | no reset hook, so no token can be minted |
200
+ */
201
+ export function switchedOffBetterAuthPaths(config: IBetterAuth | null | undefined): Set<string> {
202
+ const emailAndPassword = config?.emailAndPassword;
203
+ const paths = new Set<string>();
204
+ if (emailAndPassword?.enabled === false) {
205
+ // Better-Auth checks `enabled` in sign-in and sign-up only; password change/verify/reset keep
206
+ // working for accounts that have a password.
207
+ paths.add('/sign-in/email');
208
+ paths.add('/sign-up/email');
209
+ }
210
+ if (emailAndPassword?.disableSignUp === true) {
211
+ paths.add('/sign-up/email');
212
+ }
213
+ if (emailAndPassword?.passwordReset === false) {
214
+ for (const path of ['/request-password-reset', '/reset-password', '/reset-password/{token}']) {
215
+ paths.add(path);
216
+ }
217
+ }
218
+ return paths;
219
+ }
@@ -753,6 +753,7 @@ export class CoreBetterAuthController {
753
753
  * @see README.md "Architecture: Why Custom Controllers?"
754
754
  */
755
755
  @All('*path')
756
+ @ApiExcludeEndpoint() // A pass-through to Better-Auth: one `/iam/{path}` per HTTP method, with nothing to document
756
757
  @Roles(RoleEnum.S_EVERYONE)
757
758
  async handlePluginRoutes(@Req() req: Request, @Res() res: Response): Promise<void> {
758
759
  return this.handleBetterAuthPlugins(req, res);