@lenne.tech/nest-server 11.41.8 → 11.42.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 +4 -3
- package/.claude/rules/role-system.md +31 -0
- package/.claude/rules/testing.md +4 -4
- package/CLAUDE.md +6 -0
- package/FRAMEWORK-API.md +3 -2
- package/dist/core/common/helpers/graphql-ws-context.helper.js +1 -1
- package/dist/core/common/helpers/graphql-ws-context.helper.js.map +1 -1
- package/dist/core/common/helpers/outbound-url.helper.d.ts +14 -0
- package/dist/core/common/helpers/outbound-url.helper.js +234 -0
- package/dist/core/common/helpers/outbound-url.helper.js.map +1 -0
- package/dist/core/common/interfaces/server-options.interface.d.ts +5 -0
- package/dist/core/common/middleware/request-context.middleware.js +1 -0
- package/dist/core/common/middleware/request-context.middleware.js.map +1 -1
- package/dist/core/common/services/request-context.service.d.ts +1 -0
- package/dist/core/common/services/request-context.service.js.map +1 -1
- package/dist/core/modules/ai/core-ai-mcp.controller.js +5 -2
- package/dist/core/modules/ai/core-ai-mcp.controller.js.map +1 -1
- package/dist/core/modules/ai/services/core-ai-mcp.service.d.ts +1 -1
- package/dist/core/modules/ai/services/core-ai-mcp.service.js +3 -2
- package/dist/core/modules/ai/services/core-ai-mcp.service.js.map +1 -1
- package/dist/core/modules/ai/tools/ai-tool.registry.js +15 -2
- package/dist/core/modules/ai/tools/ai-tool.registry.js.map +1 -1
- package/dist/core/modules/tenant/core-tenant-role.helper.d.ts +2 -0
- package/dist/core/modules/tenant/core-tenant-role.helper.js +36 -0
- package/dist/core/modules/tenant/core-tenant-role.helper.js.map +1 -0
- package/dist/core/modules/tenant/core-tenant.guard.d.ts +7 -1
- package/dist/core/modules/tenant/core-tenant.guard.js +56 -4
- package/dist/core/modules/tenant/core-tenant.guard.js.map +1 -1
- package/dist/core/modules/tus/core-tus.service.d.ts +11 -0
- package/dist/core/modules/tus/core-tus.service.js +54 -14
- package/dist/core/modules/tus/core-tus.service.js.map +1 -1
- package/dist/core/modules/tus/tus.constants.d.ts +1 -0
- package/dist/core/modules/tus/tus.constants.js +2 -1
- package/dist/core/modules/tus/tus.constants.js.map +1 -1
- package/dist/core/modules/tus/tus.module.d.ts +2 -0
- package/dist/core/modules/tus/tus.module.js +14 -8
- package/dist/core/modules/tus/tus.module.js.map +1 -1
- package/dist/core/modules/user/core-user.service.d.ts +1 -0
- package/dist/core/modules/user/core-user.service.js +24 -0
- package/dist/core/modules/user/core-user.service.js.map +1 -1
- package/dist/index.d.ts +2 -0
- package/dist/index.js +2 -0
- package/dist/index.js.map +1 -1
- package/dist/tsconfig.build.tsbuildinfo +1 -1
- package/docs/REQUEST-LIFECYCLE.md +5 -3
- package/migration-guides/11.41.8-to-11.41.9.md +116 -0
- package/migration-guides/11.41.x-to-11.42.x.md +161 -0
- package/migration-guides/11.9.x-to-11.10.x.md +3 -2
- package/package.json +2 -1
- package/src/core/common/helpers/graphql-ws-context.helper.ts +1 -1
- package/src/core/common/helpers/outbound-url.helper.ts +310 -0
- package/src/core/common/interfaces/server-options.interface.ts +54 -0
- package/src/core/common/middleware/request-context.middleware.ts +1 -0
- package/src/core/common/services/request-context.service.ts +9 -0
- package/src/core/modules/ai/README.md +25 -0
- package/src/core/modules/ai/core-ai-mcp.controller.ts +22 -3
- package/src/core/modules/ai/services/core-ai-mcp.service.ts +9 -3
- package/src/core/modules/ai/tools/ai-tool.registry.ts +33 -3
- package/src/core/modules/better-auth/INTEGRATION-CHECKLIST.md +18 -11
- package/src/core/modules/tenant/INTEGRATION-CHECKLIST.md +14 -0
- package/src/core/modules/tenant/README.md +80 -8
- package/src/core/modules/tenant/core-tenant-role.helper.ts +75 -0
- package/src/core/modules/tenant/core-tenant.guard.ts +78 -5
- package/src/core/modules/tus/INTEGRATION-CHECKLIST.md +38 -11
- package/src/core/modules/tus/README.md +88 -14
- package/src/core/modules/tus/core-tus.service.ts +125 -30
- package/src/core/modules/tus/tus.constants.ts +12 -0
- package/src/core/modules/tus/tus.module.ts +41 -8
- package/src/core/modules/user/core-user.service.ts +53 -0
- package/src/index.ts +2 -0
|
@@ -0,0 +1,310 @@
|
|
|
1
|
+
import { lookup as dnsLookup, type LookupAddress } from 'node:dns';
|
|
2
|
+
import { BlockList, isIP } from 'node:net';
|
|
3
|
+
import { Agent, buildConnector, fetch as undiciFetch } from 'undici';
|
|
4
|
+
|
|
5
|
+
import { ConfigService } from '../services/config.service';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Requests to a URL a USER entered — a webhook, an export target, an ERP address.
|
|
9
|
+
*
|
|
10
|
+
* The server calls such a URL from inside its own network. Without a check, a user points it at the
|
|
11
|
+
* neighbourhood — the database, other containers, the cloud metadata service at 169.254.169.254 — and,
|
|
12
|
+
* wherever the response is stored or shown, reads the answer back (SSRF).
|
|
13
|
+
*
|
|
14
|
+
* **Only for user-entered URLs.** Targets the operator configured (AI providers, SMTP, S3, Redis,
|
|
15
|
+
* Brevo) do not go through this; nothing here is applied globally.
|
|
16
|
+
*
|
|
17
|
+
* The check runs twice on purpose:
|
|
18
|
+
* 1. {@link assertOutboundUrlAllowed} on the URL before anything is sent — a readable error, and
|
|
19
|
+
* 2. on every connection through {@link createOutboundDispatcher} — the only place that sees a
|
|
20
|
+
* redirect's next hop and the address a DNS name resolves to at connect time (rebinding).
|
|
21
|
+
*
|
|
22
|
+
* {@link outboundFetch} does both.
|
|
23
|
+
*
|
|
24
|
+
* Internal targets are refused on deployed stages (including `test`) and allowed locally, in e2e and CI,
|
|
25
|
+
* where the counterpart often runs in the same network. `security.allowPrivateOutboundTargets`
|
|
26
|
+
* (`NSC__SECURITY__ALLOW_PRIVATE_OUTBOUND_TARGETS=true|false`) decides it explicitly either way;
|
|
27
|
+
* `allowedHosts` frees individual internal hosts without opening all of them.
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
const blocked = new BlockList();
|
|
31
|
+
for (const [network, prefix] of [
|
|
32
|
+
['0.0.0.0', 8], // "this network"
|
|
33
|
+
['10.0.0.0', 8], // RFC 1918
|
|
34
|
+
['100.64.0.0', 10], // carrier-grade NAT
|
|
35
|
+
['127.0.0.0', 8], // loopback
|
|
36
|
+
['169.254.0.0', 16], // link-local, incl. the cloud metadata service
|
|
37
|
+
['172.16.0.0', 12], // RFC 1918
|
|
38
|
+
['192.0.0.0', 24], // IETF protocol assignments
|
|
39
|
+
['192.168.0.0', 16], // RFC 1918
|
|
40
|
+
['198.18.0.0', 15], // benchmarking
|
|
41
|
+
['224.0.0.0', 4], // multicast
|
|
42
|
+
['240.0.0.0', 4], // reserved, incl. broadcast
|
|
43
|
+
] as const) {
|
|
44
|
+
blocked.addSubnet(network, prefix, 'ipv4');
|
|
45
|
+
}
|
|
46
|
+
for (const [network, prefix] of [
|
|
47
|
+
['::', 96], // unspecified, loopback and IPv4-compatible (::a.b.c.d)
|
|
48
|
+
['64:ff9b::', 96], // NAT64 — embeds an IPv4 address
|
|
49
|
+
['fc00::', 7], // unique local
|
|
50
|
+
['fe80::', 10], // link-local
|
|
51
|
+
['ff00::', 8], // multicast
|
|
52
|
+
] as const) {
|
|
53
|
+
blocked.addSubnet(network, prefix, 'ipv6');
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Environments in which internal targets are allowed unless configured otherwise. Deliberately NOT
|
|
58
|
+
* `test`: in projects built from nest-server-starter that is a DEPLOYED stage.
|
|
59
|
+
*/
|
|
60
|
+
const LOCAL_ENVIRONMENTS = new Set(['ci', 'e2e', 'local']);
|
|
61
|
+
|
|
62
|
+
/** Thrown for a target that is, or resolves to, an internal address. */
|
|
63
|
+
export class OutboundUrlBlockedError extends Error {
|
|
64
|
+
constructor(
|
|
65
|
+
readonly host: string,
|
|
66
|
+
readonly address: string,
|
|
67
|
+
) {
|
|
68
|
+
super(
|
|
69
|
+
`Outbound target not allowed: "${host}" points to the internal address ${address}. ` +
|
|
70
|
+
'Use a public address, list the host in allowedHosts, or set security.allowPrivateOutboundTargets.',
|
|
71
|
+
);
|
|
72
|
+
this.name = 'OutboundUrlBlockedError';
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export interface IOutboundUrlOptions {
|
|
77
|
+
/**
|
|
78
|
+
* Internal hosts that may be reached anyway, e.g. an ERP in the same network. `'erp.local:8443'`
|
|
79
|
+
* matches that port only, `'erp.local'` and `'10.0.0.5'` any port. Matched against the host as
|
|
80
|
+
* written in the URL — and on every connection, so a redirect does not inherit the exemption.
|
|
81
|
+
*/
|
|
82
|
+
allowedHosts?: string[];
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** Whether internal targets are allowed at all — explicit config first, then the environment. */
|
|
86
|
+
export function privateOutboundTargetsAllowed(): boolean {
|
|
87
|
+
const config = ConfigService.configFastButReadOnly;
|
|
88
|
+
// Read loosely: a value from NSC__* or NEST_SERVER_CONFIG may arrive as a string.
|
|
89
|
+
const flag = config?.security?.allowPrivateOutboundTargets as unknown;
|
|
90
|
+
if (flag === true || flag === 'true') {
|
|
91
|
+
return true;
|
|
92
|
+
}
|
|
93
|
+
if (flag === false || flag === 'false') {
|
|
94
|
+
return false;
|
|
95
|
+
}
|
|
96
|
+
const env = String(config?.env ?? process.env.NODE_ENV ?? '')
|
|
97
|
+
.trim()
|
|
98
|
+
.toLowerCase();
|
|
99
|
+
return LOCAL_ENVIRONMENTS.has(env);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** Whether an IP address is internal. A hostname (not an IP) answers false — resolve it first. */
|
|
103
|
+
export function isBlockedOutboundAddress(address: string): boolean {
|
|
104
|
+
const plain = stripBrackets(address);
|
|
105
|
+
const family = isIP(plain);
|
|
106
|
+
if (family === 4) {
|
|
107
|
+
return blocked.check(plain, 'ipv4');
|
|
108
|
+
}
|
|
109
|
+
if (family === 6) {
|
|
110
|
+
const mapped = mappedIpv4(plain);
|
|
111
|
+
return mapped ? blocked.check(mapped, 'ipv4') : blocked.check(plain, 'ipv6');
|
|
112
|
+
}
|
|
113
|
+
return false;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Refuses a URL whose scheme is not http(s) or whose host is, or resolves to, an internal address.
|
|
118
|
+
* Returns the parsed URL. A redirect is NOT covered here — send the request through
|
|
119
|
+
* {@link outboundFetch} or {@link createOutboundDispatcher}.
|
|
120
|
+
*/
|
|
121
|
+
export async function assertOutboundUrlAllowed(url: string, options: IOutboundUrlOptions = {}): Promise<URL> {
|
|
122
|
+
let parsed: URL;
|
|
123
|
+
try {
|
|
124
|
+
parsed = new URL(url);
|
|
125
|
+
} catch {
|
|
126
|
+
throw new Error(`Invalid outbound URL: "${url}"`);
|
|
127
|
+
}
|
|
128
|
+
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
|
|
129
|
+
throw new Error(`Outbound URL must use http:// or https://: "${url}"`);
|
|
130
|
+
}
|
|
131
|
+
const host = stripBrackets(parsed.hostname);
|
|
132
|
+
if (privateOutboundTargetsAllowed() || isAllowedHost(host, effectivePort(parsed.port, parsed.protocol), options)) {
|
|
133
|
+
return parsed;
|
|
134
|
+
}
|
|
135
|
+
const addresses = isIP(host) ? [host] : await resolveAll(host);
|
|
136
|
+
const internal = addresses.find(isBlockedOutboundAddress);
|
|
137
|
+
if (internal) {
|
|
138
|
+
throw new OutboundUrlBlockedError(host, internal);
|
|
139
|
+
}
|
|
140
|
+
return parsed;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
const plainConnect = buildConnector({});
|
|
144
|
+
const guardedConnect = buildConnector({ lookup: guardedLookup as never });
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* An undici dispatcher that refuses every connection to an internal address — whether the host came
|
|
148
|
+
* as a name or as a literal, and whether it is the first URL or a redirect's next hop. Use it with
|
|
149
|
+
* undici's own `fetch`/`request`; {@link outboundFetch} does that for you.
|
|
150
|
+
*/
|
|
151
|
+
export function createOutboundDispatcher(options: IOutboundUrlOptions = {}): Agent {
|
|
152
|
+
return new Agent({
|
|
153
|
+
connect: (connectOptions, callback) => {
|
|
154
|
+
const host = stripBrackets(String(connectOptions.hostname ?? ''));
|
|
155
|
+
const port = effectivePort(connectOptions.port, connectOptions.protocol);
|
|
156
|
+
if (privateOutboundTargetsAllowed() || isAllowedHost(host, port, options)) {
|
|
157
|
+
plainConnect(connectOptions, callback);
|
|
158
|
+
return;
|
|
159
|
+
}
|
|
160
|
+
if (isIP(host) && isBlockedOutboundAddress(host)) {
|
|
161
|
+
callback(new OutboundUrlBlockedError(host, host), null);
|
|
162
|
+
return;
|
|
163
|
+
}
|
|
164
|
+
guardedConnect(connectOptions, callback);
|
|
165
|
+
},
|
|
166
|
+
});
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
let defaultDispatcher: Agent | undefined;
|
|
170
|
+
const allowListDispatchers = new Map<string, Agent>();
|
|
171
|
+
const MAX_ALLOW_LIST_DISPATCHERS = 50;
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* `fetch` for a user-entered URL: the URL check first (a readable {@link OutboundUrlBlockedError}),
|
|
175
|
+
* then the request through a guarded dispatcher. Uses undici's own `fetch`, so the dispatcher always
|
|
176
|
+
* matches the client — Node's built-in `fetch` bundles a different undici major on Node 22.
|
|
177
|
+
*
|
|
178
|
+
* A refusal on a later hop (redirect, rebinding) surfaces as undici's `TypeError: fetch failed` with
|
|
179
|
+
* the {@link OutboundUrlBlockedError} as its `cause`.
|
|
180
|
+
*/
|
|
181
|
+
export async function outboundFetch(
|
|
182
|
+
url: string,
|
|
183
|
+
init: Parameters<typeof undiciFetch>[1] = {},
|
|
184
|
+
options: IOutboundUrlOptions = {},
|
|
185
|
+
): ReturnType<typeof undiciFetch> {
|
|
186
|
+
await assertOutboundUrlAllowed(url, options);
|
|
187
|
+
return undiciFetch(url, { ...init, dispatcher: dispatcherFor(options) });
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function dispatcherFor(options: IOutboundUrlOptions): Agent {
|
|
191
|
+
const hosts = (options.allowedHosts ?? []).map(normalizeHost).filter(Boolean).sort();
|
|
192
|
+
if (!hosts.length) {
|
|
193
|
+
defaultDispatcher ??= createOutboundDispatcher();
|
|
194
|
+
return defaultDispatcher;
|
|
195
|
+
}
|
|
196
|
+
const key = hosts.join(',');
|
|
197
|
+
let dispatcher = allowListDispatchers.get(key);
|
|
198
|
+
if (!dispatcher) {
|
|
199
|
+
if (allowListDispatchers.size >= MAX_ALLOW_LIST_DISPATCHERS) {
|
|
200
|
+
const [oldestKey, oldest] = allowListDispatchers.entries().next().value as [string, Agent];
|
|
201
|
+
allowListDispatchers.delete(oldestKey);
|
|
202
|
+
void oldest.close().catch(() => undefined);
|
|
203
|
+
}
|
|
204
|
+
dispatcher = createOutboundDispatcher({ allowedHosts: hosts });
|
|
205
|
+
allowListDispatchers.set(key, dispatcher);
|
|
206
|
+
}
|
|
207
|
+
return dispatcher;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
function guardedLookup(
|
|
211
|
+
hostname: string,
|
|
212
|
+
options: { all?: boolean; family?: number },
|
|
213
|
+
callback: (err: Error | null, address?: LookupAddress[] | string, family?: number) => void,
|
|
214
|
+
): void {
|
|
215
|
+
dnsLookup(hostname, { ...options, all: true }, (err, addresses) => {
|
|
216
|
+
if (err) {
|
|
217
|
+
callback(err);
|
|
218
|
+
return;
|
|
219
|
+
}
|
|
220
|
+
const list = addresses as LookupAddress[];
|
|
221
|
+
const internal = list.find((entry) => isBlockedOutboundAddress(entry.address));
|
|
222
|
+
if (internal) {
|
|
223
|
+
callback(new OutboundUrlBlockedError(hostname, internal.address));
|
|
224
|
+
return;
|
|
225
|
+
}
|
|
226
|
+
if (options.all) {
|
|
227
|
+
callback(null, list);
|
|
228
|
+
return;
|
|
229
|
+
}
|
|
230
|
+
callback(null, list[0]?.address, list[0]?.family);
|
|
231
|
+
});
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
function resolveAll(host: string): Promise<string[]> {
|
|
235
|
+
return new Promise((resolve, reject) => {
|
|
236
|
+
dnsLookup(host, { all: true }, (err, addresses) => {
|
|
237
|
+
if (err) {
|
|
238
|
+
reject(new Error(`Outbound target cannot be resolved: "${host}" (${err.message})`));
|
|
239
|
+
return;
|
|
240
|
+
}
|
|
241
|
+
resolve(addresses.map((entry) => entry.address));
|
|
242
|
+
});
|
|
243
|
+
});
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
function isAllowedHost(host: string, port: string, options: IOutboundUrlOptions): boolean {
|
|
247
|
+
const wanted = host.toLowerCase();
|
|
248
|
+
return (options.allowedHosts ?? []).some((entry) => {
|
|
249
|
+
const normalized = normalizeHost(entry);
|
|
250
|
+
if (!normalized) {
|
|
251
|
+
return false;
|
|
252
|
+
}
|
|
253
|
+
const [entryHost, entryPort] = splitHostPort(normalized);
|
|
254
|
+
return entryHost === wanted && (!entryPort || entryPort === port);
|
|
255
|
+
});
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/** `Host`, `host:port`, `[v6]` and `[v6]:port`, lowercased, IPv6 canonicalised; '' when unusable. */
|
|
259
|
+
function normalizeHost(entry: string): string {
|
|
260
|
+
if (typeof entry !== 'string' || !entry.trim()) {
|
|
261
|
+
return '';
|
|
262
|
+
}
|
|
263
|
+
try {
|
|
264
|
+
const url = new URL(`http://${entry.trim()}`);
|
|
265
|
+
const host = stripBrackets(url.hostname).toLowerCase();
|
|
266
|
+
return url.port ? `${host.includes(':') ? `[${host}]` : host}:${url.port}` : host;
|
|
267
|
+
} catch {
|
|
268
|
+
return '';
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
function splitHostPort(normalized: string): [string, string] {
|
|
273
|
+
const v6 = /^\[(.+)\]:(\d+)$/.exec(normalized);
|
|
274
|
+
if (v6) {
|
|
275
|
+
return [v6[1], v6[2]];
|
|
276
|
+
}
|
|
277
|
+
if (isIP(normalized) === 6) {
|
|
278
|
+
return [normalized, ''];
|
|
279
|
+
}
|
|
280
|
+
const index = normalized.lastIndexOf(':');
|
|
281
|
+
return index === -1 ? [normalized, ''] : [normalized.slice(0, index), normalized.slice(index + 1)];
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
function effectivePort(port: number | string | null | undefined, protocol: string | null | undefined): string {
|
|
285
|
+
if (port !== undefined && port !== null && String(port) !== '') {
|
|
286
|
+
return String(port);
|
|
287
|
+
}
|
|
288
|
+
return protocol === 'https:' ? '443' : '80';
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
function stripBrackets(value: string): string {
|
|
292
|
+
return value.replace(/^\[|\]$/g, '');
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
/** The IPv4 address inside an IPv4-mapped IPv6 address, in any notation. */
|
|
296
|
+
function mappedIpv4(address: string): string | undefined {
|
|
297
|
+
let canonical: string;
|
|
298
|
+
try {
|
|
299
|
+
canonical = stripBrackets(new URL(`http://[${address}]`).hostname);
|
|
300
|
+
} catch {
|
|
301
|
+
return undefined;
|
|
302
|
+
}
|
|
303
|
+
const match = /^::ffff:([0-9a-f]{1,4}):([0-9a-f]{1,4})$/i.exec(canonical);
|
|
304
|
+
if (!match) {
|
|
305
|
+
return undefined;
|
|
306
|
+
}
|
|
307
|
+
const high = parseInt(match[1], 16);
|
|
308
|
+
const low = parseInt(match[2], 16);
|
|
309
|
+
return `${high >> 8}.${high & 255}.${low >> 8}.${low & 255}`;
|
|
310
|
+
}
|
|
@@ -10,6 +10,7 @@ import type { MongoosePingCheckSettings } from '@nestjs/terminus/dist/health-ind
|
|
|
10
10
|
import type { DiskHealthIndicatorOptions } from '@nestjs/terminus/dist/health-indicator/disk/disk-health-options.type.js';
|
|
11
11
|
import compression from 'compression';
|
|
12
12
|
import { CollationOptions } from 'mongodb';
|
|
13
|
+
import type { Connection } from 'mongoose';
|
|
13
14
|
import type * as JSONTransport from 'nodemailer/lib/json-transport';
|
|
14
15
|
import type * as SendmailTransport from 'nodemailer/lib/sendmail-transport';
|
|
15
16
|
import type * as SESTransport from 'nodemailer/lib/ses-transport';
|
|
@@ -1477,6 +1478,43 @@ export interface IMultiTenancy {
|
|
|
1477
1478
|
* @since 11.21.1
|
|
1478
1479
|
*/
|
|
1479
1480
|
cacheTtlMs?: number;
|
|
1481
|
+
|
|
1482
|
+
/**
|
|
1483
|
+
* Whether a tenant may be used at all — the hook that makes a DEACTIVATED tenant stop working.
|
|
1484
|
+
*
|
|
1485
|
+
* The tenant guard validates a membership and nothing else, so without this a tenant a project
|
|
1486
|
+
* switched off keeps working for every member, every API token and every request without a tenant
|
|
1487
|
+
* header. Asked after the membership check, on every path that establishes a tenant:
|
|
1488
|
+
*
|
|
1489
|
+
* - header paths (tenant roles, `S_USER` / `S_VERIFIED` with a header): **403**
|
|
1490
|
+
* - tenant API tokens: **403**
|
|
1491
|
+
* - public (`S_EVERYONE`) routes: not blocked, but no tenant context is set
|
|
1492
|
+
* - no header: the tenant is dropped from the user's tenant list, so its data stays out of reach
|
|
1493
|
+
* - GraphQL over WebSocket: no tenant is resolved for a NEW subscription; one already open keeps
|
|
1494
|
+
* its tenant until the client reconnects (as it does after a removed membership)
|
|
1495
|
+
*
|
|
1496
|
+
* A platform administrator under `adminBypass` still reaches the tenant — somebody has to be able to
|
|
1497
|
+
* look into it and switch it back on.
|
|
1498
|
+
*
|
|
1499
|
+
* Return `false` for an inactive tenant; any other value (including `undefined`) counts as active.
|
|
1500
|
+
* `context.connection` is the main database connection, for a lookup in the project's own tenant
|
|
1501
|
+
* collection. Answers are cached for {@link cacheTtlMs}; after switching a tenant on or off, call
|
|
1502
|
+
* `CoreTenantGuard.invalidateTenant(tenantId)` (broadcast to every replica when Redis is
|
|
1503
|
+
* configured). A function cannot arrive through `NSC__*` / `NEST_SERVER_CONFIG` — set it in
|
|
1504
|
+
* `config.env.ts`.
|
|
1505
|
+
*
|
|
1506
|
+
* @example
|
|
1507
|
+
* ```typescript
|
|
1508
|
+
* multiTenancy: {
|
|
1509
|
+
* isTenantActive: async (tenantId, { connection }) =>
|
|
1510
|
+
* (await connection.collection('tenants').findOne({ _id: new Types.ObjectId(tenantId) }))?.active !== false,
|
|
1511
|
+
* }
|
|
1512
|
+
* ```
|
|
1513
|
+
*
|
|
1514
|
+
* @default undefined (every tenant is active)
|
|
1515
|
+
* @since 11.42.0
|
|
1516
|
+
*/
|
|
1517
|
+
isTenantActive?: (tenantId: string, context: { connection: Connection }) => boolean | Promise<boolean>;
|
|
1480
1518
|
}
|
|
1481
1519
|
|
|
1482
1520
|
/**
|
|
@@ -2855,6 +2893,22 @@ export interface IServerOptions {
|
|
|
2855
2893
|
* Configuration for security pipes and interceptors
|
|
2856
2894
|
*/
|
|
2857
2895
|
security?: {
|
|
2896
|
+
/**
|
|
2897
|
+
* Whether the outbound URL guard (`outboundFetch`, `assertOutboundUrlAllowed`,
|
|
2898
|
+
* `createOutboundDispatcher`) may reach INTERNAL addresses — loopback, private networks,
|
|
2899
|
+
* link-local incl. the cloud metadata service.
|
|
2900
|
+
*
|
|
2901
|
+
* The guard is for URLs a USER entered (webhooks, export targets); configured targets do not use
|
|
2902
|
+
* it. Unset, internal targets are allowed in the `local`, `e2e` and `ci` environments and
|
|
2903
|
+
* refused everywhere else. `true` / `false` decide it explicitly
|
|
2904
|
+
* (`NSC__SECURITY__ALLOW_PRIVATE_OUTBOUND_TARGETS`). To free a single internal host, pass
|
|
2905
|
+
* `allowedHosts` to the call instead of opening all of them.
|
|
2906
|
+
*
|
|
2907
|
+
* @default undefined (allowed only in local, e2e and ci — not in `test`, a deployed stage in starter-based projects)
|
|
2908
|
+
* @since 11.42.0
|
|
2909
|
+
*/
|
|
2910
|
+
allowPrivateOutboundTargets?: boolean;
|
|
2911
|
+
|
|
2858
2912
|
/**
|
|
2859
2913
|
* Check restrictions for output (models and output objects)
|
|
2860
2914
|
* See @lenne.tech/nest-server/src/core/common/interceptors/check-response.interceptor.ts
|
|
@@ -19,6 +19,15 @@ export interface IRequestContext {
|
|
|
19
19
|
tenantRole?: string;
|
|
20
20
|
/** When true, indicates admin bypass is active (admin without header sees all data) */
|
|
21
21
|
isAdminBypass?: boolean;
|
|
22
|
+
/**
|
|
23
|
+
* Set by `RequestContextMiddleware` (HTTP) and the GraphQL WebSocket context builder: this context
|
|
24
|
+
* belongs to a CLIENT operation, signed in or not. Absent for work the server does on its own —
|
|
25
|
+
* cron jobs, migrations, seeds, a `runWithBypass…()` outside a request.
|
|
26
|
+
*
|
|
27
|
+
* It is what tells an anonymous request apart from system work: both have no current user, and a
|
|
28
|
+
* check that exempts system work must not exempt the anonymous caller of a public route with it.
|
|
29
|
+
*/
|
|
30
|
+
fromRequest?: boolean;
|
|
22
31
|
/**
|
|
23
32
|
* Tracks the nesting depth of process() calls.
|
|
24
33
|
* 0 = outermost call (full pipeline), > 0 = nested call (reduced pipeline).
|
|
@@ -600,6 +600,31 @@ ai: {
|
|
|
600
600
|
|
|
601
601
|
- tokens, limits, reset) is available via the `aiUsage` query / `GET /ai/usage`.
|
|
602
602
|
|
|
603
|
+
## Tool access is the user's access
|
|
604
|
+
|
|
605
|
+
An AI client — the chat's agent loop or an MCP client — acts **on behalf of the signed-in user and
|
|
606
|
+
never beyond that user's rights**. Three things make that hold, and a project's tools must not undo
|
|
607
|
+
them:
|
|
608
|
+
|
|
609
|
+
- **Visibility and execution** are decided by `AiToolRegistry.userCanAccess()`, evaluated per request —
|
|
610
|
+
over MCP with the user as of THAT request, not as of the session's `initialize` (11.42.0+), so a
|
|
611
|
+
revoked role stops working without a reconnect.
|
|
612
|
+
Under multi-tenancy (11.42.0+) it resolves a tool's `roles` like `CoreTenantGuard` resolves
|
|
613
|
+
`@Roles()`: a global role (`RoleEnum.ADMIN`, `multiTenancy.globalOnlyRoles`) against `user.roles`, a
|
|
614
|
+
tenant role against the **membership role in the request's tenant** (by hierarchy), and the ADMIN
|
|
615
|
+
bypass only where `multiTenancy.adminBypass` grants it. Before, every role was compared with the
|
|
616
|
+
global `user.roles` — a tool requiring the tenant role `owner` was offered to nobody who held it as
|
|
617
|
+
a membership, and to anyone holding a global role of that name.
|
|
618
|
+
- **Data** is scoped by the request context: a tool runs inside the HTTP request (chat or `/ai/mcp`),
|
|
619
|
+
so `mongooseTenantPlugin` filters by the tenant the guard validated. Without a tenant header there
|
|
620
|
+
is no tenant scope on these routes and tenant-scoped reads are refused (403) — an AI client is
|
|
621
|
+
never broader than its user, at worst narrower.
|
|
622
|
+
- **Service-level rules** apply on every entry point when the service enforces them itself —
|
|
623
|
+
`assertTenantRole()` (see the tenant module README) — instead of relying on a controller's
|
|
624
|
+
`@Roles()`, which a tool calling the service never passes through.
|
|
625
|
+
|
|
626
|
+
Without multi-tenancy, tool roles are compared with `user.roles` exactly as before.
|
|
627
|
+
|
|
603
628
|
## MCP server (`ai.mcp: true`)
|
|
604
629
|
|
|
605
630
|
The `AiToolRegistry` also feeds a real **MCP server** at `POST/GET/DELETE /ai/mcp`
|
|
@@ -11,6 +11,7 @@ import { CoreBetterAuthModule } from '../better-auth/core-better-auth.module';
|
|
|
11
11
|
import { ErrorCode } from '../error-code/error-codes';
|
|
12
12
|
import { CoreAiMcpOAuthService } from './services/core-ai-mcp-oauth.service';
|
|
13
13
|
import { CoreAiMcpService } from './services/core-ai-mcp.service';
|
|
14
|
+
import { AiToolUser } from './tools/ai-tool.registry';
|
|
14
15
|
|
|
15
16
|
/**
|
|
16
17
|
* MCP Streamable-HTTP endpoint at `/ai/mcp`.
|
|
@@ -49,6 +50,18 @@ import { CoreAiMcpService } from './services/core-ai-mcp.service';
|
|
|
49
50
|
* ids would learn both that a session id is valid and the internal hostname/PID holding it,
|
|
50
51
|
* where the same probe against the local map deliberately answers 404.
|
|
51
52
|
*/
|
|
53
|
+
/**
|
|
54
|
+
* One MCP session: its transport, its owner, and the owner AS OF THE LATEST REQUEST. `user` is
|
|
55
|
+
* refreshed on every request so the server — which reads it through a getter — always acts with the
|
|
56
|
+
* user's current rights rather than those from `initialize`.
|
|
57
|
+
*/
|
|
58
|
+
interface McpSessionEntry {
|
|
59
|
+
lastUsed: number;
|
|
60
|
+
ownerId: string;
|
|
61
|
+
transport: any;
|
|
62
|
+
user: AiToolUser;
|
|
63
|
+
}
|
|
64
|
+
|
|
52
65
|
@ApiExcludeController()
|
|
53
66
|
@Controller('ai/mcp')
|
|
54
67
|
@Roles(RoleEnum.S_EVERYONE)
|
|
@@ -65,7 +78,7 @@ export class CoreAiMcpController implements OnModuleDestroy {
|
|
|
65
78
|
* anyone holding another user's session id drives that user's server — the id travels in a
|
|
66
79
|
* response header, through proxies and client logs.
|
|
67
80
|
*/
|
|
68
|
-
private readonly transports = new Map<string,
|
|
81
|
+
private readonly transports = new Map<string, McpSessionEntry>();
|
|
69
82
|
|
|
70
83
|
/**
|
|
71
84
|
* Cap on concurrent MCP sessions held by ONE user.
|
|
@@ -160,7 +173,10 @@ export class CoreAiMcpController implements OnModuleDestroy {
|
|
|
160
173
|
}
|
|
161
174
|
const { randomUUID } = await import('node:crypto');
|
|
162
175
|
const transport: any = new StreamableHTTPServerTransport({ sessionIdGenerator: () => randomUUID() });
|
|
163
|
-
|
|
176
|
+
// The server reads the user through a getter on every request; `user` below is refreshed by
|
|
177
|
+
// each request of the session, so a role revoked mid-session takes effect at once.
|
|
178
|
+
const created: McpSessionEntry = { lastUsed: Date.now(), ownerId: user.id, transport, user };
|
|
179
|
+
const server = await this.mcpService.createServer(() => created.user);
|
|
164
180
|
await server.connect(transport);
|
|
165
181
|
// The MCP SDK transport exposes `onclose` as a callback property (not a DOM
|
|
166
182
|
// EventTarget), so addEventListener does not apply here.
|
|
@@ -171,10 +187,12 @@ export class CoreAiMcpController implements OnModuleDestroy {
|
|
|
171
187
|
this.releaseSession(transport.sessionId);
|
|
172
188
|
}
|
|
173
189
|
};
|
|
174
|
-
entry =
|
|
190
|
+
entry = created;
|
|
175
191
|
}
|
|
176
192
|
|
|
177
193
|
entry.lastUsed = Date.now();
|
|
194
|
+
// Same owner (checked above), possibly different rights than at `initialize`.
|
|
195
|
+
entry.user = user;
|
|
178
196
|
await entry.transport.handleRequest(req, res, req.body);
|
|
179
197
|
|
|
180
198
|
// The sessionId is assigned during handleRequest (initialize); register after.
|
|
@@ -280,6 +298,7 @@ export class CoreAiMcpController implements OnModuleDestroy {
|
|
|
280
298
|
return;
|
|
281
299
|
}
|
|
282
300
|
entry.lastUsed = Date.now();
|
|
301
|
+
entry.user = user;
|
|
283
302
|
this.registerSession(sessionId as string, user.id);
|
|
284
303
|
await entry.transport.handleRequest(req, res, (req as any).body);
|
|
285
304
|
}
|
|
@@ -76,17 +76,23 @@ export class CoreAiMcpService {
|
|
|
76
76
|
/**
|
|
77
77
|
* Create a low-level MCP server bound to a user. Uses the JSON-schema tool
|
|
78
78
|
* definitions directly (no zod conversion needed). The SDK is imported lazily.
|
|
79
|
+
*
|
|
80
|
+
* Pass a GETTER rather than a user object: an MCP session stays open for hours, and every
|
|
81
|
+
* `tools/list` / `tools/call` must use the user's CURRENT rights — a role revoked after
|
|
82
|
+
* `initialize` must not survive in the session. `CoreAiMcpController` passes a getter it refreshes
|
|
83
|
+
* on every request (11.42.0+). A plain object is still accepted and is then fixed for the session.
|
|
79
84
|
*/
|
|
80
|
-
async createServer(user: AiToolUser): Promise<any> {
|
|
85
|
+
async createServer(user: AiToolUser | (() => AiToolUser)): Promise<any> {
|
|
86
|
+
const currentUser = (): AiToolUser => (typeof user === 'function' ? user() : user);
|
|
81
87
|
const { Server } = await import('@modelcontextprotocol/sdk/server/index.js');
|
|
82
88
|
const { CallToolRequestSchema, ListToolsRequestSchema } = await import('@modelcontextprotocol/sdk/types.js');
|
|
83
89
|
|
|
84
90
|
// Loosely typed to decouple from the SDK's strict request/result types.
|
|
85
91
|
const server: any = new Server({ name: 'lt-nest-server-ai', version: '1.0.0' }, { capabilities: { tools: {} } });
|
|
86
92
|
|
|
87
|
-
server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: this.mcpListTools(
|
|
93
|
+
server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: this.mcpListTools(currentUser()) }));
|
|
88
94
|
server.setRequestHandler(CallToolRequestSchema, async (request: any) =>
|
|
89
|
-
this.mcpCallTool(
|
|
95
|
+
this.mcpCallTool(currentUser(), request.params.name, request.params.arguments ?? {}),
|
|
90
96
|
);
|
|
91
97
|
|
|
92
98
|
return server;
|
|
@@ -1,6 +1,14 @@
|
|
|
1
1
|
import { Injectable, Logger } from '@nestjs/common';
|
|
2
2
|
|
|
3
3
|
import { RoleEnum } from '../../../common/enums/role.enum';
|
|
4
|
+
import { ConfigService } from '../../../common/services/config.service';
|
|
5
|
+
import { RequestContext } from '../../../common/services/request-context.service';
|
|
6
|
+
import {
|
|
7
|
+
checkRoleAccess,
|
|
8
|
+
isMultiTenancyActive,
|
|
9
|
+
resolveGlobalAndTenantRoles,
|
|
10
|
+
tenantSatisfiableRoles,
|
|
11
|
+
} from '../../tenant/core-tenant.helpers';
|
|
4
12
|
import { IAiTool } from '../interfaces/ai-tool.interface';
|
|
5
13
|
|
|
6
14
|
/**
|
|
@@ -75,6 +83,13 @@ export class AiToolRegistry {
|
|
|
75
83
|
|
|
76
84
|
/**
|
|
77
85
|
* Whether a user satisfies a tool's role requirements.
|
|
86
|
+
*
|
|
87
|
+
* The answer is what an AI client — the MCP server or the chat's agent loop — may do on the user's
|
|
88
|
+
* behalf, so it must not exceed what the user may do through the API. Under multi-tenancy each role
|
|
89
|
+
* is therefore resolved like `CoreTenantGuard` resolves a `@Roles()`: a global role against
|
|
90
|
+
* `user.roles`, a tenant role against the membership role of the current request's tenant (by
|
|
91
|
+
* hierarchy), and the ADMIN bypass only where `multiTenancy.adminBypass` grants it. Without
|
|
92
|
+
* multi-tenancy, roles are compared with `user.roles` as they always were.
|
|
78
93
|
*/
|
|
79
94
|
userCanAccess(tool: IAiTool, user: AiToolUser | null | undefined): boolean {
|
|
80
95
|
const roles = tool.roles ?? [];
|
|
@@ -94,8 +109,11 @@ export class AiToolRegistry {
|
|
|
94
109
|
return false;
|
|
95
110
|
}
|
|
96
111
|
|
|
97
|
-
|
|
98
|
-
|
|
112
|
+
const multiTenancy = isMultiTenancyActive();
|
|
113
|
+
|
|
114
|
+
// Admin bypass — unless multi-tenancy withholds it (adminBypass: false).
|
|
115
|
+
const adminBypass = !multiTenancy || ConfigService.configFastButReadOnly?.multiTenancy?.adminBypass !== false;
|
|
116
|
+
if (adminBypass && user.roles?.includes(RoleEnum.ADMIN)) {
|
|
99
117
|
return true;
|
|
100
118
|
}
|
|
101
119
|
|
|
@@ -119,6 +137,18 @@ export class AiToolRegistry {
|
|
|
119
137
|
RoleEnum.S_VERIFIED,
|
|
120
138
|
];
|
|
121
139
|
const requiredRealRoles = roles.filter((r) => !systemRoles.includes(r));
|
|
122
|
-
|
|
140
|
+
if (!multiTenancy) {
|
|
141
|
+
return requiredRealRoles.some((r) => user.roles?.includes(r));
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// Multi-tenancy: each role answered by the source entitled to answer it.
|
|
145
|
+
const { global } = resolveGlobalAndTenantRoles(requiredRealRoles);
|
|
146
|
+
if (global.some((r) => user.roles?.includes(r))) {
|
|
147
|
+
return true;
|
|
148
|
+
}
|
|
149
|
+
// In a tenant, only the membership role counts; outside one, user.roles — as in the guard.
|
|
150
|
+
// The length guard matters: checkRoleAccess() answers TRUE for an empty required list.
|
|
151
|
+
const tenantRoles = tenantSatisfiableRoles(requiredRealRoles);
|
|
152
|
+
return tenantRoles.length > 0 && checkRoleAccess(tenantRoles, user.roles, RequestContext.get()?.tenantRole);
|
|
123
153
|
}
|
|
124
154
|
}
|
|
@@ -494,7 +494,12 @@ See: `.claude/rules/module-inheritance.md` for the full pattern.
|
|
|
494
494
|
|
|
495
495
|
## Client-Side Configuration
|
|
496
496
|
|
|
497
|
-
Clients must be configured to use the correct base path and hash passwords
|
|
497
|
+
Clients must be configured to use the correct base path and hash passwords.
|
|
498
|
+
|
|
499
|
+
> **Nuxt projects on `@lenne.tech/nuxt-extensions`** get all of this preconfigured: `useLtAuthClient()`
|
|
500
|
+
> is the Better-Auth client (base path, password hashing, plugins), and `useLtAuth()` holds the auth
|
|
501
|
+
> state plus the JWT-safe passkey helpers used below. The hand-written client that follows is for
|
|
502
|
+
> clients outside that stack.
|
|
498
503
|
|
|
499
504
|
```typescript
|
|
500
505
|
// auth-client.ts (e.g., for Nuxt/Vue)
|
|
@@ -622,14 +627,14 @@ async function useBackupCode(code: string) {
|
|
|
622
627
|
|
|
623
628
|
Handle passkey authentication with session validation fallback.
|
|
624
629
|
|
|
625
|
-
**IMPORTANT:** For JWT mode (`cookies: false`), you MUST use the `authenticateWithPasskey()` function from
|
|
630
|
+
**IMPORTANT:** For JWT mode (`cookies: false`), you MUST use the `authenticateWithPasskey()` function from `useLtAuth()` instead of `authClient.signIn.passkey()` directly. This is because JWT mode requires sending a `challengeId` to the server for challenge verification.
|
|
626
631
|
|
|
627
632
|
**Note on Passkey Login Response (v11.13.0+):** The server enriches the passkey verify-authentication response with user data. Better Auth's passkey plugin only returns `{ session }`, but the server fetches the user from the database and adds it to the response. The composable handles both scenarios (user in response vs. fallback to get-session).
|
|
628
633
|
|
|
629
634
|
```typescript
|
|
630
635
|
// login.vue - Passkey login (JWT-compatible)
|
|
631
|
-
//
|
|
632
|
-
const { authenticateWithPasskey, setUser, validateSession } =
|
|
636
|
+
// authenticateWithPasskey from useLtAuth() (@lenne.tech/nuxt-extensions)
|
|
637
|
+
const { authenticateWithPasskey, setUser, validateSession } = useLtAuth();
|
|
633
638
|
|
|
634
639
|
async function onPasskeyLogin() {
|
|
635
640
|
try {
|
|
@@ -677,11 +682,13 @@ In JWT mode (`cookies: false`), the server stores WebAuthn challenges in the dat
|
|
|
677
682
|
|
|
678
683
|
Register a new passkey for an authenticated user.
|
|
679
684
|
|
|
680
|
-
**IMPORTANT:** For JWT mode (`cookies: false`), you MUST use the `registerPasskey()` function from
|
|
685
|
+
**IMPORTANT:** For JWT mode (`cookies: false`), you MUST use the `registerPasskey()` function from `useLtAuth()`. This ensures the `challengeId` is correctly sent to the server.
|
|
681
686
|
|
|
682
687
|
```typescript
|
|
683
688
|
// settings.vue - Register new passkey (JWT-compatible)
|
|
684
|
-
|
|
689
|
+
// registerPasskey from useLtAuth(); listing and deleting go through the Better-Auth client
|
|
690
|
+
const { registerPasskey } = useLtAuth();
|
|
691
|
+
const authClient = useLtAuthClient();
|
|
685
692
|
|
|
686
693
|
async function onRegisterPasskey() {
|
|
687
694
|
try {
|
|
@@ -707,16 +714,16 @@ async function onRegisterPasskey() {
|
|
|
707
714
|
|
|
708
715
|
// List user's passkeys (works in both modes)
|
|
709
716
|
async function loadPasskeys() {
|
|
710
|
-
const
|
|
711
|
-
if (
|
|
712
|
-
passkeys.value =
|
|
717
|
+
const { data, error } = await authClient.passkey.listUserPasskeys();
|
|
718
|
+
if (!error) {
|
|
719
|
+
passkeys.value = data ?? [];
|
|
713
720
|
}
|
|
714
721
|
}
|
|
715
722
|
|
|
716
723
|
// Delete a passkey (works in both modes)
|
|
717
724
|
async function onDeletePasskey(passkeyId: string) {
|
|
718
|
-
const
|
|
719
|
-
if (
|
|
725
|
+
const { error } = await authClient.passkey.deletePasskey({ id: passkeyId });
|
|
726
|
+
if (!error) {
|
|
720
727
|
await loadPasskeys();
|
|
721
728
|
}
|
|
722
729
|
}
|