@lenne.tech/nest-server 11.32.2 → 11.32.4
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/configurable-features.md +1 -1
- package/FRAMEWORK-API.md +3 -1
- package/bin/migrate.js +13 -3
- package/dist/core/common/helpers/file.helper.d.ts +14 -2
- package/dist/core/common/helpers/file.helper.js +48 -9
- package/dist/core/common/helpers/file.helper.js.map +1 -1
- package/dist/core/common/interfaces/server-options.interface.d.ts +2 -0
- package/dist/core/modules/ai/inputs/core-ai-connection.input.js +2 -0
- package/dist/core/modules/ai/inputs/core-ai-connection.input.js.map +1 -1
- package/dist/core/modules/ai/services/core-ai-connection.service.d.ts +1 -0
- package/dist/core/modules/ai/services/core-ai-connection.service.js +68 -0
- package/dist/core/modules/ai/services/core-ai-connection.service.js.map +1 -1
- package/dist/core/modules/file/core-file.controller.d.ts +4 -1
- package/dist/core/modules/file/core-file.controller.js +39 -6
- package/dist/core/modules/file/core-file.controller.js.map +1 -1
- package/dist/core/modules/migrate/cli/migrate-cli.d.ts +3 -1
- package/dist/core/modules/migrate/cli/migrate-cli.js +29 -4
- package/dist/core/modules/migrate/cli/migrate-cli.js.map +1 -1
- package/dist/core/modules/migrate/helpers/migration.helper.d.ts +1 -0
- package/dist/core/modules/migrate/helpers/migration.helper.js +51 -4
- package/dist/core/modules/migrate/helpers/migration.helper.js.map +1 -1
- package/dist/tsconfig.build.tsbuildinfo +1 -1
- package/docs/security-overrides.md +9 -2
- package/migration-guides/11.32.2-to-11.32.3.md +129 -0
- package/migration-guides/11.32.3-to-11.32.4.md +323 -0
- package/package.json +1 -1
- package/src/core/common/helpers/file.helper.spec.ts +145 -0
- package/src/core/common/helpers/file.helper.ts +148 -10
- package/src/core/common/interfaces/server-options.interface.ts +23 -0
- package/src/core/modules/ai/README.md +6 -0
- package/src/core/modules/ai/inputs/core-ai-connection.input.ts +2 -0
- package/src/core/modules/ai/interfaces/ai-tool.interface.ts +18 -3
- package/src/core/modules/ai/services/core-ai-connection.service.ts +135 -0
- package/src/core/modules/file/README.md +59 -0
- package/src/core/modules/file/core-file.controller.spec.ts +164 -0
- package/src/core/modules/file/core-file.controller.ts +100 -8
- package/src/core/modules/migrate/README.md +35 -0
- package/src/core/modules/migrate/cli/migrate-cli.ts +69 -6
- package/src/core/modules/migrate/helpers/migration.helper.spec.ts +85 -0
- package/src/core/modules/migrate/helpers/migration.helper.ts +131 -4
|
@@ -2,6 +2,81 @@ import { MulterOptions } from '@nestjs/platform-express/multer/interfaces/multer
|
|
|
2
2
|
import { diskStorage } from 'multer';
|
|
3
3
|
import { extname } from 'path';
|
|
4
4
|
|
|
5
|
+
/**
|
|
6
|
+
* What an upload endpoint accepts: exact mimetypes and exact file extensions.
|
|
7
|
+
*
|
|
8
|
+
* Prefer this over the legacy `RegExp` form. A single expression `.test()`ed
|
|
9
|
+
* against BOTH the mimetype and the extension searches for a SUBSTRING, so every
|
|
10
|
+
* alternative matches anywhere inside either value. An allow-list of `text` /
|
|
11
|
+
* `txt` therefore also accepts `text/html` and `text/xml`, and one containing
|
|
12
|
+
* `md` accepts every mimetype with "md" in it. Consumers hit this in practice:
|
|
13
|
+
* a filter documented as "no html" happily accepted a file named `x.txt` that
|
|
14
|
+
* was sent as `text/html`.
|
|
15
|
+
*
|
|
16
|
+
* Two separate sets also remove the reason the expression could not simply be
|
|
17
|
+
* anchored: it had to carry mimetype FRAGMENTS (`wordprocessingml`, `ms-excel`)
|
|
18
|
+
* next to bare extensions, and no anchoring satisfies both at once.
|
|
19
|
+
*/
|
|
20
|
+
export interface UploadAllowList {
|
|
21
|
+
/** Allowed file extensions, lowercase and WITH the leading dot. */
|
|
22
|
+
extensions: readonly string[];
|
|
23
|
+
/** Allowed mimetypes, lowercase and without parameters. */
|
|
24
|
+
mimeTypes: readonly string[];
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Default for image uploads — the exact-matching equivalent of the legacy
|
|
29
|
+
* `/jpeg|jpg|png/`.
|
|
30
|
+
*/
|
|
31
|
+
export const IMAGE_UPLOAD_ALLOW_LIST: UploadAllowList = {
|
|
32
|
+
extensions: ['.jpeg', '.jpg', '.png'],
|
|
33
|
+
mimeTypes: ['image/jpeg', 'image/png'],
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Types a browser may execute as script when it renders the stored file.
|
|
38
|
+
*
|
|
39
|
+
* These are rejected by {@link multerFileFilter} REGARDLESS of the allow-list,
|
|
40
|
+
* because the danger does not depend on what a given endpoint meant to accept:
|
|
41
|
+
* an upload served back from the API origin with one of these content types
|
|
42
|
+
* runs in that origin, with the victim's session. The check is what makes the
|
|
43
|
+
* legacy `RegExp` path safe for existing consumers without breaking their
|
|
44
|
+
* expressions — see `allowScriptableTypes` to opt out.
|
|
45
|
+
*/
|
|
46
|
+
export const SCRIPTABLE_UPLOAD_MIME_TYPES: readonly string[] = [
|
|
47
|
+
'application/javascript',
|
|
48
|
+
'application/xhtml+xml',
|
|
49
|
+
'application/xml',
|
|
50
|
+
'image/svg+xml',
|
|
51
|
+
'text/html',
|
|
52
|
+
'text/javascript',
|
|
53
|
+
'text/xml',
|
|
54
|
+
];
|
|
55
|
+
|
|
56
|
+
/** Extensions matching {@link SCRIPTABLE_UPLOAD_MIME_TYPES}. */
|
|
57
|
+
export const SCRIPTABLE_UPLOAD_EXTENSIONS: readonly string[] = [
|
|
58
|
+
'.htm',
|
|
59
|
+
'.html',
|
|
60
|
+
'.js',
|
|
61
|
+
'.mjs',
|
|
62
|
+
'.svg',
|
|
63
|
+
'.xhtml',
|
|
64
|
+
'.xml',
|
|
65
|
+
];
|
|
66
|
+
|
|
67
|
+
/** Options for {@link multerFileFilter}. */
|
|
68
|
+
export interface MulterFileFilterOptions {
|
|
69
|
+
/**
|
|
70
|
+
* Accept markup and script types too (`text/html`, `image/svg+xml`, …).
|
|
71
|
+
*
|
|
72
|
+
* Only set this when the stored file is never served from an origin that
|
|
73
|
+
* carries a session — e.g. a separate download host, or a route that always
|
|
74
|
+
* answers with `Content-Disposition: attachment` AND
|
|
75
|
+
* `X-Content-Type-Options: nosniff`.
|
|
76
|
+
*/
|
|
77
|
+
allowScriptableTypes?: boolean;
|
|
78
|
+
}
|
|
79
|
+
|
|
5
80
|
/**
|
|
6
81
|
* Helper class for inputs
|
|
7
82
|
* @deprecated use functions directly
|
|
@@ -18,14 +93,18 @@ export default class FileHelper {
|
|
|
18
93
|
/**
|
|
19
94
|
* Get function to filter files for multer with a certain mimetype & extname
|
|
20
95
|
*/
|
|
21
|
-
public static multerFileFilter(
|
|
22
|
-
|
|
96
|
+
public static multerFileFilter(
|
|
97
|
+
accept: RegExp | UploadAllowList = IMAGE_UPLOAD_ALLOW_LIST,
|
|
98
|
+
options?: MulterFileFilterOptions,
|
|
99
|
+
) {
|
|
100
|
+
return multerFileFilter(accept, options);
|
|
23
101
|
}
|
|
24
102
|
|
|
25
103
|
/**
|
|
26
104
|
* Get multer options for image upload
|
|
27
105
|
*/
|
|
28
106
|
public static multerOptionsForImageUpload(options: {
|
|
107
|
+
allowList?: UploadAllowList;
|
|
29
108
|
destination?: string;
|
|
30
109
|
fileSize?: number;
|
|
31
110
|
fileTypeRegex?: RegExp;
|
|
@@ -35,24 +114,80 @@ export default class FileHelper {
|
|
|
35
114
|
}
|
|
36
115
|
|
|
37
116
|
/**
|
|
38
|
-
*
|
|
117
|
+
* Reduce a reported mimetype to the bare type: lowercase, trimmed, without the
|
|
118
|
+
* `; charset=…` parameters a user agent may append.
|
|
119
|
+
*/
|
|
120
|
+
function normalizeMimeType(value: string): string {
|
|
121
|
+
return String(value || '')
|
|
122
|
+
.split(';')[0]
|
|
123
|
+
.trim()
|
|
124
|
+
.toLowerCase();
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Get a multer `fileFilter` that accepts only the given mimetypes / extensions.
|
|
129
|
+
*
|
|
130
|
+
* Pass an {@link UploadAllowList} — both the mimetype and the extension must
|
|
131
|
+
* appear in it, each compared as a WHOLE value. The two conditions are
|
|
132
|
+
* independent: either one alone rejects the file, while a pair that is odd yet
|
|
133
|
+
* individually allowed (`report.txt` announced as `application/pdf`) passes.
|
|
134
|
+
* An extension→mimetype MAPPING is deliberately not enforced: user agents
|
|
135
|
+
* genuinely disagree about office and audio types (macOS reports `.csv` as
|
|
136
|
+
* `text/plain`), so a mapping rejects legitimate uploads.
|
|
137
|
+
*
|
|
138
|
+
* A `RegExp` is still accepted for backwards compatibility but is
|
|
139
|
+
* **deprecated**: it is `.test()`ed against both values and therefore matches
|
|
140
|
+
* SUBSTRINGS, which is how `te?xt` ends up accepting `text/html`. Whichever form
|
|
141
|
+
* is used, the types in {@link SCRIPTABLE_UPLOAD_MIME_TYPES} /
|
|
142
|
+
* {@link SCRIPTABLE_UPLOAD_EXTENSIONS} are rejected first unless
|
|
143
|
+
* `options.allowScriptableTypes` is set — that is what closes the hole for
|
|
144
|
+
* expressions that already exist in consumer projects.
|
|
145
|
+
*
|
|
146
|
+
* Rejections are reported as a real `Error`. Passing a bare string (as this
|
|
147
|
+
* helper did before) leaves multer with an "error" that has no `message`, which
|
|
148
|
+
* NestJS's `transformException` cannot map to a 4xx.
|
|
39
149
|
*/
|
|
40
|
-
export function multerFileFilter(
|
|
150
|
+
export function multerFileFilter(
|
|
151
|
+
accept: RegExp | UploadAllowList = IMAGE_UPLOAD_ALLOW_LIST,
|
|
152
|
+
options?: MulterFileFilterOptions,
|
|
153
|
+
) {
|
|
41
154
|
return (req, file, cb) => {
|
|
42
|
-
const
|
|
43
|
-
const
|
|
155
|
+
const mimeType = normalizeMimeType(file?.mimetype);
|
|
156
|
+
const extension = extname(String(file?.originalname || '')).toLowerCase();
|
|
44
157
|
|
|
45
|
-
if (
|
|
158
|
+
if (
|
|
159
|
+
!options?.allowScriptableTypes &&
|
|
160
|
+
(SCRIPTABLE_UPLOAD_MIME_TYPES.includes(mimeType) || SCRIPTABLE_UPLOAD_EXTENSIONS.includes(extension))
|
|
161
|
+
) {
|
|
162
|
+
return cb(new Error(`File upload rejected: ${mimeType || 'unknown type'} may execute as script`));
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
const accepted =
|
|
166
|
+
accept instanceof RegExp
|
|
167
|
+
? accept.test(mimeType) && accept.test(extension)
|
|
168
|
+
: accept.mimeTypes.includes(mimeType) && accept.extensions.includes(extension);
|
|
169
|
+
|
|
170
|
+
if (accepted) {
|
|
46
171
|
return cb(null, true);
|
|
47
172
|
}
|
|
48
|
-
cb(`
|
|
173
|
+
cb(new Error(`File upload only supports the following filetypes - ${describeAccept(accept)}`));
|
|
49
174
|
};
|
|
50
175
|
}
|
|
51
176
|
|
|
177
|
+
/** Render the accepted types for the rejection message. */
|
|
178
|
+
function describeAccept(accept: RegExp | UploadAllowList): string {
|
|
179
|
+
return accept instanceof RegExp ? String(accept) : accept.extensions.join(', ');
|
|
180
|
+
}
|
|
181
|
+
|
|
52
182
|
/**
|
|
53
183
|
* Get multer options for image upload
|
|
184
|
+
*
|
|
185
|
+
* Pass `allowList` for exact matching; `fileTypeRegex` is deprecated (see
|
|
186
|
+
* {@link multerFileFilter}). When neither is set, {@link IMAGE_UPLOAD_ALLOW_LIST}
|
|
187
|
+
* applies.
|
|
54
188
|
*/
|
|
55
189
|
export function multerOptionsForImageUpload(options: {
|
|
190
|
+
allowList?: UploadAllowList;
|
|
56
191
|
destination?: string;
|
|
57
192
|
fileSize?: number;
|
|
58
193
|
fileTypeRegex?: RegExp;
|
|
@@ -60,13 +195,16 @@ export function multerOptionsForImageUpload(options: {
|
|
|
60
195
|
// Set config
|
|
61
196
|
const config = {
|
|
62
197
|
fileSize: 1024 * 1024, // 1MB
|
|
63
|
-
fileTypeRegex: /jpeg|jpg|png/, // Images only
|
|
64
198
|
...options,
|
|
65
199
|
};
|
|
66
200
|
|
|
201
|
+
// An explicit regex keeps precedence so existing callers behave as before
|
|
202
|
+
// (minus the scriptable types); otherwise the exact-matching default applies.
|
|
203
|
+
const accept: RegExp | UploadAllowList = config.fileTypeRegex ?? config.allowList ?? IMAGE_UPLOAD_ALLOW_LIST;
|
|
204
|
+
|
|
67
205
|
return {
|
|
68
206
|
// File filter
|
|
69
|
-
fileFilter:
|
|
207
|
+
fileFilter: multerFileFilter(accept),
|
|
70
208
|
|
|
71
209
|
// Limits
|
|
72
210
|
limits: {
|
|
@@ -1110,6 +1110,16 @@ export interface IAiDefaultConnection {
|
|
|
1110
1110
|
/** Capability tags (free-form, e.g. 'analysis', 'vision'). */
|
|
1111
1111
|
capabilities?: string[];
|
|
1112
1112
|
|
|
1113
|
+
/**
|
|
1114
|
+
* Total context window (input + output tokens) the model supports. Drives the
|
|
1115
|
+
* orchestrator's context budget (system prompt + history + tool results). Omit to
|
|
1116
|
+
* auto-detect by probing the endpoint / `knownContextWindow()`; set it explicitly
|
|
1117
|
+
* when the endpoint exposes no limit and the model id is unknown to the heuristic
|
|
1118
|
+
* (otherwise the orchestrator assumes the conservative `ai.contextWindow` default
|
|
1119
|
+
* of 8192 and trims the prompt + tool results on every turn).
|
|
1120
|
+
*/
|
|
1121
|
+
contextWindow?: number;
|
|
1122
|
+
|
|
1113
1123
|
/** Default maximum number of tokens for completions. */
|
|
1114
1124
|
defaultMaxTokens?: number;
|
|
1115
1125
|
|
|
@@ -1212,6 +1222,19 @@ export interface IAi {
|
|
|
1212
1222
|
user?: { maxPrompts?: number; maxTokens?: number };
|
|
1213
1223
|
};
|
|
1214
1224
|
|
|
1225
|
+
/**
|
|
1226
|
+
* Opt-in boot self-check: after startup, probe each enabled connection that declares
|
|
1227
|
+
* an EXPLICIT `supportsNativeTools` / `supportsJsonResponse` and warn (log only) when
|
|
1228
|
+
* the declared value contradicts what the endpoint actually reports — a wrong explicit
|
|
1229
|
+
* flag otherwise silently degrades the assistant (e.g. forcing fragile emulated
|
|
1230
|
+
* tool-calling on a backend that supports native function calling). OFF by default
|
|
1231
|
+
* because it makes outbound calls to the LLM endpoints on every boot; the declared
|
|
1232
|
+
* value is never changed (clear it in the admin UI to re-enable auto-detection). Also
|
|
1233
|
+
* skipped in the ci/e2e runners.
|
|
1234
|
+
* @default false
|
|
1235
|
+
*/
|
|
1236
|
+
capabilityDriftCheck?: boolean;
|
|
1237
|
+
|
|
1215
1238
|
/**
|
|
1216
1239
|
* Confirmation policy for mutating tool actions (create/update/delete).
|
|
1217
1240
|
* `destructive` tools always require confirmation regardless of this policy.
|
|
@@ -111,6 +111,12 @@ never probed). Detection runs in two complementary ways:
|
|
|
111
111
|
once, persists, and uses the result. Until then the safe emulated baseline applies.
|
|
112
112
|
- **On demand:** admins can re-probe via `detectAiConnectionCapabilities` /
|
|
113
113
|
`POST /ai/connections/:id/detect-capabilities` (e.g. after changing `baseUrl`/`model`).
|
|
114
|
+
- **Boot drift check (opt-in):** set `ai.capabilityDriftCheck: true` to probe every enabled
|
|
115
|
+
connection that has an EXPLICIT flag once at startup and log a warning when the declared
|
|
116
|
+
value contradicts what the endpoint reports (a wrong explicit flag otherwise degrades the
|
|
117
|
+
assistant silently, e.g. forcing emulated tool-calling on a native-capable backend). It only
|
|
118
|
+
warns — the stored value is never changed. OFF by default because it makes outbound calls to
|
|
119
|
+
the LLM endpoints on every boot; also skipped in the ci/e2e runners.
|
|
114
120
|
|
|
115
121
|
The probe is provider-agnostic best effort: `response_format: json_object` is sent
|
|
116
122
|
(2xx → JSON supported); a trivial tool with `tool_choice: 'required'` is sent (2xx
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { InputType } from '@nestjs/graphql';
|
|
2
|
+
import { IsInt, Min } from 'class-validator';
|
|
2
3
|
|
|
3
4
|
import { Restricted } from '../../../common/decorators/restricted.decorator';
|
|
4
5
|
import { UnifiedField } from '../../../common/decorators/unified-field.decorator';
|
|
@@ -72,6 +73,7 @@ export class CoreAiConnectionInput {
|
|
|
72
73
|
isOptional: true,
|
|
73
74
|
roles: RoleEnum.ADMIN,
|
|
74
75
|
type: () => Number,
|
|
76
|
+
validator: (options) => [IsInt(options), Min(1, options)],
|
|
75
77
|
})
|
|
76
78
|
contextWindow?: number = undefined;
|
|
77
79
|
|
|
@@ -69,9 +69,21 @@ export interface IAiTool {
|
|
|
69
69
|
|
|
70
70
|
/**
|
|
71
71
|
* Whether the tool performs a destructive/irreversible action (delete, bulk
|
|
72
|
-
* update, payment, …).
|
|
73
|
-
* NOT executed until the prompt is re-sent with
|
|
74
|
-
* response lists them as `pendingActions` with
|
|
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`.
|
|
76
|
+
*
|
|
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.
|
|
75
87
|
*/
|
|
76
88
|
readonly destructive?: boolean;
|
|
77
89
|
|
|
@@ -80,6 +92,9 @@ export interface IAiTool {
|
|
|
80
92
|
* mutating tools is governed by the `ai.confirmation` policy (admin default,
|
|
81
93
|
* optionally client-overridable, optionally enforced). `destructive` is the
|
|
82
94
|
* stronger flag and always requires confirmation regardless of policy.
|
|
95
|
+
*
|
|
96
|
+
* Same MCP caveat as {@link IAiTool.destructive}: the confirmation policy is not
|
|
97
|
+
* evaluated on the `/ai/mcp` path at all.
|
|
83
98
|
*/
|
|
84
99
|
readonly mutating?: boolean;
|
|
85
100
|
|
|
@@ -32,6 +32,29 @@ import { AI_CONNECTION_CLASS, AI_CONNECTION_MODEL } from '../core-ai.constants';
|
|
|
32
32
|
*/
|
|
33
33
|
export { AI_CONNECTION_CLASS, AI_CONNECTION_MODEL } from '../core-ai.constants';
|
|
34
34
|
|
|
35
|
+
/**
|
|
36
|
+
* Minimal shape of a persisted (lean) connection document that the boot-time drift
|
|
37
|
+
* check needs to build a provider for a probe. It mirrors the fields
|
|
38
|
+
* {@link CoreAiConnectionService.resolve} reads, declared locally so the drift check can
|
|
39
|
+
* use the bulk `find()` result directly (no per-connection re-read / N+1).
|
|
40
|
+
*/
|
|
41
|
+
type ResolvableConnectionDoc = {
|
|
42
|
+
_id: unknown;
|
|
43
|
+
apiKeyEncrypted?: string;
|
|
44
|
+
apiKeyEnv?: string;
|
|
45
|
+
baseUrl: string;
|
|
46
|
+
contextWindow?: number;
|
|
47
|
+
defaultMaxTokens?: number;
|
|
48
|
+
defaultTemperature?: number;
|
|
49
|
+
defaultUserMaxPeriod?: string;
|
|
50
|
+
defaultUserMaxTokens?: number;
|
|
51
|
+
model: string;
|
|
52
|
+
name: string;
|
|
53
|
+
providerType?: string;
|
|
54
|
+
supportsJsonResponse?: boolean;
|
|
55
|
+
supportsNativeTools?: boolean;
|
|
56
|
+
};
|
|
57
|
+
|
|
35
58
|
/**
|
|
36
59
|
* CRUD service for {@link CoreAiConnection} — the database-backed LLM
|
|
37
60
|
* configuration. Admin-only (enforced by the model's `@Restricted(ADMIN)` plus
|
|
@@ -75,6 +98,9 @@ export class CoreAiConnectionService
|
|
|
75
98
|
async onModuleInit(): Promise<void> {
|
|
76
99
|
await this.seedDefaultConnection();
|
|
77
100
|
await this.assertStoredKeysDecryptable();
|
|
101
|
+
// Best-effort, non-blocking: probe endpoints and warn on capability drift. Never
|
|
102
|
+
// awaited so a slow/unreachable endpoint cannot delay boot.
|
|
103
|
+
void this.warnOnCapabilityDrift();
|
|
78
104
|
}
|
|
79
105
|
|
|
80
106
|
/**
|
|
@@ -142,6 +168,115 @@ export class CoreAiConnectionService
|
|
|
142
168
|
}
|
|
143
169
|
}
|
|
144
170
|
|
|
171
|
+
/**
|
|
172
|
+
* Opt-in boot self-check (`ai.capabilityDriftCheck`, default OFF): warn when a
|
|
173
|
+
* connection DECLARES a capability that contradicts what its endpoint actually
|
|
174
|
+
* reports. Capabilities are auto-detected only for flags left UNDEFINED (create +
|
|
175
|
+
* lazy runtime path); an EXPLICIT `supportsNativeTools` / `supportsJsonResponse` is
|
|
176
|
+
* authoritative and is never re-probed by the normal path — so a wrong explicit flag
|
|
177
|
+
* silently degrades the assistant forever (e.g. `supportsNativeTools: false` on an
|
|
178
|
+
* endpoint that DOES support native function calling forces fragile emulated
|
|
179
|
+
* tool-calling, which weaker models do not sustain once the prompt grows).
|
|
180
|
+
*
|
|
181
|
+
* To observe the endpoint's REAL capability for a DECLARED flag, it builds the provider
|
|
182
|
+
* with the flags cleared to `undefined` — otherwise the provider's `detectCapabilities()`,
|
|
183
|
+
* which probes ONLY undefined flags, would return nothing to compare against (the whole
|
|
184
|
+
* point of the check) — then diffs the probed booleans against the stored declaration.
|
|
185
|
+
*
|
|
186
|
+
* It NEVER changes the stored value (the admin's explicit choice stays authoritative),
|
|
187
|
+
* NEVER blocks boot (fire-and-forget, all errors swallowed), and issues outbound calls
|
|
188
|
+
* to the LLM endpoints — hence it is OFF by default and additionally skipped in the
|
|
189
|
+
* ci/e2e runners. It reads every enabled connection in a single query (no per-connection
|
|
190
|
+
* re-read). Connections that leave BOTH flags undefined are handled by
|
|
191
|
+
* {@link detectAndPersistCapabilities} and are skipped here (nothing declared to check).
|
|
192
|
+
*/
|
|
193
|
+
protected async warnOnCapabilityDrift(): Promise<void> {
|
|
194
|
+
// Opt-in: a framework boot must not contact third-party endpoints unless asked.
|
|
195
|
+
if (!ConfigService.get<boolean>('ai.capabilityDriftCheck')) {
|
|
196
|
+
return;
|
|
197
|
+
}
|
|
198
|
+
// Defense in depth: never probe from the integration test runner (real module boot).
|
|
199
|
+
// The unit runner (NODE_ENV=test) is intentionally NOT excluded so the method stays
|
|
200
|
+
// unit-testable with a mocked providerFactory — the opt-in flag above already prevents
|
|
201
|
+
// accidental probing there.
|
|
202
|
+
if (!this.providerFactory || ['ci', 'e2e'].includes(process.env.NODE_ENV ?? '')) {
|
|
203
|
+
return;
|
|
204
|
+
}
|
|
205
|
+
try {
|
|
206
|
+
// Single read (no per-connection re-resolve): the full docs carry everything the
|
|
207
|
+
// provider factory needs, so there is no N+1 findById per connection.
|
|
208
|
+
const docs = (await this.mainDbModel
|
|
209
|
+
.find({ enabled: { $ne: false } })
|
|
210
|
+
.lean()
|
|
211
|
+
.exec()) as unknown as ResolvableConnectionDoc[];
|
|
212
|
+
for (const doc of docs) {
|
|
213
|
+
// Only a connection that DECLARES a capability can drift; undefined flags are
|
|
214
|
+
// auto-detected on first use, so there is nothing to reconcile here.
|
|
215
|
+
if (typeof doc.supportsNativeTools !== 'boolean' && typeof doc.supportsJsonResponse !== 'boolean') {
|
|
216
|
+
continue;
|
|
217
|
+
}
|
|
218
|
+
let provider: { detectCapabilities?: () => Promise<{ jsonResponse?: boolean; nativeTools?: boolean }> };
|
|
219
|
+
try {
|
|
220
|
+
// Clear the declared flags so detectCapabilities() actually probes them (it
|
|
221
|
+
// skips any flag that is already a boolean on the connection).
|
|
222
|
+
const probeConnection: ResolvedAiConnection = {
|
|
223
|
+
apiKey: this.resolveApiKeyFromDoc(doc) ?? '',
|
|
224
|
+
baseUrl: doc.baseUrl,
|
|
225
|
+
contextWindow: doc.contextWindow,
|
|
226
|
+
defaultMaxTokens: doc.defaultMaxTokens,
|
|
227
|
+
defaultTemperature: doc.defaultTemperature,
|
|
228
|
+
defaultUserMaxPeriod: doc.defaultUserMaxPeriod,
|
|
229
|
+
defaultUserMaxTokens: doc.defaultUserMaxTokens,
|
|
230
|
+
id: String(doc._id),
|
|
231
|
+
model: doc.model,
|
|
232
|
+
name: doc.name,
|
|
233
|
+
providerType: doc.providerType || 'openai-compatible',
|
|
234
|
+
supportsJsonResponse: undefined,
|
|
235
|
+
supportsNativeTools: undefined,
|
|
236
|
+
};
|
|
237
|
+
provider = this.providerFactory.create(probeConnection);
|
|
238
|
+
} catch {
|
|
239
|
+
continue; // unresolvable / unbuildable — nothing to compare against
|
|
240
|
+
}
|
|
241
|
+
if (typeof provider.detectCapabilities !== 'function') {
|
|
242
|
+
continue;
|
|
243
|
+
}
|
|
244
|
+
const detected = await provider.detectCapabilities().catch(() => undefined);
|
|
245
|
+
if (!detected) {
|
|
246
|
+
continue; // probe failed (endpoint down / transport error) — not a drift signal
|
|
247
|
+
}
|
|
248
|
+
const drift: string[] = [];
|
|
249
|
+
if (
|
|
250
|
+
typeof doc.supportsNativeTools === 'boolean' &&
|
|
251
|
+
typeof detected.nativeTools === 'boolean' &&
|
|
252
|
+
doc.supportsNativeTools !== detected.nativeTools
|
|
253
|
+
) {
|
|
254
|
+
drift.push(
|
|
255
|
+
`supportsNativeTools declared ${doc.supportsNativeTools} but the endpoint reports ${detected.nativeTools}`,
|
|
256
|
+
);
|
|
257
|
+
}
|
|
258
|
+
if (
|
|
259
|
+
typeof doc.supportsJsonResponse === 'boolean' &&
|
|
260
|
+
typeof detected.jsonResponse === 'boolean' &&
|
|
261
|
+
doc.supportsJsonResponse !== detected.jsonResponse
|
|
262
|
+
) {
|
|
263
|
+
drift.push(
|
|
264
|
+
`supportsJsonResponse declared ${doc.supportsJsonResponse} but the endpoint reports ${detected.jsonResponse}`,
|
|
265
|
+
);
|
|
266
|
+
}
|
|
267
|
+
if (drift.length) {
|
|
268
|
+
this.logger.warn(
|
|
269
|
+
`AI connection "${doc.name || String(doc._id)}" capability drift: ${drift.join('; ')}. ` +
|
|
270
|
+
`The declared value is authoritative and was NOT changed — correct it in the admin UI, or clear it to ` +
|
|
271
|
+
`re-enable auto-detection, so the assistant uses the endpoint's real capabilities.`,
|
|
272
|
+
);
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
} catch (err) {
|
|
276
|
+
this.logger.warn(`AI capability drift check skipped: ${(err as Error).message}`);
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
|
|
145
280
|
/**
|
|
146
281
|
* Create a connection. Encrypts the optional plaintext `apiKey` and keeps the
|
|
147
282
|
* default connection unique.
|
|
@@ -74,6 +74,65 @@ export class FileController extends CoreFileController {
|
|
|
74
74
|
}
|
|
75
75
|
```
|
|
76
76
|
|
|
77
|
+
Access can also be restricted per file by overriding `CoreFileService.checkRights()`. When it
|
|
78
|
+
refuses, `getFileStream()` returns `null` and the controller answers **404** — deliberately the same
|
|
79
|
+
answer as an unknown id, so the endpoint cannot be used to probe which files exist. Do not change
|
|
80
|
+
this to a 403 in an override without accepting that trade-off.
|
|
81
|
+
|
|
82
|
+
### Error responses
|
|
83
|
+
|
|
84
|
+
| Situation | Status | Body |
|
|
85
|
+
| -------------------------------------------------------------------------------------- | ------ | ----------------------------------------------------------------------------------------------------- |
|
|
86
|
+
| Unknown id / filename, or `checkRights()` refused | `404` | `NotFoundException` with `ErrorCode.FILE_NOT_FOUND` |
|
|
87
|
+
| Missing id / filename in the route | `400` | `BadRequestException` with `ErrorCode.REQUIRED_FIELD_MISSING` |
|
|
88
|
+
| GridFS read fails **before** any byte was sent (file document exists, chunks are gone) | `404` | `{ "error": "Not Found", "message": "<FILE_NOT_FOUND>", "statusCode": 404 }` |
|
|
89
|
+
| GridFS read fails **after** streaming started | — | The connection is closed; a truncated transfer is the only signal left once the status is on the wire |
|
|
90
|
+
|
|
91
|
+
The mid-stream failure case is handled by `pipeFileToResponse()`. Without it the stream error would
|
|
92
|
+
go unhandled, Node would destroy the socket, and a reverse proxy would report **502 Bad Gateway** —
|
|
93
|
+
i.e. "the server is down", while every other route keeps answering. On the error path the headers
|
|
94
|
+
describing the file (`Content-Type`, `Content-Disposition`, `Cache-Control`, `ETag`) are removed, so
|
|
95
|
+
the JSON body is not labelled as the image it failed to deliver. The error itself is logged
|
|
96
|
+
server-side even though the client answer stays deliberately generic.
|
|
97
|
+
|
|
98
|
+
To change the status, the body or the logging, override the `protected pipeFileToResponse()` method
|
|
99
|
+
on the controller rather than the exported function of the same name.
|
|
100
|
+
|
|
101
|
+
### Upload filtering
|
|
102
|
+
|
|
103
|
+
Upload endpoints are project-specific, but the filter they install comes from the framework
|
|
104
|
+
(`multerOptionsForImageUpload()` / `multerFileFilter()` in `common/helpers/file.helper.ts`). Name what
|
|
105
|
+
the endpoint accepts as an `UploadAllowList` — both the mimetype and the extension are compared as
|
|
106
|
+
WHOLE values:
|
|
107
|
+
|
|
108
|
+
```typescript
|
|
109
|
+
@UseInterceptors(FileInterceptor('file', multerOptionsForImageUpload({
|
|
110
|
+
allowList: {
|
|
111
|
+
extensions: ['.jpeg', '.jpg', '.pdf', '.png'],
|
|
112
|
+
mimeTypes: ['application/pdf', 'image/jpeg', 'image/png'],
|
|
113
|
+
},
|
|
114
|
+
})))
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
The two conditions are **independent**: either one alone rejects the file, while a pair that is odd
|
|
118
|
+
yet individually allowed (`report.txt` announced as `application/pdf`) passes. An extension→mimetype
|
|
119
|
+
MAPPING is deliberately not enforced, because user agents genuinely disagree about office and audio
|
|
120
|
+
types (macOS reports `.csv` as `text/plain`) and a mapping would reject legitimate uploads.
|
|
121
|
+
|
|
122
|
+
The legacy `fileTypeRegex` option still works and keeps precedence, but is **deprecated**: one
|
|
123
|
+
expression is `.test()`ed against both the mimetype and the extension, so every alternative matches
|
|
124
|
+
as a SUBSTRING — an allow-list containing `te?xt` also accepts `text/html`.
|
|
125
|
+
|
|
126
|
+
Types a browser may execute as script (`text/html`, `image/svg+xml`, `application/xhtml+xml`, XML and
|
|
127
|
+
JavaScript types, plus the matching extensions) are rejected **before** the allow-list is consulted,
|
|
128
|
+
on both forms. A stored upload served back from the API origin with one of these content types runs
|
|
129
|
+
in that origin, with the victim's session. Opt out only when the file never reaches an origin that
|
|
130
|
+
carries a session:
|
|
131
|
+
|
|
132
|
+
```typescript
|
|
133
|
+
multerFileFilter({ extensions: ['.svg'], mimeTypes: ['image/svg+xml'] }, { allowScriptableTypes: true });
|
|
134
|
+
```
|
|
135
|
+
|
|
77
136
|
---
|
|
78
137
|
|
|
79
138
|
## GraphQL Support
|