@lenne.tech/nest-server 11.39.0 → 11.41.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (32) hide show
  1. package/.claude/rules/configurable-features.md +3 -0
  2. package/.claude/rules/module-inheritance.md +2 -0
  3. package/.claude/rules/package-management.md +51 -2
  4. package/.claude/rules/testing.md +182 -3
  5. package/CLAUDE.md +13 -1
  6. package/FRAMEWORK-API.md +3 -2
  7. package/dist/core/common/interfaces/server-options.interface.d.ts +2 -1
  8. package/dist/core/modules/ai/core-ai.controller.js +6 -0
  9. package/dist/core/modules/ai/core-ai.controller.js.map +1 -1
  10. package/dist/core/modules/ai/interfaces/llm-provider.interface.d.ts +1 -0
  11. package/dist/core/modules/ai/models/core-ai-prompt.model.js +1 -1
  12. package/dist/core/modules/ai/models/core-ai-prompt.model.js.map +1 -1
  13. package/dist/core/modules/ai/models/core-ai-slot.model.js +1 -1
  14. package/dist/core/modules/ai/models/core-ai-slot.model.js.map +1 -1
  15. package/dist/core/modules/ai/providers/openai-compatible.provider.d.ts +6 -0
  16. package/dist/core/modules/ai/providers/openai-compatible.provider.js +106 -12
  17. package/dist/core/modules/ai/providers/openai-compatible.provider.js.map +1 -1
  18. package/dist/core/modules/ai/services/core-ai.service.d.ts +12 -1
  19. package/dist/core/modules/ai/services/core-ai.service.js +131 -13
  20. package/dist/core/modules/ai/services/core-ai.service.js.map +1 -1
  21. package/dist/tsconfig.build.tsbuildinfo +1 -1
  22. package/migration-guides/11.39.0-to-11.40.0.md +186 -0
  23. package/migration-guides/11.40.0-to-11.41.0.md +118 -0
  24. package/package.json +5 -4
  25. package/src/core/common/interfaces/server-options.interface.ts +34 -1
  26. package/src/core/modules/ai/README.md +59 -5
  27. package/src/core/modules/ai/core-ai.controller.ts +16 -0
  28. package/src/core/modules/ai/interfaces/llm-provider.interface.ts +15 -0
  29. package/src/core/modules/ai/models/core-ai-prompt.model.ts +10 -1
  30. package/src/core/modules/ai/models/core-ai-slot.model.ts +10 -1
  31. package/src/core/modules/ai/providers/openai-compatible.provider.ts +309 -15
  32. package/src/core/modules/ai/services/core-ai.service.ts +333 -23
