@lenne.tech/nest-server 11.43.2 → 11.43.3

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.
@@ -179,7 +179,7 @@ JWT-based authentication for existing projects:
179
179
  | **AI Module** | Prompt orchestrator (auto + plan mode), DB-backed LLM connections, tool registry |
180
180
  | **REST Endpoints** | `POST /ai/prompt`, `POST /ai/stream` (SSE); `GET/POST/PUT/DELETE /ai/connections*`, `/ai/connections/available`, `/ai/connections/select`, `/ai/connections/preferences*`, `/ai/conversations*`, `/ai/interactions*`, `/ai/budget-limits*`, `GET /ai/usage` |
181
181
  | **GraphQL Endpoints** | `aiPrompt`, `aiAvailableConnections`, `aiSetUserConnection`, connection/preference/conversation/interaction/budget queries + mutations, `aiUsage` |
182
- | **MCP Server** | Streamable HTTP at `POST/GET/DELETE /ai/mcp` (Bearer auth); with `ai.mcp.oauth` an OAuth 2.1 router (`/.well-known/*`, `/authorize`, `/token`, `/register`) that `CoreAiMcpOAuthInitializer` mounts during `app.init()` — an Express router behind the Nest middleware and routes, ahead of Nest's not-found handler, so guards and interceptors do not run for it; `/authorize` answers with a built-in consent page (the Better-Auth session from the middleware is the user; Allow is a CSRF-bound POST) |
182
+ | **MCP Server** | Streamable HTTP at `POST/GET/DELETE /ai/mcp` (Bearer auth); a user the controller resolves itself (the OAuth access token, which only this controller accepts, after the guards ran without a user) gets `req.user` and the tenant the header names from an active membership only, through `CoreTenantGuard.resolveMembershipContext()` (11.43.3); with `ai.mcp.oauth` an OAuth 2.1 router (`/.well-known/*`, `/authorize`, `/token`, `/register`) that `CoreAiMcpOAuthInitializer` mounts during `app.init()` — an Express router behind the Nest middleware and routes, ahead of Nest's not-found handler, so guards and interceptors do not run for it; `/authorize` answers with a built-in consent page (the Better-Auth session from the middleware is the user; Allow is a CSRF-bound POST) |
183
183
  | **Permission Model** | `S_USER` for prompt/conversations/available/usage; `ADMIN` for connections/preferences/interactions/budgets; encrypted API keys never leave the server (`securityCheck` + `secretFields`) |
184
184
 
185
185
  ### Email & Templates
