@lenne.tech/nest-server 12.2.0 → 12.3.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 (152) hide show
  1. package/.claude/rules/better-auth.md +31 -4
  2. package/.claude/rules/configurable-features.md +7 -6
  3. package/.claude/rules/package-management.md +13 -1
  4. package/.claude/rules/persistence.md +2 -2
  5. package/.claude/rules/testing.md +5 -5
  6. package/.claude/rules/versioning.md +13 -2
  7. package/CLAUDE.md +5 -0
  8. package/FRAMEWORK-API.md +4 -3
  9. package/README.md +2 -2
  10. package/dist/config.env.js +2 -2
  11. package/dist/config.env.js.map +1 -1
  12. package/dist/core/common/helpers/graphql-ws-connection.helper.d.ts +4 -0
  13. package/dist/core/common/helpers/graphql-ws-connection.helper.js +17 -3
  14. package/dist/core/common/helpers/graphql-ws-connection.helper.js.map +1 -1
  15. package/dist/core/common/interfaces/server-options.interface.d.ts +8 -0
  16. package/dist/core/modules/ai/core-ai-mcp.controller.d.ts +3 -0
  17. package/dist/core/modules/ai/core-ai-mcp.controller.js +29 -0
  18. package/dist/core/modules/ai/core-ai-mcp.controller.js.map +1 -1
  19. package/dist/core/modules/ai/core-ai.constants.d.ts +2 -0
  20. package/dist/core/modules/ai/core-ai.constants.js +2 -0
  21. package/dist/core/modules/ai/core-ai.constants.js.map +1 -1
  22. package/dist/core/modules/ai/core-ai.controller.d.ts +6 -0
  23. package/dist/core/modules/ai/core-ai.controller.js +31 -1
  24. package/dist/core/modules/ai/core-ai.controller.js.map +1 -1
  25. package/dist/core/modules/ai/core-ai.module.d.ts +2 -0
  26. package/dist/core/modules/ai/core-ai.module.js +8 -1
  27. package/dist/core/modules/ai/core-ai.module.js.map +1 -1
  28. package/dist/core/modules/ai/core-ai.resolver.d.ts +6 -0
  29. package/dist/core/modules/ai/core-ai.resolver.js +32 -0
  30. package/dist/core/modules/ai/core-ai.resolver.js.map +1 -1
  31. package/dist/core/modules/ai/helpers/ai-mcp-oauth-consent.helper.d.ts +65 -1
  32. package/dist/core/modules/ai/helpers/ai-mcp-oauth-consent.helper.js +6 -3
  33. package/dist/core/modules/ai/helpers/ai-mcp-oauth-consent.helper.js.map +1 -1
  34. package/dist/core/modules/ai/helpers/ai-mcp-oauth.helper.js +48 -1
  35. package/dist/core/modules/ai/helpers/ai-mcp-oauth.helper.js.map +1 -1
  36. package/dist/core/modules/ai/helpers/ai-tool-authorization.helper.d.ts +9 -0
  37. package/dist/core/modules/ai/helpers/ai-tool-authorization.helper.js +54 -0
  38. package/dist/core/modules/ai/helpers/ai-tool-authorization.helper.js.map +1 -0
  39. package/dist/core/modules/ai/index.d.ts +7 -0
  40. package/dist/core/modules/ai/index.js +6 -0
  41. package/dist/core/modules/ai/index.js.map +1 -1
  42. package/dist/core/modules/ai/inputs/core-ai-settings.input.d.ts +3 -0
  43. package/dist/core/modules/ai/inputs/core-ai-settings.input.js +31 -0
  44. package/dist/core/modules/ai/inputs/core-ai-settings.input.js.map +1 -0
  45. package/dist/core/modules/ai/interfaces/ai-tool.interface.d.ts +2 -0
  46. package/dist/core/modules/ai/models/core-ai-settings.model.d.ts +4 -0
  47. package/dist/core/modules/ai/models/core-ai-settings.model.js +30 -0
  48. package/dist/core/modules/ai/models/core-ai-settings.model.js.map +1 -0
  49. package/dist/core/modules/ai/models/core-ai-user-setting.model.d.ts +8 -0
  50. package/dist/core/modules/ai/models/core-ai-user-setting.model.js +45 -0
  51. package/dist/core/modules/ai/models/core-ai-user-setting.model.js.map +1 -0
  52. package/dist/core/modules/ai/services/core-ai-conversation.service.d.ts +4 -1
  53. package/dist/core/modules/ai/services/core-ai-conversation.service.js +16 -4
  54. package/dist/core/modules/ai/services/core-ai-conversation.service.js.map +1 -1
  55. package/dist/core/modules/ai/services/core-ai-mcp-oauth.service.js +6 -6
  56. package/dist/core/modules/ai/services/core-ai-mcp-oauth.service.js.map +1 -1
  57. package/dist/core/modules/ai/services/core-ai-mcp.service.d.ts +27 -2
  58. package/dist/core/modules/ai/services/core-ai-mcp.service.js +118 -11
  59. package/dist/core/modules/ai/services/core-ai-mcp.service.js.map +1 -1
  60. package/dist/core/modules/ai/services/core-ai-prompt-builder.service.js +9 -6
  61. package/dist/core/modules/ai/services/core-ai-prompt-builder.service.js.map +1 -1
  62. package/dist/core/modules/ai/services/core-ai-user-setting.service.d.ts +17 -0
  63. package/dist/core/modules/ai/services/core-ai-user-setting.service.js +72 -0
  64. package/dist/core/modules/ai/services/core-ai-user-setting.service.js.map +1 -0
  65. package/dist/core/modules/ai/services/core-ai.service.d.ts +8 -3
  66. package/dist/core/modules/ai/services/core-ai.service.js +68 -29
  67. package/dist/core/modules/ai/services/core-ai.service.js.map +1 -1
  68. package/dist/core/modules/better-auth/better-auth.config.js +3 -1
  69. package/dist/core/modules/better-auth/better-auth.config.js.map +1 -1
  70. package/dist/core/modules/better-auth/core-better-auth-email-verification.service.js +2 -2
  71. package/dist/core/modules/better-auth/core-better-auth-email-verification.service.js.map +1 -1
  72. package/dist/core/modules/better-auth/core-better-auth-path-guard.middleware.d.ts +8 -0
  73. package/dist/core/modules/better-auth/core-better-auth-path-guard.middleware.js +64 -0
  74. package/dist/core/modules/better-auth/core-better-auth-path-guard.middleware.js.map +1 -0
  75. package/dist/core/modules/better-auth/core-better-auth-rate-limit.middleware.js +2 -1
  76. package/dist/core/modules/better-auth/core-better-auth-rate-limit.middleware.js.map +1 -1
  77. package/dist/core/modules/better-auth/core-better-auth-rate-limiter.service.d.ts +1 -1
  78. package/dist/core/modules/better-auth/core-better-auth-rate-limiter.service.js +5 -3
  79. package/dist/core/modules/better-auth/core-better-auth-rate-limiter.service.js.map +1 -1
  80. package/dist/core/modules/better-auth/core-better-auth.module.js +2 -0
  81. package/dist/core/modules/better-auth/core-better-auth.module.js.map +1 -1
  82. package/dist/core/modules/better-auth/index.d.ts +1 -0
  83. package/dist/core/modules/better-auth/index.js +1 -0
  84. package/dist/core/modules/better-auth/index.js.map +1 -1
  85. package/dist/core/modules/error-code/error-codes.d.ts +18 -0
  86. package/dist/core/modules/error-code/error-codes.js +16 -0
  87. package/dist/core/modules/error-code/error-codes.js.map +1 -1
  88. package/dist/core/modules/sync/core-sync.service.d.ts +1 -0
  89. package/dist/core/modules/sync/core-sync.service.js +31 -2
  90. package/dist/core/modules/sync/core-sync.service.js.map +1 -1
  91. package/dist/core/modules/tenant/core-tenant.guard.js +10 -0
  92. package/dist/core/modules/tenant/core-tenant.guard.js.map +1 -1
  93. package/dist/core/modules/tenant/core-tenant.service.d.ts +21 -1
  94. package/dist/core/modules/tenant/core-tenant.service.js +153 -1
  95. package/dist/core/modules/tenant/core-tenant.service.js.map +1 -1
  96. package/dist/server/modules/error-code/error-codes.d.ts +2 -0
  97. package/dist/test/test.helper.d.ts +1 -0
  98. package/dist/test/test.helper.js +9 -2
  99. package/dist/test/test.helper.js.map +1 -1
  100. package/docs/REQUEST-LIFECYCLE.md +3 -2
  101. package/docs/error-codes.md +14 -0
  102. package/docs/offline-sync-protocol.md +17 -8
  103. package/migration-guides/12.0.x-to-12.1.x.md +0 -1
  104. package/migration-guides/12.2.x-to-12.3.x.md +440 -0
  105. package/package.json +20 -20
  106. package/src/config.env.ts +6 -2
  107. package/src/core/common/helpers/graphql-ws-connection.helper.ts +32 -4
  108. package/src/core/common/interfaces/server-options.interface.ts +80 -8
  109. package/src/core/common/persistence/sync-model.ts +1 -1
  110. package/src/core/modules/ai/INTEGRATION-CHECKLIST.md +36 -15
  111. package/src/core/modules/ai/README.md +163 -25
  112. package/src/core/modules/ai/core-ai-mcp.controller.ts +48 -1
  113. package/src/core/modules/ai/core-ai.constants.ts +6 -0
  114. package/src/core/modules/ai/core-ai.controller.ts +36 -1
  115. package/src/core/modules/ai/core-ai.module.ts +12 -0
  116. package/src/core/modules/ai/core-ai.resolver.ts +36 -0
  117. package/src/core/modules/ai/helpers/ai-mcp-oauth-consent.helper.ts +35 -4
  118. package/src/core/modules/ai/helpers/ai-mcp-oauth.helper.ts +68 -1
  119. package/src/core/modules/ai/helpers/ai-tool-authorization.helper.ts +99 -0
  120. package/src/core/modules/ai/index.ts +18 -0
  121. package/src/core/modules/ai/inputs/core-ai-settings.input.ts +24 -0
  122. package/src/core/modules/ai/interfaces/ai-hook.interface.ts +1 -1
  123. package/src/core/modules/ai/interfaces/ai-tool.interface.ts +38 -26
  124. package/src/core/modules/ai/models/core-ai-mode.model.ts +1 -1
  125. package/src/core/modules/ai/models/core-ai-settings.model.ts +23 -0
  126. package/src/core/modules/ai/models/core-ai-tool-policy.model.ts +1 -1
  127. package/src/core/modules/ai/models/core-ai-user-setting.model.ts +46 -0
  128. package/src/core/modules/ai/services/core-ai-conversation.service.ts +31 -6
  129. package/src/core/modules/ai/services/core-ai-mcp-oauth.service.ts +12 -9
  130. package/src/core/modules/ai/services/core-ai-mcp.service.ts +206 -13
  131. package/src/core/modules/ai/services/core-ai-prompt-builder.service.ts +13 -6
  132. package/src/core/modules/ai/services/core-ai-user-setting.service.ts +85 -0
  133. package/src/core/modules/ai/services/core-ai.service.ts +134 -35
  134. package/src/core/modules/api-token/README.md +4 -2
  135. package/src/core/modules/better-auth/CUSTOMIZATION.md +1 -1
  136. package/src/core/modules/better-auth/INTEGRATION-CHECKLIST.md +7 -0
  137. package/src/core/modules/better-auth/README.md +32 -1
  138. package/src/core/modules/better-auth/better-auth.config.ts +12 -1
  139. package/src/core/modules/better-auth/core-better-auth-email-verification.service.ts +5 -2
  140. package/src/core/modules/better-auth/core-better-auth-path-guard.middleware.ts +85 -0
  141. package/src/core/modules/better-auth/core-better-auth-rate-limit.middleware.ts +5 -2
  142. package/src/core/modules/better-auth/core-better-auth-rate-limiter.service.ts +14 -7
  143. package/src/core/modules/better-auth/core-better-auth.module.ts +12 -2
  144. package/src/core/modules/better-auth/index.ts +1 -0
  145. package/src/core/modules/error-code/error-codes.ts +18 -0
  146. package/src/core/modules/sync/README.md +11 -1
  147. package/src/core/modules/sync/core-sync.service.ts +48 -3
  148. package/src/core/modules/tenant/INTEGRATION-CHECKLIST.md +12 -1
  149. package/src/core/modules/tenant/README.md +49 -3
  150. package/src/core/modules/tenant/core-tenant.guard.ts +18 -2
  151. package/src/core/modules/tenant/core-tenant.service.ts +287 -2
  152. package/src/test/test.helper.ts +17 -3
