@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.
Files changed (40) hide show
  1. package/.claude/rules/configurable-features.md +1 -1
  2. package/FRAMEWORK-API.md +3 -1
  3. package/bin/migrate.js +13 -3
  4. package/dist/core/common/helpers/file.helper.d.ts +14 -2
  5. package/dist/core/common/helpers/file.helper.js +48 -9
  6. package/dist/core/common/helpers/file.helper.js.map +1 -1
  7. package/dist/core/common/interfaces/server-options.interface.d.ts +2 -0
  8. package/dist/core/modules/ai/inputs/core-ai-connection.input.js +2 -0
  9. package/dist/core/modules/ai/inputs/core-ai-connection.input.js.map +1 -1
  10. package/dist/core/modules/ai/services/core-ai-connection.service.d.ts +1 -0
  11. package/dist/core/modules/ai/services/core-ai-connection.service.js +68 -0
  12. package/dist/core/modules/ai/services/core-ai-connection.service.js.map +1 -1
  13. package/dist/core/modules/file/core-file.controller.d.ts +4 -1
  14. package/dist/core/modules/file/core-file.controller.js +39 -6
  15. package/dist/core/modules/file/core-file.controller.js.map +1 -1
  16. package/dist/core/modules/migrate/cli/migrate-cli.d.ts +3 -1
  17. package/dist/core/modules/migrate/cli/migrate-cli.js +29 -4
  18. package/dist/core/modules/migrate/cli/migrate-cli.js.map +1 -1
  19. package/dist/core/modules/migrate/helpers/migration.helper.d.ts +1 -0
  20. package/dist/core/modules/migrate/helpers/migration.helper.js +51 -4
  21. package/dist/core/modules/migrate/helpers/migration.helper.js.map +1 -1
  22. package/dist/tsconfig.build.tsbuildinfo +1 -1
  23. package/docs/security-overrides.md +9 -2
  24. package/migration-guides/11.32.2-to-11.32.3.md +129 -0
  25. package/migration-guides/11.32.3-to-11.32.4.md +323 -0
  26. package/package.json +1 -1
  27. package/src/core/common/helpers/file.helper.spec.ts +145 -0
  28. package/src/core/common/helpers/file.helper.ts +148 -10
  29. package/src/core/common/interfaces/server-options.interface.ts +23 -0
  30. package/src/core/modules/ai/README.md +6 -0
  31. package/src/core/modules/ai/inputs/core-ai-connection.input.ts +2 -0
  32. package/src/core/modules/ai/interfaces/ai-tool.interface.ts +18 -3
  33. package/src/core/modules/ai/services/core-ai-connection.service.ts +135 -0
  34. package/src/core/modules/file/README.md +59 -0
  35. package/src/core/modules/file/core-file.controller.spec.ts +164 -0
  36. package/src/core/modules/file/core-file.controller.ts +100 -8
  37. package/src/core/modules/migrate/README.md +35 -0
  38. package/src/core/modules/migrate/cli/migrate-cli.ts +69 -6
  39. package/src/core/modules/migrate/helpers/migration.helper.spec.ts +85 -0
  40. 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(fileTypeRegex = /jpeg|jpg|png/) {
22
- return multerFileFilter(fileTypeRegex);
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
- * Get function to filter files for multer with a certain mimetype & extname
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(fileTypeRegex = /jpeg|jpg|png/) {
150
+ export function multerFileFilter(
151
+ accept: RegExp | UploadAllowList = IMAGE_UPLOAD_ALLOW_LIST,
152
+ options?: MulterFileFilterOptions,
153
+ ) {
41
154
  return (req, file, cb) => {
42
- const mimetype = fileTypeRegex.test(file.mimetype);
43
- const extName = fileTypeRegex.test(extname(file.originalname).toLowerCase());
155
+ const mimeType = normalizeMimeType(file?.mimetype);
156
+ const extension = extname(String(file?.originalname || '')).toLowerCase();
44
157
 
45
- if (mimetype && extName) {
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(`Error: File upload only supports the following filetypes - ${fileTypeRegex}`);
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: config.fileTypeRegex ? multerFileFilter(config.fileTypeRegex) : undefined,
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, …). Destructive tools always require confirmation: they are
73
- * NOT executed until the prompt is re-sent with `confirm: true`; the first
74
- * response lists them as `pendingActions` with `requiresConfirmation: true`.
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