@@ -0,0 +1,186 @@
1
+ # Migration Guide: 11.39.0 → 11.40.0
2
+
3
+ > **Why a MINOR for what looks like a patch.** In this package the MAJOR digit tracks the NestJS
4
+ > major (11.x = NestJS 11), so it is not ours to spend — and every breaking change of our own ships
5
+ > as a MINOR instead. This release contains one: a security control that was silently inert starts
6
+ > being enforced, and a deployment that relied on the inert behaviour can stop working. The number
7
+ > of affected projects is small; the rule does not ask how many, it asks whether a working
8
+ > deployment can break.
9
+
10
+ ## Overview
11
+
12
+ | Category | Effort | Applies to |
13
+ |----------|--------|-----------|
14
+ | **Breaking (behaviour)** | 5 minutes | Projects that set `ai.allowedBaseUrlHosts` **as a string** (i.e. via `NSC__AI__ALLOWED_BASE_URL_HOSTS`) |
15
+ | Bugfix | none | Everyone using the AI module |
16
+ | Internal tooling | none | Nobody — `scripts/` does not ship |
17
+
18
+ Almost every project can update with `pnpm update @lenne.tech/nest-server` and read no further.
19
+ **One group cannot**, and for them the change is the uncomfortable kind: a security control that was
20
+ silently inert starts working, and a working deployment can stop working as a result.
21
+
22
+ ## Quick Migration
23
+
24
+ ```bash
25
+ # Does this affect you? If both come back empty, you are done.
26
+ grep -r "NSC__AI__ALLOWED_BASE_URL_HOSTS" . --include="*.env*" --include="*.yml" --include="*.yaml" 2>/dev/null
27
+ grep -rn "allowedBaseUrlHosts" src/ 2>/dev/null
28
+ ```
29
+
30
+ ## Breaking Change: `ai.allowedBaseUrlHosts` set as a string was never enforced
31
+
32
+ ### What was wrong
33
+
34
+ `ai.allowedBaseUrlHosts` is the SSRF egress allowlist for AI connection base URLs. It is reachable
35
+ through the framework's own environment mapping:
36
+
37
+ ```bash
38
+ NSC__AI__ALLOWED_BASE_URL_HOSTS=llm.example.com,api.openai.com
39
+ ```
40
+
41
+ `getEnvironmentObject()` turns that into `{ ai: { allowedBaseUrlHosts: '<string>' } }`, and lodash
42
+ `merge` assigns the scalar straight over the configured array.
43
+
44
+ **And there is no way to avoid that from the environment.** The `NSC__*` reader coerces exactly
45
+ three things — `'true'`, `'false'`, and anything `Number()` accepts — and leaves everything else a
46
+ string. No CSV, JSON array literal or other notation produces an array:
47
+
48
+ ```bash
49
+ NSC__AI__ALLOWED_BASE_URL_HOSTS='["a.example.com"]' # -> the string '["a.example.com"]'
50
+ NSC__AI__MAX_ITERATIONS=7 # -> the number 7
51
+ ```
52
+
53
+ So this is not an edge case for people who happened to pick the wrong notation. **If you configured
54
+ the allowlist through the environment at all, it was off — completely, on every deployment, for its
55
+ whole life.** Verified empirically against the built `config.helper.js`, not inferred from the code.
56
+ Reported by the nest-server-starter session, which went looking for why a string arrives in the
57
+ first place.
58
+
59
+ That gives you a clean dividing line, and it is the only thing you need to check:
60
+
61
+ | How you set `ai.allowedBaseUrlHosts` | Were you affected? |
62
+ |---|---|
63
+ | `NSC__AI__ALLOWED_BASE_URL_HOSTS` (or any env route) | **Yes — the control was inert** |
64
+ | A real array in `config.env.ts` | No — it worked as documented |
65
+ | Not set at all | No — no restriction was intended |
66
+
67
+ The guard then did:
68
+
69
+ ```typescript
70
+ if (!Array.isArray(allowedHosts) || !allowedHosts.length) {
71
+ return; // read as "no allowlist configured"
72
+ }
73
+ ```
74
+
75
+ So a string was read as **"not configured"** and the check was skipped entirely — no log line, no
76
+ error. An operator who used the canonical `NSC__` spelling (the documented form for every other
77
+ setting) had **no egress restriction at all** while believing the control was on.
78
+
79
+ ### What changes in 11.40.0
80
+
81
+ A string is now parsed as a comma-separated list, so the setting does what it says.
82
+
83
+ **This is a behaviour change in the restrictive direction.** If you set it as a string, your
84
+ deployment went from *no restriction* to *enforced*. Any AI connection whose host is not in that
85
+ list now fails with `ServiceUnavailableException` and a WARN naming the host. There is no
86
+ deprecation window, because leaving an SSRF control off for a release cycle is worse than the
87
+ breakage.
88
+
89
+ ### Before upgrading
90
+
91
+ 1. Find the configured value:
92
+ ```bash
93
+ grep -r "NSC__AI__ALLOWED_BASE_URL_HOSTS" . --include="*.env*" --include="*.yml" 2>/dev/null
94
+ ```
95
+ 2. List every `baseUrl` your connections actually use — including any seeded via
96
+ `ai.defaultConnection`, and any added at runtime through `aiConnections`:
97
+ ```
98
+ query { aiConnections { name baseUrl enabled } }
99
+ ```
100
+ 3. Confirm every one of those hosts appears in the list. Add the missing ones, or clear the setting
101
+ entirely if you did not mean to restrict egress:
102
+ ```bash
103
+ NSC__AI__ALLOWED_BASE_URL_HOSTS=llm.example.com,api.openai.com,localhost:11434
104
+ ```
105
+
106
+ **Unset is still permissive**, unchanged — a local Ollama works out of the box.
107
+
108
+ ### Matching rules
109
+
110
+ Worth reading once, because they are the likely cause of a surprising refusal:
111
+
112
+ | Entry | Matches | Does not match |
113
+ |-------|---------|----------------|
114
+ | `llm.example.com` | any port on that host | `llm.example.com.evil.test` |
115
+ | `llm.internal:8080` | exactly that port | `llm.internal:9200` |
116
+ | `example.com:443` | `https://example.com/` (default port) | `http://example.com/` |
117
+ | `LLM.Example.com` | `https://llm.example.com/` | — |
118
+
119
+ Entries and URLs are both trimmed, lowercased and stripped of a fully-qualifying trailing dot, so
120
+ neither side can win by spelling one DNS name differently.
121
+
122
+ ## Bugfix: the allowlist now covers every outbound path
123
+
124
+ `probeContextWindow()` (the Ollama `/api/show` probe behind `detectContextWindow()`) reached the
125
+ network **without consulting the allowlist**. It is not an admin-only path: `CoreAiService` calls
126
+ `detectAndPersistCapabilities()` on an ordinary user prompt whenever `contextWindow` is undefined,
127
+ and it runs *before* the AI rate limit.
128
+
129
+ All three outbound paths — chat completions, the capability probe, and the context-window probe —
130
+ now go through the same check. A refusal there degrades to "context window unknown" and falls back
131
+ to the built-in model table, so nothing breaks for an allowed host.
132
+
133
+ **No action required**, unless you were relying on the context-window probe reaching a host your
134
+ allowlist excludes — in which case add that host.
135
+
136
+ ## Bugfix: a malformed value is now reported
137
+
138
+ A value that is neither a list nor a string (a number, a boolean, an object — all reachable through
139
+ `NEST_SERVER_CONFIG`) carries no hostnames, so the allowlist cannot be applied. That was silent.
140
+ It is now logged as an error:
141
+
142
+ ```
143
+ ai.allowedBaseUrlHosts is a number and carries no hostnames — the SSRF egress allowlist is
144
+ NOT active. Use an array or a comma-separated string.
145
+ ```
146
+
147
+ The behaviour is unchanged (permissive); only the silence is gone. From the outside, "misconfigured"
148
+ and "deliberately unset" looked identical, which is what let the original defect survive.
149
+
150
+ ## Not consumer-facing
151
+
152
+ The rest of this release is repository tooling and does not ship: a new `check:overrides` guard for
153
+ stale pnpm overrides and audit suppressions, a `pnpm peers check` step, audit-count accounting in
154
+ `scripts/check.mjs`, and test-evidence work. `package.json` `files` ships `dist` plus docs, and the
155
+ CLI's vendor transformation copies `src/core/` — neither includes `scripts/`.
156
+
157
+ Four further tooling fixes landed late in the release, all in the same area and all of the same
158
+ kind — a gate that reported safety it had not established:
159
+
160
+ | Fix | What it was |
161
+ |-----|-------------|
162
+ | Audit hang guard (`CHECK_AUDIT_TIMEOUT`, default 600s) | `pnpm audit` emits no intermediate output, so the existing idle watchdog structurally could not tell a hang from a healthy slow run. A second, absolute cap now kills it with its own cause |
163
+ | `'unreadable'` degradation | An audit exiting **0** with no parseable tally printed a GREEN tick and a literal `0`, having assessed nothing. Reachable in practice: a pnpm version collision writes its error to stderr and exits 0 |
164
+ | 5xx read from the code field only | The signature matched `\b5\d\d\b` against the whole envelope, so pnpm's own `audited 503 packages` degraded a run that had to block |
165
+ | Reachability probe asked the wrong registry | Both check scripts hardcoded `registry.npmjs.org` while pnpm audits against the **configured** registry. Behind a private registry or proxy that reproduces the very false-green the probe removes: the real registry is unreachable, npmjs.org answers, the run reports "clean" |
166
+ | Steps list contradicted the warning | A degraded audit printed a yellow warning and "NOT CHECKED" — and a **green tick** in the Steps list, which hard-coded one per step |
167
+ | JSONC comment stripping | A regex stripper ate glob patterns out of `tsconfig.tests.json` and `.oxlintrc.json` (2783 bytes, and one whole `overrides` entry). Both files still parsed, so the assertions above them ran green against a mutilated config |
168
+
169
+ None of them changes shipped behaviour; they are recorded because each one made a **check** claim
170
+ something it had not verified, and that is the class of defect a consumer inherits indirectly — via
171
+ a release that passed a gate which was not looking.
172
+
173
+ ## Troubleshooting
174
+
175
+ | Symptom | Cause | Fix |
176
+ |---------|-------|-----|
177
+ | AI stopped working after the update; log shows `host "…" is not in ai.allowedBaseUrlHosts` | The allowlist is now enforced where it previously was not | Add the host, or clear the setting |
178
+ | `ServiceUnavailableException` on one connection only | That connection's `baseUrl` host is missing from the list | Add it — check `aiConnections`, not just `defaultConnection` |
179
+ | Error log says the allowlist is `NOT active` | The value is not a list or a string | Use an array, or a comma-separated string |
180
+ | An entry with `:443` stopped matching an `http://` URL | `:443` is the https default, not http's | Write the host without a port, or with the right one |
181
+
182
+ ## Module Documentation
183
+
184
+ - `src/core/modules/ai/README.md` → "Egress allowlist (`ai.allowedBaseUrlHosts`)"
185
+ - `.claude/rules/configurable-features.md` → "AI Egress Allowlist (SSRF)"
186
+ - `src/core/common/interfaces/server-options.interface.ts` → `IAi.allowedBaseUrlHosts`
@@ -0,0 +1,118 @@
1
+ # Migration Guide: 11.40.0 → 11.41.0
2
+
3
+ > **Why a MINOR.** The MAJOR digit in this package tracks the NestJS major (11.x = NestJS 11), so it
4
+ > is not ours to spend — every behaviour change of our own ships as a MINOR. This release changes
5
+ > when the AI module asks an endpoint for JSON, which a project can notice.
6
+
7
+ ## Overview
8
+
9
+ | Category | Effort | Applies to |
10
+ |----------|--------|-----------|
11
+ | **Behaviour change** | none | Projects using the AI module — three internal calls stop requesting JSON |
12
+ | Bugfix | none | Everyone using the AI module — capability detection and context windows get more accurate |
13
+ | New feature (opt-in) | none | `ai.maxRunMs` |
14
+ | Internal | none | Nobody — a duplicate index declaration was removed |
15
+
16
+ Most projects update with `pnpm update @lenne.tech/nest-server` and read no further. **Projects that
17
+ do not use the AI module are unaffected by all of it.**
18
+
19
+ ## Quick Migration
20
+
21
+ ```bash
22
+ pnpm update @lenne.tech/nest-server
23
+ ```
24
+
25
+ ```bash
26
+ # Do you use the AI module at all? If this is empty, you are done.
27
+ grep -rn "CoreAiModule\|aiConnections\|AiTool" src/ 2>/dev/null
28
+ ```
29
+
30
+ ## Behaviour change: JSON mode is decided per PROMPT, not per connection
31
+
32
+ ### What changed
33
+
34
+ A connection with `supportsJsonResponse: true` used to send `response_format: json_object` on
35
+ **every** completion. It is now sent only when the call actually wants structured output.
36
+
37
+ That was wrong in both directions, and the second one is why it is worth fixing rather than leaving:
38
+
39
+ - The flag describes what the ENDPOINT can do, not what a given prompt NEEDS. A capability is not
40
+ an instruction.
41
+ - It leaked into calls that must not be JSON. The final answer to the user, and the compaction step
42
+ that summarises a long conversation, both want prose — and both were being asked for JSON. The
43
+ compaction case is the visible one: it spliced JSON-wrapped summaries into the history that the
44
+ model then had to read back.
45
+
46
+ ### Do I need to do anything?
47
+
48
+ **No.** There is deliberately no new option to pass: `jsonResponse` lives on `LlmCompletionOptions`,
49
+ which is the PROVIDER-level contract, and the module sets it for itself. It is not a field on the
50
+ prompt input, and nothing in a consumer project needs to change.
51
+
52
+ Concretely, the narrowing is three call sites, all of which wanted prose and were being asked for
53
+ JSON:
54
+
55
+ | Call | Now |
56
+ |------|-----|
57
+ | The agent loop, on a connection with NATIVE tool calling | JSON mode off — the tools carry the structure |
58
+ | The final answer to the user | never JSON |
59
+ | Compaction (summarising a long conversation) | never JSON |
60
+
61
+ A connection WITHOUT native tool calling is untouched: emulated tool calling is built on
62
+ prompt-driven JSON, so those calls still request it.
63
+
64
+ The one thing to know is the seam the fix deliberately leaves imperfect, because a project can reach
65
+ it: the gate keys on native tool support, not on what the prompt asks for. A project that overrides
66
+ the `native` prompt slot via `CoreAiSlotService` to request JSON anyway will have it narrowed off.
67
+ That degrades gracefully rather than failing — prompt-driven JSON is the fallback the module is
68
+ built around, and its extractor is lenient — but if you have such an override and see prose where
69
+ you expected an object, this is why.
70
+
71
+ A custom `ILlmProvider` needs no change either: `jsonResponse` is OPTIONAL, so an implementation
72
+ that ignores it keeps compiling and keeps its previous behaviour.
73
+
74
+ ## Bugfixes (no action required)
75
+
76
+ | Fix | What it was |
77
+ |-----|-------------|
78
+ | **Context window for two model families** | The known-model table matched by substring, so `ministral` did not match `mistral` and `mistral-medium` had no entry at all. Both fell back to the 8192 default instead of their real 131072 — silently truncating history on models that had 16x the room. Both are now listed |
79
+ | **Capability probes recorded permanent false negatives** | A reasoning model spends output tokens on its thinking phase BEFORE emitting `tool_calls`. With the old few-token probe budget the endpoint answered `200` with `finish_reason: 'length'` and no tool call, which read as "native tools unsupported" — persisted, never re-probed, and the assistant degraded to emulated tool calling for good. The probe now budgets 256 tokens and retries once at 1024 before answering `false` |
80
+ | **A thrown detection re-probed on every prompt** | A detection that threw left the capability `undefined`, and `undefined` is what triggers detection — so a transient endpoint blip fired an extra upstream completion before EVERY user prompt, ahead of the rate limiter and outside budget accounting. A 5-minute per-connection backoff now bounds it |
81
+ | **Duplicate `tenantId` index** | Two AI models declared an index the `mongooseTenantPlugin` already creates, producing Mongoose "Duplicate schema index" warnings. Declaration removed; the index itself is unchanged and created by the plugin as before |
82
+
83
+ ## New: `ai.maxRunMs` (opt-in, off by default)
84
+
85
+ A wall-clock ceiling for ONE prompt run, checked before each agent-loop iteration.
86
+
87
+ ```typescript
88
+ ai: {
89
+ maxRunMs: 120000, // 0 or omitted = no limit (previous behaviour)
90
+ }
91
+ ```
92
+
93
+ **Why it is worth setting.** Without it, a run's only bound is `maxIterations` multiplied by the
94
+ connection's per-call timeout — 8 iterations at the 120 s default is a request that can legitimately
95
+ hold a socket, its message buffer and a request context for 16 minutes, with compaction adding a
96
+ call per iteration on top. Set it to something a client would actually wait for.
97
+
98
+ A misconfigured value degrades to "no limit" rather than to an expired deadline: the check is
99
+ `maxRunMs > 0`, and a non-numeric value arriving through `NSC__AI__MAX_RUN_MS` yields `NaN`, which
100
+ fails that comparison. See `.claude/rules/configurable-features.md` → Numeric Sentinel, Family A.
101
+
102
+ ## Troubleshooting
103
+
104
+ **"My final answer used to come back as JSON and now returns prose."** That is the behaviour change
105
+ above, and it is the fix rather than a regression: the final answer was never meant to be JSON. If
106
+ you were parsing it, parse the prose or move the structured part into a tool result, which is what
107
+ tool calling is for.
108
+
109
+ **"An endpoint that supported tools is suddenly using emulated tool calling."** That is the OLD
110
+ defect, and it persisted the wrong flag. Clear the stored capability on the connection so it is
111
+ re-probed with the new budget; detection now records `true` where it previously recorded a false
112
+ negative.
113
+
114
+ ## Module Documentation
115
+
116
+ - [AI module README](../src/core/modules/ai/README.md)
117
+ - [AI integration checklist](../src/core/modules/ai/INTEGRATION-CHECKLIST.md)
118
+ - [Configurable features](../.claude/rules/configurable-features.md)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lenne.tech/nest-server",
3
- "version": "11.39.0",
3
+ "version": "11.41.0",
4
4
  "description": "Modern, fast, powerful Node.js web framework in TypeScript based on Nest with a GraphQL API and a connection to MongoDB (or other databases).",
