Request Lifecycle & Security Architecture

July 22, 2026 · View on GitHub

This document explains the complete lifecycle of a request through @lenne.tech/nest-server, covering both REST and GraphQL flows, all security mechanisms, and the interaction between CrudService and the Safety Net.

Audience: Developers and AI agents building on nest-server who want to understand what features are available, how data flows, where security is enforced, and how to use or extend the framework correctly.


Table of Contents


Features Overview

@lenne.tech/nest-server extends NestJS with a complete application framework for GraphQL + REST APIs with MongoDB. The following sections list all features available out of the box.

Core Module

The CoreModule is a dynamic module that bootstraps the entire framework:

FeatureDescription
GraphQL IntegrationApollo Server with auto-schema generation (disable via graphQl: false)
MongoDB IntegrationMongoose ODM with automatic connection management
Dual API SupportGraphQL and REST in the same application
Security Pipeline4 global interceptors, global validation pipe, middleware stack
Mongoose PluginsAuto-registration of ID, password, audit, and role guard plugins
GraphQL SubscriptionsWebSocket support with JWT/session authentication
Configuration Systemconfig.env.ts with ENV variables, NEST_SERVER_CONFIG JSON, NSC__* prefixes
Cookie HandlingEnabled by default (cookies: true), configurable via ICookiesConfig with exposeTokenInBody option
Unified CORSSingle cors config propagates to GraphQL, REST, and BetterAuth layers
Dual Auth ModesIAM-Only (BetterAuth) or Legacy+IAM for migration periods

Authentication & Authorization

Modern OAuth-compatible authentication with plugin architecture:

FeatureDescription
Session ManagementSecure session-based auth with automatic token rotation
JWT TokensStateless API authentication (plugin)
2FA / TOTPTwo-factor authentication (plugin)
Passkey / WebAuthnPasswordless authentication (plugin)
Social LoginOAuth providers: Google, GitHub, Apple, Discord, etc. (plugin)
Email VerificationConfigurable email verification flow
Sign-Up ValidationCustom validation hooks for registration
Rate LimitingPer-endpoint rate limits (configurable)
Cross-Subdomain CookiesAutomatic cookie domain configuration
Organization / Multi-TenantTeams and organization management (plugin)
3 Registration PatternsZero-config, overrides parameter, or manual (autoRegister: false)

Legacy Auth Module (backward compatible)

JWT-based authentication for existing projects:

FeatureDescription
JWT AuthenticationBearer token auth with Passport strategies
Refresh TokensAutomatic token renewal
Sign In / Sign Up / LogoutGraphQL mutations + REST endpoints
Rate LimitingConfigurable per-endpoint rate limits
Legacy Endpoint ControlsDisable legacy endpoints after migration (auth.legacyEndpoints)
Migration TrackingbetterAuthMigrationStatus query for monitoring

Role System

FeatureDescription
Real RolesADMIN (stored in user.roles)
System RolesS_USER, S_VERIFIED, S_CREATOR, S_SELF, S_EVERYONE, S_NO_ONE (runtime-only, never stored)
Hierarchy RolesConfigurable via multiTenancy.roleHierarchy (default: member, manager, owner). Level comparison: higher includes lower. Use DefaultHR or createHierarchyRoles().
Method-Level Auth@Roles() decorator on resolvers/controllers
Field-Level Auth@Restricted() decorator on model properties
Membership Checks@Restricted({ memberOf: 'teamMembers' })
Input/Output Restriction@Restricted({ processType: ProcessType.INPUT })

Security Features

FeatureDescription
Input WhitelistingMapAndValidatePipe strips/rejects unknown properties
Input Validationclass-validator integration via @UnifiedField()
Password Hashing PluginAutomatic BCrypt hashing on all Mongoose write operations
Role Guard PluginPrevents unauthorized role escalation at database level
Audit Fields PluginAutomatic createdBy/updatedBy tracking
Response Model InterceptorAuto-converts plain objects to CoreModel instances
Security Check InterceptorCalls securityCheck() + removes secret fields
Response Filter InterceptorEnforces @Restricted() field-level access
Translation InterceptorApplies _translations based on Accept-Language
Secret Fields RemovalConfigurable fallback removal of password, tokens, etc.
RequestContextAsyncLocalStorage-based context for current user in Mongoose hooks
Query ComplexityGraphQL query complexity analysis to prevent DoS
Tenant IsolationHeader-based multi-tenant isolation with membership validation (opt-in)
Tenant GuardCoreTenantGuard validates tenant membership; system roles (S_EVERYONE, S_USER, S_VERIFIED) are checked as OR alternatives before real roles; hierarchy roles (@Roles(DefaultHR.MEMBER)), @SkipTenantCheck(), BetterAuth auto-skip (betterAuth.skipTenantCheck)
Tenant Plugin Safety NetMongoose tenant plugin throws ForbiddenException when tenant-schema is accessed without valid tenant context

Data & CRUD

FeatureDescription
CrudServiceAbstract CRUD with process() pipeline (input/output security)
FilteringFilterArgs with comparison operators (eq, ne, gt, in, contains, etc.)
PaginationPaginationArgs with limit/offset, returns PaginationInfo
SortingSortInput with ASC/DESC
Population@GraphQLPopulate() for automatic relation loading
Field SelectionGraphQL field selection drives Mongoose population
AggregationPipeline support via CrudService
Bulk OperationsBatch create/update/delete
Force Modeforce: true bypasses all security checks
Raw Moderaw: true skips prepareInput/prepareOutput

Models & Inputs

FeatureDescription
CoreModelBase class with map(), securityCheck(), hasRole()
CorePersistenceModelAdds id, createdAt, updatedAt, createdBy, updatedBy
CoreInputBase input type for validation
@UnifiedField()Combines @Field(), @ApiProperty(), @IsOptional() in one decorator
Nested ValidationRecursive object/array validation via nestedTypeRegistry
Exclude/Include@UnifiedField({ exclude: true/false }) for inheritance control

Custom Decorators

DecoratorPurpose
@Roles(...roles)Method-level authorization (includes JWT auth)
@Restricted(...roles)Field-level access control
@CurrentUser()Inject authenticated user (REST + GraphQL)
@UnifiedField(options)Combined schema, validation, and API metadata
@GraphQLPopulate(config)Mongoose populate configuration
@GraphQLServiceOptions()Service options injection (GraphQL)
@RestServiceOptions()Service options injection (REST)
@ResponseModel(Model)REST response type hint for auto-conversion
@Translatable()Multi-language field metadata
@CommonError(code)Error code registration
@SkipTenantCheck()Opt out of CoreTenantGuard validation on a method

File Handling

FeatureDescription
File ModuleUpload/download with MongoDB GridFS storage
REST EndpointsGET /files/:id, POST /files/upload, DELETE /files/:id
GraphQL EndpointsuploadFile, file, fileByFilename, deleteFile
TUS ModuleResumable uploads via tus.io protocol (creation, termination, expiration)
GridFS MigrationCompleted TUS uploads auto-migrate to GridFS
CORS SupportAutomatic CORS headers for browser uploads

AI Assistant Module

