@lenne.tech/nest-server 11.41.9 → 11.42.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (67) hide show
  1. package/.claude/rules/configurable-features.md +4 -3
  2. package/.claude/rules/role-system.md +31 -0
  3. package/.claude/rules/testing.md +4 -4
  4. package/CLAUDE.md +6 -0
  5. package/FRAMEWORK-API.md +4 -2
  6. package/dist/core/common/helpers/graphql-ws-context.helper.js +1 -1
  7. package/dist/core/common/helpers/graphql-ws-context.helper.js.map +1 -1
  8. package/dist/core/common/helpers/outbound-url.helper.d.ts +14 -0
  9. package/dist/core/common/helpers/outbound-url.helper.js +234 -0
  10. package/dist/core/common/helpers/outbound-url.helper.js.map +1 -0
  11. package/dist/core/common/interfaces/server-options.interface.d.ts +6 -0
  12. package/dist/core/common/middleware/request-context.middleware.js +1 -0
  13. package/dist/core/common/middleware/request-context.middleware.js.map +1 -1
  14. package/dist/core/common/services/request-context.service.d.ts +1 -0
  15. package/dist/core/common/services/request-context.service.js.map +1 -1
  16. package/dist/core/modules/ai/core-ai-mcp.controller.js +5 -2
  17. package/dist/core/modules/ai/core-ai-mcp.controller.js.map +1 -1
  18. package/dist/core/modules/ai/services/core-ai-mcp.service.d.ts +1 -1
  19. package/dist/core/modules/ai/services/core-ai-mcp.service.js +3 -2
  20. package/dist/core/modules/ai/services/core-ai-mcp.service.js.map +1 -1
  21. package/dist/core/modules/ai/tools/ai-tool.registry.js +15 -2
  22. package/dist/core/modules/ai/tools/ai-tool.registry.js.map +1 -1
  23. package/dist/core/modules/tenant/core-tenant-role.helper.d.ts +2 -0
  24. package/dist/core/modules/tenant/core-tenant-role.helper.js +36 -0
  25. package/dist/core/modules/tenant/core-tenant-role.helper.js.map +1 -0
  26. package/dist/core/modules/tenant/core-tenant.guard.d.ts +7 -1
  27. package/dist/core/modules/tenant/core-tenant.guard.js +56 -4
  28. package/dist/core/modules/tenant/core-tenant.guard.js.map +1 -1
  29. package/dist/core/modules/tus/core-tus.service.d.ts +1 -0
  30. package/dist/core/modules/tus/core-tus.service.js +29 -0
  31. package/dist/core/modules/tus/core-tus.service.js.map +1 -1
  32. package/dist/core/modules/tus/interfaces/tus-config.interface.d.ts +2 -1
  33. package/dist/core/modules/tus/interfaces/tus-config.interface.js +1 -0
  34. package/dist/core/modules/tus/interfaces/tus-config.interface.js.map +1 -1
  35. package/dist/core/modules/user/core-user.service.d.ts +1 -0
  36. package/dist/core/modules/user/core-user.service.js +24 -0
  37. package/dist/core/modules/user/core-user.service.js.map +1 -1
  38. package/dist/index.d.ts +2 -0
  39. package/dist/index.js +2 -0
  40. package/dist/index.js.map +1 -1
  41. package/dist/tsconfig.build.tsbuildinfo +1 -1
  42. package/docs/REQUEST-LIFECYCLE.md +4 -2
  43. package/migration-guides/11.41.8-to-11.41.9.md +5 -0
  44. package/migration-guides/11.41.x-to-11.42.x.md +161 -0
  45. package/migration-guides/11.42.0-to-11.42.1.md +66 -0
  46. package/migration-guides/11.9.x-to-11.10.x.md +3 -2
  47. package/package.json +8 -7
  48. package/src/core/common/helpers/graphql-ws-context.helper.ts +1 -1
  49. package/src/core/common/helpers/outbound-url.helper.ts +310 -0
  50. package/src/core/common/interfaces/server-options.interface.ts +71 -0
  51. package/src/core/common/middleware/request-context.middleware.ts +1 -0
  52. package/src/core/common/services/request-context.service.ts +9 -0
  53. package/src/core/modules/ai/README.md +25 -0
  54. package/src/core/modules/ai/core-ai-mcp.controller.ts +22 -3
  55. package/src/core/modules/ai/services/core-ai-mcp.service.ts +9 -3
  56. package/src/core/modules/ai/tools/ai-tool.registry.ts +33 -3
  57. package/src/core/modules/better-auth/INTEGRATION-CHECKLIST.md +18 -11
  58. package/src/core/modules/tenant/INTEGRATION-CHECKLIST.md +14 -0
  59. package/src/core/modules/tenant/README.md +80 -8
  60. package/src/core/modules/tenant/core-tenant-role.helper.ts +75 -0
  61. package/src/core/modules/tenant/core-tenant.guard.ts +78 -5
  62. package/src/core/modules/tus/INTEGRATION-CHECKLIST.md +3 -1
  63. package/src/core/modules/tus/README.md +20 -0
  64. package/src/core/modules/tus/core-tus.service.ts +45 -0
  65. package/src/core/modules/tus/interfaces/tus-config.interface.ts +4 -1
  66. package/src/core/modules/user/core-user.service.ts +53 -0
  67. package/src/index.ts +2 -0
@@ -320,7 +320,7 @@ This pattern is currently applied to:
320
320
  | Response Model Interceptor | `security.responseModelInterceptor` | Boolean Shorthand | `true` (enabled), `debug: false` |
321
321
  | Translate Response Interceptor | `security.translateResponseInterceptor` | Boolean Shorthand | `true` (enabled) |
322
322
  | Secret Fields Removal | `security.secretFields` | Array | `['password', 'verificationToken', ...]` |
323
- | Multi-Tenancy | `multiTenancy` | Presence Implies Enabled | **`excludeSchemas` is an OFF SWITCH for isolation, per model** — a listed schema gets no tenant filter at all; since 11.35.0 the plugin WARNS once per model when the excluded schema declares `tenantId` (i.e. was built for isolation). The framework's own docs recommended `['User', 'Session']` until then; that is withdrawn — it is only right for the global-user model. Since 11.35.0 GraphQL operations over the WEBSOCKET also run inside a `RequestContext` (`CoreModule` installs a context-aware `execute`/`subscribe` pair inside `subscriptions`, where `ApolloDriver` forwards it): the tenant comes from the handshake header validated against an active membership, else from the subscriber's memberships, and an unresolvable tenant leaves the safety net to refuse the read rather than returning everything. `headerName: 'x-tenant-id'`, `membershipModel: 'TenantMember'`, `adminBypass: true`, `excludeSchemas: []`, `roleHierarchy: { member: 1, manager: 2, owner: 3 }`, `cacheTtlMs: 30000` (0 disables, process-local). System roles (`S_EVERYONE`, `S_USER`, `S_VERIFIED`) are checked as OR alternatives before real roles; method-level system roles take precedence; membership validated for context when system role grants access + header present. Hierarchy roles use level comparison, normal roles use exact match. Use `DefaultHR` or `createHierarchyRoles()` for type-safe role constants. Bypass: `RequestContext.runWithBypassTenantGuard()`. Cache invalidation: `invalidateUser(userId)` / `invalidateAll()` are **instance** methods on the singleton `CoreTenantGuard` — inject it and call `this.tenantGuard?.invalidateUser(userId)`, never `CoreTenantGuard.invalidateUser(...)` (there is no static). With `redis` configured, both additionally BROADCAST the invalidation to every replica (`<keyPrefix>:tenant-cache:invalidate` pub/sub); without Redis they clear the local process only, so other replicas stay stale until `cacheTtlMs`. A received broadcast clears locally without re-publishing |
323
+ | Multi-Tenancy | `multiTenancy` | Presence Implies Enabled | **`excludeSchemas` is an OFF SWITCH for isolation, per model** — a listed schema gets no tenant filter at all; since 11.35.0 the plugin WARNS once per model when the excluded schema declares `tenantId` (i.e. was built for isolation). The framework's own docs recommended `['User', 'Session']` until then; that is withdrawn — it is only right for the global-user model. Since 11.35.0 GraphQL operations over the WEBSOCKET also run inside a `RequestContext` (`CoreModule` installs a context-aware `execute`/`subscribe` pair inside `subscriptions`, where `ApolloDriver` forwards it): the tenant comes from the handshake header validated against an active membership, else from the subscriber's memberships, and an unresolvable tenant leaves the safety net to refuse the read rather than returning everything. `headerName: 'x-tenant-id'`, `membershipModel: 'TenantMember'`, `adminBypass: true`, `excludeSchemas: []`, `roleHierarchy: { member: 1, manager: 2, owner: 3 }`, `cacheTtlMs: 30000` (0 disables, process-local). System roles (`S_EVERYONE`, `S_USER`, `S_VERIFIED`) are checked as OR alternatives before real roles; method-level system roles take precedence; membership validated for context when system role grants access + header present. Hierarchy roles use level comparison, normal roles use exact match. Use `DefaultHR` or `createHierarchyRoles()` for type-safe role constants. Bypass: `RequestContext.runWithBypassTenantGuard()`. Cache invalidation: `invalidateUser(userId)` / `invalidateAll()` are **instance** methods on the singleton `CoreTenantGuard` — inject it and call `this.tenantGuard?.invalidateUser(userId)`, never `CoreTenantGuard.invalidateUser(...)` (there is no static). With `redis` configured, both additionally BROADCAST the invalidation to every replica (`<keyPrefix>:tenant-cache:invalidate` pub/sub); without Redis they clear the local process only, so other replicas stay stale until `cacheTtlMs`. A received broadcast clears locally without re-publishing. **`isTenantActive` (11.42.0)**: optional hook `(tenantId, { connection }) => boolean \| Promise<boolean>`, absent = every tenant active (no change). Asked AFTER the membership check on every path that establishes a tenant: 403 on header paths and for tenant API tokens, no enrichment on `S_EVERYONE`, dropped from the header-less tenant list, no tenant for a NEW WebSocket subscription (an open one keeps its tenant until it reconnects — known limit, shared with removed memberships); an admin under `adminBypass` is exempt (someone must be able to switch it back on). Only `false` deactivates. Cached for `cacheTtlMs`; `invalidateTenant(tenantId)` clears and broadcasts (`{ scope: 'tenant' }`). A function, so `config.env.ts` only — not reachable through `NSC__*`. **`adminBypass: false` (11.42.0)** additionally withholds two things that went around it: `CoreUserService.update()` refuses changing another account's e-mail or password (`assertCredentialChangeAllowed()`, administrator targets included — promote-then-change — system work without a current user exempt), and the AI tool registry grants no ADMIN bypass |
324
324
  | API Tokens | `apiTokens` | Boolean Shorthand + Presence Implies Enabled — absent = off | `prefix: 'ltt'`, `scopes: []` (vocabulary; empty = no token creatable, boot warning), `manageRole`: highest hierarchy role, `maxAssertionLifetimeSeconds: 900` (invalid → default, never unbounded — Family C), `rateLimit: { max: 600, windowSeconds: 60 }` per token (**on by default**, `false` disables; Redis-shared when configured), `userTokens: true`, `tenantTokens: true` (effective only with `multiTenancy`), `encryptionKey` → `SECRETS_ENCRYPTION_KEY` → dev default (boot FAILS in production/staging). Top-level, NOT under `multiTenancy`: user tokens must work without it. **Deny by default**: every guard refuses a token on a route without `@ApiTokenScopes()`, public routes included, via ONE shared function (`enforceApiTokenRoute`) called BEFORE each guard's public-route shortcut. USER tokens act as their user with global roles REMOVED (no admin bypass); TENANT tokens act with the lowest hierarchy role in their own tenant, and a route guarded ONLY by `S_SELF` / `S_CREATOR` refuses them (a tenant token is nobody's self — skipping those roles would grant for any target). A tenant token depends on no member, so it outlives an off-boarded or deleted tenant until the project calls `deleteAllForTenant()`. The boot refuses a hierarchy in which that lowest role reaches `manageRole`. A prefixed credential that fails answers 401 on every route (not anonymous); `CoreBetterAuthMiddleware` skips prefixed credentials entirely, cookie fallback included. A password reset that ends the user's sessions ends their USER tokens too (legacy reset always; IAM reset while `betterAuth.emailAndPassword.revokeSessionsOnPasswordReset` is on), wired through `core-api-token.registry.ts` so neither reset path imports the optional module. Implementation: `src/core/modules/api-token/` |
