@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.
- package/.claude/rules/configurable-features.md +3 -0
- package/.claude/rules/module-inheritance.md +2 -0
- package/.claude/rules/package-management.md +51 -2
- package/.claude/rules/testing.md +182 -3
- package/CLAUDE.md +13 -1
- package/FRAMEWORK-API.md +3 -2
- package/dist/core/common/interfaces/server-options.interface.d.ts +2 -1
- package/dist/core/modules/ai/core-ai.controller.js +6 -0
- package/dist/core/modules/ai/core-ai.controller.js.map +1 -1
- package/dist/core/modules/ai/interfaces/llm-provider.interface.d.ts +1 -0
- package/dist/core/modules/ai/models/core-ai-prompt.model.js +1 -1
- package/dist/core/modules/ai/models/core-ai-prompt.model.js.map +1 -1
- package/dist/core/modules/ai/models/core-ai-slot.model.js +1 -1
- package/dist/core/modules/ai/models/core-ai-slot.model.js.map +1 -1
- package/dist/core/modules/ai/providers/openai-compatible.provider.d.ts +6 -0
- package/dist/core/modules/ai/providers/openai-compatible.provider.js +106 -12
- package/dist/core/modules/ai/providers/openai-compatible.provider.js.map +1 -1
- package/dist/core/modules/ai/services/core-ai.service.d.ts +12 -1
- package/dist/core/modules/ai/services/core-ai.service.js +131 -13
- package/dist/core/modules/ai/services/core-ai.service.js.map +1 -1
- package/dist/tsconfig.build.tsbuildinfo +1 -1
- package/migration-guides/11.39.0-to-11.40.0.md +186 -0
- package/migration-guides/11.40.0-to-11.41.0.md +118 -0
- package/package.json +5 -4
- package/src/core/common/interfaces/server-options.interface.ts +34 -1
- package/src/core/modules/ai/README.md +59 -5
- package/src/core/modules/ai/core-ai.controller.ts +16 -0
- package/src/core/modules/ai/interfaces/llm-provider.interface.ts +15 -0
- package/src/core/modules/ai/models/core-ai-prompt.model.ts +10 -1
- package/src/core/modules/ai/models/core-ai-slot.model.ts +10 -1
- package/src/core/modules/ai/providers/openai-compatible.provider.ts +309 -15
- 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.
|
|
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:
|
|
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
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
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: {
|
|
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: {
|
|
147
|
+
mongoose: { type: String },
|
|
139
148
|
roles: RoleEnum.ADMIN,
|
|
140
149
|
})
|
|
141
150
|
tenantId?: string = undefined;
|