@lenne.tech/nest-server 11.42.15 → 11.43.0

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 (64) hide show
  1. package/.claude/rules/better-auth.md +22 -0
  2. package/.claude/rules/configurable-features.md +4 -3
  3. package/.claude/rules/testing.md +4 -4
  4. package/FRAMEWORK-API.md +2 -2
  5. package/dist/core/common/filters/mongoose-cast-error.filter.d.ts +17 -0
  6. package/dist/core/common/filters/mongoose-cast-error.filter.js +171 -0
  7. package/dist/core/common/filters/mongoose-cast-error.filter.js.map +1 -0
  8. package/dist/core/common/interfaces/server-options.interface.d.ts +4 -0
  9. package/dist/core/modules/ai/core-ai-mcp-oauth.initializer.d.ts +19 -0
  10. package/dist/core/modules/ai/core-ai-mcp-oauth.initializer.js +145 -0
  11. package/dist/core/modules/ai/core-ai-mcp-oauth.initializer.js.map +1 -0
  12. package/dist/core/modules/ai/core-ai-mcp.controller.js +6 -5
  13. package/dist/core/modules/ai/core-ai-mcp.controller.js.map +1 -1
  14. package/dist/core/modules/ai/core-ai.module.d.ts +2 -0
  15. package/dist/core/modules/ai/core-ai.module.js +6 -1
  16. package/dist/core/modules/ai/core-ai.module.js.map +1 -1
  17. package/dist/core/modules/ai/helpers/ai-mcp-oauth-consent.helper.d.ts +33 -0
  18. package/dist/core/modules/ai/helpers/ai-mcp-oauth-consent.helper.js +282 -0
  19. package/dist/core/modules/ai/helpers/ai-mcp-oauth-consent.helper.js.map +1 -0
  20. package/dist/core/modules/ai/helpers/ai-mcp-oauth.helper.d.ts +15 -2
  21. package/dist/core/modules/ai/helpers/ai-mcp-oauth.helper.js +73 -19
  22. package/dist/core/modules/ai/helpers/ai-mcp-oauth.helper.js.map +1 -1
  23. package/dist/core/modules/ai/helpers/ai-mcp-oauth.registry.d.ts +5 -0
  24. package/dist/core/modules/ai/helpers/ai-mcp-oauth.registry.js.map +1 -1
  25. package/dist/core/modules/ai/index.d.ts +1 -0
  26. package/dist/core/modules/ai/index.js +1 -0
  27. package/dist/core/modules/ai/index.js.map +1 -1
  28. package/dist/core/modules/ai/services/core-ai-mcp-oauth.service.d.ts +30 -2
  29. package/dist/core/modules/ai/services/core-ai-mcp-oauth.service.js +222 -15
  30. package/dist/core/modules/ai/services/core-ai-mcp-oauth.service.js.map +1 -1
  31. package/dist/core/modules/better-auth/better-auth.config.d.ts +4 -0
  32. package/dist/core/modules/better-auth/better-auth.config.js +29 -2
  33. package/dist/core/modules/better-auth/better-auth.config.js.map +1 -1
  34. package/dist/core/modules/better-auth/core-better-auth.controller.d.ts +2 -0
  35. package/dist/core/modules/better-auth/core-better-auth.controller.js +36 -1
  36. package/dist/core/modules/better-auth/core-better-auth.controller.js.map +1 -1
  37. package/dist/core.module.js +7 -0
  38. package/dist/core.module.js.map +1 -1
  39. package/dist/index.d.ts +1 -0
  40. package/dist/index.js +1 -0
  41. package/dist/index.js.map +1 -1
  42. package/dist/tsconfig.build.tsbuildinfo +1 -1
  43. package/docs/REQUEST-LIFECYCLE.md +30 -1
  44. package/docs/security-overrides.md +3 -2
  45. package/migration-guides/11.42.15-to-11.42.16.md +114 -0
  46. package/migration-guides/11.42.x-to-11.43.x.md +195 -0
  47. package/package.json +7 -7
  48. package/src/core/common/filters/mongoose-cast-error.filter.ts +310 -0
  49. package/src/core/common/helpers/swagger.helper.ts +4 -4
  50. package/src/core/common/interfaces/server-options.interface.ts +59 -9
  51. package/src/core/modules/ai/INTEGRATION-CHECKLIST.md +50 -24
  52. package/src/core/modules/ai/README.md +98 -13
  53. package/src/core/modules/ai/core-ai-mcp-oauth.initializer.ts +199 -0
  54. package/src/core/modules/ai/core-ai-mcp.controller.ts +13 -6
  55. package/src/core/modules/ai/core-ai.module.ts +14 -2
  56. package/src/core/modules/ai/helpers/ai-mcp-oauth-consent.helper.ts +369 -0
  57. package/src/core/modules/ai/helpers/ai-mcp-oauth.helper.ts +149 -43
  58. package/src/core/modules/ai/helpers/ai-mcp-oauth.registry.ts +18 -5
  59. package/src/core/modules/ai/index.ts +1 -0
  60. package/src/core/modules/ai/services/core-ai-mcp-oauth.service.ts +385 -28
  61. package/src/core/modules/better-auth/better-auth.config.ts +49 -5
  62. package/src/core/modules/better-auth/core-better-auth.controller.ts +75 -1
  63. package/src/core.module.ts +13 -1
  64. package/src/index.ts +1 -0
