@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,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
+ }