@@ -125,9 +125,31 @@ export function mcpOAuthConsentHeaders(nonce: string): Record<string, string> {
125
125
  };
126
126
  }
127
127
 
128
+ /** Every text of the page in one locale. */
129
+ export type McpOAuthConsentTexts = (typeof TEXTS)[McpOAuthConsentLocale];
130
+
131
+ /**
132
+ * Project adjustments for {@link renderMcpOAuthConsentPage}: replacement texts per locale (merged over
133
+ * the built-in ones, so a project changes only what it needs, e.g. its form of address) and CSS
134
+ * appended after the built-in styles (its colours and font). Neither touches the markup, the scripts or
135
+ * the security headers — what makes the page safe stays the framework's.
136
+ *
137
+ * Fill both from code or configuration, never from user or tenant input. Texts are escaped like the
138
+ * built-in ones; styles cannot leave their `<style>` element (every `<` becomes the CSS escape `\3c `), and
139
+ * the page's CSP blocks what CSS could load from elsewhere (`img-src 'self' data:`, no font source, no
140
+ * `@import` without the nonce) — but CSS still decides what the page shows.
141
+ */
142
+ export interface IMcpOAuthConsentPageOptions {
143
+ styles?: string;
144
+ texts?: Partial<Record<McpOAuthConsentLocale, Partial<McpOAuthConsentTexts>>>;
145
+ }
146
+
128
147
  /** Renders the page. Every dynamic value goes through {@link escapeHtml}. */