5
5
  "keywords": [
6
6
  "node",
@@ -25,11 +25,12 @@
25
25
  "cf": "pnpm run check:fix",
26
26
  "check": "node scripts/check.mjs",
27
27
  "check:consumer": "node scripts/check-consumer.mjs",
28
- "check:fix": "pnpm install && pnpm run spectaql:sync && pnpm audit --fix && pnpm run format && pnpm run lint:fix && pnpm run typecheck:tests && pnpm run check:swc-tdz && pnpm test && pnpm run build && pnpm run check:manifest && bash scripts/check-server-start.sh",
28
+ "check:fix": "pnpm install && pnpm run spectaql:sync && pnpm audit --fix && pnpm run check:overrides && pnpm peers check && pnpm run format && pnpm run lint:fix && pnpm run typecheck:tests && pnpm run check:swc-tdz && pnpm test && pnpm run build && pnpm run check:manifest && bash scripts/check-server-start.sh",
29
29
  "check:manifest": "node scripts/check-package-manifest.mjs",
30
30
  "check:mutations": "node scripts/check-mutations.mjs",
31
- "check:naf": "pnpm install && pnpm run spectaql:sync && pnpm run format && pnpm run lint:fix && pnpm run typecheck:tests && pnpm run check:swc-tdz && pnpm test && pnpm run build && pnpm run check:manifest && bash scripts/check-server-start.sh",
32
- "check:raw": "pnpm install --frozen-lockfile && pnpm run spectaql:sync && pnpm audit && pnpm run format:check && pnpm run lint && pnpm run typecheck:tests && pnpm run check:swc-tdz && pnpm test && pnpm run build && pnpm run check:manifest && bash scripts/check-server-start.sh",
31
+ "check:naf": "pnpm install && pnpm run spectaql:sync && pnpm run check:overrides && pnpm peers check && pnpm run format && pnpm run lint:fix && pnpm run typecheck:tests && pnpm run check:swc-tdz && pnpm test && pnpm run build && pnpm run check:manifest && bash scripts/check-server-start.sh",
32
+ "check:overrides": "node scripts/check-overrides.mjs",
33
+ "check:raw": "pnpm install --frozen-lockfile && pnpm run spectaql:sync && pnpm audit && pnpm run check:overrides && pnpm peers check && pnpm run format:check && pnpm run lint && pnpm run typecheck:tests && pnpm run check:swc-tdz && pnpm test && pnpm run build && pnpm run check:manifest && bash scripts/check-server-start.sh",
33
34
  "check:swc-tdz": "nest build -b swc -p tsconfig.swc-tdz.json && node scripts/check-swc-tdz.mjs",
34
35
  "cnaf": "pnpm run check:naf",
35
36
  "docs": "pnpm run docs:ci && open http://127.0.0.1:8080/ && open ./public/index.html && compodoc -p tsconfig.json -s ",
@@ -1712,9 +1712,25 @@ export interface IAi {
1712
1712
  * bare `hostname`); unset → permissive (so local providers like Ollama on localhost
1713
1713
  * work out of the box). `baseUrl` is admin-only, so this guards a compromised or
1714
1714
  * misconfigured admin, not end-user input.
1715
+ *
1716
+ * Accepts an array OR a comma-separated string. The string form is not a convenience:
1717
+ * it is the shape the framework's own env mapping produces. `NSC__AI__ALLOWED_BASE_URL_HOSTS`
1718
+ * becomes `{ ai: { allowedBaseUrlHosts: '<string>' } }`, and lodash `merge` assigns that
1719
+ * scalar straight over a configured array — so the canonical `NSC__` spelling MUST be
1720
+ * understood here or the control silently switches itself off.
1721
+ *
1722
+ * Entries are trimmed, lowercased and stripped of a fully-qualifying trailing dot, and the
1723
+ * same normalisation is applied to the URL being checked, so neither side can win by
1724
+ * spelling the same DNS name differently. A bare hostname entry matches any port on that
1725
+ * host; an entry naming the scheme's default port (`example.com:443` for https) also matches
1726
+ * the portless URL. A value that is neither an array nor a string carries no hostnames, so
1727
+ * the allowlist is inactive — that case is LOGGED as an error rather than passed over, since
1728
+ * it looks identical to "correctly unset" from the outside.
1729
+ *
1715
1730
  * @example ['llm.example.com', 'localhost:11434']
1731
+ * @example 'llm.example.com,localhost:11434' // NSC__AI__ALLOWED_BASE_URL_HOSTS
1716
1732
  */
1717
- allowedBaseUrlHosts?: string[];
1733
+ allowedBaseUrlHosts?: string | string[];
1718
1734
 
1719
1735
  /**
1720
1736
  * Persist an audit record (`aiInteractions`) for every prompt run (admin-readable).
@@ -1885,6 +1901,23 @@ export interface IAi {
1885
1901
  /** Maximum number of agent-loop iterations (tool round-trips). @default 5 */
1886
1902
  maxIterations?: number;
1887
1903
 
1904
+ /**
1905
+ * Wall-clock ceiling for ONE prompt run, in milliseconds. Checked before each
1906
+ * agent-loop iteration; once exceeded the run stops and answers with whatever it
1907
+ * has (or the translated "no final answer" message).
1908
+ *
1909
+ * Without it the only bound is `maxIterations` multiplied by the connection's
1910
+ * PER-CALL timeout — e.g. 8 iterations at the 120 s default is a request that can
1911
+ * legitimately occupy a socket, its message buffer and a request context for 16
1912
+ * minutes, and compaction can add a further call per iteration on top. Set this
1913
+ * to something a client would actually wait for.
1914
+ *
1915
+ * `0` or omitted disables the check (previous behaviour).
1916
+ *
1917
+ * @default 0
1918
+ */
1919
+ maxRunMs?: number;
1920
+
1888
1921
  /** Maximum characters of a tool-results payload fed back to the model. @default 12000 */
1889
1922
  maxToolResultChars?: number;
1890
1923
 
@@ -118,11 +118,32 @@ never probed). Detection runs in two complementary ways:
118
118
  warns — the stored value is never changed. OFF by default because it makes outbound calls to