325
325
  | BetterAuth Tenant Skip | `betterAuth.skipTenantCheck` | Explicit Boolean | `true` (default). When `true` and no `X-Tenant-Id` header is sent, IAM endpoints (controller + resolver) skip `CoreTenantGuard` tenant validation. When header IS present, normal membership validation runs regardless. Set `false` for tenant-aware auth scenarios (subdomain-based, invite links, SSO per tenant) |
326
326
  | Debug Process Input | `debugProcessInput` | Explicit Boolean | `false` (default). When `true`, logs a debug message when `prepareInput()` changes the input type during `process()`. Has performance cost due to `JSON.stringify` on every `process()` call — enable only for debugging |
@@ -329,15 +329,16 @@ This pattern is currently applied to:
329
329
  | CORS | `cors` | Boolean Shorthand | `enabled: true`, `allowAll: false`, `deriveAppUrl: true`. Origins come from `appUrl`/`baseUrl`, resolved by the shared `resolveServerUrls()` helper (`cookies.helper.ts`) that ALL three CORS layers use (GraphQL, REST, BetterAuth `trustedOrigins`) — they can no longer drift. `appUrl` resolution: explicit → derived from a **host-split** localhost `baseUrl` (its `api.` label strips to a sibling host: `https://api.crm.localhost` → `https://crm.localhost`, as served by `lt dev up`; the port is preserved) → localhost default (`http://localhost:3001`, only for `env: local`/`ci`/`e2e` with a **port-split** localhost `baseUrl` — one host, API `:3000`, app `:3001`; `https://api.localhost` strips to the bare `localhost` the API already answers on and is therefore a port split, not a host split) → derived from `baseUrl` by stripping a leading `api.` label (`https://api.example.com` → `https://example.com`). **Security:** the derived origin receives credentialed CORS; set `deriveAppUrl: false` when the apex domain is not trusted, then list the frontend origin via `appUrl`/`allowedOrigins` (a host-split localhost `baseUrl` then falls back to the localhost default). The derivation never yields a bare TLD (`https://api.dev` unchanged) and never emits the opaque `null` origin (non-http(s) `baseUrl` passes through verbatim). `allowAll: true` mirrors the request origin for REST/GraphQL, but BetterAuth's `trustedOrigins` still resolve to `[appUrl]` (+ passkey origins) — an origin check has no "allow everything" mode, so a separately hosted frontend must appear in `appUrl`/`allowedOrigins` (or set `betterAuth.trustedOrigins` explicitly). `enabled: false` disables CORS on all layers including BetterAuth (`trustedOrigins: []`, which still trusts BetterAuth's own `baseURL`). Explicit `betterAuth.trustedOrigins` always takes precedence |