@@ -0,0 +1,80 @@
1
+ # Migration Guide: 11.43.2 → 11.43.3
2
+
3
+ ## Overview
4
+
5
+ | Category | Details |
6
+ | -------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
7
+ | **Bugfixes** | An MCP OAuth access token reaches the tenant context: tenant-scoped AI tools answered "Tenant context required" to every OAuth client, members included (§1). The OAuth user is the current user of the request, so audit fields and the role guard plugin apply to tool writes (§2) |
8
+ | **New Features** | None |
9
+ | **Framework API** | `CoreTenantGuard.resolveMembershipContext()` (new, public), the optional `resolveMembership()` on the exported `TenantContextResolver` interface, `CoreAiMcpController.resolveScopedUser()` / `scopeLateResolvedUser()` (new, protected) |
10
+ | **Dependencies** | Unchanged |
11
+ | **Breaking Changes** | None |
12
+ | **Migration Effort** | None. Check §2 if MCP tools of your project write data |
13
+
14
+ ## Quick Migration
15
+
16
+ ```bash
17
+ pnpm update @lenne.tech/nest-server
18
+ ```
19
+
20
+ Vendor-mode projects pick it up with the core update. No code or configuration change is required.
21
+
22
+ ## 1. MCP OAuth clients got no tenant context
23
+
24
+ `CoreTenantGuard` turns the tenant header (`X-Tenant-Id`) into a tenant context only for a user it sees on the
25
+ request. A session cookie and a Better-Auth JWT are resolved by the middleware before the guards. An MCP OAuth
26
+ access token is not: nothing but `CoreAiMcpController` accepts it, so it is resolved after the guards. The request
27
+ therefore never got a tenant, and `mongooseTenantPlugin` refused every tenant-scoped read — a generic MCP client
28
+ could discover, register, pass the consent page, open a session and list its tools, and then every tenant-scoped
29
+ tool answered `Tenant context required`, even with a header naming a tenant the user is an active member of.
30
+
31
+ The controller now asks the guard's own membership check (`CoreTenantGuard.resolveMembershipContext()`, the same
32
+ decision as its `@Roles(S_EVERYONE)` branch) for a user it resolved itself. The result does not depend on the
33
+ credential:
34
+
35
+ | Request | Session cookie / JWT | MCP OAuth token (11.43.2) | MCP OAuth token (11.43.3) |
36
+ | ----------------------------------------------------- | -------------------- | ------------------------- | ------------------------- |
37
+ | Header names a tenant the user is an active member of | that tenant | none — refused | that tenant |
38
+ | Header names a tenant the user is not a member of | none — refused | none — refused | none — refused |
39
+ | No header | none — refused | none — refused | none — refused |
40
+
41
+ The header alone never establishes a tenant, which is what keeps a freely chosen `X-Tenant-Id` harmless.
42
+
43
+ ## 2. The OAuth user is the current user of the request
44
+
45
+ For the same reason `req.user` stayed unset for an OAuth request, and `RequestContext.currentUser` is read from
46
+ it. The Mongoose plugins read an empty current user as work the server does on its own:
47
+
48
+ - `createdBy` / `updatedBy` were not written for data an MCP tool created or changed,
49
+ - `mongooseRoleGuardPlugin` let a tool's write change `roles` without checking the caller,
50
+ - `assertTenantRole()` refused the call as anonymous.
51
+
52
+ Since 11.43.3 the controller sets `req.user` for a user it resolved itself, so all three behave as for a cookie or
53
+ JWT request.
54
+
55
+ **What to check:**
56
+
57
+ - **An MCP tool of your project writes `roles` and relied on the role guard plugin letting it through:** under an
58
+ OAuth token that write is now checked against the user, as it always was under a cookie or a JWT. Run such a
59
+ write inside `RequestContext.runWithBypassRoleGuard()` when the tool is meant to assign roles.
60
+ - **A test in your project pins the old refusal** ("Tenant context required" for an OAuth member): flip it to
61
+ assert the data, and keep the cases for a non-member and for a missing header refused.
62
+ - **You override `CoreAiMcpController.handlePost()` or `handleGet()` / `handleDelete()`** without calling
63
+ `super`: resolve the user through `resolveScopedUser(req)` instead of `resolveUser(req)`, or the fix does not
64
+ reach your controller.
65
+
66
+ ## Compatibility Notes
67
+
68
+ | Pattern | Status |
69
+ | ---------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------- |
70
+ | MCP over a session cookie or a Better-Auth JWT | Unchanged — the guards saw the user before |
71
+ | MCP OAuth without `multiTenancy` | Unchanged apart from §2 |
72
+ | Your own `TenantContextResolver` registered via `setTenantContextResolver()` | Compiles unchanged — `resolveMembership()` is optional; without it an OAuth request gets no tenant, as before |
73
+ | An override of `resolveUser()` | Still used — `resolveScopedUser()` calls it |
74
+
75
+ ## Module Documentation
76
+
77
+ - [AI module → Tool access is the user's access](../src/core/modules/ai/README.md#tool-access-is-the-users-access)
78
+ - [Tenant module](../src/core/modules/tenant/README.md)
79
+ - Implementation: `src/core/modules/ai/core-ai-mcp.controller.ts` → `scopeLateResolvedUser()`,
80
+ `src/core/modules/tenant/core-tenant.guard.ts` → `resolveMembershipContext()`
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lenne.tech/nest-server",
3
- "version": "11.43.2",
3
+ "version": "11.43.3",
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",
@@ -28,6 +28,18 @@ export interface TenantContextResolver {
28
28
  * validated against an active membership before it is returned
29
29
  */
30
30
  resolve(user: { id: string; roles?: string[] } | undefined, headerTenantId?: string): Promise<ResolvedTenantContext>;
31
+
32
+ /**
33
+ * The narrower answer `CoreTenantGuard` gives an `@Roles(S_EVERYONE)` route: a tenant only from an
34
+ * ACTIVE membership in the tenant the header names (in an active tenant) — no admin bypass, and no
35
+ * fallback to every membership when no tenant is named. For an HTTP route that authenticates its
36
+ * caller itself, after the guards ran without a user (the MCP endpoint and its OAuth access token),
37
+ * so that route scopes the request exactly as the guard would have.
38
+ *
39
+ * Optional because the interface is public. A caller finding it absent establishes no tenant, which
40
+ * leaves the plugin's safety net to refuse tenant-scoped reads.
41
+ */
42
+ resolveMembership?(user: { id: string; roles?: string[] }, headerTenantId?: string): Promise<ResolvedTenantContext>;
31
43
  }
32
44
 
33
45
  let resolver: TenantContextResolver | undefined;
@@ -619,6 +619,12 @@ them:
619
619
  so `mongooseTenantPlugin` filters by the tenant the guard validated. Without a tenant header there
620
620
  is no tenant scope on these routes and tenant-scoped reads are refused (403) — an AI client is
621
621
  never broader than its user, at worst narrower.
622
+ An **MCP OAuth access token** is accepted only by the MCP controller, after the guards ran without a
623
+ user. Since 11.43.3 the controller gives such a request what the guard gives a user it saw: the user
624
+ on the request (so `RequestContext.currentUser`, the audit fields and `mongooseRoleGuardPlugin` see
625
+ it), and the tenant the header names — only when the user is an ACTIVE member of it, through the
626
+ guard's own check (`CoreTenantGuard.resolveMembershipContext()`). Before, every tenant-scoped tool
627
+ answered "Tenant context required" to an OAuth client, even for a member.
622
628
  - **Service-level rules** apply on every entry point when the service enforces them itself —
623
629
  `assertTenantRole()` (see the tenant module README) — instead of relying on a controller's
624
630
  `@Roles()`, which a tool calling the service never passes through.
@@ -7,6 +7,7 @@ import { Roles } from '../../common/decorators/roles.decorator';
7
7
  import { RoleEnum } from '../../common/enums/role.enum';
8
8
  import { ConfigService } from '../../common/services/config.service';
9
9
  import { CoreRedisService } from '../../common/services/core-redis.service';
10
+ import { getTenantContextResolver } from '../../common/services/core-tenant-context.registry';
10
11
  import { CoreBetterAuthModule } from '../better-auth/core-better-auth.module';
11
12
  import { ErrorCode } from '../error-code/error-codes';
12
13
  import { getMountedMcpOAuth } from './helpers/ai-mcp-oauth.registry';
@@ -36,7 +37,9 @@ import { AiToolUser } from './tools/ai-tool.registry';
36
37
  *
37
38
  * When `ai.mcp.oauth` is enabled, the handler additionally accepts OAuth 2.1 access
38
39
  * tokens (endpoints mounted by `CoreAiMcpOAuthInitializer`); otherwise it authenticates via
39
- * Bearer/session token.
40
+ * Bearer/session token. A user resolved here rather than by the guards — the OAuth token always —
41
+ * gets the request scope the guards would have given it (`req.user`, and a tenant from an active
42
+ * membership in the tenant the header names): see {@link CoreAiMcpController.scopeLateResolvedUser}.
40
43
  *
41
44
  * ## Multi-replica
42
45
  *
@@ -121,7 +124,7 @@ export class CoreAiMcpController implements OnModuleDestroy {
121
124
  })