129
- export function renderMcpOAuthConsentPage(view: IMcpOAuthConsentView): string {
130
- const t = TEXTS[view.locale];
148
+ export function renderMcpOAuthConsentPage(
149
+ view: IMcpOAuthConsentView,
150
+ options: IMcpOAuthConsentPageOptions = {},
151
+ ): string {
152
+ const t: Texts = { ...TEXTS[view.locale], ...options.texts?.[view.locale] };
131
153
  const clientName = view.client.name?.trim() || t.unnamedClient;
132
154
  // One pass: a client name is chosen by whoever registered the client, so a name containing
133
155
  // `{app}` must not be expanded by a second replace.
@@ -146,7 +168,7 @@ export function renderMcpOAuthConsentPage(view: IMcpOAuthConsentView): string {
146
168
  <meta name="robots" content="noindex">
147
169
  <link rel="icon" href="data:,">
148
170
  <title>${escapeHtml(t.title)} – ${escapeHtml(view.appHost)}</title>
149
- <style nonce="${escapeHtml(view.nonce)}">${STYLES}</style>
171
+ <style nonce="${escapeHtml(view.nonce)}">${STYLES}${confinedStyles(options.styles)}</style>
150
172
  </head>
151
173
  <body>
152
174
  <main class="card">
@@ -158,7 +180,16 @@ ${pageScript(view)}
158
180
  `;
159
181
  }
160
182
 
161
- type Texts = (typeof TEXTS)[McpOAuthConsentLocale];
183
+ type Texts = McpOAuthConsentTexts;
184
+
185
+ /**
186
+ * Project CSS that cannot end the `<style>` element: a `</style>` in it would continue as markup — an injected
187
+ * form, for one, since the page sets no `form-action`. `<` has no meaning in CSS outside strings and comments, and
188
+ * the CSS escape `\3c ` keeps it where it does.
189
+ */
190
+ function confinedStyles(styles: string | undefined): string {
191
+ return typeof styles === 'string' ? styles.replace(/</g, '\\3c ') : '';
192
+ }
162
193
 
163
194
  function consentView(view: IMcpOAuthConsentView, t: Texts, fill: (text: string) => string): string {
164
195
  const target = view.redirect.loopback
@@ -122,6 +122,7 @@ async function mountRouter(
122
122
  const { getOAuthProtectedResourceMetadataUrl, mcpAuthRouter } =
123
123
  await import('@modelcontextprotocol/sdk/server/auth/router.js');
124
124
  const provider = oauthService.buildOAuthProvider() as any;
125
+ markAuthorizeReached(provider);
125
126
 
126
127
  let router: unknown;
127
128
  try {
@@ -136,7 +137,7 @@ async function mountRouter(
136
137
  });
137
138
  }
138
139
 
139
- use(router);
140
+ use(guardErrorRedirects(router as (req: any, res: any, next: (error?: unknown) => void) => void));
140
141
  const mounted: IMountedMcpOAuth = {
141
142
  authBasePath: options.authBasePath,
142
143
  baseUrl: baseUrl.replace(/\/$/, ''),
@@ -153,6 +154,72 @@ async function mountRouter(
153
154
  return mounted;
154
155
  }
155
156
 
157
+ /** `res.locals` key: the SDK's authorize handler handed this request to the provider */
158
+ const AUTHORIZE_REACHED = Symbol('ltMcpOAuthAuthorizeReached');
159
+
160
+ /** Marks every request the provider's `authorize` receives, so {@link refuseUnvouchedErrorRedirects} lets its redirects pass */
161
+ function markAuthorizeReached(provider: { authorize?: (...args: any[]) => Promise<void> }): void {
162
+ const authorize = provider.authorize;
163
+ if (typeof authorize !== 'function') {
164
+ return;
165
+ }
166
+ provider.authorize = (client: unknown, params: unknown, res: any) => {
167
+ if (res) {
168
+ res.locals = res.locals ?? {};
169
+ res.locals[AUTHORIZE_REACHED] = true;
170
+ }
171
+ return authorize.call(provider, client, params, res);
172
+ };
173
+ }
174
+
175
+ /**
176
+ * The SDK router behind a guard on `/authorize`: an error the SDK finds after the client check (a missing
177
+ * `code_challenge`, a wrong `response_type`) is answered here, never sent to the client's redirect URI. Dynamic client
178
+ * registration takes any redirect URI — it must, for claude.ai, ChatGPT, Cursor and Claude Code — so that 302 made
179
+ * `/authorize` an open redirect on this domain for whoever registered a client (RFC 9700 §4.11.2). The answer has the
180
+ * shape of the SDK's own errors before the client check. What the provider does keeps its redirects: the signed-in
181
+ * user's Allow or Deny, which carry the consent form's token, and the provider's own refusals. One handler, so the
182
+ * router is still mounted with a single `use()`.
183
+ */
184
+ function guardErrorRedirects(
185
+ router: (req: any, res: any, next: (error?: unknown) => void) => void,
186
+ ): (req: any, res: any, next: (error?: unknown) => void) => void {
187
+ return (req, res, next) => {
188
+ if (
189
+ String(req.path ?? '')
190
+ .toLowerCase()
191
+ .replace(/\/+$/, '') === '/authorize'
192
+ ) {
193
+ refuseUnvouchedErrorRedirects(res);
194
+ }
195
+ router(req, res, next);
196
+ };
197
+ }
198
+
199
+ /** Answers an error redirect of the SDK directly unless the request reached the provider's `authorize` */
200
+ function refuseUnvouchedErrorRedirects(res: any): void {
201
+ const redirect = res.redirect.bind(res);
202
+ res.redirect = (...args: unknown[]) => {
203
+ const target = typeof args[0] === 'number' ? args[1] : args[0];
204
+ let error: null | string = null;
205
+ let description: null | string = null;
206
+ try {
207
+ const url = new URL(String(target));
208
+ error = url.searchParams.get('error');
209
+ description = url.searchParams.get('error_description');
210
+ } catch {
211
+ // Not an absolute URL: nothing the SDK's error redirect would produce
212
+ }
213
+ if (error && !res.locals?.[AUTHORIZE_REACHED]) {
214
+ res
215
+ .status(error === 'server_error' ? 500 : 400)
216
+ .json({ error, ...(description ? { error_description: description } : {}) });
217
+ return;
218
+ }
219
+ return redirect(...args);
220
+ };
221
+ }
222
+
156
223
  /** The Express instance behind a Nest application, or the object itself for any other shape. */
157
224
  function mountTargetOf(app: IAiMcpOAuthMountTarget): object {
158
225
  const instance = app.getHttpAdapter?.()?.getInstance?.();
@@ -0,0 +1,99 @@
1
+ import { Logger } from '@nestjs/common';
2
+
3
+ import { ConfigService } from '../../../common/services/config.service.js';
4
+ import type { AiToolAuthorization, AiToolContext, IAiTool } from '../interfaces/ai-tool.interface.js';
5
+
6
+ const logger = new Logger('AiToolAuthorization');
7
+
8
+ /**
9
+ * Runs a tool's `authorize()` for one call. Shared by the chat orchestrator (auto and plan mode) and the MCP
10
+ * endpoint, so a data-level check holds on every path a tool is executed on (12.3.0+).
11
+ *
12
+ * Fails closed: a thrown `authorize()`, a missing answer or an `allowed` that is not exactly `true` is a refusal. The
13
+ * error itself is logged, never handed to the model or the MCP client, because it may name records or internals.
14
+ * A tool without `authorize()` is allowed — the registry role filter has already decided that the user may see it.
15
+ */
16
+ export async function authorizeToolCall(
17
+ tool: IAiTool,
18
+ args: Record<string, any>,
19
+ context: AiToolContext,
20
+ ): Promise<AiToolAuthorization> {
21
+ if (!tool.authorize) {
22
+ return { allowed: true };
23
+ }
24
+ try {
25
+ const result = await tool.authorize(args, context);
26
+ if (typeof result === 'boolean') {
27
+ return { allowed: result };
28
+ }
29
+ if (!result || typeof result !== 'object') {
30
+ return { allowed: false };
31
+ }
32
+ return { ...result, allowed: result.allowed === true };
33
+ } catch (err) {
34
+ logger.warn(`authorize() of AI tool "${tool.name}" threw, call refused: ${(err as Error).message}`);
35
+ return { allowed: false, reason: 'The authorization check for this action failed.' };
36
+ }
37
+ }
38
+
39
+ /**
40
+ * Whether a call of this tool needs the user's confirmation before it runs: a `destructive` tool always, a
41
+ * `mutating` tool as the `ai.confirmation.mutating` policy says (`enforced`, else the caller's `requireConfirmation`,
42
+ * else `default`), any other tool never.
43
+ *
44
+ * `requireConfirmation` is the chat client's per-prompt wish; the MCP endpoint passes none, so there the policy's
45
+ * `default` decides.
46
+ */
47
+ export function toolRequiresConfirmation(tool: IAiTool, requireConfirmation?: boolean): boolean {
48
+ if (tool.destructive) {
49
+ return true;
50
+ }
51
+ if (!tool.mutating) {
52
+ return false;
53
+ }
54
+ const cfg = ConfigService.get<{ default?: boolean; enabled?: boolean; enforced?: boolean }>(
55
+ 'ai.confirmation.mutating',
56
+ );
57
+ if (cfg?.enforced === true) {
58
+ return true;
59
+ }
60
+ return requireConfirmation ?? cfg?.default === true;
61
+ }
62
+
63
+ /**
64
+ * The server's MCP confirmation policy, from `ai.mcp.confirmation` (12.3.0+): `default` is whether MCP asks a user
65
+ * who has not chosen for themselves, `enforced` whether users may choose at all.
66
+ */
67
+ export interface AiMcpConfirmationPolicy {
68
+ default: boolean;
69
+ enforced: boolean;
70
+ }
71
+
72
+ /**
73
+ * Reads `ai.mcp.confirmation`: `false` → asks nobody unless a user switches it on; `true`, `{}`, unset or any other
74
+ * value → asks everybody unless a user switches it off; `{ default, enforced }` as written. Only `false` switches the
75
+ * default off and only `true` enforces, so a misspelt value never relaxes the gate.
76
+ */
77
+ export function mcpConfirmationPolicy(): AiMcpConfirmationPolicy {
78
+ const mcp = ConfigService.get<unknown>('ai.mcp');
79
+ const setting = mcp && typeof mcp === 'object' ? (mcp as { confirmation?: unknown }).confirmation : undefined;
80
+ if (setting === false) {
81
+ return { default: false, enforced: false };
82
+ }
83
+ if (setting && typeof setting === 'object') {
84
+ const { default: defaultValue, enforced } = setting as { default?: unknown; enforced?: unknown };
85
+ return { default: defaultValue !== false, enforced: enforced === true };
86
+ }
87
+ return { default: true, enforced: false };
88
+ }
89
+
90
+ /**
91
+ * Whether MCP asks a user before a tool that needs confirmation: the user's own choice, unless the policy is
92
+ * enforced or the user has not chosen.
93
+ */
94
+ export function effectiveMcpConfirmation(policy: AiMcpConfirmationPolicy, userChoice?: boolean | null): boolean {
95
+ if (policy.enforced || typeof userChoice !== 'boolean') {
96
+ return policy.default;
97
+ }
98
+ return userChoice;
99
+ }
@@ -2,6 +2,20 @@
2
2
  export * from './core-ai-mcp-oauth.initializer.js';
3
3
  export * from './core-ai-mcp.controller.js';
4
4
  export * from './helpers/ai-mcp-oauth.helper.js';
5
+ // Named on purpose: the page's scripts and security headers stay internal, so a project cannot pin them
6
+ export {
7
+ MCP_OAUTH_CONSENT_DECISION_FIELD,
8
+ MCP_OAUTH_CONSENT_TOKEN_FIELD,
9
+ mcpOAuthConsentLocale,
10
+ renderMcpOAuthConsentPage,
11
+ } from './helpers/ai-mcp-oauth-consent.helper.js';
12
+ export type {
13
+ IMcpOAuthConsentPageOptions,
14
+ IMcpOAuthConsentView,
15
+ McpOAuthConsentLocale,
16
+ McpOAuthConsentTexts,
17
+ } from './helpers/ai-mcp-oauth-consent.helper.js';
18
+ export * from './helpers/ai-tool-authorization.helper.js';
5
19
  export * from './core-ai.controller.js';
6
20
  export * from './core-ai.module.js';
7
21
  export * from './core-ai.resolver.js';
@@ -19,6 +33,7 @@ export * from './inputs/core-ai-prompt-update.input.js';
19
33
  export * from './inputs/core-ai-slot-create.input.js';
20
34
  export * from './inputs/core-ai-slot-update.input.js';
21
35
  export * from './inputs/core-ai-prompt.input.js';
36
+ export * from './inputs/core-ai-settings.input.js';
22
37
  export * from './hooks/ai-hook.base.js';
23
38
  export * from './hooks/ai-hook.registry.js';
24
39
  export * from './interfaces/ai-interaction-record.interface.js';
@@ -41,6 +56,8 @@ export * from './models/core-ai-slot.model.js';
41
56
  export * from './models/core-ai-tool-grant.model.js';
42
57
  export * from './models/core-ai-mode.model.js';
43
58
  export * from './models/core-ai-tool-policy.model.js';
59
+ export * from './models/core-ai-settings.model.js';
60
+ export * from './models/core-ai-user-setting.model.js';
44
61
  export * from './models/core-ai-response.model.js';
45
62
  export * from './models/core-ai-usage-info.model.js';
46
63
  export * from './models/core-ai-usage.model.js';
@@ -65,6 +82,7 @@ export * from './services/core-ai-tool-grant.service.js';
65
82
  export * from './services/core-ai-mcp-client.service.js';
66
83
  export * from './services/core-ai-mode.service.js';
67
84
  export * from './services/core-ai-tool-policy.service.js';
85
+ export * from './services/core-ai-user-setting.service.js';
68
86
  export * from './services/core-ai.service.js';
69
87
  export * from './tools/ai-tool.base.js';
70
88
  export * from './tools/ai-tool.registry.js';
@@ -0,0 +1,24 @@
1
+ import { InputType } from '@nestjs/graphql';
2
+
3
+ import { Restricted } from '../../../common/decorators/restricted.decorator.js';
4
+ import { UnifiedField } from '../../../common/decorators/unified-field.decorator.js';
5
+ import { RoleEnum } from '../../../common/enums/role.enum.js';
6
+
7
+ /**
8
+ * Changes to the current user's own AI settings (12.3.0+). A field left out stays as it is.
9
+ */
10
+ @InputType({ description: "Changes to the current user's AI settings" })
11
+ @Restricted(RoleEnum.S_USER)
12
+ export class CoreAiSettingsInput {
13
+ /**
14
+ * Whether MCP asks before a tool that needs confirmation. Refused with 403 while the server enforces the setting
15
+ * (`ai.mcp.confirmation.enforced`).
16
+ */
17
+ @UnifiedField({
18
+ description: 'Whether MCP asks before a tool that needs confirmation',
19
+ isOptional: true,
20
+ roles: RoleEnum.S_USER,
21
+ type: () => Boolean,
22
+ })
23
+ mcpConfirmation?: boolean = undefined;
24
+ }
@@ -39,7 +39,7 @@ export interface AiHookEvent {
39
39
  * **Security:** hooks can only ADD restrictions (block calls, redact args) — they
40
40
  * cannot relax the permission system. A hook returning no block does not bypass
41
41
  * `@Restricted`/`@Roles`/`securityCheck()`; those still apply on every path
42
- * (`authorize()` only runs in plan mode).
42
+ * (`authorize()` runs before every execution since 12.3.0, on the arguments after the PreToolUse hooks).
43
43
  */
44
44
  export interface IAiHook {
45
45
  /** Unique hook name (for diagnostics and deterministic ordering). */
@@ -12,7 +12,13 @@ export interface AiToolContext {
12
12
  /** Authenticated user (shortcut for `serviceOptions.currentUser`). */
13
13
  currentUser?: { [key: string]: any; id: string; roles?: string[] };
14
14
 
15
- /** Accept-Language of the original request. */
15
+ /**
16
+ * The decision {@link IAiTool.authorize} returned for THIS call, handed to `execute()` (12.3.0+). Lets a tool reuse
17
+ * what its check loaded (`data`) instead of loading it twice. Absent for a tool without `authorize()`.
18
+ */
19
+ authorization?: AiToolAuthorization;
20
+
21
+ /** Accept-Language of the original request. Absent over MCP, which carries no request language. */
16
22
  language?: string;
17
23
 
18
24
  /**
@@ -26,10 +32,19 @@ export interface AiToolContext {
26
32
  * Result of a tool's pre-flight authorization check ({@link IAiTool.authorize}).
27
33
  */
28
34
  export interface AiToolAuthorization {
29
- /** Whether the user may run the tool with these arguments. */
35
+ /** Whether the user may run the tool with these arguments. Only `true` allows; anything else refuses. */
30
36
  allowed: boolean;
31
37
 
32
- /** Optional human-readable reason when not allowed. */
38
+ /**
39
+ * Whatever the check loaded and `execute()` may reuse (e.g. the record whose ownership it verified). Reaches
40
+ * `execute()` as `context.authorization.data`; never sent to the model or the client.
41
+ */
42
+ data?: unknown;
43
+
44
+ /**
45
+ * Optional reason when not allowed. It reaches the model (chat) or the MCP client, so it must not name anything
46
+ * the user may not know.
47
+ */
33
48
  reason?: string;
34
49
  }
35
50
 
@@ -69,21 +84,15 @@ export interface IAiTool {
69
84
 
70
85
  /**
71
86
  * Whether the tool performs a destructive/irreversible action (delete, bulk
72
- * update, payment, …). In the CHAT orchestrator destructive tools always require
73
- * confirmation: they are NOT executed until the prompt is re-sent with
74
- * `confirm: true`; the first response lists them as `pendingActions` with
75
- * `requiresConfirmation: true`.
87
+ * update, payment, …). Such a tool always needs the user's confirmation:
76
88
  *
77
- * **No confirmation gate over MCP.** `CoreAiMcpService.mcpCallTool` consults
78
- * neither this flag nor {@link IAiTool.mutating}, so a destructive tool invoked
79
- * through `/ai/mcp` executes IMMEDIATELY, on the first call. This flag is
80
- * therefore a chat-orchestrator contract, not a global execution barrier. The
81
- * barriers that DO hold on every path are the registry role filter ({@link
82
- * IAiTool.roles}, applied by `forUser()` before `execute()`) and the authorization
83
- * inside `execute()` itself — so a destructive tool restricted to a real role stays
84
- * unreachable by lesser-privileged MCP clients; MCP only skips the extra confirmation
85
- * step for clients that may already see the tool. Expose MCP only to clients you trust
86
- * to obtain user consent themselves.
89
+ * - **Chat:** it is NOT executed until the prompt is re-sent with `confirm: true`;
90
+ * the first response lists it as `pendingActions` with `requiresConfirmation: true`.
91
+ * - **MCP (12.3.0+):** `tools/call` asks the user through MCP elicitation and runs the
92
+ * tool only after they accepted. A client that cannot ask (no `elicitation`
93
+ * capability) gets a refusal. Whether MCP asks is `ai.mcp.confirmation` (on by
94
+ * default), which each user may switch for themselves unless it is enforced;
95
+ * `tools/list` marks the tool with `destructiveHint: true`.
87
96
  */
88
97
  readonly destructive?: boolean;
89
98
 
@@ -93,22 +102,25 @@ export interface IAiTool {
93
102
  * optionally client-overridable, optionally enforced). `destructive` is the
94
103
  * stronger flag and always requires confirmation regardless of policy.
95
104
  *
96
- * Same MCP caveat as {@link IAiTool.destructive}: the confirmation policy is not
97
- * evaluated on the `/ai/mcp` path at all.
105
+ * Over MCP (12.3.0+) the same policy applies through elicitation, with the
106
+ * policy's `default` in place of the chat client's `requireConfirmation`.
98
107
  */
99
108
  readonly mutating?: boolean;
100
109
 
101
110
  /**
102
- * Optional pre-flight authorization check. MUST NOT mutate anything — it only
111
+ * Optional authorization check for one call. MUST NOT mutate anything — it only
103
112
  * decides whether the user may run the tool with these arguments (e.g. load the
104
113
  * target record and verify ownership).
105
114
  *
106
- * **Runs in PLAN MODE ONLY.** Auto mode (the default, `ai.defaultMode`) and the
107
- * MCP endpoint call {@link IAiTool.execute} directly, without consulting this
108
- * method. A data-level check placed ONLY here therefore does not run for most
109
- * callers — put ownership and tenant checks INSIDE `execute()`, routed through
110
- * `CrudService` with `context.serviceOptions`, and treat `authorize()` as the
111
- * plan-mode pre-flight that lets a whole plan be rejected before any step runs.
115
+ * **Runs before every execution (12.3.0+)**: in auto mode, in plan mode (once for
116
+ * the whole plan before any step runs, and again before each step) and over MCP. It
117
+ * sees the arguments that will be executed, i.e. after the PreToolUse hooks. A refusal,
118
+ * a thrown error or an `allowed` that is not `true` skips the call. Return `data` to
119
+ * hand what the check loaded to `execute()` (`context.authorization.data`).
120
+ *
121
+ * Still route the data access in `execute()` through `CrudService` with
122
+ * `context.serviceOptions`: `authorize()` decides whether to try, the service decides
123
+ * what the user may read and write.
112
124
  *
113
125
  * When omitted, only the registry role filter applies.
114
126
  */
@@ -19,7 +19,7 @@ export type AiModeDocument = CoreAiMode & Document;
19
19
  * domain-specialized assistants without forking the orchestrator. Like every
20
20
  * other ai layer, modes can only ADD restrictions; they cannot relax the
21
21
  * permission model (`@Restricted` / `@Roles` / `securityCheck()` still apply;
22
- * `authorize()` only in plan mode).
22
+ * `authorize()` before every execution).
23
23
  */
24
24
  @MongooseSchema({ collection: 'aiModes', timestamps: true })
25
25
  @ObjectType({ description: 'Named agent mode' })
@@ -0,0 +1,23 @@
1
+ import { Field, ObjectType } from '@nestjs/graphql';
2
+
3
+ import { Restricted } from '../../../common/decorators/restricted.decorator.js';
4
+ import { RoleEnum } from '../../../common/enums/role.enum.js';
5
+
6
+ /**
7
+ * The current user's AI settings as they take effect (12.3.0+): the user's own choice where the server allows one,
8
+ * else the server configuration. Answer of `GET/PUT /ai/settings`, `aiSettings` and `updateAiSettings`.
9
+ */
10
+ @ObjectType({ description: "The current user's effective AI settings" })
11
+ @Restricted(RoleEnum.S_EVERYONE)
12
+ export class CoreAiSettings {
13
+ /**
14
+ * Whether MCP asks this user before a tool that needs confirmation (`destructive`, or `mutating` under
15
+ * `ai.confirmation.mutating`). `false` works like a "bypass permissions" mode for this user's MCP clients.
16
+ */
17
+ @Field(() => Boolean, { description: 'Whether MCP asks before a tool that needs confirmation' })
18
+ mcpConfirmation: boolean;
19
+
20
+ /** Whether the server fixed {@link mcpConfirmation} (`ai.mcp.confirmation.enforced`), so the user cannot change it. */
21
+ @Field(() => Boolean, { description: 'Whether the server fixed mcpConfirmation, so it cannot be changed' })
22
+ mcpConfirmationEnforced: boolean;
23
+ }
@@ -51,7 +51,7 @@ export class CoreAiToolPolicyRule {
51
51
  * `user`. A more specific scope wins over a more generic one. Hints only ever
52
52
  * tighten or relax the confirmation gate; the underlying permission model
53
53
  * (`@Restricted`, `@Roles`, `securityCheck()`) is enforced regardless;
54
- * `authorize()` adds to that in plan mode only.
54
+ * `authorize()` adds to that before every execution. Rules apply in the chat and over MCP (12.3.0+).
55
55
  */
56
56
  @MongooseSchema({ collection: 'aiToolPolicies', timestamps: true })
57
57
  @ObjectType({ description: 'Admin-editable scoped policy for a tool call' })
@@ -0,0 +1,46 @@
1
+ import { ObjectType } from '@nestjs/graphql';
2
+ import { Schema as MongooseSchema, SchemaFactory } from '../../../common/persistence/mongo/mongoose-compat.js';
3
+ import type { Document } from 'mongoose';
4
+
5
+ import { Restricted } from '../../../common/decorators/restricted.decorator.js';
6
+ import { UnifiedField } from '../../../common/decorators/unified-field.decorator.js';
7
+ import { RoleEnum } from '../../../common/enums/role.enum.js';
8
+ import { CorePersistenceModel } from '../../../common/models/core-persistence.model.js';
9
+
10
+ export type AiUserSettingDocument = CoreAiUserSetting & Document;
11
+
12
+ /**
13
+ * Personal AI settings of one user (12.3.0+), one record per user. Only what the user chose is stored; an unset
14
+ * field follows the server configuration.
15
+ *
16
+ * Users read and change their own settings through `GET/PUT /ai/settings` and the `aiSettings` /
17
+ * `updateAiSettings` operations, which answer with {@link CoreAiSettings} (the effective values), never with this
18
+ * record — hence admin-only here.
19
+ */
20
+ @MongooseSchema({ collection: 'aiUserSettings', timestamps: true })
21
+ @ObjectType({ description: 'Personal AI settings of one user' })
22
+ @Restricted(RoleEnum.ADMIN)
23
+ export class CoreAiUserSetting extends CorePersistenceModel {
24
+ /**
25
+ * Whether MCP asks this user before a tool that needs confirmation. Unset follows `ai.mcp.confirmation`; ignored
26
+ * while that is `enforced`.
27
+ */
28
+ @UnifiedField({
29
+ description: 'Whether MCP asks this user before a tool that needs confirmation (unset = server default)',
30
+ isOptional: true,
31
+ mongoose: true,
32
+ roles: RoleEnum.ADMIN,
33
+ type: () => Boolean,
34
+ })
35
+ mcpConfirmation?: boolean = undefined;
36
+
37
+ /** The user the settings belong to. */
38
+ @UnifiedField({
39
+ description: 'The user the settings belong to',
40
+ mongoose: { unique: true },
41
+ roles: RoleEnum.ADMIN,
42
+ })
43
+ userId: string = undefined;
44
+ }
45
+
46
+ export const AiUserSettingSchema = SchemaFactory.createForClass(CoreAiUserSetting);
@@ -1,4 +1,4 @@
1
- import { Inject, Injectable } from '@nestjs/common';
1
+ import { Inject, Injectable, Logger } from '@nestjs/common';
2
2
  import { InjectConnection, InjectModel } from '../../../common/persistence/mongo/mongoose-compat.js';
3
3
  import type { Connection, Model } from 'mongoose';
4
4
 
@@ -41,6 +41,8 @@ export class CoreAiConversationService extends CrudService<
41
41
  CoreAiConversationCreateInput,
42
42
  CoreAiConversationInput
43
43
  > {
44
+ protected readonly logger = new Logger(CoreAiConversationService.name);
45
+
44
46
  /** Maximum number of messages retained per conversation (capped on `$push`). */
45
47
  protected readonly maxRetainedMessages = 500;
46
48
 
@@ -155,12 +157,35 @@ export class CoreAiConversationService extends CrudService<
155
157
  }
156
158
 
157
159
  /**
158
- * Append a message to a conversation via `$push` (system-internal; the user
159
- * already authorized the prompt that produced it). The array is capped at
160
- * {@link maxRetainedMessages} via `$slice` so a long-lived conversation cannot
161
- * grow unbounded.
160
+ * Whether the conversation exists and was created by `userId`. Admins are not owners: they may READ any
161
+ * conversation (`securityCheck`), but a prompt only ever continues the caller's own one, because its turns
162
+ * become part of that conversation's next runs.
162
163
  */
163
- async appendMessage(id: string, message: { content: string; createdAt?: Date; role: string }): Promise<void> {
164
+ async isOwnedBy(id: string, userId: string | undefined): Promise<boolean> {
165
+ if (!isWellFormedId(id) || !isWellFormedId(userId)) {
166
+ return false;
167
+ }
168
+ return !!(await this.dataStore.findOne({ createdBy: userId, id }, { select: ['id'] }));
169
+ }
170
+
171
+ /**
172
+ * Append a message to the conversation of `ownerId` via `$push` (system-internal; the user already authorized
173
+ * the prompt that produced it). Writes nothing unless `ownerId` created the conversation: a conversation's turns
174
+ * feed its owner's next runs, in which tools act with the owner's rights, so a write on anybody else's behalf
175
+ * would plant prompt input into another user's assistant. The check sits here rather than in each caller so that
176
+ * no caller can leave it out. `createdBy` is never changed after creation, so nothing can happen between the
177
+ * check and the push that would make the write foreign. The array is capped at {@link maxRetainedMessages} via
178
+ * `$slice` so a long-lived conversation cannot grow unbounded.
179
+ */
180
+ async appendMessage(
181
+ id: string,
182
+ message: { content: string; createdAt?: Date; role: string },
183
+ ownerId: string | undefined,
184
+ ): Promise<void> {
185
+ if (!(await this.isOwnedBy(id, ownerId))) {
186
+ this.logger.warn(`Message not appended: conversation ${id} is not owned by user ${ownerId ?? '(none)'}`);
187
+ return;
188
+ }
164
189
  await this.dataStore.pushToArray(id, 'messages', [{ ...message, createdAt: message.createdAt ?? new Date() }], {
165
190
  slice: -this.maxRetainedMessages,
166
191
  });
@@ -597,8 +597,10 @@ export class CoreAiMcpOAuthService implements OnModuleInit {
597
597
  * - signed in, GET → the consent view: which client, where the code goes, Allow / Deny, with a
598
598
  * CSRF token bound to user, client, redirect URI and PKCE challenge.
599
599
  * - signed in, POST with Allow and a valid token → the code is issued, the browser goes to
600
- * `redirect_uri?code=…&state=…`. Deny → `access_denied`. A stale token shows the form again; a
601
- * foreign or tampered one too, and issues nothing.
600
+ * `redirect_uri?code=…&state=…`. Deny with a valid token → `access_denied`. A stale token shows the
601
+ * form again; a foreign or tampered one too, and decides nothing.
602
+ * - an error before this method runs (a malformed request) is answered with 400, never sent to the
603
+ * client's redirect URI — `mountAiMcpOAuthRouter()` guards `/authorize` (RFC 9700 §4.11.2).
602
604
  *
603
605
  * The signed-in user is whoever the Better-Auth middleware put on the request (session cookie or
604
606
  * bearer). An API token never gets here — `buildOAuthProvider().authorize` refuses it first — and a
@@ -615,20 +617,21 @@ export class CoreAiMcpOAuthService implements OnModuleInit {
615
617
  const user = await this.consentUser(req);
616
618
  const decision = req?.method === 'POST' ? req.body?.[MCP_OAUTH_CONSENT_DECISION_FIELD] : undefined;
617
619
 
618
- if (user && decision === 'deny') {
619
- const { AccessDeniedError } = await this.loadOAuthErrors();
620
- throw new AccessDeniedError('The user denied access');
621
- }
622
-
623
620
  let notice: IMcpOAuthConsentView['notice'];
624
- if (user && decision === 'approve') {
621
+ if (user && (decision === 'approve' || decision === 'deny')) {
625
622
  const binding: IAiMcpOAuthConsentBinding = {
626
623
  clientId: String(client.client_id),
627
624
  codeChallenge: String(params.codeChallenge),
628
625
  redirectUri: String(params.redirectUri),
629
626
  userId: user.id,
630
627
  };
628
+ // Deny needs the token as Allow does: it sends the browser to the client's redirect URI, and without the token a
629
+ // cross-site POST could do that for a signed-in user whose session cookie is `SameSite=None`
631
630
  const verdict = this.verifyConsentToken(req.body?.[MCP_OAUTH_CONSENT_TOKEN_FIELD], binding);
631
+ if (verdict === 'valid' && decision === 'deny') {
632
+ const { AccessDeniedError } = await this.loadOAuthErrors();
633
+ throw new AccessDeniedError('The user denied access');
634
+ }
632
635
  if (verdict === 'valid') {
633
636
  const code = this.generateId();
634
637
  await this.saveAuthorizationCode(code, {
@@ -649,7 +652,7 @@ export class CoreAiMcpOAuthService implements OnModuleInit {
649
652
  } else {
650
653
  this.logger.warn(
651
654
  `MCP OAuth consent for client ${binding.clientId} carried an invalid token (forged, tampered, or from ` +
652
- 'another session); no code issued, the form is shown again.',
655
+ 'another session); neither approved nor denied, the form is shown again.',
653
656
  );
654
657
  }
655
658
  }