@@ -107,6 +107,7 @@ JWT-based authentication for existing projects:
107
107
  | **Security Check Interceptor** | Calls `securityCheck()` + removes secret fields |
108
108
  | **Response Filter Interceptor** | Enforces `@Restricted()` field-level access |
109
109
  | **Translation Interceptor** | Applies `_translations` based on `Accept-Language` |
110
+ | **CastError Filter** | `MongooseCastErrorFilter` (`APP_FILTER`, 11.42.16): a malformed id from a route parameter answers 404, one from the query string or an update/filter body 400, instead of an unhandled 500 (a create body raises `ValidationError` and stays a 500); a cast failure on a value the request does not contain stays a 500. See "Malformed ids: 404 / 400, not 500" below |
110
111
  | **Secret Fields Removal** | Configurable fallback removal of password, tokens, etc. |
111
112
  | **RequestContext** | `AsyncLocalStorage`-based context for current user in Mongoose hooks. `fromRequest` (11.42.0) marks a context created for a CLIENT operation (HTTP middleware, GraphQL WebSocket), so service-level checks such as `assertTenantRole()` tell an anonymous request apart from system work |
112
113
  | **Query Complexity** | GraphQL query complexity analysis to prevent DoS |
@@ -178,7 +179,7 @@ JWT-based authentication for existing projects:
178
179
  | **AI Module** | Prompt orchestrator (auto + plan mode), DB-backed LLM connections, tool registry |
179
180
  | **REST Endpoints** | `POST /ai/prompt`, `POST /ai/stream` (SSE); `GET/POST/PUT/DELETE /ai/connections*`, `/ai/connections/available`, `/ai/connections/select`, `/ai/connections/preferences*`, `/ai/conversations*`, `/ai/interactions*`, `/ai/budget-limits*`, `GET /ai/usage` |
180
181
  | **GraphQL Endpoints** | `aiPrompt`, `aiAvailableConnections`, `aiSetUserConnection`, connection/preference/conversation/interaction/budget queries + mutations, `aiUsage` |
181
- | **MCP Server** | Streamable HTTP at `POST/GET/DELETE /ai/mcp` (Bearer auth); optional OAuth 2.1 router mounted in `main.ts` via `mountAiMcpOAuth(app)` (`/.well-known/*`, `/authorize`, `/token`, `/register`) |
182
+ | **MCP Server** | Streamable HTTP at `POST/GET/DELETE /ai/mcp` (Bearer auth); with `ai.mcp.oauth` an OAuth 2.1 router (`/.well-known/*`, `/authorize`, `/token`, `/register`) that `CoreAiMcpOAuthInitializer` mounts during `app.init()` — an Express router behind the Nest middleware and routes, ahead of Nest's not-found handler, so guards and interceptors do not run for it; `/authorize` answers with a built-in consent page (the Better-Auth session from the middleware is the user; Allow is a CSRF-bound POST) |
182
183
  | **Permission Model** | `S_USER` for prompt/conversations/available/usage; `ADMIN` for connections/preferences/interactions/budgets; encrypted API keys never leave the server (`securityCheck` + `secretFields`) |
183
184
 
184
185
  ### Email & Templates
@@ -1215,6 +1216,33 @@ throw accessDeniedException(currentUser);
1215
1216
  See `src/core/common/exceptions/access-denied.exception.ts` and
1216
1217
  `migration-guides/11.27.7-to-11.28.0.md`.
1217
1218
 
1219
+ ### Malformed ids: 404 / 400, not 500 (v11.42.16+)
1220
+
1221
+ A malformed id reaches Mongoose, which throws `mongoose.Error.CastError`. That is not an
1222
+ `HttpException`, so it escapes `HttpExceptionLogFilter` (`@Catch(HttpException)`), and Nest used to
1223
+ answer **500** for what is simply "this resource does not exist". `CoreModule` registers
1224
+ `MongooseCastErrorFilter` as `APP_FILTER`; it decides by where the failing value came from:
1225
+
1226
+ | Value of the failed cast | REST | GraphQL |
1227
+ |--------------------------|------|---------|
1228
+ | a route parameter (`GET /users/not-an-id`) / a top-level argument (`getUser(id: …)`) | **404** `ErrorCode.RESOURCE_NOT_FOUND` | same |
1229
+ | the query string or the body / nested in an input object | **400** `ErrorCode.INVALID_FIELD_FORMAT [<path>]` | same |
1230
+ | not in the request — a broken stored id, a value computed by server code | **500**, Nest's own body and log line | unchanged (Nest rethrows) |
1231
+
1232
+ The third row is deliberate: that cast failure is a data or code defect, and answering it as a client
1233
+ error would hide it from monitoring. The Mongoose message never reaches the client — it names the
1234
+ model. Opt out with `security.mongooseCastErrorFilter: false`.
1235
+
1236
+ Mongoose throws `CastError` only while casting a **query or an update** — `findById`, filters,
1237
+ `findByIdAndUpdate` / `updateOne` (the path `CrudService.update()` takes). Two neighbours are not
1238
+ covered and stay a 500:
1239
+
1240
+ - a malformed id in a document being **created or saved** (`save()`, `Model.create()`, `insertMany()`
1241
+ — the path `CrudService.create()` takes) arrives as a `ValidationError` wrapping the CastError;
1242
+ validate reference ids in the input (`validator: () => [IsMongoId()]` on the `@UnifiedField`);
1243
+ - a `BSONError` from `new Types.ObjectId(bad)` in service code carries no value to classify; validate
1244
+ such ids in the service, as `CoreFileService.isMalformedFileId()` does.
1245
+
1218
1246
  ### Depth-Based Optimization (v11.23.0+)
