@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.
- package/.claude/rules/better-auth.md +31 -4
- package/.claude/rules/configurable-features.md +7 -6
- package/.claude/rules/package-management.md +13 -1
- package/.claude/rules/persistence.md +2 -2
- package/.claude/rules/testing.md +5 -5
- package/.claude/rules/versioning.md +13 -2
- package/CLAUDE.md +5 -0
- package/FRAMEWORK-API.md +4 -3
- package/README.md +2 -2
- package/dist/config.env.js +2 -2
- package/dist/config.env.js.map +1 -1
- package/dist/core/common/helpers/graphql-ws-connection.helper.d.ts +4 -0
- package/dist/core/common/helpers/graphql-ws-connection.helper.js +17 -3
- package/dist/core/common/helpers/graphql-ws-connection.helper.js.map +1 -1
- package/dist/core/common/interfaces/server-options.interface.d.ts +8 -0
- package/dist/core/modules/ai/core-ai-mcp.controller.d.ts +3 -0
- package/dist/core/modules/ai/core-ai-mcp.controller.js +29 -0
- package/dist/core/modules/ai/core-ai-mcp.controller.js.map +1 -1
- package/dist/core/modules/ai/core-ai.constants.d.ts +2 -0
- package/dist/core/modules/ai/core-ai.constants.js +2 -0
- package/dist/core/modules/ai/core-ai.constants.js.map +1 -1
- package/dist/core/modules/ai/core-ai.controller.d.ts +6 -0
- package/dist/core/modules/ai/core-ai.controller.js +31 -1
- package/dist/core/modules/ai/core-ai.controller.js.map +1 -1
- package/dist/core/modules/ai/core-ai.module.d.ts +2 -0
- package/dist/core/modules/ai/core-ai.module.js +8 -1
- package/dist/core/modules/ai/core-ai.module.js.map +1 -1
- package/dist/core/modules/ai/core-ai.resolver.d.ts +6 -0
- package/dist/core/modules/ai/core-ai.resolver.js +32 -0
- package/dist/core/modules/ai/core-ai.resolver.js.map +1 -1
- package/dist/core/modules/ai/helpers/ai-mcp-oauth-consent.helper.d.ts +65 -1
- package/dist/core/modules/ai/helpers/ai-mcp-oauth-consent.helper.js +6 -3
- package/dist/core/modules/ai/helpers/ai-mcp-oauth-consent.helper.js.map +1 -1
- package/dist/core/modules/ai/helpers/ai-mcp-oauth.helper.js +48 -1
- package/dist/core/modules/ai/helpers/ai-mcp-oauth.helper.js.map +1 -1
- package/dist/core/modules/ai/helpers/ai-tool-authorization.helper.d.ts +9 -0
- package/dist/core/modules/ai/helpers/ai-tool-authorization.helper.js +54 -0
- package/dist/core/modules/ai/helpers/ai-tool-authorization.helper.js.map +1 -0
- package/dist/core/modules/ai/index.d.ts +7 -0
- package/dist/core/modules/ai/index.js +6 -0
- package/dist/core/modules/ai/index.js.map +1 -1
- package/dist/core/modules/ai/inputs/core-ai-settings.input.d.ts +3 -0
- package/dist/core/modules/ai/inputs/core-ai-settings.input.js +31 -0
- package/dist/core/modules/ai/inputs/core-ai-settings.input.js.map +1 -0
- package/dist/core/modules/ai/interfaces/ai-tool.interface.d.ts +2 -0
- package/dist/core/modules/ai/models/core-ai-settings.model.d.ts +4 -0
- package/dist/core/modules/ai/models/core-ai-settings.model.js +30 -0
- package/dist/core/modules/ai/models/core-ai-settings.model.js.map +1 -0
- package/dist/core/modules/ai/models/core-ai-user-setting.model.d.ts +8 -0
- package/dist/core/modules/ai/models/core-ai-user-setting.model.js +45 -0
- package/dist/core/modules/ai/models/core-ai-user-setting.model.js.map +1 -0
- package/dist/core/modules/ai/services/core-ai-conversation.service.d.ts +4 -1
- package/dist/core/modules/ai/services/core-ai-conversation.service.js +16 -4
- package/dist/core/modules/ai/services/core-ai-conversation.service.js.map +1 -1
- package/dist/core/modules/ai/services/core-ai-mcp-oauth.service.js +6 -6
- package/dist/core/modules/ai/services/core-ai-mcp-oauth.service.js.map +1 -1
- package/dist/core/modules/ai/services/core-ai-mcp.service.d.ts +27 -2
- package/dist/core/modules/ai/services/core-ai-mcp.service.js +118 -11
- package/dist/core/modules/ai/services/core-ai-mcp.service.js.map +1 -1
- package/dist/core/modules/ai/services/core-ai-prompt-builder.service.js +9 -6
- package/dist/core/modules/ai/services/core-ai-prompt-builder.service.js.map +1 -1
- package/dist/core/modules/ai/services/core-ai-user-setting.service.d.ts +17 -0
- package/dist/core/modules/ai/services/core-ai-user-setting.service.js +72 -0
- package/dist/core/modules/ai/services/core-ai-user-setting.service.js.map +1 -0
- package/dist/core/modules/ai/services/core-ai.service.d.ts +8 -3
- package/dist/core/modules/ai/services/core-ai.service.js +68 -29
- package/dist/core/modules/ai/services/core-ai.service.js.map +1 -1
- package/dist/core/modules/better-auth/better-auth.config.js +3 -1
- package/dist/core/modules/better-auth/better-auth.config.js.map +1 -1
- package/dist/core/modules/better-auth/core-better-auth-email-verification.service.js +2 -2
- package/dist/core/modules/better-auth/core-better-auth-email-verification.service.js.map +1 -1
- package/dist/core/modules/better-auth/core-better-auth-path-guard.middleware.d.ts +8 -0
- package/dist/core/modules/better-auth/core-better-auth-path-guard.middleware.js +64 -0
- package/dist/core/modules/better-auth/core-better-auth-path-guard.middleware.js.map +1 -0
- package/dist/core/modules/better-auth/core-better-auth-rate-limit.middleware.js +2 -1
- package/dist/core/modules/better-auth/core-better-auth-rate-limit.middleware.js.map +1 -1
- package/dist/core/modules/better-auth/core-better-auth-rate-limiter.service.d.ts +1 -1
- package/dist/core/modules/better-auth/core-better-auth-rate-limiter.service.js +5 -3
- package/dist/core/modules/better-auth/core-better-auth-rate-limiter.service.js.map +1 -1
- package/dist/core/modules/better-auth/core-better-auth.module.js +2 -0
- package/dist/core/modules/better-auth/core-better-auth.module.js.map +1 -1
- package/dist/core/modules/better-auth/index.d.ts +1 -0
- package/dist/core/modules/better-auth/index.js +1 -0
- package/dist/core/modules/better-auth/index.js.map +1 -1
- package/dist/core/modules/error-code/error-codes.d.ts +18 -0
- package/dist/core/modules/error-code/error-codes.js +16 -0
- package/dist/core/modules/error-code/error-codes.js.map +1 -1
- package/dist/core/modules/sync/core-sync.service.d.ts +1 -0
- package/dist/core/modules/sync/core-sync.service.js +31 -2
- package/dist/core/modules/sync/core-sync.service.js.map +1 -1
- package/dist/core/modules/tenant/core-tenant.guard.js +10 -0
- package/dist/core/modules/tenant/core-tenant.guard.js.map +1 -1
- package/dist/core/modules/tenant/core-tenant.service.d.ts +21 -1
- package/dist/core/modules/tenant/core-tenant.service.js +153 -1
- package/dist/core/modules/tenant/core-tenant.service.js.map +1 -1
- package/dist/server/modules/error-code/error-codes.d.ts +2 -0
- package/dist/test/test.helper.d.ts +1 -0
- package/dist/test/test.helper.js +9 -2
- package/dist/test/test.helper.js.map +1 -1
- package/docs/REQUEST-LIFECYCLE.md +3 -2
- package/docs/error-codes.md +14 -0
- package/docs/offline-sync-protocol.md +17 -8
- package/migration-guides/12.0.x-to-12.1.x.md +0 -1
- package/migration-guides/12.2.x-to-12.3.x.md +440 -0
- package/package.json +20 -20
- package/src/config.env.ts +6 -2
- package/src/core/common/helpers/graphql-ws-connection.helper.ts +32 -4
- package/src/core/common/interfaces/server-options.interface.ts +80 -8
- package/src/core/common/persistence/sync-model.ts +1 -1
- package/src/core/modules/ai/INTEGRATION-CHECKLIST.md +36 -15
- package/src/core/modules/ai/README.md +163 -25
- package/src/core/modules/ai/core-ai-mcp.controller.ts +48 -1
- package/src/core/modules/ai/core-ai.constants.ts +6 -0
- package/src/core/modules/ai/core-ai.controller.ts +36 -1
- package/src/core/modules/ai/core-ai.module.ts +12 -0
- package/src/core/modules/ai/core-ai.resolver.ts +36 -0
- package/src/core/modules/ai/helpers/ai-mcp-oauth-consent.helper.ts +35 -4
- package/src/core/modules/ai/helpers/ai-mcp-oauth.helper.ts +68 -1
- package/src/core/modules/ai/helpers/ai-tool-authorization.helper.ts +99 -0
- package/src/core/modules/ai/index.ts +18 -0
- package/src/core/modules/ai/inputs/core-ai-settings.input.ts +24 -0
- package/src/core/modules/ai/interfaces/ai-hook.interface.ts +1 -1
- package/src/core/modules/ai/interfaces/ai-tool.interface.ts +38 -26
- package/src/core/modules/ai/models/core-ai-mode.model.ts +1 -1
- package/src/core/modules/ai/models/core-ai-settings.model.ts +23 -0
- package/src/core/modules/ai/models/core-ai-tool-policy.model.ts +1 -1
- package/src/core/modules/ai/models/core-ai-user-setting.model.ts +46 -0
- package/src/core/modules/ai/services/core-ai-conversation.service.ts +31 -6
- package/src/core/modules/ai/services/core-ai-mcp-oauth.service.ts +12 -9
- package/src/core/modules/ai/services/core-ai-mcp.service.ts +206 -13
- package/src/core/modules/ai/services/core-ai-prompt-builder.service.ts +13 -6
- package/src/core/modules/ai/services/core-ai-user-setting.service.ts +85 -0
- package/src/core/modules/ai/services/core-ai.service.ts +134 -35
- package/src/core/modules/api-token/README.md +4 -2
- package/src/core/modules/better-auth/CUSTOMIZATION.md +1 -1
- package/src/core/modules/better-auth/INTEGRATION-CHECKLIST.md +7 -0
- package/src/core/modules/better-auth/README.md +32 -1
- package/src/core/modules/better-auth/better-auth.config.ts +12 -1
- package/src/core/modules/better-auth/core-better-auth-email-verification.service.ts +5 -2
- package/src/core/modules/better-auth/core-better-auth-path-guard.middleware.ts +85 -0
- package/src/core/modules/better-auth/core-better-auth-rate-limit.middleware.ts +5 -2
- package/src/core/modules/better-auth/core-better-auth-rate-limiter.service.ts +14 -7
- package/src/core/modules/better-auth/core-better-auth.module.ts +12 -2
- package/src/core/modules/better-auth/index.ts +1 -0
- package/src/core/modules/error-code/error-codes.ts +18 -0
- package/src/core/modules/sync/README.md +11 -1
- package/src/core/modules/sync/core-sync.service.ts +48 -3
- package/src/core/modules/tenant/INTEGRATION-CHECKLIST.md +12 -1
- package/src/core/modules/tenant/README.md +49 -3
- package/src/core/modules/tenant/core-tenant.guard.ts +18 -2
- package/src/core/modules/tenant/core-tenant.service.ts +287 -2
- 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(
|
|
130
|
-
|
|
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 =
|
|
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()`
|
|
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
|
-
/**
|
|
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
|
-
/**
|
|
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, …).
|
|
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
|
-
* **
|
|
78
|
-
*
|
|
79
|
-
*
|
|
80
|
-
*
|
|
81
|
-
*
|
|
82
|
-
*
|
|
83
|
-
*
|
|
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
|
-
*
|
|
97
|
-
*
|
|
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
|
|
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
|
|
107
|
-
*
|
|
108
|
-
*
|
|
109
|
-
*
|
|
110
|
-
*
|
|
111
|
-
*
|
|
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()`
|
|
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
|
|
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
|
-
*
|
|
159
|
-
*
|
|
160
|
-
*
|
|
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
|
|
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
|
|
601
|
-
* foreign or tampered one too, and
|
|
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);
|
|
655
|
+
'another session); neither approved nor denied, the form is shown again.',
|
|
653
656
|
);
|
|
654
657
|
}
|
|
655
658
|
}
|