330
330
  | Central Redis | `redis` | Boolean Shorthand + Presence Implies Enabled | `host: 'localhost'`, `port: 6379`, `db: 0`, `keyPrefix: <package.json name, slugified>` (per APPLICATION, not per framework — a constant default silently collides when two apps share one Redis; set it explicitly when sharing IS intended); `url` (takes precedence over host/port/db/credentials), `username`/`password`, `options` (passed to the ioredis constructor). `true` / `{}` enables with defaults; `{ enabled: false }` pre-configures without enabling; **absent = every consumer keeps its process-local fallback** (that fallback is the whole backward-compatibility story of 11.33.0). Requires the OPTIONAL peer `ioredis` — configured-but-missing **fails the boot** with a named error rather than crashing on first use. `keyPrefix` is applied by the framework per key, NOT as ioredis `keyPrefix` (that would collide with BullMQ's own prefix). One `CoreRedisService` serves all features: shared client (`getClient()`), one cached subscriber (`getSubscriber()` — a subscribing client cannot run commands), dedicated connections (`createClient(label)`); all are tracked and quit on shutdown. Switches on automatically: exact cross-replica rate limits via `RedisRateLimitStore` (see the Legacy Auth / BetterAuth / AI rate-limit rows), cron dedup (see Cron Job Deduplication row), `CoreRedisPubSub` as `PUB_SUB` for cluster-wide GraphQL subscriptions (**payloads must be JSON-serializable — `Date`, class instances, `Map`/`Set`, `undefined` do not survive**), tenant-cache invalidation broadcast, Hub collector mirroring, MCP session registry (turns a wrong-replica request from a misleading `404` into a `409` — `/ai/mcp` still REQUIRES sticky sessions, sessions are not portable) |
331
331
  | File Access Roles | `file.downloadRoles`, `file.uploadRoles`, `file.deleteRoles` | Config-Driven Role List | `[RoleEnum.ADMIN]` each. **These are the only breaking change a single-replica project gets in 11.33.0** — six members moved from `@Roles(S_EVERYONE)` to these knobs: `GET /files/id/:id`, `GET /files/:filename` and `getFileInfo` (`downloadRoles`), `uploadFile` / `uploadFiles` (`uploadRoles`), `deleteFile` (`deleteRoles`). Plain role STRINGS, not `RoleEnum` members, so project roles work (`['company-admin', 'editor']`). Applied at boot by `applyFileRoles()` via `Reflect.defineMetadata('roles', …)` on the base-class methods — the same runtime mechanism `CorePermissionsModule` uses, because the value is only known from config. **`[]`, a non-array, or an array holding a non-string is REJECTED with a warning and the default applies** — an all-empty role set reads to the guards as "no roles required" and would OPEN the route, the exact opposite of the intent. **ADMIN is always unioned in**: both `CoreFileController` and `CoreFileResolver` carry a class-level `@Roles(RoleEnum.ADMIN)` and the guards UNION class + handler metadata, so these knobs can grant but never exclude admins. Both classes also carry `@SkipTenantCheck()` — GridFS and the S3 metadata collection are reached outside Mongoose, so `mongooseTenantPlugin` never scopes them and a role name alone cannot express a per-tenant rule; roles resolve against `user.roles`, never `membership.role`. **A subclass that OVERRIDES a member opts out permanently** (decorator metadata lives on the function object, and an override is a different function) — inherit the member instead. **Since 11.35.1 that opt-out is REPORTED**: `CoreFileAccessAuditInitializer` reads the roles off the class the project actually registered at `onApplicationBootstrap` (the earliest point at which the route table exists, which is why it is a provider and not part of `applyFileRoles()`) and warns when a member is open beyond ADMIN for a reason the configuration cannot explain — the case the construction-time warning below is structurally blind to. It only REPORTS: overwriting an override's metadata, the way `TusModule.applyRoles()` does, would silently relax a route a project pinned on purpose. Roles are the coarse filter only; per-file rules belong in `CoreFileService.checkRights()`, which now receives `currentUser` and can read raw metadata via `getRawFileInfo()`. **Since 11.35.0 a reused FILENAME resolves to the most recent file** in all three stores (`uploadDate` desc, `_id` tie-break) and every by-name read path resolves a document and then reads by ID — under GridFS the by-name metadata lookup answered the oldest document while `openDownloadStreamByName()` served the newest, so a per-file rule approved one file and the bytes of another came back. Consequence: `duplicateById()` keeps the source's filename and the copy carries no metadata, so the copy now wins the name and an `ownerId` rule refuses it — give the copy its own metadata or its own name. **Since 11.35.0 `CoreFileService` WARNS at construction** when `multiTenancy` is active AND a knob names a role other than ADMIN AND `checkRights()` is not overridden — the file stores are reached outside Mongoose, so these role names resolve against `user.roles` (global) and every holder reaches every tenant's files; only `checkRights()` can narrow that. Silent on the admin-only default and silent when a per-file rule exists. Implementation: `src/core/modules/file/file-roles.config.ts` (`FILE_ROLE_DEFAULTS`, `FILE_ROLE_MEMBERS`, the three warnings, the shared `hasDeclaredFilePolicy()` silencer) + `file-roles.helper.ts` (`applyFileRoles`) + `core-file-access-audit.initializer.ts` (the registered-class audit) |
332
- | TUS Roles | `tus.roles` | Config-Driven Role List | `[RoleEnum.S_USER]` (was `S_EVERYONE` — breaking). A TUS upload writes into the SAME store the download routes guard, and the termination extension (on by default) can delete from it, so anonymous writes into a store only privileged callers may read is the wrong way round. Applied by `TusModule.applyRoles()` onto the registered controller class **and** onto `handleTus` / `handleTusWithId`. Same rejection rule as the file roles: `[]` / non-array / non-string → warning + `DEFAULT_TUS_CONFIG.roles`. **`OPTIONS` is deliberately exempt**: `handleTusOptions` / `handleTusOptionsWithId` keep their own handler-level `@Roles(RoleEnum.S_EVERYONE)`, because that is the CORS preflight — browsers send it WITHOUT credentials, and it returns server capabilities only. Gating it would make every browser upload fail before the first byte. `CoreTusController` also carries `@SkipTenantCheck()`. A custom controller is covered as long as it INHERITS the handlers; one that re-declares `@All()` / `@Roles()` carries its own metadata and thereby opts out — the documented way to hard-code a policy config must not be able to change. **Set `roles: [RoleEnum.S_EVERYONE]` explicitly if you accept attachments on a public form.** **Since 11.35.0 `tus.roles` is no longer the only gate**: `onUploadCreate` records the authenticated creator under `TUS_OWNER_METADATA_KEY` (`ltOwnerId`, always OVERWRITING any client-supplied value) and `onIncomingRequest` refuses HEAD/PATCH/DELETE naming an upload the caller does not own — with **404**, the file module's refusal policy. Before that, any authenticated caller who learned an upload id could APPEND BYTES to somebody else's upload, which are then migrated into the file store under the victim's filename. The finished file also gains `metadata.ownerId`, without which the documented per-file ownership rule could never authorize a tus-uploaded file (admin-only in practice). An OWNER-LESS upload stays reachable — uploads predating 11.35.0 have none, and neither does a deliberately public form. `readRequestUserId()` / `assertUploadOwnership()` are `protected`. **Since 11.41.9 the tenant is recorded too, but ONLY with `multiTenancy` configured** (not `enabled: false`) and a tenant guard registered: `onUploadCreate` validates the tenant header against the caller's active memberships through `core-tenant-context.registry.ts` (the routes carry `@SkipTenantCheck()`, so the guard did not), refuses a non-member with **403**, writes it under `TUS_TENANT_METADATA_KEY` (`ltTenantId`, always overwritten like the owner key) and the finished file gains `metadata.tenantId`. No header → tenant-less, as before; without `multiTenancy` nothing is read or written. A project hooks in through `TusModule.forRoot({ service })` (a `CoreTusService` subclass, constructed by the module as `(connection, options)` — project providers come from `options.moduleRef` at call time) and the `protected onUploadCreate(req, upload)`, the one point where `Upload-Length` is known and nothing is stored yet; an override must return `super.onUploadCreate()`. Note that `@tus/server` v2 hands its hooks a WHATWG `ServerRequest`, so a guard's `req.user` is reachable only via `runtime.node.req`. Implementation: `src/core/modules/tus/tus.module.ts`, `src/core/modules/tus/core-tus.service.ts`, `src/core/modules/tus/tus.constants.ts` |
332
+ | TUS Roles | `tus.roles` | Config-Driven Role List | `[RoleEnum.S_USER]` (was `S_EVERYONE` — breaking). A TUS upload writes into the SAME store the download routes guard, and the termination extension (on by default) can delete from it, so anonymous writes into a store only privileged callers may read is the wrong way round. Applied by `TusModule.applyRoles()` onto the registered controller class **and** onto `handleTus` / `handleTusWithId`. Same rejection rule as the file roles: `[]` / non-array / non-string → warning + `DEFAULT_TUS_CONFIG.roles`. **`OPTIONS` is deliberately exempt**: `handleTusOptions` / `handleTusOptionsWithId` keep their own handler-level `@Roles(RoleEnum.S_EVERYONE)`, because that is the CORS preflight — browsers send it WITHOUT credentials, and it returns server capabilities only. Gating it would make every browser upload fail before the first byte. `CoreTusController` also carries `@SkipTenantCheck()`. A custom controller is covered as long as it INHERITS the handlers; one that re-declares `@All()` / `@Roles()` carries its own metadata and thereby opts out — the documented way to hard-code a policy config must not be able to change. **Set `roles: [RoleEnum.S_EVERYONE]` explicitly if you accept attachments on a public form.** **Since 11.35.0 `tus.roles` is no longer the only gate**: `onUploadCreate` records the authenticated creator under `TUS_OWNER_METADATA_KEY` (`ltOwnerId`, always OVERWRITING any client-supplied value) and `onIncomingRequest` refuses HEAD/PATCH/DELETE naming an upload the caller does not own — with **404**, the file module's refusal policy. Before that, any authenticated caller who learned an upload id could APPEND BYTES to somebody else's upload, which are then migrated into the file store under the victim's filename. The finished file also gains `metadata.ownerId`, without which the documented per-file ownership rule could never authorize a tus-uploaded file (admin-only in practice). An OWNER-LESS upload stays reachable — uploads predating 11.35.0 have none, and neither does a deliberately public form. `readRequestUserId()` / `assertUploadOwnership()` are `protected`. **Since 11.41.9 the tenant is recorded too, but ONLY with `multiTenancy` configured** (not `enabled: false`) and a tenant guard registered: `onUploadCreate` validates the tenant header against the caller's active memberships through `core-tenant-context.registry.ts` (the routes carry `@SkipTenantCheck()`, so the guard did not), refuses a non-member with **403**, writes it under `TUS_TENANT_METADATA_KEY` (`ltTenantId`, always overwritten like the owner key) and the finished file gains `metadata.tenantId`. No header → tenant-less, as before; without `multiTenancy` nothing is read or written. A project hooks in through `TusModule.forRoot({ service })` (a `CoreTusService` subclass, constructed by the module as `(connection, options)` — project providers come from `options.moduleRef` at call time) and the `protected onUploadCreate(req, upload)`, the one point where `Upload-Length` is known and nothing is stored yet; an override must return `super.onUploadCreate()`. Note that `@tus/server` v2 hands its hooks a WHATWG `ServerRequest`, so a guard's `req.user` is reachable only via `runtime.node.req`. **CORS (11.42.1)**: the tus handler's `Access-Control-Allow-Origin` comes from `buildCorsConfig()` (or `tus.allowedOrigins`), plus `allowedCredentials` — without it `@tus/server` answered `*`, which browsers refuse on a credentialed request, so cookie-authenticated uploads across split app/API origins failed in every browser while the server answered 201; no credentialed CORS configured → the `*` default stays. Implementation: `src/core/modules/tus/tus.module.ts`, `src/core/modules/tus/core-tus.service.ts`, `src/core/modules/tus/tus.constants.ts` |
333
333
  | File Storage | `file.storage` + `file.storageDir` + `s3` | Explicit Enum with DERIVED default | `'filesystem' \| 'gridfs' \| 's3'`. **Unset → derived**, most capable first: `'s3'` when `s3.bucket` is set, else `'gridfs'` when `mongoose.uri` is set, else `'filesystem'`. **Set → enforced**: an unavailable store FAILS THE BOOT (`assertFileStorageAvailable()`), it never falls back — a silent fallback puts files in a store the operator does not believe they are in, unrecoverably. A DERIVED driver is enforced too (`s3.bucket` set but `s3Service` not forwarded to `super()` → boot error). Metadata always lives in MongoDB whichever driver holds the bytes (`fs.files` / `s3-files` / `filesystem-files`) — it has to stay queryable for `findFileInfo()` and `checkRights()`. Reads consult ALL stores so switching drivers is forward-only with no migration; writes go to the active driver only. `'filesystem'` is pod-local: not shared between replicas, lost on restart unless `storageDir` is a mounted volume. `s3`: `bucket` (required — the one thing S3 cannot default, hence the eligibility test), `region: 'us-east-1'`, `forcePathStyle: false`, `autoCreateBucket: false` (off by design: production buckets come from infrastructure code, whose credentials usually cannot CreateBucket), `stagingBucket: bucket`, `presignedDownloads: false` (`true`/`{}` → `expiresInSeconds: 300`), `endpoint` (MinIO/RustFS), `accessKeyId`/`secretAccessKey` (omit → AWS default credential chain), `enabled: false` to pre-configure. Requires the OPTIONAL peer `@aws-sdk/client-s3`, plus `@aws-sdk/s3-request-presigner` for presigned downloads. **Uploads are never buffered on the S3 path** (11.41.7): a known length (`FileUploadSource.size`, TUS, a buffer) goes up as one streamed PutObject up to `CoreS3Service.MAX_SINGLE_PUT_BYTES` (5 GiB — AWS S3 refuses more in one request, `EntityTooLarge`) and as a multipart upload above it, an unknown one — every GraphQL and streamed REST upload — as a multipart upload via the OPTIONAL peer `@aws-sdk/lib-storage`. Missing peer → one boot warning and the old in-memory buffering, NOT a boot failure (multipart optimises a path that works without it). `size` must be the file's EXACT length — never a multipart request's `Content-Length`; anything but a positive integer counts as unknown (`isKnownUploadSize()`), and the recorded length of an unknown-length upload comes from a HEAD of the stored object. `presignedDownloads` makes `GET /files/id/:id` answer `302` to a time-limited S3 URL instead of streaming — the URL is a bearer capability, authorized once at issue time. The resolved driver is logged at boot (`[CoreFileStorage] File storage: …`) |
334
334
  | TUS S3 Staging | `tus.s3Staging` | Explicit Boolean (default ON when S3 configured) | `true` when `s3` is configured, otherwise inert. Stages in-progress uploads in `s3.stagingBucket` via `@tus/s3-store` instead of `tus.uploadDir` on local disk, so resumable uploads survive replica restarts and need no sticky sessions. Set `false` to force local disk. Missing OPTIONAL peer `@tus/s3-store` → warning + fall back to local disk (NOT a boot failure). **Give the staging bucket a lifecycle rule expiring incomplete multipart uploads** — the framework's own expiration cleanup is skipped in S3 mode (S3 is the right place for that policy), so aborted uploads otherwise accumulate parts nothing removes |
335
335
  | Cron Job Deduplication | per job: `distributed` in `CronJobConfig` | Explicit Boolean | **`true` when `redis` is configured, otherwise `false`** — a single-replica project that upgrades must not silently gain a `cron-locks` collection, a lease write per tick, and a new way for a tick to be skipped. A Redis-less multi-replica fleet opts in per job with `distributed: true` (MongoDB lease). Mechanism: BullMQ job scheduler when Redis + the OPTIONAL peer `bullmq` are present AND `cronTime` is a string without `utcOffset`; otherwise local timer + lease (Redis `SET NX`, else a TTL-indexed `cron-locks` document). Tick lease TTL 3600 s. **Leases fail open** — an unreachable lease store runs the tick everywhere rather than stopping all scheduled work fleet-wide. **`runOnInit` (default `true`) deduplicates over a FIXED per-job key with a 300 s TTL**, because replicas do not share a boot instant: replicas booting within 5 min run the startup tick once between them, and a replica restarting inside that window SKIPS its startup tick — set `distributed: false` on jobs whose `runOnInit` work is per-process (warming a process-local cache). No constructor change needed: `CoreModule` fills `core-cron-jobs.registry.ts` via `CoreCronJobsInitializer` and `CoreCronJobs` reads it lazily; explicit `{ connection, redisService }` in `CoreCronJobsOptions` wins. With neither source it warns once and every replica runs every tick |
336
336
  | Shutdown Delay | `shutdownDelayMs` | Numeric Sentinel — Family B (`0` = off) | `0` (default, no delay, no log). Waits N ms **in the SIGTERM/SIGINT handler, before `close()` is entered**, so a load balancer can finish deregistering while the instance is still fully healthy. NOT a lifecycle hook: `close()` runs `onModuleDestroy` → `beforeApplicationShutdown` → dispose → `onApplicationShutdown`, so a delay in `beforeApplicationShutdown` would wait with every module already torn down while the socket still accepts — worse than no delay. **Requires `installGracefulShutdown(app)` in main.ts, which REPLACES `server.enableShutdownHooks()`** — keeping both makes Nest close the app in parallel with the wait, so the delay silently never happens. At delay `0` the helper IS `enableShutdownHooks()`. **Keep the value well below the orchestrator grace period AND leave room for the drain that follows**: Compose `stop_grace_period` 10s, Kubernetes `terminationGracePeriodSeconds` 30s, and `installProcessDiagnostics()` force-exits after 30s — exceed any and the process is SIGKILLed mid-wait with no hook running. Warns above `10000`, capped at `60000`. Non-numeric / negative values behave like `0` |
337
337
  | Trust Proxy | `trustProxy` | Explicit Value (pass-through, Express default) | `false` (Express's own default — the forwarded chain is not trusted). Passed verbatim to `app.set('trust proxy', …)` by `CoreTrustProxyInitializer`, a `CoreModule` provider, so a consumer inherits it by upgrading and needs no `main.ts` edit. Accepts `false` / a hop count (`1`, `2`) / `'loopback'` / a subnet list — **not** Express's predicate function: the value must survive `NEST_SERVER_CONFIG` / `NSC__*` (JSON) and the ConfigService deep clone. **This is what makes `request.ip` correct, and every IP-keyed rate limit depends on it**: unset behind Caddy/nginx/an ingress, `req.ip` is the PROXY address for every request, so all clients share ONE bucket and `auth.rateLimit.max` throttles everybody at once — exactly fleet-wide once `redis` is configured. Trusting MORE hops than exist is the opposite failure: a client prepends its own header entry and picks a fresh bucket per request. Applied at module init (inside `app.init()`/`listen()`, i.e. AFTER `main.ts`), so a configured value wins over a hand-written `app.set()`; an UNSET value is never applied, which keeps `app.set('trust proxy', fn)` in `main.ts` available as the escape hatch for the predicate form. **Unset + an IP-keyed limiter enabled (`auth.rateLimit` / `betterAuth.rateLimit`) logs a boot warning naming the shared-bucket consequence**; `trustProxy: false` is the explicit "nothing proxies me" answer that silences it. The AI limiter keys on the user id and is unaffected |
338
+ | Outbound URL Guard (SSRF) | `security.allowPrivateOutboundTargets` | Explicit Boolean with DERIVED default | **Opt-in helper for URLs a USER entered** (webhooks, export targets, ERP addresses) — `outboundFetch()`, `assertOutboundUrlAllowed()`, `createOutboundDispatcher()` in `common/helpers/outbound-url.helper.ts` (11.42.0). Nothing is applied globally: configured targets (AI providers, SMTP, S3, Redis, Brevo) never go through it. Refuses loopback, RFC 1918, link-local incl. `169.254.169.254`, CGNAT, `0/8`, benchmarking, multicast/reserved, IPv6 unspecified/loopback/IPv4-compatible (`::/96`), NAT64, ULA, link-local and multicast, and IPv4-mapped IPv6 in every notation (canonicalised through the URL parser). Checked TWICE: on the URL before sending (readable `OutboundUrlBlockedError`) and on every connection through an undici `Agent` with a guarded connector — the only place that sees a redirect's next hop and a DNS answer at connect time (rebinding). **Unset → derived from the environment**: allowed in `local`, `e2e`, `ci`, refused everywhere else — deliberately including `test`, a DEPLOYED stage in starter-based projects; `true`/`false` (also the strings, from `NSC__SECURITY__ALLOW_PRIVATE_OUTBOUND_TARGETS`) decide explicitly. Per call, `allowedHosts` frees single internal hosts (`'erp.local:8443'` = that port, `'erp.local'` = any port) without opening the rest — re-checked per connection, so a redirect does not inherit it. `outboundFetch()` uses undici's OWN `fetch` (runtime dependency `undici` since 11.42.0), because Node 22's built-in fetch bundles a different undici major than the agent |
338
339
  | AI Egress Allowlist (SSRF) | `ai.allowedBaseUrlHosts` | Explicit Value — **unset = permissive**, malformed = INTERPRETED or reported, never silently off | `undefined` → no restriction, so a local provider (Ollama on `localhost:11434`) works out of the box. Set → only those hosts are reachable, matched against `URL.host` (incl. port) and `URL.hostname`. **Accepts an array OR a comma-separated string, and the string form is not a convenience** — it is what the framework's own env mapping produces: `NSC__AI__ALLOWED_BASE_URL_HOSTS` becomes `{ ai: { allowedBaseUrlHosts: '<string>' } }` and lodash `merge` assigns that scalar straight over a configured array. Until 11.39.x a bare `!Array.isArray(...) -> return` read that as "not configured" and skipped the check entirely, so the canonical `NSC__` spelling silently disabled SSRF egress control with no log line at all. Entries are trimmed, lowercased and stripped of a fully-qualifying trailing dot, and the SAME normalisation is applied to the URL, so neither side wins by spelling one DNS name differently; a bare hostname entry matches any port, and an entry naming the scheme's default port (`example.com:443` on https) also matches the portless URL. A value that is neither array nor string carries no hostnames — the allowlist is then inactive and that is LOGGED as an error, because from the outside it looks exactly like "correctly unset". Enforced on **all three** outbound paths (`chat()`, the capability `probe()`, and `probeContextWindow()` — the last was unguarded before 11.39.x and is reachable from an ordinary user prompt via `detectAndPersistCapabilities()`, before the rate limit). Implementation: `openai-compatible.provider.ts` → `assertBaseUrlAllowed()` / `resolveAllowedBaseUrlHosts()` |
339
340
  | AI Run Time Limit | `ai.maxRunMs` | **Numeric Sentinel — Family A** (`0` = no limit) | `0` (omitted = unbounded, the previous behaviour). Wall-clock ceiling for ONE prompt run, checked before each agent-loop iteration; once exceeded the run stops and answers with whatever it has. **Without it a run's only bound is `maxIterations` MULTIPLIED BY the connection's per-call timeout** — 8 iterations at the 120s default is a request that can legitimately hold a socket, its message buffer and a request context for 16 minutes, and compaction can add a call per iteration on top. Guarded as `maxRunMs > 0`, never `<= 0`: a value arriving as a non-numeric string through `NSC__AI__MAX_RUN_MS` (the reader coerces only `'true'`, `'false'` and numbers) yields `NaN`, and `NaN > 0` is `false`, so a misconfiguration degrades to "no limit" rather than to an instantly-expired deadline that would break every prompt. Set it to something a client would actually wait for. Implementation: `core-ai.service.ts` -> `runDeadlineExceeded()` |
340
- | AI Assistant | `ai` | Presence Implies Enabled | Core: `maxIterations: 5`, `defaultMode: 'auto'` (or `'plan'`), `rateLimit` (presence implies enabled: `max: 20`, `windowSeconds: 60`), `systemPrompt`, `documentation` (injected into the system prompt), `encryptionSecret`. **DB-backed LLM connections** (`aiConnections`, admin CRUD) with AES-256-GCM-encrypted API keys (`AiCryptoService`, secret from `ai.encryptionSecret` / `NSC__AI__ENCRYPTION_SECRET` / `SECRETS_ENCRYPTION_KEY`; `apiKeyEncrypted` is a global `secretFields` entry, never returned — only `hasApiKey`); optional `defaultConnection` one-time seed. **Provider abstraction** (`ILlmProvider`, default `OpenAiCompatibleProvider` for any OpenAI-compatible endpoint via `fetch`; per-connection `supportsNativeTools`/`supportsJsonResponse` capabilities, emulated tool calling when native tools are unavailable). **Tool registry** (`AiToolRegistry`, tools self-register, role-filtered; tools may be `mutating`/`destructive` and define `authorize()` for pre-flight data-level checks). **Plan mode** (`input.mode: 'plan'`): full plan → pre-flight authorize ALL steps → all-or-nothing execution with a translated (de/en) error when any step is not permitted. **Confirmation policy**: `confirmation.mutating: { default, enforced }` + client `input.requireConfirmation` (ignored when enforced); `destructive` always confirms. **Client metadata** (`input.metadata`: URL/nav/console logs, untrusted+capped). **Multi-turn conversations** (`aiConversations`, owner-scoped). **SSE streaming** (`POST /ai/stream`). **Audit** (`audit: false` → persist to `aiInteractions`, admin-readable). **Token budgets** (`budget: { period: 'day'|'month'|'none', user: { maxTokens?, maxPrompts? }, tenant: { maxTokens?, maxPrompts? } }`, requires audit): per-user AND per-tenant limits with config defaults; admins override per user/tenant at runtime (`aiBudgetLimits`, `CoreAiBudgetService`). Resolution: override → default → unlimited (missing/0 = unlimited). Enforced before the run (HTTP 429 + translated). Each response carries a compact `budget` summary (promptTokens, usedTokens, remainingTokens, resetAt); full breakdown via `aiUsage` query / `GET /ai/usage`. **Self-optimizing prompts**: the system prompt is assembled from keyed fragments (`CoreAiPromptBuilderService` ships built-in defaults; works with zero rows). Admin-editable overrides per slot (`aiSlots`, admin CRUD, `/ai/slots`) scoped by `key`/`locale`/`capability`/`tenantId`, with tenant override/reset semantics and placeholder tokens resolved at run time via the placeholder registry. **Governed learning loop** (`promptLearning: { enabled: true, autoApply: false }`): tool errors record `suggested` hints (`aiPromptHints`, admin CRUD, `/ai/prompt-hints`) that only reach the prompt once admin-approved (or auto-approved when `autoApply`); hints only ADD guidance, never relax permissions. **Context window** (`contextWindow`, default 8192; auto-detected per connection via `ILlmProvider.detectContextWindow()` — Ollama `/api/show` probe / known-model table / Claude alias — and persisted): per-user/session history is trimmed (oldest non-system turns dropped, last truncated) and tool-results capped to `maxToolResultChars` (default 12000) so a session never overflows the model. A connection's window can be seeded via `ai.defaultConnection.contextWindow` (validated: a non-positive/non-integer value is dropped with a warning). **Capability drift check** (`capabilityDriftCheck`, default `false`): opt-in boot self-check that probes each enabled connection with an EXPLICIT `supportsNativeTools`/`supportsJsonResponse` (built with those flags cleared so the endpoint is actually re-probed) and logs a warning on mismatch — the stored value is never changed. OFF by default because it makes outbound calls to the LLM endpoints on every boot; also skipped in the ci/e2e runners. **Deferred tool schemas** (`deferToolSchemas`, default `false`): the system-prompt tool catalog then lists only tool NAMES + descriptions instead of full JSON schemas, and the model fetches a schema on demand via the built-in `search_tools` meta-tool — with a large registry the schemas alone can dominate a small context window. `deferToolSummaryChars` (default `0` = untruncated) additionally caps each description in that DEFERRED catalog: whole sentences up to the cap (always at least the first), word-boundary cut when the first sentence already exceeds it, and a `…` marker appended ON TOP of the cap. The default of `0` keeps the saving opt-in, so enabling `deferToolSchemas` alone never changes what a description says; set roughly 200–400 alongside it to actually reclaim the context. Both apply to EMULATED providers only — a connection with `supportsNativeTools: true` receives every full description + schema via `buildToolSchemas()` regardless, so truncation and the banner are skipped there rather than asserting a cut the tool payload contradicts. The omitted tail is where preconditions and role restrictions usually live — the catalog banner tells the model to fetch the full text via `search_tools` first, but this is model GUIDANCE only: authorization is enforced server-side by the registry's role filter (`forUser()`), the execution-time re-check, and the `mutating`/`destructive` flags read by the confirmation gate — never by what the catalog shows. (`AiTool.authorize()` runs in PLAN MODE only; in auto mode and over MCP, data-level checks must live inside `execute()`.) **MCP server** (`mcp: false` → `/ai/mcp` Streamable HTTP, Bearer auth, lazy `@modelcontextprotocol/sdk`; `mcp: { oauth: true, oauthSecret }` adds OAuth 2.1 — HMAC tokens + PKCE S256 + dynamic registration via `mountAiMcpOAuth(app)` in main.ts). Overrides via `CoreModule.forRoot(env, { ai: { budgetService, connectionResolver, connectionService, controller, conversationService, interactionService, mcpClientService, modeService, placeholderRegistry, preferenceService, promptBuilder, promptHintService, promptService, resolver, service, slotService, toolGrantService, toolPolicyService } })` |
341
+ | AI Assistant | `ai` | Presence Implies Enabled | Core: `maxIterations: 5`, `defaultMode: 'auto'` (or `'plan'`), `rateLimit` (presence implies enabled: `max: 20`, `windowSeconds: 60`), `systemPrompt`, `documentation` (injected into the system prompt), `encryptionSecret`. **DB-backed LLM connections** (`aiConnections`, admin CRUD) with AES-256-GCM-encrypted API keys (`AiCryptoService`, secret from `ai.encryptionSecret` / `NSC__AI__ENCRYPTION_SECRET` / `SECRETS_ENCRYPTION_KEY`; `apiKeyEncrypted` is a global `secretFields` entry, never returned — only `hasApiKey`); optional `defaultConnection` one-time seed. **Provider abstraction** (`ILlmProvider`, default `OpenAiCompatibleProvider` for any OpenAI-compatible endpoint via `fetch`; per-connection `supportsNativeTools`/`supportsJsonResponse` capabilities, emulated tool calling when native tools are unavailable). **Tool registry** (`AiToolRegistry`, tools self-register, role-filtered — under multi-tenancy (11.42.0+) a tool's roles resolve like `@Roles()` in `CoreTenantGuard`: global roles against `user.roles`, tenant roles against the request's membership role, ADMIN bypass only where `adminBypass` grants it, so chat and MCP never exceed the user's own rights; an MCP session reads the user per request, so a revoked role stops working without a reconnect; tools may be `mutating`/`destructive` and define `authorize()` for pre-flight data-level checks). **Plan mode** (`input.mode: 'plan'`): full plan → pre-flight authorize ALL steps → all-or-nothing execution with a translated (de/en) error when any step is not permitted. **Confirmation policy**: `confirmation.mutating: { default, enforced }` + client `input.requireConfirmation` (ignored when enforced); `destructive` always confirms. **Client metadata** (`input.metadata`: URL/nav/console logs, untrusted+capped). **Multi-turn conversations** (`aiConversations`, owner-scoped). **SSE streaming** (`POST /ai/stream`). **Audit** (`audit: false` → persist to `aiInteractions`, admin-readable). **Token budgets** (`budget: { period: 'day'|'month'|'none', user: { maxTokens?, maxPrompts? }, tenant: { maxTokens?, maxPrompts? } }`, requires audit): per-user AND per-tenant limits with config defaults; admins override per user/tenant at runtime (`aiBudgetLimits`, `CoreAiBudgetService`). Resolution: override → default → unlimited (missing/0 = unlimited). Enforced before the run (HTTP 429 + translated). Each response carries a compact `budget` summary (promptTokens, usedTokens, remainingTokens, resetAt); full breakdown via `aiUsage` query / `GET /ai/usage`. **Self-optimizing prompts**: the system prompt is assembled from keyed fragments (`CoreAiPromptBuilderService` ships built-in defaults; works with zero rows). Admin-editable overrides per slot (`aiSlots`, admin CRUD, `/ai/slots`) scoped by `key`/`locale`/`capability`/`tenantId`, with tenant override/reset semantics and placeholder tokens resolved at run time via the placeholder registry. **Governed learning loop** (`promptLearning: { enabled: true, autoApply: false }`): tool errors record `suggested` hints (`aiPromptHints`, admin CRUD, `/ai/prompt-hints`) that only reach the prompt once admin-approved (or auto-approved when `autoApply`); hints only ADD guidance, never relax permissions. **Context window** (`contextWindow`, default 8192; auto-detected per connection via `ILlmProvider.detectContextWindow()` — Ollama `/api/show` probe / known-model table / Claude alias — and persisted): per-user/session history is trimmed (oldest non-system turns dropped, last truncated) and tool-results capped to `maxToolResultChars` (default 12000) so a session never overflows the model. A connection's window can be seeded via `ai.defaultConnection.contextWindow` (validated: a non-positive/non-integer value is dropped with a warning). **Capability drift check** (`capabilityDriftCheck`, default `false`): opt-in boot self-check that probes each enabled connection with an EXPLICIT `supportsNativeTools`/`supportsJsonResponse` (built with those flags cleared so the endpoint is actually re-probed) and logs a warning on mismatch — the stored value is never changed. OFF by default because it makes outbound calls to the LLM endpoints on every boot; also skipped in the ci/e2e runners. **Deferred tool schemas** (`deferToolSchemas`, default `false`): the system-prompt tool catalog then lists only tool NAMES + descriptions instead of full JSON schemas, and the model fetches a schema on demand via the built-in `search_tools` meta-tool — with a large registry the schemas alone can dominate a small context window. `deferToolSummaryChars` (default `0` = untruncated) additionally caps each description in that DEFERRED catalog: whole sentences up to the cap (always at least the first), word-boundary cut when the first sentence already exceeds it, and a `…` marker appended ON TOP of the cap. The default of `0` keeps the saving opt-in, so enabling `deferToolSchemas` alone never changes what a description says; set roughly 200–400 alongside it to actually reclaim the context. Both apply to EMULATED providers only — a connection with `supportsNativeTools: true` receives every full description + schema via `buildToolSchemas()` regardless, so truncation and the banner are skipped there rather than asserting a cut the tool payload contradicts. The omitted tail is where preconditions and role restrictions usually live — the catalog banner tells the model to fetch the full text via `search_tools` first, but this is model GUIDANCE only: authorization is enforced server-side by the registry's role filter (`forUser()`), the execution-time re-check, and the `mutating`/`destructive` flags read by the confirmation gate — never by what the catalog shows. (`AiTool.authorize()` runs in PLAN MODE only; in auto mode and over MCP, data-level checks must live inside `execute()`.) **MCP server** (`mcp: false` → `/ai/mcp` Streamable HTTP, Bearer auth, lazy `@modelcontextprotocol/sdk`; `mcp: { oauth: true, oauthSecret }` adds OAuth 2.1 — HMAC tokens + PKCE S256 + dynamic registration via `mountAiMcpOAuth(app)` in main.ts). Overrides via `CoreModule.forRoot(env, { ai: { budgetService, connectionResolver, connectionService, controller, conversationService, interactionService, mcpClientService, modeService, placeholderRegistry, preferenceService, promptBuilder, promptHintService, promptService, resolver, service, slotService, toolGrantService, toolPolicyService } })` |
341
342
 
342
343
  ## Module Override Pattern (via `ICoreModuleOverrides`)
343
344
 
@@ -417,6 +417,37 @@ otherwise lets the role guard check `user.roles` — fail-closed, warned once. P
417
417
  **Configured is not registered.** When you add a guard that delegates to another one, ask whether
418
418
  the other one RUNS, not whether it is configured.
419
419
 
420
+ ## Every entry point, not just the route (11.42.0)
421
+
422
+ A `@Roles()` decides who may call a ROUTE. An operation is also reached from MCP tools, AI tools,
423
+ queue processors and other services, and on those paths the route's roles are checked by nobody.
424
+ Three mechanisms keep those paths inside the user's rights:
425
+
426
+ | Path | Mechanism |
427
+ |------|-----------|
428
+ | A service that must demand a tenant role whatever calls it | `assertTenantRole(roles, message?)` / `hasTenantRole(...roles)` (`core-tenant-role.helper.ts`) — decided like the guard's header path |
429
+ | AI tools (chat and `/ai/mcp`) | `AiToolRegistry.userCanAccess()` resolves tool roles like the guard under multi-tenancy (global vs membership role, `adminBypass` honoured), with the user as of the CURRENT request — an MCP session refreshes it on every request |
430
+ | Data | `mongooseTenantPlugin` via `RequestContext` — tools run inside the HTTP request, so the validated tenant scopes them |
431
+
432
+ **System work vs. an anonymous request.** Both have no current user. `assertTenantRole()` exempts
433
+ system work (cron, migration, seed) and refuses the anonymous caller of a public route with 401 — told
434
+ apart by `RequestContext.fromRequest`, which `RequestContextMiddleware` and the GraphQL WebSocket
435
+ context set. Do not reduce that to "no current user = system": that is exactly the hole the marker
436
+ closes (mutation `tenant-role-anonymous-as-system`).
437
+
438
+ **Credentials under `adminBypass: false`.** `CoreUserService.update()` refuses a change of another
439
+ account's e-mail address or password (`assertCredentialChangeAllowed()`) — administrator targets
440
+ included, because an administrator may write `roles` and could otherwise promote first and change
441
+ second (mutation `tenant-credential-admin-target-exempt`). Without it, a platform administrator — or
442
+ the account's creator, since `S_CREATOR` may update and `email` is open to every updater — takes the
443
+ account over with an address change and a reset, which is precisely the access `adminBypass: false`
444
+ withholds.
445
+
446
+ **Deactivated tenants.** `multiTenancy.isTenantActive` is asked after the membership on every path that
447
+ establishes a tenant (see `.claude/rules/configurable-features.md`, Multi-Tenancy row). A GraphQL
448
+ subscription that is already open keeps its tenant until the client reconnects — a known limit it
449
+ shares with a removed membership.
450
+
420
451
  ## Role Check Implementation
421
452
 
422
453
  The role system is evaluated in:
@@ -266,7 +266,7 @@ pnpm run check:mutations -- --id=<id> # one mutation
266
266
  pnpm run check:mutations -- --list # the registry, without running anything
267
267
  pnpm run check:mutations -- --allow-dirty # when the fix and its evidence share a working tree
268
268
  pnpm run check:mutations -- --jobs=4 # N mutations at a time (default: 2, or 4 on >=12 cores)
269
- pnpm run check:mutations -- --no-infra # only the 86 that need no MongoDB
269
+ pnpm run check:mutations -- --no-infra # only the 92 that need no MongoDB
270
270
  pnpm run check:mutations -- --since=<ref> # only mutations touching files changed since <ref>
271
271
  ```
272
272
 
@@ -285,7 +285,7 @@ when the registry, a vitest config or a setup file changed, since those can move
285
285
  commit, and in CI alongside the publish without blocking it. The price of caching is a cache that
286
286
  has to model each spec's full dependency closure correctly, and getting that wrong produces a stale
287
287
  PASS for a test that has since gone vacuous — exactly what the gate is there to prevent. Bad trade
288
- at 136 mutations.
288
+ at 146 mutations.
289
289
 
290
290
  **Decided 2026-10-03 (repo owner): keep the full run.** Measured at 128 mutations, the CI job for
291
291
  11.41.6 took **14 minutes**. The trigger to revisit is that wall time, not the mutation count —
@@ -330,9 +330,9 @@ exactly the environment where the answer matters.
330
330
 
331
331
  ### The cost is vitest's cold start, not the tests
332
332
 
333
- Worth knowing before optimising the wrong thing: the specs behind all 50 e2e mutations add up to
333
+ Worth knowing before optimising the wrong thing: the specs behind all 54 e2e mutations add up to
334
334
  **~40 seconds**. The step takes ~740s. The remaining ~700s is paying vitest's startup — process
335
- spawn, transform, module graph, mongod connect, DB create and drop — once per mutation, 136 times.
335
+ spawn, transform, module graph, mongod connect, DB create and drop — once per mutation, 146 times.
336
336
  That work is largely single-threaded I/O and barely scales with cores: the full registry measures
337
337
  **744s on a 12-core laptop and 777s on a 4-vCPU CI runner**.
338
338
 
package/CLAUDE.md CHANGED
@@ -227,6 +227,12 @@ spelling), **`@Restricted` also applies to nested embedded subdocuments** whose
227
227
  that a WebSocket operation had none, so `mongooseTenantPlugin` read it as "system operation, no filter"
228
228
  and a tenant-scoped read on that path returned every tenant's rows.
229
229
 
230
+ Since 11.42.0 an operation is protected on **every entry point**, not just its route: `assertTenantRole()`
231
+ in services (MCP/AI tools call services directly), tenant-aware tool roles in the AI registry, a
232
+ `multiTenancy.isTenantActive` hook for deactivated tenants, and no credential changes across the tenant
233
+ boundary under `adminBypass: false`. User-entered URLs (webhooks, export targets) go through
234
+ `outboundFetch()` (SSRF guard) — configured targets do not.
235
+
230
236
  See `.claude/rules/role-system.md` for complete documentation.
231
237
 
232
238
  ### Versioning
package/FRAMEWORK-API.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # @lenne.tech/nest-server — Framework API Reference
2
2
 
3
- > Auto-generated from source code as of 2026-10-03 (v11.41.9)
3
+ > Auto-generated from source code as of 2026-10-04 (v11.42.1)
4
4
  > File: `FRAMEWORK-API.md` — compact, machine-readable API surface for Claude Code
5
5
 
6
6
  ## CoreModule.forRoot()
@@ -46,7 +46,7 @@
46
46
  - `port?`: `number | undefined` — Port number of the server
47
47
  - `redis?`: `boolean | IRedisConfig | undefined` (default: `undefined (disabled)`) — Optional central Redis connection used by all distributed features
48
48
  - `s3?`: `IS3Config | undefined` (default: `undefined (disabled)`) — Optional central S3-compatible object storage (AWS S3, MinIO, ...).
49
- - `security?`: `{ checkResponseInterceptor?: boolean | { checkObjectItself?: boolean; debug?:...` — Configuration for security pipes and interceptors
49
+ - `security?`: `{ allowPrivateOutboundTargets?: boolean; checkResponseInterceptor?: boolean |...` — Configuration for security pipes and interceptors
50
50
  - `sha256?`: `boolean | undefined` — Whether to enable verification and automatic encryption for received passwords that are not in sha256 format
51
51
  - `shutdownDelayMs?`: `number | undefined` (default: `0 (no delay)`) — Delay in milliseconds between receiving a shutdown signal and starting the
52
52
  - `staticAssets?`: `{ options?: ServeStaticOptions; path?: string; } | undefined` — Configuration for useStaticAssets
@@ -81,6 +81,7 @@ When `passkey` is enabled, `trustedOrigins` is required (compile-time enforcemen
81
81
  - `strictMembershipRoles?`: `boolean | undefined` (default: `false`) — Refuse membership roles that are not declared anywhere (deny by default).
82
82
  - `additionalMembershipRoles?`: `string[] | undefined` (default: `[]`) — Membership roles that are valid but carry no hierarchy level (exact-match roles).
83
83
  - `cacheTtlMs?`: `number | undefined` (default: `30000 (30 seconds)`) — TTL in milliseconds for the tenant guard's in-memory membership cache.
84
+ - `isTenantActive?`: `((tenantId: string, context: { connection: Connection; }) => boolean | Promis...` (default: `undefined (every tenant is active)`) — Whether a tenant may be used at all — the hook that makes a DEACTIVATED tenant stop working.
84
85
 
85
86
  ### IErrorCode
86
87
 
@@ -193,6 +194,7 @@ When `passkey` is enabled, `trustedOrigins` is required (compile-time enforcemen
193
194
  ### ITusConfig
194
195
 
195
196
  - `allowedHeaders?`: `string[] | undefined` (default: `[] (no additional headers needed)`) — Additional allowed HTTP headers for TUS requests (beyond
197
+ - `allowedOrigins?`: `string[] | undefined` (default: `undefined (derived from the API CORS configuration)`) — Origins the tus handler may answer a cross-origin request with (`Access-Control-Allow-Origin`).
196
198
  - `allowedTypes?`: `string[] | undefined` (default: `undefined (all types allowed)`) — Allowed MIME types for uploads.
197
199
  - `checksum?`: `boolean | undefined` (default: `true`) — Checksum extension configuration.
198
200
  - `concatenation?`: `boolean | undefined` (default: `true`) — Concatenation extension configuration.
@@ -33,7 +33,7 @@ async function buildWsRequestContext(contextValue) {
33
33
  return undefined;
34
34
  }
35
35
  const user = readUser(contextValue);
36
- const context = { currentUser: user };
36
+ const context = { currentUser: user, fromRequest: true };
37
37
  const acceptLanguage = contextValue.headers?.['accept-language'] ??
38
38
  contextValue.connectionParams?.['accept-language'] ??
39
39
  contextValue.request?.headers?.['accept-language'];
@@ -1 +1 @@
1
- {"version":3,"file":"graphql-ws-context.helper.js","sourceRoot":"","sources":["../../../../src/core/common/helpers/graphql-ws-context.helper.ts"],"names":[],"mappings":";;AAyFA,sDA8BC;AAQD,0EAiBC;AAYD,4EAQC;AAGD,gFASC;AASD,0EAEC;AAED,8EAEC;AA1LD,mCAAoC;AAEpC,2FAAoF;AACpF,+DAA2D;AAC3D,iFAAsF;AAoDtF,SAAS,gBAAgB,CAAC,YAAiB;IACzC,MAAM,UAAU,GAAG,CAAC,8BAAa,CAAC,qBAAqB,EAAE,YAAY,EAAE,UAAU,IAAI,aAAa,CAAC,CAAC,WAAW,EAAE,CAAC;IAClH,MAAM,OAAO,GAAG,CAAC,YAAY,EAAE,OAAO,EAAE,YAAY,EAAE,gBAAgB,EAAE,YAAY,EAAE,OAAO,EAAE,OAAO,CAAC,CAAC;IACxG,KAAK,MAAM,MAAM,IAAI,OAAO,EAAE,CAAC;QAC7B,IAAI,CAAC,MAAM,IAAI,OAAO,MAAM,KAAK,QAAQ,EAAE,CAAC;YAC1C,SAAS;QACX,CAAC;QACD,KAAK,MAAM,CAAC,GAAG,EAAE,KAAK,CAAC,IAAI,MAAM,CAAC,OAAO,CAAC,MAAiC,CAAC,EAAE,CAAC;YAC7E,IAAI,GAAG,CAAC,WAAW,EAAE,KAAK,UAAU,IAAI,OAAO,KAAK,KAAK,QAAQ,IAAI,KAAK,EAAE,CAAC;gBAC3E,OAAO,KAAK,CAAC;YACf,CAAC;QACH,CAAC;IACH,CAAC;IACD,OAAO,SAAS,CAAC;AACnB,CAAC;AAGD,SAAS,QAAQ,CAAC,YAAiB;IACjC,OAAO,YAAY,EAAE,IAAI,IAAI,YAAY,EAAE,KAAK,EAAE,IAAI,IAAI,SAAS,CAAC;AACtE,CAAC;AASM,KAAK,UAAU,qBAAqB,CAAC,YAAiB;IAC3D,IAAI,CAAC,YAAY,IAAI,OAAO,YAAY,KAAK,QAAQ,IAAI,YAAY,CAAC,GAAG,EAAE,CAAC;QAC1E,OAAO,SAAS,CAAC;IACnB,CAAC;IAED,MAAM,IAAI,GAAG,QAAQ,CAAC,YAAY,CAAC,CAAC;IACpC,MAAM,OAAO,GAAoB,EAAE,WAAW,EAAE,IAAI,EAAE,CAAC;IAGvD,MAAM,cAAc,GAClB,YAAY,CAAC,OAAO,EAAE,CAAC,iBAAiB,CAAC;QACzC,YAAY,CAAC,gBAAgB,EAAE,CAAC,iBAAiB,CAAC;QAClD,YAAY,CAAC,OAAO,EAAE,OAAO,EAAE,CAAC,iBAAiB,CAAC,CAAC;IACrD,IAAI,OAAO,cAAc,KAAK,QAAQ,EAAE,CAAC;QACvC,OAAO,CAAC,QAAQ,GAAG,cAAc,CAAC;IACpC,CAAC;IAED,MAAM,QAAQ,GAAG,IAAA,uDAAwB,GAAE,CAAC;IAC5C,IAAI,QAAQ,EAAE,CAAC;QAIb,IAAI,CAAC;YACH,MAAM,CAAC,MAAM,CAAC,OAAO,EAAE,MAAM,QAAQ,CAAC,OAAO,CAAC,IAAI,EAAE,gBAAgB,CAAC,YAAY,CAAC,CAAC,CAAC,CAAC;QACvF,CAAC;QAAC,MAAM,CAAC;QAET,CAAC;IACH,CAAC;IAED,OAAO,OAAO,CAAC;AACjB,CAAC;AAQD,SAAgB,+BAA+B,CAC7C,QAAgF,EAChF,OAAwB;IAExB,MAAM,OAAO,GAA6B;QACxC,IAAI,EAAE,CAAC,GAAG,IAAsB,EAAE,EAAE,CAAC,wCAAc,CAAC,GAAG,CAAC,OAAO,EAAE,GAAG,EAAE,CAAC,QAAQ,CAAC,IAAI,CAAC,GAAI,IAAW,CAAC,CAAC;QACtG,CAAC,MAAM,CAAC,aAAa,CAAC;YACpB,OAAO,OAAO,CAAC;QACjB,CAAC;KACF,CAAC;IACF,IAAI,QAAQ,CAAC,MAAM,EAAE,CAAC;QACpB,OAAO,CAAC,MAAM,GAAG,CAAC,KAAW,EAAE,EAAE,CAAC,wCAAc,CAAC,GAAG,CAAC,OAAO,EAAE,GAAG,EAAE,CAAC,QAAQ,CAAC,MAAO,CAAC,KAAK,CAAC,CAAC,CAAC;IAC/F,CAAC;IACD,IAAI,QAAQ,CAAC,KAAK,EAAE,CAAC;QACnB,OAAO,CAAC,KAAK,GAAG,CAAC,KAAW,EAAE,EAAE,CAAC,wCAAc,CAAC,GAAG,CAAC,OAAO,EAAE,GAAG,EAAE,CAAC,QAAQ,CAAC,KAAM,CAAC,KAAK,CAAC,CAAC,CAAC;IAC7F,CAAC;IACD,OAAO,OAAO,CAAC;AACjB,CAAC;AAGD,SAAS,eAAe,CAAC,KAAU;IACjC,OAAO,CAAC,CAAC,KAAK,IAAI,OAAO,KAAK,KAAK,QAAQ,IAAI,OAAO,KAAK,CAAC,MAAM,CAAC,aAAa,CAAC,KAAK,UAAU,CAAC;AACnG,CAAC;AAOD,SAAgB,gCAAgC,CAAC,OAAkB;IACjE,OAAO,KAAK,EAAE,IAAiB,EAAE,EAAE;QACjC,MAAM,OAAO,GAAG,MAAM,qBAAqB,CAAC,IAAI,EAAE,YAAY,CAAC,CAAC;QAChE,IAAI,CAAC,OAAO,EAAE,CAAC;YACb,OAAO,OAAO,CAAC,IAAI,CAAC,CAAC;QACvB,CAAC;QACD,OAAO,wCAAc,CAAC,GAAG,CAAC,OAAO,EAAE,GAAG,EAAE,CAAC,OAAO,CAAC,IAAI,CAAC,CAAC,CAAC;IAC1D,CAAC,CAAC;AACJ,CAAC;AAGD,SAAgB,kCAAkC,CAAC,SAAoB;IACrE,OAAO,KAAK,EAAE,IAAiB,EAAE,EAAE;QACjC,MAAM,OAAO,GAAG,MAAM,qBAAqB,CAAC,IAAI,EAAE,YAAY,CAAC,CAAC;QAChE,IAAI,CAAC,OAAO,EAAE,CAAC;YACb,OAAO,SAAS,CAAC,IAAI,CAAC,CAAC;QACzB,CAAC;QACD,MAAM,MAAM,GAAG,MAAM,wCAAc,CAAC,GAAG,CAAC,OAAO,EAAE,GAAG,EAAE,CAAC,SAAS,CAAC,IAAI,CAAC,CAAC,CAAC;QACxE,OAAO,eAAe,CAAC,MAAM,CAAC,CAAC,CAAC,CAAC,+BAA+B,CAAC,MAA4B,EAAE,OAAO,CAAC,CAAC,CAAC,CAAC,MAAM,CAAC;IACnH,CAAC,CAAC;AACJ,CAAC;AASD,SAAgB,+BAA+B;IAC7C,OAAO,gCAAgC,CAAC,OAAO,CAAC,OAAc,CAAC,CAAC;AAClE,CAAC;AAED,SAAgB,iCAAiC;IAC/C,OAAO,kCAAkC,CAAC,OAAO,CAAC,SAAgB,CAAC,CAAC;AACtE,CAAC"}
1
+ {"version":3,"file":"graphql-ws-context.helper.js","sourceRoot":"","sources":["../../../../src/core/common/helpers/graphql-ws-context.helper.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,14 @@
1
+ import { Agent, fetch as undiciFetch } from 'undici';
2
+ export declare class OutboundUrlBlockedError extends Error {
3
+ readonly host: string;
4
+ readonly address: string;
5
+ constructor(host: string, address: string);
6
+ }
7
+ export interface IOutboundUrlOptions {
8
+ allowedHosts?: string[];
9
+ }
10
+ export declare function privateOutboundTargetsAllowed(): boolean;
11
+ export declare function isBlockedOutboundAddress(address: string): boolean;
12
+ export declare function assertOutboundUrlAllowed(url: string, options?: IOutboundUrlOptions): Promise<URL>;
13
+ export declare function createOutboundDispatcher(options?: IOutboundUrlOptions): Agent;
14
+ export declare function outboundFetch(url: string, init?: Parameters<typeof undiciFetch>[1], options?: IOutboundUrlOptions): ReturnType<typeof undiciFetch>;
@@ -0,0 +1,234 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.OutboundUrlBlockedError = void 0;
4
+ exports.privateOutboundTargetsAllowed = privateOutboundTargetsAllowed;
5
+ exports.isBlockedOutboundAddress = isBlockedOutboundAddress;
6
+ exports.assertOutboundUrlAllowed = assertOutboundUrlAllowed;
7
+ exports.createOutboundDispatcher = createOutboundDispatcher;
8
+ exports.outboundFetch = outboundFetch;
9
+ const node_dns_1 = require("node:dns");
10
+ const node_net_1 = require("node:net");
11
+ const undici_1 = require("undici");
12
+ const config_service_1 = require("../services/config.service");
13
+ const blocked = new node_net_1.BlockList();
14
+ for (const [network, prefix] of [
15
+ ['0.0.0.0', 8],
16
+ ['10.0.0.0', 8],
17
+ ['100.64.0.0', 10],
18
+ ['127.0.0.0', 8],
19
+ ['169.254.0.0', 16],
20
+ ['172.16.0.0', 12],
21
+ ['192.0.0.0', 24],
22
+ ['192.168.0.0', 16],
23
+ ['198.18.0.0', 15],
24
+ ['224.0.0.0', 4],
25
+ ['240.0.0.0', 4],
26
+ ]) {
27
+ blocked.addSubnet(network, prefix, 'ipv4');
28
+ }
29
+ for (const [network, prefix] of [
30
+ ['::', 96],
31
+ ['64:ff9b::', 96],
32
+ ['fc00::', 7],
33
+ ['fe80::', 10],
34
+ ['ff00::', 8],
35
+ ]) {
36
+ blocked.addSubnet(network, prefix, 'ipv6');
37
+ }
38
+ const LOCAL_ENVIRONMENTS = new Set(['ci', 'e2e', 'local']);
39
+ class OutboundUrlBlockedError extends Error {
40
+ host;
41
+ address;
42
+ constructor(host, address) {
43
+ super(`Outbound target not allowed: "${host}" points to the internal address ${address}. ` +
44
+ 'Use a public address, list the host in allowedHosts, or set security.allowPrivateOutboundTargets.');
45
+ this.host = host;
46
+ this.address = address;
47
+ this.name = 'OutboundUrlBlockedError';
48
+ }
49
+ }
50
+ exports.OutboundUrlBlockedError = OutboundUrlBlockedError;
51
+ function privateOutboundTargetsAllowed() {
52
+ const config = config_service_1.ConfigService.configFastButReadOnly;
53
+ const flag = config?.security?.allowPrivateOutboundTargets;
54
+ if (flag === true || flag === 'true') {
55
+ return true;
56
+ }
57
+ if (flag === false || flag === 'false') {
58
+ return false;
59
+ }
60
+ const env = String(config?.env ?? process.env.NODE_ENV ?? '')
61
+ .trim()
62
+ .toLowerCase();
63
+ return LOCAL_ENVIRONMENTS.has(env);
64
+ }
65
+ function isBlockedOutboundAddress(address) {
66
+ const plain = stripBrackets(address);
67
+ const family = (0, node_net_1.isIP)(plain);
68
+ if (family === 4) {
69
+ return blocked.check(plain, 'ipv4');
70
+ }
71
+ if (family === 6) {
72
+ const mapped = mappedIpv4(plain);
73
+ return mapped ? blocked.check(mapped, 'ipv4') : blocked.check(plain, 'ipv6');
74
+ }
75
+ return false;
76
+ }
77
+ async function assertOutboundUrlAllowed(url, options = {}) {
78
+ let parsed;
79
+ try {
80
+ parsed = new URL(url);
81
+ }
82
+ catch {
83
+ throw new Error(`Invalid outbound URL: "${url}"`);
84
+ }
85
+ if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
86
+ throw new Error(`Outbound URL must use http:// or https://: "${url}"`);
87
+ }
88
+ const host = stripBrackets(parsed.hostname);
89
+ if (privateOutboundTargetsAllowed() || isAllowedHost(host, effectivePort(parsed.port, parsed.protocol), options)) {
90
+ return parsed;
91
+ }
92
+ const addresses = (0, node_net_1.isIP)(host) ? [host] : await resolveAll(host);
93
+ const internal = addresses.find(isBlockedOutboundAddress);
94
+ if (internal) {
95
+ throw new OutboundUrlBlockedError(host, internal);
96
+ }
97
+ return parsed;
98
+ }
99
+ const plainConnect = (0, undici_1.buildConnector)({});
100
+ const guardedConnect = (0, undici_1.buildConnector)({ lookup: guardedLookup });
101
+ function createOutboundDispatcher(options = {}) {
102
+ return new undici_1.Agent({
103
+ connect: (connectOptions, callback) => {
104
+ const host = stripBrackets(String(connectOptions.hostname ?? ''));
105
+ const port = effectivePort(connectOptions.port, connectOptions.protocol);
106
+ if (privateOutboundTargetsAllowed() || isAllowedHost(host, port, options)) {
107
+ plainConnect(connectOptions, callback);
108
+ return;
109
+ }
110
+ if ((0, node_net_1.isIP)(host) && isBlockedOutboundAddress(host)) {
111
+ callback(new OutboundUrlBlockedError(host, host), null);
112
+ return;
113
+ }
114
+ guardedConnect(connectOptions, callback);
115
+ },
116
+ });
117
+ }
118
+ let defaultDispatcher;
119
+ const allowListDispatchers = new Map();
120
+ const MAX_ALLOW_LIST_DISPATCHERS = 50;
121
+ async function outboundFetch(url, init = {}, options = {}) {
122
+ await assertOutboundUrlAllowed(url, options);
123
+ return (0, undici_1.fetch)(url, { ...init, dispatcher: dispatcherFor(options) });
124
+ }
125
+ function dispatcherFor(options) {
126
+ const hosts = (options.allowedHosts ?? []).map(normalizeHost).filter(Boolean).sort();
127
+ if (!hosts.length) {
128
+ defaultDispatcher ??= createOutboundDispatcher();
129
+ return defaultDispatcher;
130
+ }
131
+ const key = hosts.join(',');
132
+ let dispatcher = allowListDispatchers.get(key);
133
+ if (!dispatcher) {
134
+ if (allowListDispatchers.size >= MAX_ALLOW_LIST_DISPATCHERS) {
135
+ const [oldestKey, oldest] = allowListDispatchers.entries().next().value;
136
+ allowListDispatchers.delete(oldestKey);
137
+ void oldest.close().catch(() => undefined);
138
+ }
139
+ dispatcher = createOutboundDispatcher({ allowedHosts: hosts });
140
+ allowListDispatchers.set(key, dispatcher);
141
+ }
142
+ return dispatcher;
143
+ }
144
+ function guardedLookup(hostname, options, callback) {
145
+ (0, node_dns_1.lookup)(hostname, { ...options, all: true }, (err, addresses) => {
146
+ if (err) {
147
+ callback(err);
148
+ return;
149
+ }
150
+ const list = addresses;
151
+ const internal = list.find((entry) => isBlockedOutboundAddress(entry.address));
152
+ if (internal) {
153
+ callback(new OutboundUrlBlockedError(hostname, internal.address));
154
+ return;
155
+ }
156
+ if (options.all) {
157
+ callback(null, list);
158
+ return;
159
+ }
160
+ callback(null, list[0]?.address, list[0]?.family);
161
+ });
162
+ }
163
+ function resolveAll(host) {
164
+ return new Promise((resolve, reject) => {
165
+ (0, node_dns_1.lookup)(host, { all: true }, (err, addresses) => {
166
+ if (err) {
167
+ reject(new Error(`Outbound target cannot be resolved: "${host}" (${err.message})`));
168
+ return;
169
+ }
170
+ resolve(addresses.map((entry) => entry.address));
171
+ });
172
+ });
173
+ }
174
+ function isAllowedHost(host, port, options) {
175
+ const wanted = host.toLowerCase();
176
+ return (options.allowedHosts ?? []).some((entry) => {
177
+ const normalized = normalizeHost(entry);
178
+ if (!normalized) {
179
+ return false;
180
+ }
181
+ const [entryHost, entryPort] = splitHostPort(normalized);
182
+ return entryHost === wanted && (!entryPort || entryPort === port);
183
+ });
184
+ }
185
+ function normalizeHost(entry) {
186
+ if (typeof entry !== 'string' || !entry.trim()) {
187
+ return '';
188
+ }
189
+ try {
190
+ const url = new URL(`http://${entry.trim()}`);
191
+ const host = stripBrackets(url.hostname).toLowerCase();
192
+ return url.port ? `${host.includes(':') ? `[${host}]` : host}:${url.port}` : host;
193
+ }
194
+ catch {
195
+ return '';
196
+ }
197
+ }
198
+ function splitHostPort(normalized) {
199
+ const v6 = /^\[(.+)\]:(\d+)$/.exec(normalized);
200
+ if (v6) {
201
+ return [v6[1], v6[2]];
202
+ }
203
+ if ((0, node_net_1.isIP)(normalized) === 6) {
204
+ return [normalized, ''];
205
+ }
206
+ const index = normalized.lastIndexOf(':');
207
+ return index === -1 ? [normalized, ''] : [normalized.slice(0, index), normalized.slice(index + 1)];
208
+ }
209
+ function effectivePort(port, protocol) {
210
+ if (port !== undefined && port !== null && String(port) !== '') {
211
+ return String(port);
212
+ }
213
+ return protocol === 'https:' ? '443' : '80';
214
+ }
215
+ function stripBrackets(value) {
216
+ return value.replace(/^\[|\]$/g, '');
217
+ }
218
+ function mappedIpv4(address) {
219
+ let canonical;
220
+ try {
221
+ canonical = stripBrackets(new URL(`http://[${address}]`).hostname);
222
+ }
223
+ catch {
224
+ return undefined;
225
+ }
226
+ const match = /^::ffff:([0-9a-f]{1,4}):([0-9a-f]{1,4})$/i.exec(canonical);
227
+ if (!match) {
228
+ return undefined;
229
+ }
230
+ const high = parseInt(match[1], 16);
231
+ const low = parseInt(match[2], 16);
232
+ return `${high >> 8}.${high & 255}.${low >> 8}.${low & 255}`;
233
+ }
234
+ //# sourceMappingURL=outbound-url.helper.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"outbound-url.helper.js","sourceRoot":"","sources":["../../../../src/core/common/helpers/outbound-url.helper.ts"],"names":[],"mappings":"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"}
@@ -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';
@@ -156,6 +157,9 @@ export interface IMultiTenancy {
156
157
  strictMembershipRoles?: boolean;
157
158
  additionalMembershipRoles?: string[];
158
159
  cacheTtlMs?: number;
160
+ isTenantActive?: (tenantId: string, context: {
161
+ connection: Connection;
162
+ }) => boolean | Promise<boolean>;
159
163
  }
160
164
  export interface IApiTokens {
161
165
  enabled?: boolean;
@@ -354,6 +358,7 @@ export interface IServerOptions {
354
358
  redis?: boolean | IRedisConfig;
355
359
  s3?: IS3Config;
356
360
  security?: {
361
+ allowPrivateOutboundTargets?: boolean;
357
362
  checkResponseInterceptor?: boolean | {
358
363
  checkObjectItself?: boolean;
359
364
  debug?: boolean | number;
@@ -437,6 +442,7 @@ export interface ISystemSetupInitialAdmin {
437
442
  }
438
443
  export interface ITusConfig {
439
444
  allowedHeaders?: string[];
445
+ allowedOrigins?: string[];
440
446
  allowedTypes?: string[];
441
447
  checksum?: boolean;
442
448
  concatenation?: boolean;
@@ -30,6 +30,7 @@ let RequestContextMiddleware = class RequestContextMiddleware {
30
30
  get isAdminBypass() {
31
31
  return req.isAdminBypass ?? false;
32
32
  },
33
+ fromRequest: true,
33
34
  };
34
35
  request_context_service_1.RequestContext.run(context, () => next());
35
36
  }
@@ -1 +1 @@
1
- {"version":3,"file":"request-context.middleware.js","sourceRoot":"","sources":["../../../../src/core/common/middleware/request-context.middleware.ts"],"names":[],"mappings":";;;;;;;;;AAAA,2CAA4D;AAG5D,iFAAsF;AAS/E,IAAM,wBAAwB,GAA9B,MAAM,wBAAwB;IACnC,GAAG,CAAC,GAAY,EAAE,IAAc,EAAE,IAAkB;QAClD,MAAM,OAAO,GAAoB;YAC/B,IAAI,WAAW;gBACb,OAAQ,GAAW,CAAC,IAAI,IAAI,SAAS,CAAC;YACxC,CAAC;YACD,IAAI,QAAQ;gBACV,OAAO,GAAG,CAAC,OAAO,EAAE,CAAC,iBAAiB,CAAC,IAAI,SAAS,CAAC;YACvD,CAAC;YACD,IAAI,QAAQ;gBAGV,OAAQ,GAAW,CAAC,QAAQ,IAAI,SAAS,CAAC;YAC5C,CAAC;YACD,IAAI,SAAS;gBACX,OAAQ,GAAW,CAAC,SAAS,IAAI,SAAS,CAAC;YAC7C,CAAC;YACD,IAAI,UAAU;gBACZ,OAAQ,GAAW,CAAC,UAAU,IAAI,SAAS,CAAC;YAC9C,CAAC;YACD,IAAI,aAAa;gBACf,OAAQ,GAAW,CAAC,aAAa,IAAI,KAAK,CAAC;YAC7C,CAAC;SACF,CAAC;QACF,wCAAc,CAAC,GAAG,CAAC,OAAO,EAAE,GAAG,EAAE,CAAC,IAAI,EAAE,CAAC,CAAC;IAC5C,CAAC;CACF,CAAA;AA1BY,4DAAwB;mCAAxB,wBAAwB;IADpC,IAAA,mBAAU,GAAE;GACA,wBAAwB,CA0BpC"}
1
+ {"version":3,"file":"request-context.middleware.js","sourceRoot":"","sources":["../../../../src/core/common/middleware/request-context.middleware.ts"],"names":[],"mappings":";;;;;;;;;AAAA,2CAA4D;AAG5D,iFAAsF;AAS/E,IAAM,wBAAwB,GAA9B,MAAM,wBAAwB;IACnC,GAAG,CAAC,GAAY,EAAE,IAAc,EAAE,IAAkB;QAClD,MAAM,OAAO,GAAoB;YAC/B,IAAI,WAAW;gBACb,OAAQ,GAAW,CAAC,IAAI,IAAI,SAAS,CAAC;YACxC,CAAC;YACD,IAAI,QAAQ;gBACV,OAAO,GAAG,CAAC,OAAO,EAAE,CAAC,iBAAiB,CAAC,IAAI,SAAS,CAAC;YACvD,CAAC;YACD,IAAI,QAAQ;gBAGV,OAAQ,GAAW,CAAC,QAAQ,IAAI,SAAS,CAAC;YAC5C,CAAC;YACD,IAAI,SAAS;gBACX,OAAQ,GAAW,CAAC,SAAS,IAAI,SAAS,CAAC;YAC7C,CAAC;YACD,IAAI,UAAU;gBACZ,OAAQ,GAAW,CAAC,UAAU,IAAI,SAAS,CAAC;YAC9C,CAAC;YACD,IAAI,aAAa;gBACf,OAAQ,GAAW,CAAC,aAAa,IAAI,KAAK,CAAC;YAC7C,CAAC;YACD,WAAW,EAAE,IAAI;SAClB,CAAC;QACF,wCAAc,CAAC,GAAG,CAAC,OAAO,EAAE,GAAG,EAAE,CAAC,IAAI,EAAE,CAAC,CAAC;IAC5C,CAAC;CACF,CAAA;AA3BY,4DAAwB;mCAAxB,wBAAwB;IADpC,IAAA,mBAAU,GAAE;GACA,wBAAwB,CA2BpC"}
@@ -11,6 +11,7 @@ export interface IRequestContext {
11
11
  tenantIds?: string[];
12
12
  tenantRole?: string;
13
13
  isAdminBypass?: boolean;
14
+ fromRequest?: boolean;
14
15
  processDepth?: number;
15
16
  }
16
17
  export declare class RequestContext {
@@ -1 +1 @@
1
- {"version":3,"file":"request-context.service.js","sourceRoot":"","sources":["../../../../src/core/common/services/request-context.service.ts"],"names":[],"mappings":";;;AAAA,6CAAgD;AAmChD,MAAa,cAAc;IACjB,MAAM,CAAC,OAAO,GAAG,IAAI,+BAAiB,EAAmB,CAAC;IAElE,MAAM,CAAC,GAAG,CAAI,OAAwB,EAAE,EAAW;QACjD,OAAO,IAAI,CAAC,OAAO,CAAC,GAAG,CAAC,OAAO,EAAE,EAAE,CAAC,CAAC;IACvC,CAAC;IAED,MAAM,CAAC,GAAG;QACR,OAAO,IAAI,CAAC,OAAO,CAAC,QAAQ,EAAE,CAAC;IACjC,CAAC;IAED,MAAM,CAAC,cAAc;QACnB,OAAO,IAAI,CAAC,OAAO,CAAC,QAAQ,EAAE,EAAE,WAAW,CAAC;IAC9C,CAAC;IAED,MAAM,CAAC,WAAW;QAChB,OAAO,IAAI,CAAC,OAAO,CAAC,QAAQ,EAAE,EAAE,QAAQ,CAAC;IAC3C,CAAC;IAKD,MAAM,CAAC,iBAAiB;QACtB,OAAO,IAAI,CAAC,OAAO,CAAC,QAAQ,EAAE,EAAE,eAAe,KAAK,IAAI,CAAC;IAC3D,CAAC;IAkBD,MAAM,CAAC,sBAAsB,CAAI,EAAW;QAC1C,MAAM,YAAY,GAAG,IAAI,CAAC,OAAO,CAAC,QAAQ,EAAE,CAAC;QAE7C,IAAI,YAAY,EAAE,eAAe,EAAE,CAAC;YAClC,OAAO,EAAE,EAAE,CAAC;QACd,CAAC;QACD,MAAM,OAAO,GAAoB;YAC/B,GAAG,YAAY;YACf,eAAe,EAAE,IAAI;SACtB,CAAC;QACF,OAAO,IAAI,CAAC,OAAO,CAAC,GAAG,CAAC,OAAO,EAAE,EAAE,CAAC,CAAC;IACvC,CAAC;IAMD,MAAM,CAAC,eAAe;QACpB,OAAO,IAAI,CAAC,OAAO,CAAC,QAAQ,EAAE,EAAE,YAAY,IAAI,CAAC,CAAC;IACpD,CAAC;IAwBD,MAAM,CAAC,8BAA8B,CAAI,EAAW;QAClD,MAAM,YAAY,GAAG,IAAI,CAAC,OAAO,CAAC,QAAQ,EAAE,CAAC;QAC7C,MAAM,YAAY,GAAG,YAAY,EAAE,YAAY,IAAI,CAAC,CAAC;QACrD,MAAM,OAAO,GAAoB;YAC/B,GAAG,YAAY;YACf,YAAY,EAAE,YAAY,GAAG,CAAC;SAC/B,CAAC;QACF,OAAO,IAAI,CAAC,OAAO,CAAC,GAAG,CAAC,OAAO,EAAE,EAAE,CAAC,CAAC;IACvC,CAAC;IAED,MAAM,CAAC,WAAW;QAChB,OAAO,IAAI,CAAC,OAAO,CAAC,QAAQ,EAAE,EAAE,QAAQ,CAAC;IAC3C,CAAC;IAED,MAAM,CAAC,mBAAmB;QACxB,OAAO,IAAI,CAAC,OAAO,CAAC,QAAQ,EAAE,EAAE,iBAAiB,KAAK,IAAI,CAAC;IAC7D,CAAC;IAkBD,MAAM,CAAC,wBAAwB,CAAI,EAAW;QAC5C,MAAM,YAAY,GAAG,IAAI,CAAC,OAAO,CAAC,QAAQ,EAAE,CAAC;QAE7C,IAAI,YAAY,EAAE,iBAAiB,EAAE,CAAC;YACpC,OAAO,EAAE,EAAE,CAAC;QACd,CAAC;QACD,MAAM,OAAO,GAAoB;YAC/B,GAAG,YAAY;YACf,iBAAiB,EAAE,IAAI;SACxB,CAAC;QACF,OAAO,IAAI,CAAC,OAAO,CAAC,GAAG,CAAC,OAAO,EAAE,EAAE,CAAC,CAAC;IACvC,CAAC;;AAlIH,wCAmIC"}
1
+ {"version":3,"file":"request-context.service.js","sourceRoot":"","sources":["../../../../src/core/common/services/request-context.service.ts"],"names":[],"mappings":";;;AAAA,6CAAgD;AA4ChD,MAAa,cAAc;IACjB,MAAM,CAAC,OAAO,GAAG,IAAI,+BAAiB,EAAmB,CAAC;IAElE,MAAM,CAAC,GAAG,CAAI,OAAwB,EAAE,EAAW;QACjD,OAAO,IAAI,CAAC,OAAO,CAAC,GAAG,CAAC,OAAO,EAAE,EAAE,CAAC,CAAC;IACvC,CAAC;IAED,MAAM,CAAC,GAAG;QACR,OAAO,IAAI,CAAC,OAAO,CAAC,QAAQ,EAAE,CAAC;IACjC,CAAC;IAED,MAAM,CAAC,cAAc;QACnB,OAAO,IAAI,CAAC,OAAO,CAAC,QAAQ,EAAE,EAAE,WAAW,CAAC;IAC9C,CAAC;IAED,MAAM,CAAC,WAAW;QAChB,OAAO,IAAI,CAAC,OAAO,CAAC,QAAQ,EAAE,EAAE,QAAQ,CAAC;IAC3C,CAAC;IAKD,MAAM,CAAC,iBAAiB;QACtB,OAAO,IAAI,CAAC,OAAO,CAAC,QAAQ,EAAE,EAAE,eAAe,KAAK,IAAI,CAAC;IAC3D,CAAC;IAkBD,MAAM,CAAC,sBAAsB,CAAI,EAAW;QAC1C,MAAM,YAAY,GAAG,IAAI,CAAC,OAAO,CAAC,QAAQ,EAAE,CAAC;QAE7C,IAAI,YAAY,EAAE,eAAe,EAAE,CAAC;YAClC,OAAO,EAAE,EAAE,CAAC;QACd,CAAC;QACD,MAAM,OAAO,GAAoB;YAC/B,GAAG,YAAY;YACf,eAAe,EAAE,IAAI;SACtB,CAAC;QACF,OAAO,IAAI,CAAC,OAAO,CAAC,GAAG,CAAC,OAAO,EAAE,EAAE,CAAC,CAAC;IACvC,CAAC;IAMD,MAAM,CAAC,eAAe;QACpB,OAAO,IAAI,CAAC,OAAO,CAAC,QAAQ,EAAE,EAAE,YAAY,IAAI,CAAC,CAAC;IACpD,CAAC;IAwBD,MAAM,CAAC,8BAA8B,CAAI,EAAW;QAClD,MAAM,YAAY,GAAG,IAAI,CAAC,OAAO,CAAC,QAAQ,EAAE,CAAC;QAC7C,MAAM,YAAY,GAAG,YAAY,EAAE,YAAY,IAAI,CAAC,CAAC;QACrD,MAAM,OAAO,GAAoB;YAC/B,GAAG,YAAY;YACf,YAAY,EAAE,YAAY,GAAG,CAAC;SAC/B,CAAC;QACF,OAAO,IAAI,CAAC,OAAO,CAAC,GAAG,CAAC,OAAO,EAAE,EAAE,CAAC,CAAC;IACvC,CAAC;IAED,MAAM,CAAC,WAAW;QAChB,OAAO,IAAI,CAAC,OAAO,CAAC,QAAQ,EAAE,EAAE,QAAQ,CAAC;IAC3C,CAAC;IAED,MAAM,CAAC,mBAAmB;QACxB,OAAO,IAAI,CAAC,OAAO,CAAC,QAAQ,EAAE,EAAE,iBAAiB,KAAK,IAAI,CAAC;IAC7D,CAAC;IAkBD,MAAM,CAAC,wBAAwB,CAAI,EAAW;QAC5C,MAAM,YAAY,GAAG,IAAI,CAAC,OAAO,CAAC,QAAQ,EAAE,CAAC;QAE7C,IAAI,YAAY,EAAE,iBAAiB,EAAE,CAAC;YACpC,OAAO,EAAE,EAAE,CAAC;QACd,CAAC;QACD,MAAM,OAAO,GAAoB;YAC/B,GAAG,YAAY;YACf,iBAAiB,EAAE,IAAI;SACxB,CAAC;QACF,OAAO,IAAI,CAAC,OAAO,CAAC,GAAG,CAAC,OAAO,EAAE,EAAE,CAAC,CAAC;IACvC,CAAC;;AAlIH,wCAmIC"}