122
125
  @Post()
123
126
  async handlePost(@Req() req: Request, @Res() res: Response): Promise<void> {
124
- const user = await this.resolveUser(req);
127
+ const user = await this.resolveScopedUser(req);
125
128
  if (!user?.id) {
126
129
  this.unauthorized(res);
127
130
  return;
@@ -274,6 +277,59 @@ export class CoreAiMcpController implements OnModuleDestroy {
274
277
  return null;
275
278
  }
276
279
 
280
+ /**
281
+ * {@link resolveUser}, plus — for a user resolved HERE rather than seen by the guards — the request
282
+ * scope the guards give a user they saw (see {@link scopeLateResolvedUser}).
283
+ *
284
+ * Whether the guards saw the user is read BEFORE `resolveUser()` runs, so an override of it that sets
285
+ * `req.user` itself still counts as resolved late.
286
+ */
287
+ protected async resolveScopedUser(req: Request): Promise<any | null> {
288
+ const seenByGuards = !!(req as any).user?.id;
289
+ const user = await this.resolveUser(req);
290
+ if (user?.id && !seenByGuards) {
291
+ await this.scopeLateResolvedUser(req, user);
292
+ }
293
+ return user;
294
+ }
295
+
296
+ /**
297
+ * Give a request whose user this controller resolved itself what the guards give a request whose
298
+ * user they saw: the user on the request, and a tenant from an active membership only.
299
+ *
300
+ * The guards run before this controller, and an MCP OAuth access token is accepted by nothing else
301
+ * — so `CoreTenantGuard` found no `request.user` and never turned the tenant header into a context.
302
+ * In 11.43.2 and earlier that left two things behind:
303
+ * - **No tenant scope.** Every tenant-scoped tool answered "Tenant context required", even with a
304
+ * tenant header naming a tenant the user is an active member of. Fail-closed, but unusable.
305
+ * - **No current user in `RequestContext`**, which reads `req.user` lazily. The Mongoose plugins read
306
+ * that as SYSTEM work: no `createdBy` / `updatedBy`, `mongooseRoleGuardPlugin` let a tool's write
307
+ * change roles, and `assertTenantRole()` refused the call as anonymous.
308
+ *
309
+ * The tenant comes from `CoreTenantGuard.resolveMembershipContext()`, the same decision its
310
+ * `@Roles(S_EVERYONE)` branch makes for a user it saw — so an OAuth token, a session cookie and a
311
+ * Better-Auth JWT reach the same scope. The header alone never establishes one: a tenant the user is
312
+ * not an active member of yields none, and the plugin's safety net refuses the read.
313
+ */
314
+ protected async scopeLateResolvedUser(req: Request, user: { id: string; roles?: string[] }): Promise<void> {
315
+ (req as any).user = user;
316
+
317
+ const config = ConfigService.configFastButReadOnly?.multiTenancy;
318
+ if (!config || config.enabled === false || (req as any).tenantId) {
319
+ return;
320
+ }
321
+ const headerName = (config.headerName ?? 'x-tenant-id').toLowerCase();
322
+ const header = req.headers?.[headerName];
323
+ const resolved = await getTenantContextResolver()?.resolveMembership?.(
324
+ user,
325
+ typeof header === 'string' ? header : undefined,
326
+ );
327
+ if (resolved?.tenantId) {
328
+ (req as any).tenantId = resolved.tenantId;
329
+ (req as any).tenantRole = resolved.tenantRole;
330
+ }
331
+ }
332
+
277
333
  /**
278
334
  * Whether the body is an `initialize` — the one request that legitimately starts a session.
279
335
  *
@@ -298,7 +354,7 @@ export class CoreAiMcpController implements OnModuleDestroy {
298
354
  * Forward a GET (SSE stream) or DELETE (close) to the session's transport.
299
355
  */
300
356
  private async handleSessionRequest(req: Request, res: Response): Promise<void> {
301
- const user = await this.resolveUser(req);
357
+ const user = await this.resolveScopedUser(req);
302
358
  if (!user?.id) {
303
359
  this.unauthorized(res);
304
360
  return;
@@ -181,6 +181,7 @@ export class CoreTenantGuard implements CanActivate, OnApplicationBootstrap, OnM
181
181
  // GraphQL WebSocket. See core-tenant-context.registry.ts for why this is a registry and not DI.
182
182
  setTenantContextResolver({
183
183
  resolve: (user, headerTenantId) => this.resolveTenantContext(user, headerTenantId),
184
+ resolveMembership: (user, headerTenantId) => this.resolveMembershipContext(user, headerTenantId),
184
185
  });
185
186
 
186
187
  // Lets the role guards delegate non-system roles to this guard — only while it exists.
@@ -248,6 +249,39 @@ export class CoreTenantGuard implements CanActivate, OnApplicationBootstrap, OnM
248
249
  return { tenantIds: carrier.tenantIds ?? [] };
249
250
  }
250
251
 
252
+ /**
253
+ * The tenant context `canActivate()` gives an `@Roles(S_EVERYONE)` route, for a caller it could not
254
+ * see: a route that authenticates its caller ITSELF, after the guards ran without a user.
255
+ *
256
+ * That is the MCP endpoint with an MCP OAuth access token — a credential nothing but its controller
257
+ * accepts, so the guard found no `request.user`, never checked the tenant header against a
258
+ * membership, and every tenant-scoped tool answered "Tenant context required" to a member of that
259
+ * very tenant. Answering through the same {@link memberTenantContext} as the guard's own branch is
260
+ * what keeps the result independent of which credential the caller used.
261
+ *
262
+ * Narrower than {@link resolveTenantContext} on purpose: that one answers for a transport whose every
263
+ * operation needs a scope (no admin bypass here, no list of all memberships without a header).
264
+ */
265
+ async resolveMembershipContext(
266
+ user: { id: string; roles?: string[] } | undefined,
267
+ headerTenantId?: string,
268
+ ): Promise<ResolvedTenantContext> {
269
+ const config = ConfigService.configFastButReadOnly?.multiTenancy;
270
+ if (!config || config.enabled === false) {
271
+ return {};
272
+ }
273
+ this.syncConfig(config);
274
+
275
+ const tenantId =
276
+ headerTenantId && typeof headerTenantId === 'string' && headerTenantId.length <= 128
277
+ ? headerTenantId.trim()
278
+ : undefined;
279
+ if (!tenantId || !user?.id) {
280
+ return {};
281
+ }
282
+ return (await this.memberTenantContext(user, tenantId)) ?? {};
283
+ }
284
+
251
285
  /**
252
286
  * Subscribe to the cross-replica cache invalidation channel.
253
287
  *
@@ -414,10 +448,10 @@ export class CoreTenantGuard implements CanActivate, OnApplicationBootstrap, OnM
414
448
  // Optionally enrich with tenant context when header is present and user is an active member.
415
449
  // Never block access — S_EVERYONE endpoints are always public.
416
450
  if (headerTenantId && request.user?.id) {
417
- const membership = await this.findMembershipCached(request.user.id, headerTenantId);
418
- if (membership && (await this.isTenantActive(headerTenantId))) {
419
- request.tenantId = headerTenantId;
420
- request.tenantRole = capApiTokenTenantRole(request.user, membership.role as string) ?? undefined;
451
+ const member = await this.memberTenantContext(request.user, headerTenantId);
452
+ if (member) {
453
+ request.tenantId = member.tenantId;
454
+ request.tenantRole = member.tenantRole;
421
455
  }
422
456
  }
423
457
  return true;
@@ -901,6 +935,22 @@ export class CoreTenantGuard implements CanActivate, OnApplicationBootstrap, OnM
901
935
  return active;
902
936
  }
903
937
 
938
+ /**
939
+ * The context an ACTIVE membership in an active tenant establishes, or undefined. Never the header
940
+ * alone: a freely chosen tenant id only becomes a scope once it names one of the caller's own tenants.
941
+ * Shared by the `S_EVERYONE` branch of `canActivate()` and {@link resolveMembershipContext}.
942
+ */
943
+ private async memberTenantContext(
944
+ user: { id: string; roles?: string[] },
945
+ tenantId: string,
946
+ ): Promise<{ tenantId: string; tenantRole?: string } | undefined> {
947
+ const membership = await this.findMembershipCached(user.id, tenantId);
948
+ if (!membership || !(await this.isTenantActive(tenantId))) {
949
+ return undefined;
950
+ }
951
+ return { tenantId, tenantRole: capApiTokenTenantRole(user, membership.role as string) ?? undefined };
952
+ }
953
+
904
954
  /** 403 for a deactivated tenant. */
905
955
  private async assertTenantActive(tenantId: string): Promise<void> {
906
956
  if (!(await this.isTenantActive(tenantId))) {