@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.
- package/.claude/rules/better-auth.md +24 -0
- package/.claude/rules/configurable-features.md +5 -3
- package/.claude/rules/testing.md +4 -4
- package/FRAMEWORK-API.md +3 -2
- package/dist/core/common/decorators/api-methods.decorator.d.ts +2 -0
- package/dist/core/common/decorators/api-methods.decorator.js +8 -0
- package/dist/core/common/decorators/api-methods.decorator.js.map +1 -0
- package/dist/core/common/helpers/swagger.helper.js +324 -15
- package/dist/core/common/helpers/swagger.helper.js.map +1 -1
- package/dist/core/common/interfaces/server-options.interface.d.ts +1 -0
- package/dist/core/modules/ai/core-ai-mcp.controller.js +15 -1
- package/dist/core/modules/ai/core-ai-mcp.controller.js.map +1 -1
- package/dist/core/modules/ai/helpers/ai-mcp-oauth.helper.js +8 -1
- package/dist/core/modules/ai/helpers/ai-mcp-oauth.helper.js.map +1 -1
- package/dist/core/modules/ai/helpers/ai-mcp-oauth.registry.d.ts +7 -0
- package/dist/core/modules/ai/helpers/ai-mcp-oauth.registry.js +12 -0
- package/dist/core/modules/ai/helpers/ai-mcp-oauth.registry.js.map +1 -0
- package/dist/core/modules/better-auth/better-auth.config.js +12 -0
- package/dist/core/modules/better-auth/better-auth.config.js.map +1 -1
- package/dist/core/modules/better-auth/core-better-auth-api.middleware.js +2 -2
- package/dist/core/modules/better-auth/core-better-auth-api.middleware.js.map +1 -1
- package/dist/core/modules/better-auth/core-better-auth-openapi.helper.d.ts +62 -0
- package/dist/core/modules/better-auth/core-better-auth-openapi.helper.js +95 -0
- package/dist/core/modules/better-auth/core-better-auth-openapi.helper.js.map +1 -0
- package/dist/core/modules/better-auth/core-better-auth.controller.js +1 -0
- package/dist/core/modules/better-auth/core-better-auth.controller.js.map +1 -1
- package/dist/core/modules/better-auth/core-better-auth.service.d.ts +10 -2
- package/dist/core/modules/better-auth/core-better-auth.service.js +89 -0
- package/dist/core/modules/better-auth/core-better-auth.service.js.map +1 -1
- package/dist/core/modules/tus/core-tus.controller.js +3 -0
- package/dist/core/modules/tus/core-tus.controller.js.map +1 -1
- package/dist/core/modules/tus/core-tus.service.d.ts +3 -0
- package/dist/core/modules/tus/core-tus.service.js +58 -25
- package/dist/core/modules/tus/core-tus.service.js.map +1 -1
- package/dist/core/modules/tus/interfaces/tus-config.interface.js +1 -0
- package/dist/core/modules/tus/interfaces/tus-config.interface.js.map +1 -1
- package/dist/core/modules/tus/tus.module.d.ts +2 -0
- package/dist/core/modules/tus/tus.module.js +23 -1
- package/dist/core/modules/tus/tus.module.js.map +1 -1
- package/dist/core.module.js +1 -1
- package/dist/core.module.js.map +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/dist/tsconfig.build.tsbuildinfo +1 -1
- package/docs/REQUEST-LIFECYCLE.md +9 -1
- package/migration-guides/11.42.7-to-11.42.8.md +74 -0
- package/migration-guides/11.42.8-to-11.42.9.md +186 -0
- package/package.json +2 -2
- package/src/core/common/decorators/api-methods.decorator.ts +25 -0
- package/src/core/common/helpers/swagger.helper.ts +479 -19
- package/src/core/common/interfaces/server-options.interface.ts +46 -8
- package/src/core/common/interfaces/swagger-setup-options.interface.ts +5 -2
- package/src/core/modules/ai/README.md +7 -0
- package/src/core/modules/ai/core-ai-mcp.controller.ts +19 -2
- package/src/core/modules/ai/helpers/ai-mcp-oauth.helper.ts +12 -2
- package/src/core/modules/ai/helpers/ai-mcp-oauth.registry.ts +29 -0
- package/src/core/modules/better-auth/README.md +34 -0
- package/src/core/modules/better-auth/better-auth.config.ts +27 -1
- package/src/core/modules/better-auth/core-better-auth-api.middleware.ts +4 -17
- package/src/core/modules/better-auth/core-better-auth-openapi.helper.ts +219 -0
- package/src/core/modules/better-auth/core-better-auth.controller.ts +1 -0
- package/src/core/modules/better-auth/core-better-auth.service.ts +166 -2
- package/src/core/modules/tus/README.md +50 -14
- package/src/core/modules/tus/core-tus.controller.ts +6 -0
- package/src/core/modules/tus/core-tus.service.ts +97 -26
- package/src/core/modules/tus/interfaces/tus-config.interface.ts +1 -0
- package/src/core/modules/tus/tus.module.ts +43 -1
- package/src/core.module.ts +2 -1
- 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
|
-
|
|
59
|
-
|
|
60
|
-
|
|
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
|
-
|
|
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:
|
|
247
|
-
for (const method of
|
|
248
|
-
|
|
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:
|
|
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
|
|
447
|
-
|
|
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
|
-
|
|
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
|
/**
|