119
119
  the LLM endpoints on every boot; also skipped in the ci/e2e runners.
120
120
 
121
- The probe is provider-agnostic best effort: `response_format: json_object` is sent
122
- (2xx JSON supported); a trivial tool with `tool_choice: 'required'` is sent (2xx
123
- returning a `tool_calls` result → native tools supported; a `4xx` or a silent ignore
124
- → unsupported). Override `OpenAiCompatibleProvider.detectCapabilities()` for custom
125
- backends, or implement the optional `ILlmProvider.detectCapabilities()` in your own provider.
121
+ The probe is provider-agnostic best effort, and a 2xx alone is never the verdict —
122
+ a backend that does not implement a parameter typically ignores it and answers
123
+ normally, which would persist a `true` it never earns:
124
+
125
+ - **JSON:** `response_format: json_object` is sent and the CONTENT must actually
126
+ parse. A response truncated by the output budget (`finish_reason: 'length'`, empty
127
+ OR partial) proves nothing and is retried once with a larger budget before the
128
+ probe settles on `false`.
129
+ - **Native tools:** a trivial tool with `tool_choice: 'required'` is sent; a
130
+ `tool_calls` result → supported, a `4xx` or a complete answer without tool calls →
131
+ unsupported, a truncation → the same one retry.
132
+
133
+ Both probes run concurrently. Override `OpenAiCompatibleProvider.detectCapabilities()`
134
+ for custom backends, or implement the optional `ILlmProvider.detectCapabilities()` in
135
+ your own provider.
136
+
137
+ > **`supportsJsonResponse` is a CONNECTION flag, but whether an answer must be JSON is
138
+ > a property of the PROMPT.** The JSON output contract is carried only by the
139
+ > `output_contract` / `tool_protocol_emulated` fragments (both `capability: 'emulated'`)
140
+ > and by `plan_protocol`. A **native**-tools run receives none of them and is asked for
141
+ > prose — attaching `response_format` on top is a contradiction the model can only
142
+ > resolve by inventing a shape of its own, which then reaches the user as the answer.
143
+ > The orchestrator therefore decides JSON mode per CALL, not per connection: pass
144
+ > `jsonResponse: false` in `LlmCompletionOptions` from any call whose prompt asks for
145
+ > prose. The option only ever NARROWS — it can never assert JSON mode for a connection
146
+ > whose endpoint was not probed for it.
126
147
 