FeatureDescription
AI ModulePrompt orchestrator (auto + plan mode), DB-backed LLM connections, tool registry
REST EndpointsPOST /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
GraphQL EndpointsaiPrompt, aiAvailableConnections, aiSetUserConnection, connection/preference/conversation/interaction/budget queries + mutations, aiUsage
MCP ServerStreamable HTTP at POST/GET/DELETE /ai/mcp (Bearer auth); optional OAuth 2.1 router mounted in main.ts via mountAiMcpOAuth(app) (/.well-known/*, /authorize, /token, /register)
Permission ModelS_USER for prompt/conversations/available/usage; ADMIN for connections/preferences/interactions/budgets; encrypted API keys never leave the server (securityCheck + secretFields)

Email & Templates

FeatureDescription
EmailServiceMulti-provider email sending
Mailjet / BrevoAPI-based email providers
SMTPStandard SMTP email sending
TemplateServiceEJS template rendering for emails
Template InheritanceProject templates override nest-server fallbacks
Multi-LanguageLocale-aware template resolution (template-de.ejstemplate.ejs)

Database & Migration

FeatureDescription
Mongoose PluginsID handling, password hashing, audit fields, role guard
Migration ModuleMongoDB migration state management with cluster locking
Synchronized MigrationssynchronizedMigration() with distributed locks
Migration CLITypeScript-based migration scripts with getDb() helper
GridFS HelperDirect GridFS file access and migration utilities

GraphQL Features

FeatureDescription
Apollo ServerFull GraphQL server with schema-first or code-first
Custom ScalarsDate, DateTime (timestamp), JSON, Any
SubscriptionsWebSocket support via graphql-ws with auth
Complexity AnalysisQuery cost calculation to prevent DoS attacks
Enum RegistrationregisterEnum() helper for GraphQL enum types
Upload SupportgraphqlUploadExpress() for multipart file uploads

Development & Operations

FeatureDescription
Health Check ModuleGET /health + GraphQL healthCheck query
Error Code ModuleCentralized error registry with unique IDs
Permissions ReportInteractive HTML dashboard, JSON, and Markdown reports
Hub (Operator Cockpit)Build-free ADMIN-gated dashboard at /hub (config-gated per environment). Adds an optional HTTP trace middleware (registered by CoreHubModule.configure() only when traces are enabled), a chaining Logger.overrideLogger() delegate for the log buffer, an optional EmailService capture hook (HUB_EMAIL_CAPTURE token) for the mailbox, and — when the query profiler is enabled — opts the MongoDB driver into monitorCommands from core.module.ts. See src/core/modules/hub/README.md.
Process DiagnosticsOpt-in process-level exit diagnostics (installProcessDiagnostics() + handleFatalBootstrapError, src/core/common/helpers/process-diagnostics.helper.ts). Wired into main.tsNOT into CoreModule.forRoot(), because it must run before NestFactory.create() and installs a process.exit(1) path that must never arm inside Test.createTestingModule(). Logs unhandled rejections without crashing (configurable), uncaught exceptions before the exit, non-zero exit codes, and labels SIGTERM/SIGINT/SIGHUP/SIGQUIT as external terminations. Pair with server.enableShutdownHooks() — see the helper docblock
System Setup ModuleInitial admin creation for fresh deployments
Cron JobsCoreCronJobsService with timezone/UTC offset support
Model DocumentationAuto-generated model docs via ModelDocService
SCIM SupportSCIM filtering and query parsing utilities

Testing Utilities

FeatureDescription
TestHelperAPI testing helper for GraphQL and REST
Cookie SupportSession and JWT token testing
Dynamic PortshttpServer.listen(0) for parallel test execution
Database CleanupTest data management in afterAll hooks

Configuration Patterns

PatternUse CaseExample
Presence Implies EnabledObject config = enabledrateLimit: {} enables with defaults
Boolean ShorthandSimple togglejwt: true or jwt: { expiresIn: '1h' }
Explicit DisablePre-configured but off{ enabled: false, max: 10 }
Backward CompatibleUndefined = disabledNo config = feature off

Key TypeScript Utilities

TypePurpose
IServerOptionsComplete framework configuration interface
IServiceOptionsService method options (force, raw, currentUser)
PlainObject / PlainInputType-safe plain object types
ID / IDsMongoDB ObjectId or string types
MaybePromise<T>Sync or async return type
RequireOnlyOne<T>Require exactly one property

Architecture Overview

nest-server implements defense-in-depth security with three complementary layers:

+===================================================================+
|                          HTTP Request                              |
+===================================================================+
|                                                                   |
|  Layer 1: Guardian Gates (Middleware -> Guards -> Pipes)           |
|  +------------------+  +-----------+  +--------+  +------------+  |
|  | RequestContext   |  | Roles     |  | Tenant |  | MapAndValid|  |
|  | BetterAuth       |->| Guard     |->| Guard  |->| atePipe    |  |
|  | Middleware        |  |           |  |        |  |            |  |
|  +------------------+  +-----------+  +--------+  +------------+  |
|                                                                   |
|  Layer 2: Application Logic (Controllers/Resolvers -> Services)   |
|  +----------------+  +---------------------------------------+   |
|  | Controller /   |  | CrudService.process()                 |   |
|  | Resolver       |->| prepareInput -> serviceFunc ->         |   |
|  |                |  | processFieldSelection -> prepareOutput |   |
|  +----------------+  +---------------------------------------+   |
|                                                                   |
|  Layer 3: Safety Net (Mongoose Plugins + Response Interceptors)   |
|  +--------------------------+  +------------------------------+   |
|  | Mongoose Plugins         |  | Response Interceptors        |   |
|  |  - Password Hashing      |  |  - ResponseModelInterceptor  |   |
|  |  - Role Guard            |  |  - TranslateResponse         |   |
|  |  - Audit Fields          |  |  - CheckSecurity (secrets)   |   |
|  |  - Tenant Isolation      |  |  - CheckResponse (@Restrict) |   |
|  |    (Safety Net: 403)     |  |                              |   |
|  +--------------------------+  +------------------------------+   |
|                                                                   |
+===================================================================+
|                          HTTP Response                            |
+===================================================================+

Key principle: Layer 2 (CrudService) provides the primary security pipeline. Layer 3 (Safety Net) catches anything that bypasses Layer 2, ensuring security even when developers use direct Mongoose queries.


Request Flow Diagram

The following diagram shows the exact order of execution from HTTP request to response:

                    +---------------------+
                    |    HTTP Request      |
                    |    (REST or GQL)     |
                    +----------+----------+
                               |
  +----------------------------v----------------------------+
  |              EXPRESS-LEVEL MIDDLEWARE                    |
  |              (registered in main.ts)                    |
  |                                                         |
  |  0a. cookie-parser  [if cookies enabled, default: yes]  |
  |      - Parses Cookie header into req.cookies            |
  |      - Required for session cookie authentication       |
  |                                                         |
  |  0b. compression  [if configured]                       |
  |      - gzip response compression                        |
  |                                                         |
  |  0c. CORS  [if not disabled]                            |
  |      - credentials: true when cookies enabled           |
  |      - Origins from appUrl/baseUrl/cors.allowedOrigins  |
  |      - Propagated to BetterAuth trustedOrigins          |
  +----------------------------+----------------------------+
                               |
  +----------------------------v----------------------------+
  |              NESTJS MIDDLEWARE CHAIN                     |
  |              (registered in CoreModule.configure())     |
  |                                                         |
  |  1. RequestContextMiddleware                            |
  |     - AsyncLocalStorage context                         |
  |     - Lazy currentUser getter (from req.user)           |
  |     - Accept-Language for translations                  |
  |                                                         |
  |  2. CoreBetterAuthMiddleware                            |
  |     - Strategy 1: Auth header (JWT/Session)             |
  |     - Strategy 2: JWT cookie                            |
  |     - Strategy 3: Session cookie                        |
  |     - Sets req.user                                     |
  |                                                         |
  |  3. graphqlUploadExpress()  [GraphQL only]              |
  |     - Handles multipart file uploads                    |
  +----------------------------+----------------------------+
                               |
  +----------------------------v----------------------------+
  |                      GUARDS                             |
  |                                                         |
  |  4. RolesGuard / BetterAuthRolesGuard                   |
  |     - Reads @Roles() metadata                           |
  |     - Validates JWT / session token                     |
  |     - Checks real roles (ADMIN)                         |
  |     - Evaluates system roles (S_USER, ...)              |
  |     - Throws 401 (Unauthorized) or 403 (Forbidden)     |
  |                                                         |
  |  4b. CoreTenantGuard  [if multiTenancy enabled]          |
  |     - Reads X-Tenant-Id header                          |
  |     - Validates membership via hierarchy roles (level comparison)  |
  |     - Non-admin + header + no membership = always 403   |
  |     - Checks configurable roleHierarchy levels          |
  |     - Admin bypass: sets isAdminBypass (sees all data)  |
  |     - Sets tenantId in RequestContext                    |
  |     - @SkipTenantCheck() opts out of tenant validation  |
  |     - BetterAuth auto-skip: IAM handlers skip tenant    |
  |       validation when no X-Tenant-Id header is present  |
  |       (betterAuth.skipTenantCheck, default: true)       |
  |     - Throws 403 (Forbidden) on failure                |
  +----------------------------+----------------------------+
                               |
  +----------------------------v----------------------------+
  |                       PIPES                             |
  |                                                         |
  |  5. MapAndValidatePipe                                  |
  |     - Transform plain object -> class instance          |
  |     - Whitelist: strip/reject unknown fields            |
  |     - Validate via class-validator decorators           |
  |     - Inheritance-aware (child overrides)               |
  +----------------------------+----------------------------+
                               |
  +----------------------------v----------------------------+
  |                 HANDLER EXECUTION                       |
  |                                                         |
  |  6. Controller method / Resolver method                 |
  |     - @CurrentUser() injects authenticated user         |
  |     - Calls service methods                             |
  |     - Service uses CrudService.process()                |
  |       OR direct Mongoose queries                        |
  |                                                         |
  |     +-----------------------------------------------+   |
  |     |  Mongoose Plugins (fire on DB operations)     |   |
  |     |   - mongoosePasswordPlugin (hash password)    |   |
  |     |   - mongooseRoleGuardPlugin (block roles)     |   |
  |     |   - mongooseAuditFieldsPlugin (set by/at)     |   |
  |     |   - mongooseTenantPlugin (tenant isolation)   |   |
  |     +-----------------------------------------------+   |
  +----------------------------+----------------------------+
                               |
                               |  <-- Response data flows back
                               |
  +----------------------------v----------------------------+
  |              RESPONSE INTERCEPTORS                      |
  |        (NestJS runs in REVERSE registration order)      |
  |                                                         |
  |  7. ResponseModelInterceptor              [runs 1st]    |
  |     - Plain object -> CoreModel instance                |
  |     - Enables securityCheck() on output                 |
  |     - Resolves model via @Query/@Mutation type,         |
  |       @ResponseModel(), or @ApiOkResponse()             |
  |                                                         |
  |  8. TranslateResponseInterceptor          [runs 2nd]    |
  |     - Applies _translations for Accept-Language         |
  |     - Skips when no _translations present               |
  |                                                         |
  |  9. CheckSecurityInterceptor              [runs 3rd]    |
  |     - Calls securityCheck(user) on models               |
  |     - Fallback: removes secret fields                   |
  |       (password, tokens, etc.)                          |
  |                                                         |
  | 10. CheckResponseInterceptor              [runs 4th]    |
  |     - Filters @Restricted() fields                      |
  |     - Role-based: removes fields user can't see         |
  |     - Membership-based: checks memberOf                 |
  +----------------------------+----------------------------+
                               |
                    +----------v----------+
                    |   HTTP Response      |
                    |   (filtered &        |
                    |    secured)          |
                    +---------------------+

Phase 1: Incoming Request

Express-Level Middleware (main.ts)

These run before NestJS processes the request. They are registered in the application bootstrap (main.ts), not in CoreModule:

Parses the Cookie header into req.cookies. Loaded when cookies are enabled (default: true since v11.25.0).

// main.ts
if (isCookiesEnabled(envConfig.cookies)) {
  server.use(cookieParser());
}

Without cookie-parser, session cookie authentication in CoreBetterAuthMiddleware falls back to manual header parsing. With it, req.cookies is a parsed object — more reliable and required for signed cookie verification.

0b. CORS

CORS headers are configured based on the unified cors config (since v11.25.0). The same origin list is propagated to GraphQL (Apollo), REST (Express), and BetterAuth (trustedOrigins).

// main.ts — uses helpers from cookies.helper.ts
if (!isCorsDisabled(envConfig.cors)) {
  server.enableCors({ credentials: cookiesEnabled, origin: resolvedOrigins });
}
ConfigREST (Express)GraphQL (Apollo)BetterAuth
cors: { allowAll: true }origin: trueorigin: truetrustedOrigins: [appUrl] (+ passkey origins)
cors: { allowedOrigins: [...] }origin: [merged list]origin: [merged list]trustedOrigins: [merged list]
cors: { enabled: false }No CORS headersNo CORS headerstrustedOrigins: []
cors: { deriveAppUrl: false }appUrl not derived from baseUrlsamesame

BetterAuth has no "allow all origins" mode (since v11.27.6). cors.allowAll mirrors the request origin for REST/GraphQL, but BetterAuth's origin check is a security control with no meaningful "allow everything" setting. So allowAll yields the known-good origins (appUrl + any passkey origins), NOT undefined — returning nothing there would leave BetterAuth trusting only its own baseURL and silently answer 403 INVALID_ORIGIN for a separately hosted frontend on two-factor/enable, passkey registration, etc. For the same reason trustedOrigins: [] (the enabled: false row) does not switch the origin check off: BetterAuth always trusts its own baseURL, so [] and undefined behave identically. To accept arbitrary origins for auth, set betterAuth.trustedOrigins explicitly.

URL resolution (since v11.27.5): all three layers resolve appUrl/baseUrl through the single resolveServerUrls() helper in cookies.helper.ts, so they can no longer drift:

  1. appUrl set explicitly → used as-is
  2. env: 'local' | 'ci' | 'e2e' with a localhost baseUrl that splits API and app by host → derived from baseUrl (see below)
  3. env: 'local' | 'ci' | 'e2e' with any other localhost baseUrlappUrl defaults to http://localhost:3001
  4. otherwise derived from baseUrl by stripping a leading api. label (https://api.example.comhttps://example.com), unless cors.deriveAppUrl: false

Port split vs. host split (step 2 vs. 3, since v11.27.6). The localhost defaults encode a port split: one host, API on :3000, app on :3001. lt dev up instead serves a host split behind Caddy — API on https://api.<slug>.localhost, app on https://<slug>.localhost. The two are told apart by what the api. label strips to, never by the port:

baseUrl (env: 'local')SplitResolved appUrl
https://api.crm.localhosthosthttps://crm.localhost
https://api.crm.localhost:8443hosthttps://crm.localhost:8443
https://api.localhostport (strips to the bare host the API answers on)http://localhost:3001
http://api.localhost:3000porthttp://localhost:3001
http://localhost:3000port (no api. label)http://localhost:3001

Security: steps 2 and 4 grant the derived origin credentialed CORS. If the apex domain is not trusted (e.g. a third-party-hosted marketing site whose XSS surface you do not control), set cors.deriveAppUrl: false and list the frontend origin explicitly via appUrl or cors.allowedOrigins. The derivation never yields a bare TLD (https://api.dev stays unchanged) and never emits the opaque null origin. With cors.deriveAppUrl: false, a host-split localhost baseUrl falls back to the http://localhost:3001 default.

NestJS Middleware Chain (CoreModule)

NestJS middleware runs for every request after Express-level middleware. Registration happens in CoreModule.configure():

// src/core.module.ts
configure(consumer: MiddlewareConsumer) {
  consumer.apply(RequestContextMiddleware).forRoutes('*');
  consumer.apply(graphqlUploadExpress()).forRoutes('graphql');
}

1. RequestContextMiddleware

Wraps the entire request in an AsyncLocalStorage context, making the current user and language available anywhere — including Mongoose hooks — without dependency injection.

// src/core/common/middleware/request-context.middleware.ts
use(req: Request, _res: Response, next: NextFunction) {
  const context: IRequestContext = {
    get currentUser() {
      return (req as any).user || undefined;   // Lazy: evaluated when accessed
    },
    get language() {
      return req.headers?.['accept-language'] || undefined;
    },
  };
  RequestContext.run(context, () => next());
}

Key design: The currentUser getter is lazy. At middleware time, req.user is not yet set (auth middleware hasn't run). By using a getter, the value is resolved at access time, after authentication.

2. CoreBetterAuthMiddleware

Authenticates the request using three strategies in priority order:

PriorityStrategySourceToken Type
1Authorization headerBearer <token>JWT or Session token
2JWT cookiebetter-auth.jwt_tokenJWT token
3Session cookiebetter-auth.session_tokenSession token

If authentication succeeds, req.user is set with the authenticated user (including hasRole() method).

Native /iam/* routes bypass the @Roles()/checkRoles layer. Better-Auth's own endpoints (e.g. POST /iam/update-user, POST /iam/sign-up/email) that are not in CONTROLLER_HANDLED_PATHS are forwarded raw by CoreBetterAuthApiMiddleware to Better-Auth's native handler under its sessionMiddleware — i.e. reachable by any authenticated user, independent of the controller's class-level @Roles(ADMIN) and nest-server's checkRoles. This is why server-managed user fields (roles, verified, verifiedAt, twoFactorEnabled, iamId) are locked at the Better-Auth schema layer with input: false (see betterAuth.additionalUserFields[].input in .claude/rules/configurable-features.md and .claude/rules/better-auth.md §2): field-level input rejection is the correct control here because the guard layer does not run on these raw-forwarded routes. A forged POST /iam/update-user {"roles":["admin"]} is rejected with FIELD_NOT_ALLOWED (HTTP 400) at the input-parse stage, before any persistence.

3. graphqlUploadExpress

Only for GraphQL routes. Handles multipart file upload requests according to the GraphQL multipart request specification.

NestJS docs: Middleware


Phase 2: Authorization & Validation

Guards — @Roles() Enforcement

Guards run after middleware but before the handler. The @Roles() decorator specifies who can access a method:

@Query(() => User)
@Roles(RoleEnum.ADMIN)          // Only admins
async getUser(@Args('id') id: string): Promise<User> { ... }

@Mutation(() => User)
@Roles(RoleEnum.S_USER)         // Any authenticated user
async updateUser(...): Promise<User> { ... }

@Query(() => [User])
@Roles(RoleEnum.S_EVERYONE)     // Public access (no auth required)
async getPublicUsers(): Promise<User[]> { ... }

Important: @Roles() already handles JWT authentication internally. Do NOT add @UseGuards(AuthGuard(JWT)) — it is redundant.

System Roles (S_ prefix)

System roles are evaluated at runtime and must never be stored in user.roles.

OR semantics in CoreTenantGuard: When multiTenancy is active, system roles are checked as OR alternatives in priority order (S_EVERYONES_USERS_VERIFIED) before real roles. If ANY system role in @Roles() is satisfied, access is granted immediately — real roles in the same @Roles() are treated as alternatives, not additional requirements.

Example: @Roles(RoleEnum.S_USER, DefaultHR.OWNER) — any authenticated user passes (owner is an alternative).

When X-Tenant-Id header is present and a system role grants access, membership is still validated to set tenant context (tenantId, tenantRole). A non-member gets 403 even with S_USER or S_VERIFIED satisfied.

System RoleCheck LogicUse Case
S_EVERYONEAlways truePublic endpoints
S_NO_ONEAlways falsePermanently locked
S_USERcurrentUser existsAny authenticated user
S_VERIFIEDuser.verified || user.verifiedAt || user.emailVerifiedEmail-verified users
S_CREATORobject.createdBy === user.idCreator of the resource (object-level, checked by interceptor)
S_SELFobject.id === user.idUser accessing own data (object-level, checked by interceptor)
DefaultHR.MEMBER ('member')Active membership in current tenant (level >= 1)Tenant member access
DefaultHR.MANAGER ('manager')At least manager-level role (level >= 2)Tenant manager access
DefaultHR.OWNER ('owner')Highest role level (level >= 3)Tenant owner access
Custom hierarchy rolesConfigurable via createHierarchyRoles()Level comparison
Normal (non-hierarchy) rolesExact match against membership.role or user.rolesNo level compensation

NestJS docs: Guards, Authorization

Pipes — Input Validation & Whitelisting

The MapAndValidatePipe runs on every incoming argument/body:

Plain Object --> Transform to Class Instance --> Whitelist Check --> Validation --> Clean Input

Whitelisting via @UnifiedField()

Properties without @UnifiedField() are subject to the whitelist policy:

ModeConfig ValueBehavior
Strip (default)'strip'Unknown properties silently removed
Error'error'Throws 400 Bad Request with property names
DisabledfalseAll properties accepted
// config.env.ts
security: {
  mapAndValidatePipe: {
    nonWhitelistedFields: 'strip',  // 'strip' | 'error' | false
  },
}

@UnifiedField() Decorator

Combines GraphQL @Field(), Swagger @ApiProperty(), and class-validator decorators into one:

export class CreateUserInput extends CoreInput {
  @UnifiedField({ description: 'Email address' })
  email: string = undefined;

  @UnifiedField({ isOptional: true, description: 'Display name' })
  displayName?: string = undefined;

  @UnifiedField({ exclude: true })  // Hidden from schema, rejected at runtime
  internalFlag?: boolean = undefined;
}

NestJS docs: Pipes, Validation


Phase 3: Handler Execution

Controllers (REST) vs Resolvers (GraphQL)

+----------------------------+      +----------------------------+
|     REST Controller        |      |     GraphQL Resolver       |
+----------------------------+      +----------------------------+
| @Controller('/users')      |      | @Resolver(() => User)      |
| @Get(':id')                |      | @Query(() => User)         |
| @Post()                    |      | @Mutation(() => User)      |
| @Patch(':id')              |      |                            |
| @Delete(':id')             |      |                            |
+----------------------------+      +----------------------------+
| Input: @Body(), @Param()   |      | Input: @Args()             |
| User:  @CurrentUser()      |      | User:  @CurrentUser()      |
| Type:  @ApiOkResponse()    |      | Type:  @Query(() => User)  |
|        @ResponseModel()    |      |        @Mutation(() => User)|
+----------------------------+      +----------------------------+
              |                                   |
              +----------------+------------------+
                               |
              +----------------v------------------+
              |          Service Layer            |
              |                                   |
              | A: CrudService.process()          |
              |    Full pipeline with security     |
              |                                   |
              | B: Direct Mongoose query           |
              |    Safety Net catches              |
              |                                   |
              | C: processResult()                |
              |    Population + output only        |
              +-----------------------------------+

@CurrentUser() Decorator

Injects the authenticated user into the handler. This is a custom parameter decorator that bypasses the pipe (no validation/whitelist applied):

@Query(() => User)
@Roles(RoleEnum.S_USER)
async getMe(
  @CurrentUser() currentUser: User,
  @GraphQLServiceOptions() serviceOptions: ServiceOptions,
): Promise<User> {
  return this.userService.get(currentUser.id, serviceOptions);
}

Mongoose Plugins (Write Operations)

When the service performs write operations (save, update), Mongoose plugins fire at the database level:

Password Hashing Plugin

                              +------------------+
                              | Input password   |
                              +--------+---------+
                                       |
                              +--------v---------+
                              | Already hashed?  |
                              | (BCrypt pattern) |
                              +--------+---------+
                              Yes /          \ No
                                 /            \
                   +------------+    +---------v---------+
                   | Skip       |    | Sentinel value?   |
                   | pass thru  |    | (skipPatterns)     |
                   +------------+    +---------+---------+
                                     Yes /          \ No
                                        /            \
                          +------------+    +---------v---------+
                          | Skip       |    | SHA256 -> BCrypt  |
                          | pass thru  |    | hash -> MongoDB   |
                          +------------+    +-------------------+

Role Guard Plugin

                        +---------------------+
                        | Write includes      |
                        | roles?              |
                        +----------+----------+
                       No /              \ Yes
                         /                \
           +------------+     +------------v-----------+
           | Pass       |     | No currentUser?        |
           | through    |     | (system operation)     |
           +------------+     +------------+-----------+
                              Yes /              \ No
                                 /                \
                   +------------+     +------------v-----------+
                   | Allow      |     | bypassRoleGuard        |
                   |            |     | active?                |
                   +------------+     +------------+-----------+
                                      Yes /              \ No
                                         /                \
                           +------------+     +------------v-----------+
                           | Allow      |     | User is ADMIN?         |
                           |            |     |                        |
                           +------------+     +------------+-----------+
                                              Yes /              \ No
                                                 /                \
                                   +------------+     +------------v-----------+
                                   | Allow      |     | User in allowedRoles?  |
                                   |            |     |                        |
                                   +------------+     +------------+-----------+
                                                      Yes /              \ No
                                                         /                \
                                           +------------+    +-------------+
                                           | Allow      |    | Block       |
                                           |            |    | (strip      |
                                           +------------+    |  roles)     |
                                                             +-------------+

Audit Fields Plugin

                        +---------------------+
                        | Write operation     |
                        +----------+----------+
                                   |
                        +----------v----------+
                        | currentUser exists? |
                        +----------+----------+
                       No /              \ Yes
                         /                \
           +------------+     +------------v-----------+
           | Skip       |     | New document?          |
           +------------+     +------------+-----------+
                              Yes /              \ No
                                 /                \
                   +------------+     +------------v-----------+
                   | Set         |     | Set updatedBy         |
                   | createdBy + |     | only                  |
                   | updatedBy   |     |                       |
                   +-------------+     +-----------------------+

Tenant Isolation Plugin (opt-in)

Enabled via multiTenancy: {} in config. Auto-activates only on schemas with a tenantId field. The tenantId is read from RequestContext (set by CoreTenantGuard via req.tenantId), not the raw header.

Safety Net: If a tenant-schema is accessed without a valid tenant context (no tenantId and no bypass), the plugin throws a ForbiddenException. This prevents accidental cross-tenant data leaks when developers forget to set the tenant header or bypass.

                        +---------------------+
                        | DB operation         |
                        | (query/save/agg)     |
                        +----------+----------+
                                   |
                        +----------v----------+
                        | multiTenancy config |
                        | enabled?            |
                        +----------+----------+
                       No /              \ Yes
                         /                \
           +------------+     +------------v-----------+
           | Skip       |     | RequestContext exists?  |
           +------------+     +------------+-----------+
                              No /              \ Yes
                                 /                \
                   +------------+     +------------v-----------+
                   | No filter  |     | bypassTenantGuard?     |
                   | (system op)|     |                        |
                   +------------+     +------------+-----------+
                                      Yes /              \ No
                                         /                \
                           +------------+     +------------v-----------+
                           | No filter  |     | Schema in              |
                           |            |     | excludeSchemas?        |
                           +------------+     +------------+-----------+
                                              Yes /              \ No
                                                 /                \
                                   +------------+     +------------v-----------+
                                   | No filter  |     | isAdminBypass?         |
                                   +------------+     +------------+-----------+
                                                      Yes /              \ No
                                                         /                \
                                           +------------+     +------------v-----------+
                                           | No filter  |     | tenantId?              |
                                           | (admin sees|     +------------+-----------+
                                           |  all data) |     Yes /              \ No
                                           +------------+        /                \
                                                   +------------+    +-------------+
                                                   | Filter by  |    | FORBIDDEN   |
                                                   | tenantId   |    | (Safety Net)|
                                                   +------------+    +-------------+

Important: When multiTenancy.adminBypass is true (default), system admins without a tenant header get isAdminBypass set in RequestContext and see all data (no tenant filter). Non-admin users with a tenant header but no membership always get 403. For cross-tenant admin operations, use RequestContext.runWithBypassTenantGuard().

NestJS docs: Custom decorators, Mongoose


Phase 4: Response Processing

NestJS runs interceptors in reverse registration order on the response. Since ResponseModelInterceptor is registered last in CoreModule, it runs first on the response:

  Handler return value
           |
  +--------v-------------------------------------------------+
  |  Step 7: ResponseModelInterceptor                         |
  |                                                           |
  |  Resolves the expected model class:                       |
  |    1. @ResponseModel(User) decorator (explicit)           |
  |    2. @Query(() => User) / @Mutation() return type (GQL)  |
  |    3. @ApiOkResponse({ type: User }) (Swagger/REST)       |
  |                                                           |
  |  Converts plain objects -> CoreModel instances via .map() |
  |  Skips if already instanceof or _objectAlreadyChecked     |
  |  Enables securityCheck() and @Restricted on the result    |
  +--------+--------------------------------------------------+
           |
  +--------v-------------------------------------------------+
  |  Step 8: TranslateResponseInterceptor                     |
  |                                                           |
  |  Checks Accept-Language header                            |
  |  If _translations exists on response objects:             |
  |    -> Applies matching translation to base fields         |
  |  Early bailout when no _translations present              |
  +--------+--------------------------------------------------+
           |
  +--------v-------------------------------------------------+
  |  Step 9: CheckSecurityInterceptor                         |
  |                                                           |
  |  Calls securityCheck(user, force) on model instances      |
  |  Recursively processes nested objects                      |
  |                                                           |
  |  Fallback: Removes secret fields from ALL objects         |
  |  (password, verificationToken, refreshTokens, etc.)       |
  |  Even if object is NOT a CoreModel instance               |
  +--------+--------------------------------------------------+
           |
  +--------v-------------------------------------------------+
  |  Step 10: CheckResponseInterceptor                        |
  |                                                           |
  |  Reads @Restricted() metadata from each property          |
  |  For each field:                                          |
  |    - Role check: Does user have required role?            |
  |    - Membership check: Is user.id in field's memberOf?    |
  |    - System role check: S_CREATOR, S_SELF, etc.           |
  |  Removes fields the user is not allowed to see            |
  |  Sets _objectAlreadyCheckedForRestrictions = true         |
  +--------+--------------------------------------------------+
           |
      +----v-----------+
      | HTTP Response   |
      +----------------+

NestJS docs: Interceptors


CrudService.process() Pipeline

The process() method in ModuleService is the primary way to handle CRUD operations with full security. It orchestrates input preparation, authorization, the database operation, and output preparation:

  +---------------------------------------------------------------+
  |                    CrudService.process()                       |
  |                                                               |
  |  +----------------------------------------------------------+ |
  |  |  1. prepareInput()                                        | |
  |  |     - Hash password (SHA256 + BCrypt)                     | |
  |  |     - Check roles (if checkRoles: true)                   | |
  |  |     - Convert ObjectIds to strings                        | |
  |  |     - Map to target model type                            | |
  |  |     - Remove undefined properties                         | |
  |  +----------------------------+-----------------------------+  |
  |                               |                                |
  |  +----------------------------v-----------------------------+  |
  |  |  2. checkRights(INPUT)                                   |  |
  |  |     - Evaluate @Restricted() on input properties         |  |
  |  |     - Verify user has required roles/memberships         |  |
  |  |     - Strip/reject unauthorized input fields             |  |
  |  +----------------------------+-----------------------------+  |
  |                               |                                |
  |  +----------------------------v-----------------------------+  |
  |  |  3. serviceFunc()   <-- Your database operation          |  |
  |  |     - findById, create, findByIdAndUpdate, aggregate...  |  |
  |  |     - Mongoose plugins fire here (password, roles, audit)|  |
  |  |     - If force: true -> runs inside bypassRoleGuard      |  |
  |  +----------------------------+-----------------------------+  |
  |                               |                                |
  |  +----------------------------v-----------------------------+  |
  |  |  4. processFieldSelection()                              |  |
  |  |     - Populate referenced documents (GraphQL selections) |  |
  |  +----------------------------+-----------------------------+  |
  |                               |                                |
  |  +----------------------------v-----------------------------+  |
  |  |  5. prepareOutput()                                      |  |
  |  |     - Map Mongoose document -> model instance (.map())   |  |
  |  |     - Convert ObjectIds to strings                       |  |
  |  |     - Remove secrets (if removeSecrets: true)            |  |
  |  |     - Apply custom transformations (overridable)         |  |
  |  +----------------------------+-----------------------------+  |
  |                               |                                |
  |  +----------------------------v-----------------------------+  |
  |  |  6. checkRights(OUTPUT, throwError: false)               |  |
  |  |     - Filter output properties based on @Restricted()   |  |
  |  |     - Non-throwing: strips fields instead of erroring    |  |
  |  +----------------------------+-----------------------------+  |
  |                               |                                |
  |  Return processed result                                       |
  +---------------------------------------------------------------+

Status Codes: 401 vs 403 (v11.28.0+)

One policy across all five permission layers — the role guards, the tenant guard, check() / checkRights, checkRestricted() (object and field level), and a model's securityCheck():

SituationStatusThrown by
Requester is not authenticated401 ErrorCode.UNAUTHORIZEDguards, accessDeniedException(undefined)
Requester is authenticated but lacks a right403 ErrorCode.ACCESS_DENIEDguards, accessDeniedException(user)
Resource is locked via S_NO_ONE403, always — even for anonymous requestersguards, check()

S_NO_ONE is 403 for everyone because authenticating can never unlock it; a 401 would tell the client to retry after logging in, which is a lie.

Why this matters: SPA auth layers commonly treat 401 as "session expired" and clear the session (the @lenne.tech/nuxt-extensions auth interceptor patches $fetch/fetch globally and does exactly this). A permission error answered with 401 therefore logs the user out of the whole app. With this policy a frontend may treat 401 as "session invalid" — with one exception: ErrorCode.EMAIL_VERIFICATION_REQUIRED is a legitimate 401 (no session exists yet at sign-in) that must not trigger a logout. Branch on the ErrorCode, not on the status alone.

Writing new denial code: use the exported factory rather than hand-rolling the decision. It returns the native ForbiddenException / UnauthorizedException, so instanceof checks and @Catch(...) filters in consuming projects keep working:

import { accessDeniedException } from '@lenne.tech/nest-server';

// In a service, a custom guard, or a model's securityCheck():
throw accessDeniedException(currentUser);

See src/core/common/exceptions/access-denied.exception.ts and migration-guides/11.27.7-to-11.28.0.md.

Depth-Based Optimization (v11.23.0+)

When process() is called from within another process() call (service cascades like A.create → B.create → C.create), steps 4–6 are conditionally skipped on inner calls to avoid redundant work:

StepDepth 0 (outermost)Depth > 0 (nested)
1. prepareInputRunsRuns
2. checkRights (INPUT)RunsRuns
3. serviceFuncRunsRuns
4. processFieldSelectionRunsSkipped (unless populate explicitly set)
5. prepareOutput (model mapping)RunsSkipped (secret removal still active)
6. checkRights (OUTPUT)RunsSkipped

Security is maintained because:

  1. Input authorization (step 2) always runs at every depth
  2. Output authorization (step 6) runs at the outermost call
  3. CheckSecurityInterceptor (Safety Net) runs on the final HTTP response

Important: Code running at depth > 0 (cron jobs, queue consumers, event handlers outside the HTTP cycle) must NOT return data directly to external consumers without either an outer depth-0 process() call or manual checkRights — the output rights check is skipped at depth > 0.

See process() Performance Optimization for details.

Key Options

OptionTypeDefaultEffect
forcebooleanfalseDisables checkRights, checkRoles, removeSecrets, bypasses role guard plugin
rawbooleanfalseDisables prepareInput and prepareOutput entirely
checkRightsbooleantrueEnable/disable authorization checks
populateobject-Field selection for population (overrides nested skip)
currentUserobjectfrom requestOverride the current user
debugProcessInputbooleanfalseConfig flag: log when prepareInput changes the input type (performance cost)

Alternative: processResult()

For direct Mongoose queries that need population and output preparation but not the full pipeline:

// Direct query + simplified processing
const doc = await this.mainDbModel.findById(id).exec();
return this.processResult(doc, serviceOptions);

processResult() handles population and prepareOutput() only. It does not perform authorization checks (checkRights). Security is handled by the Safety Net (Mongoose plugins for input, interceptors for output). If called outside an HTTP request cycle (cron, queue), call checkRights manually before returning data to external consumers.


Safety Net Architecture

The Safety Net ensures security even when developers bypass CrudService.process() and use direct Mongoose queries. It consists of two complementary layers:

  +-----------------------------------------------------------+
  |  Developer writes direct query                            |
  |                                                           |
  |  const user = await this.mainDbModel.findById(id).exec(); |
  |  return user;  // Plain Mongoose document                 |
  +----------------------------+------------------------------+
                               |
           +-------------------v--------------------+
           |  INPUT PROTECTION                      |
           |  (Mongoose Plugins -- on write)        |
           |                                        |
           |  - Password auto-hashed                |
           |    (mongoosePasswordPlugin)            |
           |                                        |
           |  - Roles guarded                       |
           |    (mongooseRoleGuardPlugin)            |
           |                                        |
           |  - Audit fields set                    |
           |    (mongooseAuditFieldsPlugin)          |
           |                                        |
           |  - Tenant isolation enforced           |
           |    (mongooseTenantPlugin)              |
           |    403 if no valid tenant context       |
           +-------------------+--------------------+
                               |
           +-------------------v--------------------+
           |  OUTPUT PROTECTION                     |
           |  (Response Interceptors)               |
           |                                        |
           |  - Plain -> Model conversion           |
           |    (ResponseModelInterceptor)           |
           |                                        |
           |  - Translations applied                |
           |    (TranslateResponseInterceptor)       |
           |                                        |
           |  - securityCheck() called              |
           |    (CheckSecurityInterceptor)           |
           |                                        |
           |  - @Restricted fields filtered         |
           |    (CheckResponseInterceptor)           |
           |                                        |
           |  - Secret fields removed (fallback)    |
           |    (CheckSecurityInterceptor)           |
           +-------------------+--------------------+
                               |
                      +--------v--------+
                      | Secure Response |
                      +-----------------+

When is process() vs Safety Net used?

ApproachInput SecurityOutput SecurityPopulationCustom Logic
process()prepareInput + pluginsprepareOutput + interceptorsYescheckRights, serviceOptions
Direct query + returnPlugins onlyInterceptors onlyNoNone
Direct query + processResult()Plugins onlyprepareOutput + interceptorsYesCustom prepareOutput

Recommendation: Use process() for full CRUD operations. Use direct queries + Safety Net for simple read-only queries, aggregations, or performance-critical paths.


Decorators Reference

@Roles() — Method-Level Authorization

Controls who can access a resolver/controller method. Evaluated by the RolesGuard.

// Only admins
@Roles(RoleEnum.ADMIN)

// Any authenticated user
@Roles(RoleEnum.S_USER)

// Public access
@Roles(RoleEnum.S_EVERYONE)

// Multiple roles (OR logic — user needs at least one)
@Roles(RoleEnum.ADMIN, 'MANAGER')

// Tenant roles — validated by CoreTenantGuard when multiTenancy is enabled
@Roles(DefaultHR.MEMBER)   // Any active tenant member (level >= 1)
@Roles(DefaultHR.MANAGER)  // At least manager-level (level >= 2)
@Roles(DefaultHR.OWNER)    // Highest role level (level >= 3)
@Roles('auditor')           // Normal role: exact match only

Note: @Roles() includes JWT authentication. Do NOT add @UseGuards(AuthGuard(JWT)). When multiTenancy is enabled, CoreTenantGuard checks system roles as OR alternatives first (S_EVERYONES_USERS_VERIFIED). Hierarchy roles use level comparison. Normal (non-hierarchy) roles use exact match. When X-Tenant-Id header is present, only membership.role is checked (user.roles ignored, except ADMIN bypass). With a system role granting access + header present, membership is still validated to set tenant context.

@Restricted() — Field-Level Access Control

Controls who can see or modify specific properties. Evaluated by CheckResponseInterceptor (output) and checkRights() (input).

On output a denied field is silently removed (no exception). On input the request is rejected: 403 for an authenticated requester, 401 for an anonymous one, and 403 always for S_NO_ONE — see Status Codes: 401 vs 403 above.

export class User extends CorePersistenceModel {
  // Only admins or the user themselves can see the email
  @Restricted(RoleEnum.ADMIN, RoleEnum.S_SELF)
  email: string = undefined;

  // Only admins can see roles
  @Restricted(RoleEnum.ADMIN)
  roles: string[] = undefined;

  // Only users who are members of the 'teamMembers' array
  @Restricted({ memberOf: 'teamMembers' })
  internalNotes: string = undefined;

  // Restrict for input only (anyone can read, but only admins can write)
  @Restricted({ roles: RoleEnum.ADMIN, processType: ProcessType.INPUT })
  status: string = undefined;
}

@UnifiedField() — Schema & Validation

Single decorator that replaces @Field(), @ApiProperty(), @IsOptional(), and more:

export class CreateUserInput extends CoreInput {
  // Required string field (shown in both GraphQL and Swagger)
  @UnifiedField({ description: 'User email address' })
  email: string = undefined;

  // Optional field
  @UnifiedField({ isOptional: true })
  displayName?: string = undefined;

  // Enum field
  @UnifiedField({ enum: RoleEnum, isOptional: true })
  role?: RoleEnum = undefined;

  // Excluded from input (hidden from schema, rejected at runtime)
  @UnifiedField({ exclude: true })
  internalId?: string = undefined;

  // Re-include a field excluded by parent class
  @UnifiedField({ exclude: false })
  parentExcludedField?: string = undefined;
}

@ResponseModel() — REST Response Type Hint

For REST controllers, specifies the model class for automatic response conversion:

@ResponseModel(User)
@Get(':id')
async getUser(@Param('id') id: string): Promise<User> {
  return this.mainDbModel.findById(id).exec();
  // Even though this returns a Mongoose document,
  // ResponseModelInterceptor converts it to User model
}

Note: For GraphQL, the return type is resolved automatically from @Query(() => User).

@ApiOkResponse() — Swagger + Response Type

For REST controllers with Swagger. Also serves as response type hint for ResponseModelInterceptor:

@ApiOkResponse({ type: User })
@Get(':id')
async getUser(@Param('id') id: string): Promise<User> { ... }

NestJS docs: Custom decorators, OpenAPI


Model System

Class Hierarchy

CoreModel                        Abstract base (map, securityCheck, hasRole)
  |
  +-- CorePersistenceModel       Adds id, createdAt, updatedAt, createdBy, updatedBy
        |
        +-- User                 Your concrete model

Key Methods

Model.map(data) — Static Factory

Creates a model instance from a plain object or Mongoose document. Copies only properties that exist on the model class (defined with = undefined):

const user = User.map(mongooseDoc);
// user is now a User instance with securityCheck(), hasRole(), etc.

This is what prepareOutput() and ResponseModelInterceptor call internally.

model.securityCheck(user, force) — Instance Security

Called by CheckSecurityInterceptor on every response object. Override this in your model to implement custom security logic:

export class User extends CorePersistenceModel {
  password: string = undefined;
  internalScore: number = undefined;

  override securityCheck(user: any, force?: boolean): this {
    // Remove password from output (should never be returned)
    this.password = undefined;

    // Only admins can see internalScore
    if (!force && !user?.hasRole?.([RoleEnum.ADMIN])) {
      this.internalScore = undefined;
    }

    return this;
  }
}

When securityCheck runs:

  1. CheckSecurityInterceptor calls it on every response object
  2. CrudService.process() runs it via prepareOutput() (before the interceptor)
  3. Safety Net: ResponseModelInterceptor converts to model first → then CheckSecurityInterceptor calls securityCheck

prepareOutput() in Services

Override prepareOutput() in your service for custom output transformations:

export class UserService extends CoreUserService<User> {
  override async prepareOutput(output: any, options?: any): Promise<User> {
    // Call parent (handles mapping, ObjectId conversion)
    output = await super.prepareOutput(output, options);

    // Custom transformations
    if (output && !options?.force) {
      output.fullName = `${output.firstName} ${output.lastName}`;
    }

    return output;
  }
}

REST vs GraphQL Differences

AspectRESTGraphQL
Entry point@Controller() class@Resolver() class
Method decorators@Get(), @Post(), @Patch(), @Delete()@Query(), @Mutation()
Input@Body(), @Param(), @Query()@Args()
User injection@CurrentUser() (same)@CurrentUser() (same)
Response type resolution@ResponseModel() or @ApiOkResponse()Automatic from @Query(() => Type)
Context extractioncontext.switchToHttp().getRequest()GqlExecutionContext.create(context)
Field selectionNot available (all fields returned)GraphQL field selection → population
File uploadsStandard multipartgraphqlUploadExpress() middleware
SubscriptionsNot supportedWebSocket via graphql-ws

Guard Context Detection

Guards handle both REST and GraphQL contexts:

// Inside RolesGuard
const gqlContext = GqlExecutionContext.create(context).getContext();
const request = gqlContext?.req || context.switchToHttp().getRequest();

NestJS docs: REST Controllers, GraphQL Resolvers


Configuration Reference

All security features are configured in config.env.ts under the security key:

Cookies & CORS (since v11.25.0)

Config PathTypeDefaultDescription
cookiesboolean | ICookiesConfigtrueEnable cookie-parser and session cookies
cookies.exposeTokenInBodybooleanfalseKeep token in response body alongside cookies
corsboolean | ICorsConfigundefined (enabled)Unified CORS across GraphQL, REST, BetterAuth
cors.allowAllbooleanfalseAllow all origins (mirrors request origin)
cors.allowedOriginsstring[][]Additional origins beyond appUrl/baseUrl
cors.enabledbooleantrueEnable/disable CORS on all layers

Cookie modes:

ModeConfigToken in bodyCookie setJWT via header
Cookie-only (default)cookies: trueNoYesYes (always)
JWT-onlycookies: falseYesNoYes
Hybridcookies: { exposeTokenInBody: true }YesYesYes

Guardian Gates

Config PathTypeDefaultDescription
security.checkResponseInterceptorboolean | objecttrueEnable @Restricted field filtering
security.checkSecurityInterceptorboolean | objecttrueEnable securityCheck() calls
security.mapAndValidatePipeboolean | objecttrueEnable input validation
security.mapAndValidatePipe.nonWhitelistedFields'strip' | 'error' | false'strip'Whitelist behavior

Safety Net — Mongoose Plugins

Config PathTypeDefaultDescription
security.mongoosePasswordPluginboolean | { skipPatterns }trueAuto password hashing
security.mongooseRoleGuardPluginboolean | { allowedRoles }trueRole escalation prevention
security.mongooseAuditFieldsPluginbooleantrueAuto createdBy/updatedBy
multiTenancyIMultiTenancyundefined (disabled)Tenant-based data isolation (header + membership)

Safety Net — Response Interceptors

Config PathTypeDefaultDescription
security.responseModelInterceptorboolean | { debug }truePlain → Model auto-conversion
security.translateResponseInterceptorbooleantrueAuto translation application
security.secretFieldsstring[]['password', ...]Global secret field removal list
security.checkSecurityInterceptor.removeSecretFieldsbooleantrueFallback secret removal

Role Guard Bypass

// Option 1: Programmatic bypass in service code
import { RequestContext } from '@lenne.tech/nest-server';

await RequestContext.runWithBypassRoleGuard(async () => {
  await this.mainDbModel.create({ roles: ['EMPLOYEE'] });
});

// Option 2: CrudService force mode
this.process(serviceFunc, { serviceOptions, force: true });

// Option 3: Config-based (permanently allow roles)
security: {
  mongooseRoleGuardPlugin: { allowedRoles: ['HR_MANAGER'] },
}

Tenant Guard Bypass

// Cross-tenant admin operations
import { RequestContext } from '@lenne.tech/nest-server';

const allOrders = await RequestContext.runWithBypassTenantGuard(async () => {
  return this.orderService.find(); // sees all tenants
});

// Exclude specific schemas from tenant filtering
multiTenancy: {
  excludeSchemas: ['User', 'Session'], // model names, not collection names
}

TopicURL
Request Lifecyclehttps://docs.nestjs.com/faq/request-lifecycle
Middlewarehttps://docs.nestjs.com/middleware
Guardshttps://docs.nestjs.com/guards
Interceptorshttps://docs.nestjs.com/interceptors
Pipeshttps://docs.nestjs.com/pipes
Custom Decoratorshttps://docs.nestjs.com/custom-decorators
Validationhttps://docs.nestjs.com/techniques/validation
Authenticationhttps://docs.nestjs.com/security/authentication
Authorizationhttps://docs.nestjs.com/security/authorization
MongoDB / Mongoosehttps://docs.nestjs.com/techniques/mongodb
GraphQLhttps://docs.nestjs.com/graphql/quick-start
GraphQL Resolvershttps://docs.nestjs.com/graphql/resolvers
REST Controllershttps://docs.nestjs.com/controllers
OpenAPI / Swaggerhttps://docs.nestjs.com/openapi/introduction
Dynamic Moduleshttps://docs.nestjs.com/fundamentals/dynamic-modules