1219
1247
 
1220
1248
  When `process()` is called from within another `process()` call (service cascades like A.create → B.create → C.create), steps 4–6 are **conditionally skipped** on inner calls to avoid redundant work:
@@ -1643,6 +1671,7 @@ All security features are configured in `config.env.ts` under the `security` key
1643
1671
  | `security.checkSecurityInterceptor` | `boolean \| object` | `true` | Enable securityCheck() calls |
1644
1672
  | `security.mapAndValidatePipe` | `boolean \| object` | `true` | Enable input validation |
1645
1673
  | `security.mapAndValidatePipe.nonWhitelistedFields` | `'strip' \| 'error' \| false` | `'strip'` | Whitelist behavior |
1674
+ | `security.mongooseCastErrorFilter` | `boolean` | `true` | Malformed request ids answer 404 / 400 instead of 500 |
1646
1675
 
1647
1676
  ### Safety Net — Mongoose Plugins
1648
1677
 
@@ -64,13 +64,14 @@ overrides:
64
64
  'multer@>=2.0.0 <2.4.0': '2.4.0'
65
65
 
66
66
  # @nestjs/graphql 13.4.5 exact-pins @graphql-tools/utils@12.0.0 — GHSA-7mx3-vvmw-hjmv (high, patched >=12.0.1).
67
- # Target the newest 12.x, so the caret requesters (@graphql-tools/schema / merge) are not held back.
67
+ # Target the newest 12.x, so the caret requesters (@graphql-tools/schema / merge) are not held back —
68
+ # @graphql-tools/schema 10.1.5 requests ^12.1.0, so a lower target leaves a second copy in the tree.
68
69
  # 12.0.2+ requires Node >=22.15 via @whatwg-node/promise-helpers@2 — the floor this framework declares
69
70
  # since 11.42.10. On an older Node, take 12.0.1 instead.
70
71
  # Does NOT reach a utils 11.x pulled in by an older @graphql-tools/schema 10.0.x — refresh that one instead:
71
72
  # pnpm update --depth Infinity @graphql-tools/schema @graphql-tools/merge
72
73
  # Remove once @nestjs/graphql pins >=12.0.1 in the major you use.