127
148
  ### Backend examples (external, local, CLI)
128
149
 
@@ -159,6 +180,39 @@ calling and executes tools itself through `CrudService` with the caller's permis
159
180
  the child runs in a temp dir so no `CLAUDE.md`/settings leak into the context. See
160
181
  `ClaudeCliProvider` for the full security model and the optional `ai.claudeCli` config.
161
182
 
183
+ ## Egress allowlist (`ai.allowedBaseUrlHosts`)
184
+
185
+ A connection's `baseUrl` decides where the server sends outbound HTTP. It is admin-set, so the
186
+ threat model is a compromised or mistyped admin rather than end-user input — but the request still
187
+ leaves from inside your network, which is what makes it an SSRF surface.
188
+
189
+ **Unset (the default) means no restriction**, so a local provider works out of the box. When set,
190
+ only the listed hosts are reachable and everything else is refused with
191
+ `ServiceUnavailableException` plus a WARN naming the host:
192
+
193
+ ```typescript
194
+ ai: {
195
+ allowedBaseUrlHosts: ['llm.example.com', 'localhost:11434'],
196
+ }
197
+ ```
198
+
199
+ ```bash
200
+ # Same setting via the canonical env spelling — a comma-separated string is understood
201
+ NSC__AI__ALLOWED_BASE_URL_HOSTS=llm.example.com,localhost:11434
202
+ ```
203
+
204
+ Matching details worth knowing before you debug a refusal:
205
+
206
+ - A bare hostname entry matches **any port** on that host. An entry that names the scheme's default
207
+ port (`example.com:443` for https) also matches the portless URL.
208
+ - Entries and URLs are both trimmed, lowercased, and stripped of a fully-qualifying trailing dot, so
209
+ `LLM.Example.com`, `llm.example.com` and `llm.example.com.` are one host.
210
+ - The check covers **all three** outbound paths: chat completions, the capability probe, and the
211
+ Ollama context-window probe.
212
+ - A value that is neither a list nor a string carries no hostnames. The allowlist is then inactive
213
+ and the framework logs an error — from the outside that state is indistinguishable from
214
+ "correctly unset", which is exactly why it is not silent.
215
+
162
216
  ## Connections (DB configuration)
163
217
 
164
218
  Connections live in the `aiConnections` collection and are managed by admins via
@@ -96,7 +96,22 @@ export class CoreAiController {
96
96
  res.setHeader('Cache-Control', 'no-cache');
97
97
  res.setHeader('Connection', 'keep-alive');
98
98
  res.setHeader('Content-Type', 'text/event-stream');
99
+ // Disable proxy-side response buffering (nginx and friends honour this); without
100
+ // it an intermediary can hold the events until the response ends, which defeats
101
+ // the whole point of the stream.
102
+ res.setHeader('X-Accel-Buffering', 'no');
99
103
  res.flushHeaders?.();
104
+
105
+ // A prompt run can stay silent for a long time — a multi-step turn measured
106
+ // 30-40 s, and `ai.maxRunMs` allows up to two minutes. Many proxies close an
107
+ // idle connection at 60 s, which the client then sees as a turn that silently
108
+ // vanished. Comment frames are ignored by every SSE client and keep the
109
+ // connection observably alive.
110
+ const heartbeat = setInterval(() => {
111
+ res.write(': keep-alive\n\n');
112
+ }, 15_000);
113
+ heartbeat.unref?.();
114
+
100
115
  try {
101
116
  for await (const event of this.aiService.promptStream(input, serviceOptions)) {
102
117
  res.write(`data: ${JSON.stringify(event)}\n\n`);
@@ -104,6 +119,7 @@ export class CoreAiController {
104
119
  } catch (err) {
105
120
  res.write(`data: ${JSON.stringify({ message: (err as Error).message, type: 'error' })}\n\n`);
106
121
  } finally {
122
+ clearInterval(heartbeat);
107
123
  res.end();
108
124
  }
109
125
  }
@@ -108,6 +108,21 @@ export interface LlmUsage {
108
108
  * when omitted.
109
109
  */
110
110
  export interface LlmCompletionOptions {
111
+ /**
112
+ * Set `false` to suppress structured-JSON mode for THIS call even though the
113
+ * connection advertises `supportsJsonResponse`.
114
+ *
115
+ * Narrowing only — it can never switch JSON mode ON for a connection whose
116
+ * endpoint was not probed for it, because the flag is measured per connection and
117
+ * asserting it elsewhere is what produces a 4xx nobody expected.
118
+ *
119
+ * It exists because `supportsJsonResponse` is CONNECTION state while whether an
120
+ * answer must be JSON is a property of the PROMPT. A caller that asks for prose —
121
+ * a summary, a native-tools chat turn, a plan summary — must be able to say so
122
+ * without rebuilding the connection object around the flag.
123
+ */
124
+ jsonResponse?: boolean;
125
+
111
126
  /** Maximum number of tokens to generate. */
112
127
  maxTokens?: number;
113
128
 
@@ -89,9 +89,18 @@ export class CoreAiPrompt extends CorePersistenceModel {
89
89
 
90
90
  /** Tenant id when scope = 'tenant' (set from the creator's tenant at create time). */
91
91
  @UnifiedField({
92
+ // No `index: true`: declaring the `tenantId` PATH is what activates
93
+ // `mongooseTenantPlugin`, and the plugin then adds `schema.index({ tenantId: 1 })`
94
+ // itself. Declaring it here as well makes Mongoose log
95
+ // "Duplicate schema index on {"tenantId":1}" on every boot.
96
+ //
97
+ // The `mongoose` key itself MUST stay: `UnifiedField` emits `@Prop` only inside
98
+ // `if (opts.mongoose)`, so dropping the whole key would remove the schema path —
99
+ // and `mongooseTenantPlugin` returns early on `!schema.path('tenantId')`, i.e.
100
+ // the model would lose its tenant filtering entirely.
92
101
  description: 'Tenant id (when scope = "tenant")',
93
102
  isOptional: true,
94
- mongoose: { index: true },
103
+ mongoose: { type: String },
95
104
  roles: RoleEnum.S_USER,
96
105
  })
97
106
  tenantId?: string = undefined;
@@ -133,9 +133,18 @@ export class CoreAiSlot extends CorePersistenceModel {
133
133
  * slot is effectively system-wide.
134
134
  */
135
135
  @UnifiedField({
136
+ // No `index: true`: declaring the `tenantId` PATH is what activates
137
+ // `mongooseTenantPlugin`, and the plugin then adds `schema.index({ tenantId: 1 })`
138
+ // itself. Declaring it here as well makes Mongoose log
139
+ // "Duplicate schema index on {"tenantId":1}" on every boot.
140
+ //
141
+ // The `mongoose` key itself MUST stay: `UnifiedField` emits `@Prop` only inside
142
+ // `if (opts.mongoose)`, so dropping the whole key would remove the schema path —
143
+ // and `mongooseTenantPlugin` returns early on `!schema.path('tenantId')`, i.e.
144
+ // the model would lose its tenant filtering entirely.
136
145
  description: 'Tenant id the slot applies to (auto-set; undefined = system-wide)',
137
146
  isOptional: true,
138
- mongoose: { index: true },
147
+ mongoose: { type: String },
139
148
  roles: RoleEnum.ADMIN,
140
149
  })
141
150
  tenantId?: string = undefined;