73
- '@graphql-tools/utils@>=12.0.0 <12.0.3': '12.0.3'
74
+ '@graphql-tools/utils@>=12.0.0 <12.1.0': '12.1.0'
74
75
  ```
75
76
 
76
77
  ### Retired: `@hono/node-server` (removed 2026-08-22, nest-server 11.36.1)
@@ -0,0 +1,114 @@
1
+ # Migration Guide: 11.42.15 → 11.42.16
2
+
3
+ ## Overview
4
+
5
+ | Category | Details |
6
+ | -------------------- | -------------------------------------------------------------------------------------------------------- |
7
+ | **Bugfixes** | A malformed id in a route parameter answers **404** instead of **500**; one in the query string or an update body **400** (§1) |
8
+ | **New Features** | `MongooseCastErrorFilter`, registered automatically; opt-out `security.mongooseCastErrorFilter: false` (§1) |
9
+ | **Framework API** | New exports: `MongooseCastErrorFilter`, `classifyHttpCastError()`, `classifyGraphQlCastError()`, `castErrorCandidates()`, `containsCastValue()`, type `CastErrorOrigin`. No changed signatures |
10
+ | **Dependencies** | Development tooling only: `vitest` / `@vitest/coverage-v8` 4.1.11 → 4.1.12 (security, §2), `oxfmt` 0.72.0 → 0.73.0; override `@graphql-tools/utils` → 12.1.0 (§2). Runtime dependencies and peer ranges unchanged |
11
+ | **Breaking Changes** | None. Status codes change from 500 to 404 / 400 for requests that were already failing (§1) |
12
+ | **Migration Effort** | None for most projects. Check tests or clients that expected a 500 for a malformed id (§1) |
13
+
14
+ ## Quick Migration
15
+
16
+ ```bash
17
+ pnpm update @lenne.tech/nest-server
18
+ ```
19
+
20
+ Vendor-mode projects pick it up with the core update. No code or configuration change is required — the filter is
21
+ registered by `CoreModule`, so neither `main.ts` nor test setups built with `Test.createTestingModule()` need a new
22
+ line.
23
+
24
+ ## 1. Malformed ids answer 404 / 400 instead of 500
25
+
26
+ A malformed id in a route parameter (`GET /users/not-an-id`, `/ai/connections/abc`) reached Mongoose, which throws
27
+ `mongoose.Error.CastError`. That is not an `HttpException`, so it escaped `HttpExceptionLogFilter`
28
+ (`@Catch(HttpException)`) and Nest answered **500 Internal server error** for what is simply "this resource does not
29
+ exist". In a typical project that affected every `:id` route, including the framework's own `/users/:id` and the AI
30
+ module's routes.
31
+
32
+ `CoreModule` now registers `MongooseCastErrorFilter` as `APP_FILTER`. It decides by where the value that failed to
33
+ cast came from:
34
+
35
+ | Value of the failed cast | REST | GraphQL |
36
+ | ------------------------ | ---- | ------- |
37
+ | a route parameter / a top-level argument (`getUser(id: …)`) | **404** `#LTNS_0400: Resource not found` | same |
38
+ | the query string or an update / filter body / a value nested in an input | **400** `#LTNS_0302: Invalid field format [<path>]` | same |
39
+ | not in the request (a broken stored id, a value computed by server code) | **500**, unchanged — Nest's body, logged as an error | unchanged |
40
+
41
+ The third row is deliberate: a cast failure on a value the request does not contain is a data or code defect, and
42
+ answering it as a client error would hide it from monitoring. The Mongoose message is never sent to the client — it
43
+ names the model. REST bodies have the same shape as every other error `HttpExceptionLogFilter` answers.
44
+
45
+ **What to check:**
46
+
47
+ - **A test or client expected 500 for a malformed id:** it now gets 404 (route parameter) or 400 (query / body).
48
+ A well-formed id that matches nothing keeps answering 404 as before, so 404 now means the same for both.
49
+ - **A project filter already handled `CastError`:** a catch-all filter registered with `app.useGlobalFilters()` is
50
+ consulted before this one and keeps winning. One registered as `APP_FILTER` in the project's root module is
51
+ consulted after it — set `security.mongooseCastErrorFilter: false` if your own filter must see every `CastError`
52
+ (for example a Sentry filter that reports them).
53
+ - **You want the previous behaviour:** `security.mongooseCastErrorFilter: false` restores the 500.
54
+
55
+ **Not covered — both stay a 500:**
56
+
57
+ - A malformed id in a document being **created** (`save()`, `Model.create()`, `insertMany()` — the path
58
+ `CrudService.create()` takes). Mongoose reports that as a `ValidationError` wrapping the `CastError`. Validate
59
+ reference ids in the input instead:
60
+
61
+ ```typescript
62
+ import { IsMongoId } from 'class-validator';
63
+
64
+ @UnifiedField({ isOptional: true, type: () => String, validator: () => [IsMongoId()] })
65
+ owner?: string = undefined;
66
+ ```
67
+
68
+ - A `BSONError` from `new Types.ObjectId(bad)` in service code. It carries no value to classify; check the id
69
+ before converting it, as `CoreFileService.isMalformedFileId()` does.
70
+
71
+ ## 2. Development tooling and the `@graphql-tools/utils` override
72
+
73
+ Nothing here reaches the published package's runtime dependencies. Worth mirroring in your own project:
74
+
75
+ - **`vitest` and `@vitest/coverage-v8` 4.1.12** fix two `@vitest/mocker` advisories published on 2026-10-16
76
+ (GHSA-qxg6-89mg-rqv8, GHSA-xp26-3454-mjgp, both medium). Development tooling only. `pnpm audit` may not list them
77
+ yet, so raise both together without waiting for it: `@vitest/coverage-v8` peer-requires the exact vitest version.
78
+ - **`@graphql-tools/utils` override:** if your project carries its own entry for GHSA-7mx3-vvmw-hjmv (overrides do
79
+ not reach consumers, so many do), raise it:
80
+
81
+ ```yaml
82
+ # pnpm-workspace.yaml
83
+ overrides:
84
+ '@graphql-tools/utils@>=12.0.0 <12.1.0': '12.1.0' # was: '<12.0.3': '12.0.3' / '<12.0.4': '12.0.4'
85
+ ```
86
+
87
+ The old target still clears the advisory, but `@graphql-tools/schema` 10.1.5 (pulled in by `@apollo/server`)
88
+ requests `^12.1.0`, so a lower target leaves two copies of `@graphql-tools/utils` in the tree.
89
+ See [`docs/security-overrides.md`](../docs/security-overrides.md).
90
+
91
+ ## Compatibility Notes
92
+
93
+ | Pattern | Status |
94
+ | ------- | ------ |
95
+ | `main.ts` with `app.useGlobalFilters(new HttpExceptionLogFilter())` | Unchanged; both filters coexist (they catch different classes) |
96
+ | A project catch-all filter via `app.useGlobalFilters()` | Unchanged; it still sees every `CastError` |
97
+ | A project catch-all filter as `APP_FILTER` in the root module | No longer sees `CastError` — opt out if it must (§1) |
98
+ | Tests built with `Test.createTestingModule({ imports: [ServerModule] })` | Get the filter automatically |
99
+ | `security.mongooseCastErrorFilter: false` | Previous behaviour (500) |
100
+
101
+ ## Troubleshooting
102
+
103
+ **A malformed id still answers 500**
104
+ Check where it fails. A create path raises `ValidationError` (see "Not covered" above); a `new Types.ObjectId(bad)` in
105
+ service code raises `BSONError`; a value from a header or from stored data is not in the request and stays a 500 on
106
+ purpose. The server log names the cast at debug level (`MongooseCastErrorFilter`) when the filter answered 404 / 400.
107
+
108
+ **A 400 names a field the client did not send**
109
+ The path in brackets is the schema path Mongoose failed to cast. For a filter on `id` it reads `[_id]`.
110
+
111
+ ## Module Documentation
112
+
113
+ - [Request lifecycle](../docs/REQUEST-LIFECYCLE.md) — "Malformed ids: 404 / 400, not 500"
114
+ - Implementation: `src/core/common/filters/mongoose-cast-error.filter.ts`
@@ -0,0 +1,195 @@
1
+ # Migration Guide: 11.42.x → 11.43.x
2
+
3
+ ## Why this is a MINOR
4
+
5
+ Per `.claude/rules/versioning.md` the MAJOR mirrors NestJS, so **MINOR is where breaking changes
6
+ live**. Two behaviour changes can turn a call that used to succeed into a refusal: a browser sign-in
7
+ or sign-up from an origin Better-Auth does not trust now answers **403** in every project (§5), and
8
+ with `ai.mcp.oauth` enabled a deployed environment without a signing secret or without `baseUrl`
9
+ now fails the boot instead of signing tokens with the public development key (§1, §4). So this is
10
+ `11.43.0` rather than a patch.
11
+
12
+ **Projects whose frontends sign in from `appUrl` or an origin in `cors.allowedOrigins`, and that do
13
+ not use `ai.mcp.oauth`, see no behaviour change.** Server-side clients (scripts, seeders, SSR
14
+ routes, API tests) are not affected either (§5).
15
+
16
+ ## Overview
17
+
18
+ | Category | Details |
19
+ | -------------------- | ------- |
20
+ | **Bugfixes** | MCP OAuth endpoints were unreachable in every Nest application (404) — `CoreModule` now mounts them itself (§1). A refused refresh token or authorization code answers **400 `invalid_grant`** instead of **500** (§2). The MCP endpoint's 401 names the protected resource metadata, not the endpoint (§3) |
21
+ | **New Features** | Built-in consent page with sign-in for `/authorize` (§4); override `ai: { mcpOAuthService }` (§4); config `ai.mcp.oauthBaseUrl` (§1) and `ai.mcp.oauthLoginUrl` (§4) |
22
+ | **Security** | A code issued while answering a GET to `/authorize` is refused — consent must be a POST (§4). Without a signing secret, every environment except `local` / `ci` / `e2e` refuses to sign MCP tokens instead of using the public development key (§4). IAM sign-in / sign-up run Better-Auth's login-CSRF protection again (§5) |
23
+ | **Framework API** | New: `CoreAiMcpOAuthInitializer`, `mountAiMcpOAuthRouter()`, `renderMcpOAuthConsentPage()` + `IMcpOAuthConsentView`, `IAiMcpOAuthMountTarget`, `IAiMcpOAuthRouterOptions`, `IAiMcpOAuthConsentBinding`; on `CoreAiMcpOAuthService`: `isOAuthEnabled()`, `usesBuiltInConsent()`, protected `loadOAuthErrors()`, `renderConsentPage()`, `buildConsentView()`, `consentUser()`, `consentLoginUrl()`, `signConsentToken()`, `verifyConsentToken()`; `consumeAuthorizationCode(code, clientId?)`. `mountAiMcpOAuth()` is deprecated (a no-op once `CoreModule` mounted the router). No changed signatures |
24
+ | **Dependencies** | `@tus/server` 2.4.5 → 2.4.6 (typings fix). Lockfile: `follow-redirects` 1.16.0 → 1.16.1 (via `node-mailjet` → `axios`; GHSA-8r9p-f939-6h3c / GHSA-q3jh-wrc6-jgq8 — `Authorization` / `Cookie` forwarded on a cross-origin redirect). Run `pnpm update --depth Infinity follow-redirects` if your lockfile still holds 1.16.0 |
25
+ | **Breaking Changes** | With `ai.mcp.oauth`: see "What to check" in §1 — notably a deployed environment without `baseUrl` or without a signing secret (`develop` / `test` stages included) now fails the boot. For every project: a browser sign-in from an origin not in `trustedOrigins` now answers 403 (§5) |
26
+ | **Migration Effort** | Check that every browser origin that signs in is in `trustedOrigins` (§5). With `ai.mcp.oauth`: remove the `mountAiMcpOAuth()` call, check `NSC__BASE_URL`; the consent page comes built in (§4) |
27
+
28
+ ## Quick Migration
29
+
30
+ ```bash
31
+ pnpm update @lenne.tech/nest-server
32
+ ```
33
+
34
+ Vendor-mode projects pick it up with the core update. **Every project:** check that each browser origin that signs in is
35
+ trusted (§5). **With `ai.mcp.oauth: true`:** also §1 and §4.
36
+
37
+ ## 1. The MCP OAuth endpoints are mounted by `CoreModule`
38
+
39
+ The OAuth 2.1 endpoints for MCP clients (`/.well-known/oauth-authorization-server`,
40
+ `/.well-known/oauth-protected-resource/ai/mcp`, `/authorize`, `/token`, `/register`) are an Express router. The
41
+ documentation said to mount it in `main.ts` **after** `app.init()`:
42
+
43
+ ```typescript
44
+ // main.ts — what the documentation prescribed until 11.42.16
45
+ await app.init();
46
+ await mountAiMcpOAuth(app);
47
+ ```
48
+
49
+ That never worked in a Nest application. Nest registers its not-found handler inside `app.init()`; a router added
50
+ afterwards sits behind it and is never reached. Every OAuth endpoint answered Nest's 404, so no MCP client could
51
+ discover the server, register or get a token.
52
+
53
+ Since 11.43.0 `CoreAiMcpOAuthInitializer` (registered by `CoreAiModule` together with the MCP endpoint) mounts the
54
+ router during `app.init()` whenever `ai.mcp.oauth` is `true`: behind every Nest middleware (security headers, request
55
+ context, CORS) and the Nest routes, ahead of the not-found handler. It needs no `main.ts` code and works the same
56
+ under `Test.createTestingModule()`. Guards and interceptors do not run for it — the SDK's handlers authenticate the
57
+ client and rate-limit per IP themselves.
58
+
59
+ ```typescript
60
+ // main.ts — remove the call; it is a no-op now and logs that it can go
61
+ await mountAiMcpOAuth(app);
62
+ ```
63
+
64
+ The protected MCP resource is described at the path Nest registered `/ai/mcp` at — global prefix and its exclusions,
65
+ URI versioning and a `RouterModule` path included. The issuer is the server's `baseUrl`, or the new
66
+ `ai.mcp.oauthBaseUrl` when MCP clients reach the server under another URL (its origin is added to Better-Auth's
67
+ trusted origins automatically, so the consent page can sign users in there; it replaces the `baseUrl` option of
68
+ `mountAiMcpOAuth()`, which is ignored with a warning once the router is mounted).
69
+
70
+ **What to check:**
71
+
72
+ - **A deployed environment without `baseUrl` now fails the boot** when `ai.mcp.oauth` is `true` — before, only the
73
+ `mountAiMcpOAuth()` call failed. Set `NSC__BASE_URL` (or `ai.mcp.oauthBaseUrl`). `local` / `ci` / `e2e` keep the
74
+ `http://localhost:3000` default.
75
+ - **The endpoints now exist.** With `ai.mcp.oauth: true`, dynamic client registration (`/register`) is reachable,
76
+ rate-limited per IP by the SDK — configure `trustProxy` behind a proxy, or all clients share one bucket — and so is
77
+ the consent page (§4): users can now authorize MCP clients.
78
+ - **CORS.** A project's `enableCors()` runs before the router, so a browser-based MCP client on an origin it does not
79
+ allow is refused at the preflight. Native clients (Claude Code, Claude Desktop) are unaffected.
80
+ - **Express only.** `ai.mcp.oauth` on another HTTP adapter fails the boot with a named error.
81
+ - **A route of your own at `/token`, `/register` or `/authorize`** now wins over the OAuth router, which comes after
82
+ the Nest routes.
83
+
84
+ ## 2. Refusals answer `400 invalid_grant`, not `500`
85
+
86
+ The SDK's handlers answer an error with its OAuth code only when it is one of the SDK's own `OAuthError` classes;
87
+ anything else becomes `500 server_error`. The provider refused with plain `Error('invalid_grant')`, so an unknown,
88
+ rotated or foreign refresh token, or a code issued to another client, was answered with a 500. MCP clients discard
89
+ their stored tokens only on `invalid_grant`: on a 500, Claude Code kept the dead token, retried the refresh on every
90
+ start and never asked the user to sign in again.
91
+
92
+ Now: `invalid_grant` (400) for refresh tokens and codes, `invalid_client` for a missing client, `invalid_token` from
93
+ `verifyAccessToken`, and `access_denied` when an API token tries to approve a consent (the client is redirected back
94
+ with `error=access_denied` instead of `server_error`). `verifyAccessToken` also returns `expiresAt`, which the SDK's
95
+ bearer middleware requires. A code presented by another client is no longer deleted on the way to the refusal: the
96
+ exchange consumes only a code issued to the presenting client (`consumeAuthorizationCode(code, clientId)`), so the
97
+ owner can still redeem it.
98
+
99
+ **What to check:** code that matched the error MESSAGE (`/invalid_grant/`) — the code is in `error.errorCode` now, the
100
+ message is a description. An `authorizeConsent()` of your own must refuse the same way:
101
+
102
+ ```typescript
103
+ const { AccessDeniedError } = await this.loadOAuthErrors();
104
+ throw new AccessDeniedError('The user declined');
105
+ ```
106
+
107
+ ## 3. The MCP 401 points at the metadata document
108
+
109
+ `POST /ai/mcp` without credentials answered `WWW-Authenticate: Bearer resource_metadata="<request host>/ai/mcp"` — the
110
+ endpoint itself, built from the caller-controlled `Host` header. RFC 9728 §5.1 wants the URL of the protected resource
111
+ METADATA. It is now exactly the document the mounted router serves
112
+ (`https://api.example.com/.well-known/oauth-protected-resource/ai/mcp`), and a plain `Bearer` without OAuth, where no
113
+ such document exists.
114
+
115
+ **What to check:** a test or client that parsed the old header value.
116
+
117
+ ## 4. A built-in consent page
118
+
119
+ `/authorize` needs a consent step: the page on which a user decides whether an MCP client may act for them. The
120
+ documentation told projects to override `authorizeConsent()` for it, but `CoreAiModule` offered no way to register a
121
+ subclass, so no project had one and no authorization could ever complete. The framework now brings the page itself:
122
+
123
+ - **Nobody signed in** → e-mail + password and the two-factor step (TOTP or backup code), against Better-Auth's own
124
+ endpoints on the API origin, then a reload of the pending request. Passkey and social login through the app's login
125
+ page: `ai.mcp.oauthLoginUrl`, default `<appUrl>/auth/login`, opened next to the consent page. A user already signed in
126
+ to the API origin skips this.
127
+ - **Signed in** → the account first (with "use another account"), which client asks, where the code goes; Allow /
128
+ Deny as a form POST. Allow counts only after intent on the page (visible, focused, a pointer move / key / touch at
129
+ least 600 ms later), against double-clickjacking; without JavaScript nothing can be approved.
130
+ - **Allow** issues the code only with the page's CSRF token, signed and bound to user, client, redirect URI and PKCE
131
+ challenge (10 minutes). **Deny** → `access_denied`.
132
+
133
+ The page sets its own CSP (nonces, `frame-ancestors 'none'`), `X-Frame-Options: DENY` and `Cache-Control: no-store`,
134
+ escapes every value from outside (the client's name comes from whoever registered it), and speaks German to a German
135
+ browser. It needs Better-Auth active; without it the boot warns.
136
+
137
+ **A signed-in session is not consent.** Dynamic registration is open, so anybody can register a client with their own
138
+ redirect URI and start the flow — PKCE is then theirs too. A GET to `/authorize` is what any link produces, and it
139
+ carries the SameSite=Lax session cookie. The framework therefore refuses a code saved while answering a GET to
140
+ `/authorize`: the client is sent back with `error=server_error`, and the server log names the refusal. That applies to
141
+ a consent page of your own as well.
142
+
143
+ **Your own page** (optional): override `renderConsentPage(view)` to restyle, or `authorizeConsent(client, params, res)`
144
+ to replace it, and register the subclass:
145
+
146
+ ```typescript
147
+ CoreModule.forRoot(envConfig, { ai: { mcpOAuthService: McpOAuthService } })
148
+ ```
149
+
150
+ Two checks stay outside the overridable methods: an API token can never approve a consent, and the production secret
151
+ check runs again before the endpoints are mounted. Without a signing secret, every environment except `local`, `ci` and
152
+ `e2e` now refuses to boot with `ai.mcp.oauth` — and refuses to sign or verify tokens at all — instead of falling back
153
+ to the public development key. That includes the DEPLOYED `develop` and `test` stages of a starter-based project:
154
+ before, anybody who knew a user id could forge that user's MCP access token there.
155
+
156
+ ## 5. IAM sign-in and sign-up run Better-Auth's login-CSRF protection
157
+
158
+ Better-Auth protects `sign-in/email` and `sign-up/email` itself (`formCsrfMiddleware`): it refuses a cross-site
159
+ navigation (`Sec-Fetch-Site: cross-site` + `Sec-Fetch-Mode: navigate`, i.e. a form a hostile page submits) and checks
160
+ a present `Origin` / `Referer` against `trustedOrigins`. It reads both from the request — and
161
+ `CoreBetterAuthController` called `api.signInEmail()` / `api.signUpEmail()` without one, so the check never ran. A
162
+ hostile page could sign a visitor in to an account of the attacker's choosing (login CSRF); whatever the visitor did
163
+ next, approving an MCP client on the consent page included, happened in the attacker's account.
164
+
165
+ The controller now hands Better-Auth those headers (`Origin`, `Referer`, `Sec-Fetch-*` — not the cookie, see below). A
166
+ refused request answers **403** (`#LTNS_0101`, `ErrorCode.ACCESS_DENIED`) instead of a session, and the server logs the reason.
167
+
168
+ **What to check:**
169
+
170
+ - **Every origin that signs in from a browser must be trusted.** Trusted by default: the API's own origin, `appUrl`,
171
+ every origin in `cors.allowedOrigins` (merged with `appUrl` and `baseUrl`) and, with `ai.mcp.oauth`, the origin of
172
+ `ai.mcp.oauthBaseUrl`. A frontend on any other origin — a second web app, a native app's web view
173
+ (`capacitor://localhost`, `http://localhost`), a preview deployment — now answers 403 where it used to succeed.
174
+ Add it to **`cors.allowedOrigins`**; it needs that entry for credentialed CORS anyway. `cors.allowAll: true` does not
175
+ trust arbitrary origins for sign-in — an origin check has no "allow everything" mode.
176
+ - **Do not reach for `betterAuth.trustedOrigins` for this unless you list everything.** An explicit list REPLACES the
177
+ derived one: `['capacitor://localhost']` alone stops trusting `appUrl` and `cors.allowedOrigins`, and every sign-in from
178
+ the main web app then answers 403 — with the project's test suite still green (see "Test runners" below). If you set
179
+ it, include `appUrl` and every other frontend origin.
180
+ - **Non-browser clients are unaffected:** a request without `Origin`, `Referer` and `Sec-Fetch-Site` (server-to-server,
181
+ API tests, mobile HTTP clients, scripts and SSR routes using Node's built-in `fetch`) is not checked. Node's `fetch`
182
+ sends a lone `Sec-Fetch-Mode: cors`; that is not a browser signal and is not forwarded, because Better-Auth would read
183
+ it as a browser request missing its origin. The cookie is deliberately not forwarded either: its presence alone makes
184
+ Better-Auth demand an `Origin`, which would refuse such clients when they keep a cookie jar.
185
+ - **Test runners:** Better-Auth skips this check under a test runner by default (`isTest()`), so project test suites
186
+ behave as before; set `betterAuth.options.advanced.disableOriginCheck: false` in a spec that must see production
187
+ behaviour.
188
+ - A subclass overriding `signIn()` / `signUp()` that calls `super` keeps the protection; one that calls
189
+ `api.signInEmail()` itself should pass `request: this.csrfCheckedRequest(req, '/sign-in/email')` and
190
+ `asResponse: false` the same way.
191
+
192
+ ## Module Documentation
193
+
194
+ - [AI module README — OAuth 2.1](../src/core/modules/ai/README.md)
195
+ - [AI module integration checklist — MCP OAuth 2.1](../src/core/modules/ai/INTEGRATION-CHECKLIST.md)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lenne.tech/nest-server",
3
- "version": "11.42.15",
3
+ "version": "11.43.0",
4
4
  "description": "Modern, fast, powerful Node.js web framework in TypeScript based on Nest with a GraphQL API and a connection to MongoDB (or other databases).",
5
5
  "keywords": [
6
6
  "node",
@@ -105,7 +105,7 @@
105
105
  "@nestjs/swagger": "11.4.7",
106
106
  "@nestjs/terminus": "11.1.1",
107
107
  "@tus/file-store": "2.1.1",
108
- "@tus/server": "2.4.5",
108
+ "@tus/server": "2.4.6",
109
109
  "@types/supertest": "7.2.1",
110
110
  "bcrypt": "6.0.0",
111
111
  "class-transformer": "0.5.1",
@@ -193,7 +193,7 @@
193
193
  "@nestjs/testing": "11.2.7",
194
194
  "@swc/cli": "0.8.1",
195
195
  "@swc/core": "1.16.18",
196
- "@tus/s3-store": "2.0.7",
196
+ "@tus/s3-store": "2.0.8",
197
197
  "@types/compression": "1.8.1",
198
198
  "@types/cookie-parser": "1.4.10",
199
199
  "@types/ejs": "3.1.5",
@@ -202,15 +202,15 @@
202
202
  "@types/multer": "2.3.0",
203
203
  "@types/node": "26.6.5",
204
204
  "@types/passport": "1.0.17",
205
- "@vitest/coverage-v8": "4.1.11",
205
+ "@vitest/coverage-v8": "4.1.12",
206
206
  "better-auth": "1.7.7",
207
- "bullmq": "6.3.15",
207
+ "bullmq": "6.3.16",
208
208
  "cross-env": "10.1.0",
209
209
  "ioredis": "6.0.0",
210
210
  "nodemon": "3.1.14",
211
211
  "npm-watch": "0.13.0",
212
212
  "otpauth": "9.5.2",
213
- "oxfmt": "0.72.0",
213
+ "oxfmt": "0.73.0",
214
214
  "oxlint": "1.88.0",
215
215
  "rimraf": "6.1.3",
216
216
  "smtp-server": "3.19.23",
@@ -222,7 +222,7 @@
222
222
  "unplugin-swc": "2.0.0",
223
223
  "vite": "8.3.4",
224
224
  "vite-plugin-node": "8.0.0",
225
- "vitest": "4.1.11"
225
+ "vitest": "4.1.12"
226
226
  },
227
227
  "main": "dist/index.js",
228
228
  "types": "dist/index.d.ts",