@lenne.tech/nest-server 11.23.1 → 11.24.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.
@@ -0,0 +1,272 @@
1
+ # Migration Guide: 11.23.x → 11.24.0
2
+
3
+ ## Overview
4
+
5
+ | Category | Details |
6
+ |----------|---------|
7
+ | **Breaking Changes** | None (one edge-case: `checkRestricted` 4th parameter type changed — see below) |
8
+ | **New Features** | `CrudService.pushToArray()`, `CrudService.pullFromArray()` |
9
+ | **Bugfixes** | `checkRestricted` group member checks broken (concat bug), `prepareInput`/`prepareOutput` array element skipping (splice bug), RoleGuard missing `$pull.roles` guard |
10
+ | **Performance** | `checkRestricted` sample-based array validation, `WeakSet` for circular reference detection, single-pass DFS in `prepareOutput` (secret removal + ObjectId conversion merged) |
11
+ | **Security** | Recursive secret removal in `prepareOutput`, RoleGuard now guards `$pull.roles`, field-name validation on `pushToArray`/`pullFromArray` |
12
+ | **Migration Effort** | 0 minutes (no code changes required) |
13
+
14
+ ---
15
+
16
+ ## Quick Migration
17
+
18
+ No code changes required. All improvements activate automatically.
19
+
20
+ ```bash
21
+ # Update package
22
+ pnpm add @lenne.tech/nest-server@11.24.0
23
+
24
+ # Verify build
25
+ pnpm run build
26
+
27
+ # Run tests
28
+ pnpm test
29
+ ```
30
+
31
+ ---
32
+
33
+ ## What's New in 11.24.0
34
+
35
+ ### 1. `CrudService.pushToArray()` — Atomic Array Append
36
+
37
+ New first-class method for appending items to array fields without loading the full array into memory. Bypasses the `process()` pipeline entirely, preventing OOM on large subdocument arrays. Mongoose plugins (Tenant, Audit, RoleGuard) remain active via `pre('findOneAndUpdate')`.
38
+
39
+ ```typescript
40
+ // Append a single item
41
+ await this.entityService.pushToArray(id, 'logs', newLog);
42
+
43
+ // Append with $slice to cap array length (keep last 500)
44
+ await this.entityService.pushToArray(id, 'logs', newLog, { $slice: -500 });
45
+
46
+ // Append multiple items at once
47
+ await this.entityService.pushToArray(id, 'tags', ['urgent', 'reviewed']);
48
+
49
+ // Append with sort and position control
50
+ await this.entityService.pushToArray(id, 'scores', newScore, {
51
+ $sort: { value: -1 },
52
+ $slice: 10, // keep top 10
53
+ });
54
+ ```
55
+
56
+ **When to use:**
57
+ - Appending logs, history, metrics, comments, tags
58
+ - Any growing array regardless of size
59
+ - Whenever you would have done `entity.field.push(item); await service.update(id, { field: entity.field })`
60
+
61
+ **When to still use `CrudService.update()`:**
62
+ - Replacing the entire array content
63
+ - Small arrays (< 10 items) where full validation is desired
64
+ - User-controlled field selection requiring `@Restricted` input checks
65
+
66
+ ### 2. `CrudService.pullFromArray()` — Atomic Array Removal
67
+
68
+ New method for removing items from array fields by condition.
69
+
70
+ ```typescript
71
+ // Remove by exact match
72
+ await this.entityService.pullFromArray(id, 'tags', 'obsolete');
73
+
74
+ // Remove by condition (all DEBUG logs)
75
+ await this.entityService.pullFromArray(id, 'logs', { level: 'DEBUG' });
76
+ ```
77
+
78
+ ### 3. `checkRestricted()` Array Performance Optimization
79
+
80
+ For typed arrays (all items share the same class), class-level `@Restricted` metadata is now validated once on the first item instead of on every item. Per-item checks are only performed when `S_CREATOR` or `S_SELF` restrictions exist (because `createdBy`/`id` differ per item).
81
+
82
+ **Impact:** Reduces redundant class-level validation from O(n) to O(1) for homogeneous arrays. Property-level checks still run per item with cached metadata lookups.
83
+
84
+ ### 4. `checkRestricted()` Circular Reference Protection — WeakSet
85
+
86
+ The `processedObjects` tracking changed from `Array` with O(n) `.includes()` to `WeakSet` with O(1) `.has()`. This improves performance for deeply nested object graphs and enables garbage collection of processed objects.
87
+
88
+ ### 5. Recursive Secret Removal in `prepareOutput()`
89
+
90
+ Secret fields (`password`, `verificationToken`, `passwordResetToken`) are now removed **recursively** from nested objects and arrays, not just at the top level. Previously, `output.author.password` would survive — now it is correctly removed.
91
+
92
+ **Note:** `refreshTokens` and `tempTokens` are intentionally NOT removed by `prepareOutput` — they are needed internally for token validation and process flows (password reset, email verification). The `CheckSecurityInterceptor` (Safety Net) removes these from HTTP responses as a separate layer.
93
+
94
+ ### 6. Recursive ObjectId Conversion in `prepareOutput()`
95
+
96
+ ObjectId→string conversion now uses `processDeep()` (recursive) instead of a flat `Object.entries()` loop, consistent with `prepareInput()`. Previously, nested ObjectIds (e.g. `output.nested.refId`) were not converted.
97
+
98
+ ### 7. Single-Pass DFS in `prepareOutput()`
99
+
100
+ Secret removal and ObjectId conversion are now merged into a single `processDeep()` traversal instead of two separate recursive walks. This halves the object graph traversal cost for API responses with nested objects.
101
+
102
+ ### 8. Field-Name Validation on `pushToArray()` / `pullFromArray()`
103
+
104
+ Both methods now validate the `field` parameter at runtime. Field names starting with `$` or containing null bytes are rejected with an error. This prevents MongoDB operator injection when field names are accidentally derived from unvalidated sources.
105
+
106
+ ```typescript
107
+ // Throws: "pushToArray: invalid field name "$where""
108
+ await this.entityService.pushToArray(id, '$where', 'value');
109
+ ```
110
+
111
+ ### 9. `pushToArray()` Empty Array Guard
112
+
113
+ Passing an empty array to `pushToArray()` now returns immediately without issuing a database round-trip. Previously, `$push: { field: { $each: [] } }` was sent to MongoDB as a valid no-op.
114
+
115
+ ---
116
+
117
+ ## Bugfixes
118
+
119
+ ### Fix: `checkRestricted` Group Member Checks Were Silently Broken
120
+
121
+ **File:** `src/core/common/decorators/restricted.decorator.ts`
122
+
123
+ `members.concat(items)` returned a new array but the result was discarded. This meant `@Restricted({ memberOf: 'members' })` group-based access checks **never worked** for array-valued membership properties.
124
+
125
+ **Before:**
126
+ ```typescript
127
+ members.concat(items); // BUG: result discarded, members stays empty
128
+ ```
129
+
130
+ **After:**
131
+ ```typescript
132
+ members.push(...items); // FIXED: items added to members array
133
+ ```
134
+
135
+ **Impact:** If your project uses `@Restricted({ memberOf: 'propertyName' })` where the property is an array of user IDs, access was incorrectly denied for all users. After this fix, group member checks work correctly.
136
+
137
+ ### Fix: `prepareInput()` / `prepareOutput()` Array Element Skipping
138
+
139
+ **File:** `src/core/common/helpers/service.helper.ts`
140
+
141
+ Both functions used `splice(i, 1)` inside a forward `for` loop when `removeUndefined` was active. This caused array elements to be skipped after each removal because indices shifted.
142
+
143
+ **Before:**
144
+ ```typescript
145
+ for (let i = 0; i <= input.length - 1; i++) {
146
+ processedArray[i] = await prepareInput(input[i], ...);
147
+ if (processedArray[i] === undefined && config.removeUndefined) {
148
+ processedArray.splice(i, 1); // BUG: shifts indices, next element skipped
149
+ }
150
+ }
151
+ ```
152
+
153
+ **After:**
154
+ ```typescript
155
+ const result = [];
156
+ for (let i = 0; i < input.length; i++) {
157
+ const processed = await prepareInput(input[i], ...);
158
+ if (processed !== undefined || !config.removeUndefined) {
159
+ result.push(processed);
160
+ }
161
+ }
162
+ return result;
163
+ ```
164
+
165
+ **Impact:** Arrays with `undefined` elements (rare in practice) could have had elements silently dropped. The fix also ensures arrays are never mutated in place — a new array is always returned.
166
+
167
+ ### Fix: RoleGuard Plugin Did Not Guard `$pull.roles`
168
+
169
+ **File:** `src/core/common/plugins/mongoose-role-guard.plugin.ts`
170
+
171
+ The `mongooseRoleGuardPlugin` checked `$push.roles`, `$set.roles`, and `$addToSet.roles` for unauthorized role changes, but not `$pull.roles`. This meant `Model.findByIdAndUpdate(id, { $pull: { roles: 'admin' } })` could strip roles without authorization.
172
+
173
+ **Impact:** The new `pullFromArray()` method exposed this gap. After this fix, `$pull.roles` is also guarded — unauthorized callers cannot remove roles via any MongoDB operator.
174
+
175
+ ---
176
+
177
+ ## Edge-Case Breaking Change
178
+
179
+ ### `checkRestricted()` 4th Parameter Type: `any[]` → `WeakSet<object>`
180
+
181
+ The `processedObjects` parameter of the exported `checkRestricted()` function changed from `any[]` to `WeakSet<object>`. This parameter has a default value (`new WeakSet()`) and is only used internally for recursive calls.
182
+
183
+ **Who is affected:** Only projects that call `checkRestricted()` directly AND pass a custom 4th argument. This is extremely unlikely — the function is typically called by the framework interceptors, not by consuming projects.
184
+
185
+ **If you are affected:**
186
+ ```typescript
187
+ // Before (11.23.x):
188
+ checkRestricted(data, user, options, []);
189
+
190
+ // After (11.24.0):
191
+ checkRestricted(data, user, options, new WeakSet());
192
+ // Or simply omit the 4th parameter (recommended):
193
+ checkRestricted(data, user, options);
194
+ ```
195
+
196
+ ---
197
+
198
+ ## Deprecations
199
+
200
+ ### `getNewArray` Option in `PrepareInputOptions` / `PrepareOutputOptions`
201
+
202
+ The `getNewArray` option is now deprecated. Array processing always returns a new array to prevent mutation bugs. The option is still accepted for backward compatibility but has no effect.
203
+
204
+ ---
205
+
206
+ ## Compatibility Notes
207
+
208
+ ### Existing Code
209
+
210
+ All existing code continues to work without changes:
211
+
212
+ - `CrudService.update()` works identically — the `process()` pipeline is unchanged
213
+ - `@Restricted()` decorators behave the same (with the group member bug now fixed)
214
+ - `prepareInput()` / `prepareOutput()` produce the same results (with bugs fixed)
215
+ - Direct Mongoose operations (`Model.findByIdAndUpdate()`, `$push`, etc.) work as before
216
+
217
+ ### Adopting `pushToArray()` / `pullFromArray()` (Recommended)
218
+
219
+ If your project has patterns like:
220
+
221
+ ```typescript
222
+ entity.logs.push(newLog);
223
+ await this.entityService.update(id, { logs: entity.logs });
224
+ ```
225
+
226
+ Consider migrating to:
227
+
228
+ ```typescript
229
+ await this.entityService.pushToArray(id, 'logs', newLog);
230
+ ```
231
+
232
+ This is especially important for:
233
+ - Arrays that grow over time (logs, history, comments)
234
+ - Documents with hundreds of subdocument entries
235
+ - High-frequency operations (monitoring, metrics)
236
+
237
+ ### Security Architecture
238
+
239
+ The multi-layered security architecture ensures safety regardless of which method is used:
240
+
241
+ | Layer | What it does | Active for pushToArray? |
242
+ |-------|-------------|------------------------|
243
+ | Mongoose Tenant Plugin | Scopes queries to tenant | Yes |
244
+ | Mongoose Audit Plugin | Sets `updatedBy` | Yes |
245
+ | Mongoose RoleGuard Plugin | Prevents unauthorized role changes | Yes |
246
+ | `process()` pipeline | Input validation, authorization, output filtering | No (bypassed) |
247
+ | CheckResponseInterceptor | Filters `@Restricted` fields from HTTP response | Yes (Safety Net) |
248
+ | CheckSecurityInterceptor | Runs `securityCheck()`, removes secrets from HTTP response | Yes (Safety Net) |
249
+
250
+ ---
251
+
252
+ ## Troubleshooting
253
+
254
+ ### Group member checks now grant access where they were denied before
255
+
256
+ This is expected — the `concat()` bug meant group member checks were silently broken. If your project relied on the (incorrect) denial behavior, review your `@Restricted({ memberOf: '...' })` usage.
257
+
258
+ ### Nested ObjectIds now appear as strings in API responses
259
+
260
+ Previously, ObjectIds nested inside objects (e.g. `response.nested.refId`) were returned as ObjectId objects. They are now consistently converted to strings, matching the behavior of top-level ObjectIds. If your frontend parsed ObjectId objects, this is now a plain string.
261
+
262
+ ### Nested secret fields now removed from service-level output
263
+
264
+ If your code accessed `result.author.password` after a CrudService call (within the service layer, not in API responses), this value is now `undefined`. This is the correct behavior — passwords should never be accessible in output. If you need the raw data, use `updateRaw()` / `getRaw()` or direct Mongoose queries.
265
+
266
+ ---
267
+
268
+ ## References
269
+
270
+ - [SubDocument Array Optimization](../docs/subdocument-array-optimization-plan.md) — Detailed OOM analysis and implementation notes
271
+ - [process() Performance Optimization](../docs/process-performance-optimization.md) — Pipeline optimization details
272
+ - [nest-server-starter](https://github.com/lenneTech/nest-server-starter) — Reference implementation
@@ -0,0 +1,67 @@
1
+ # Migration Guide: 11.24.0 → 11.24.1
2
+
3
+ ## Overview
4
+
5
+ | Category | Details |
6
+ |----------|---------|
7
+ | **Breaking Changes** | None |
8
+ | **New Features** | `pushToArray()`/`pullFromArray()` now accept `ObjectId` and objects with `id`/`_id` property |
9
+ | **Migration Effort** | 0 minutes (no code changes required) |
10
+
11
+ ---
12
+
13
+ ## Quick Migration
14
+
15
+ No code changes required.
16
+
17
+ ```bash
18
+ # Update package
19
+ pnpm add @lenne.tech/nest-server@11.24.1
20
+
21
+ # Verify build
22
+ pnpm run build
23
+
24
+ # Run tests
25
+ pnpm test
26
+ ```
27
+
28
+ ---
29
+
30
+ ## What's New in 11.24.1
31
+
32
+ ### Flexible `id` Parameter on `pushToArray()` / `pullFromArray()`
33
+
34
+ Both methods now accept `string`, `Types.ObjectId`, or any object with an `id`/`_id` property (e.g. a Mongoose Document). IDs are normalized internally via `getStringIds()`, consistent with the rest of the framework.
35
+
36
+ **Before (11.24.0):** Only `string` was accepted.
37
+ ```typescript
38
+ await this.entityService.pushToArray(entity.id, 'logs', newLog);
39
+ await this.entityService.pullFromArray(entity.id.toString(), 'tags', 'old');
40
+ ```
41
+
42
+ **After (11.24.1):** All ID types work directly.
43
+ ```typescript
44
+ // String (still works)
45
+ await this.entityService.pushToArray('507f1f77bcf86cd799439011', 'logs', newLog);
46
+
47
+ // ObjectId
48
+ await this.entityService.pushToArray(new Types.ObjectId(id), 'logs', newLog);
49
+
50
+ // Object with id property (e.g. Mongoose Document or entity reference)
51
+ await this.entityService.pushToArray(entity, 'logs', newLog);
52
+
53
+ // Same for pullFromArray
54
+ await this.entityService.pullFromArray(entity, 'tags', 'obsolete');
55
+ ```
56
+
57
+ ---
58
+
59
+ ## Compatibility Notes
60
+
61
+ Existing code using `string` IDs continues to work unchanged. The wider type signature is additive — no existing call sites break.
62
+
63
+ ---
64
+
65
+ ## References
66
+
67
+ - [Migration Guide 11.23.x → 11.24.0](./11.23.x-to-11.24.0.md) — Full list of changes in 11.24.0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lenne.tech/nest-server",
3
- "version": "11.23.1",
3
+ "version": "11.24.1",
4
4
  "description": "Modern, fast, powerful Node.js web framework in TypeScript based on Nest with a GraphQL API and a connection to MongoDB (or other databases).",
5
5
  "keywords": [
6
6
  "node",
@@ -124,7 +124,7 @@ export const checkRestricted = (
124
124
  removeUndefinedFromResultArray?: boolean;
125
125
  throwError?: boolean;
126
126
  } = {},
127
- processedObjects: any[] = [],
127
+ processedObjects: WeakSet<object> = new WeakSet(),
128
128
  ) => {
129
129
  // Act like Roles handling: checkObjectItself = false & mergeRoles = true
130
130
  // For Input: throwError = true
@@ -147,15 +147,81 @@ export const checkRestricted = (
147
147
  return data;
148
148
  }
149
149
 
150
- // Prevent infinite recourse
151
- if (processedObjects.includes(data)) {
150
+ // Prevent infinite recursion
151
+ if (processedObjects.has(data)) {
152
152
  return data;
153
153
  }
154
- processedObjects.push(data);
154
+ processedObjects.add(data);
155
155
 
156
156
  // Array
157
157
  if (Array.isArray(data)) {
158
- // Check array items
158
+ if (data.length === 0) {
159
+ return data;
160
+ }
161
+
162
+ // Optimization for typed arrays: all items share the same class → same @Restricted metadata.
163
+ // Validate restrictions on the first item, then apply the result to all items.
164
+ // Per-item checks are only needed when S_CREATOR or S_SELF restrictions exist
165
+ // (because createdBy/id differ per item).
166
+ const sample = data[0];
167
+ if (
168
+ sample &&
169
+ typeof sample === 'object' &&
170
+ !Array.isArray(sample) &&
171
+ sample.constructor &&
172
+ sample.constructor !== Object
173
+ ) {
174
+ // Check class-level restrictions once
175
+ const classRestrictions = getRestricted(sample.constructor) || [];
176
+ if (classRestrictions.length) {
177
+ const hasCreatorOrSelf = classRestrictions.some(
178
+ (r) =>
179
+ // Bare role string: @Restricted(RoleEnum.S_CREATOR)
180
+ r === RoleEnum.S_CREATOR ||
181
+ r === RoleEnum.S_SELF ||
182
+ // Role array: @Restricted([RoleEnum.S_CREATOR, RoleEnum.ADMIN])
183
+ (Array.isArray(r) && (r.includes(RoleEnum.S_CREATOR) || r.includes(RoleEnum.S_SELF))) ||
184
+ // Object with roles: @Restricted({ roles: RoleEnum.S_CREATOR }) or { roles: [...] }
185
+ (typeof r === 'object' &&
186
+ !Array.isArray(r) &&
187
+ 'roles' in r &&
188
+ r.roles &&
189
+ ((Array.isArray(r.roles) &&
190
+ (r.roles.includes(RoleEnum.S_CREATOR) || r.roles.includes(RoleEnum.S_SELF))) ||
191
+ r.roles === RoleEnum.S_CREATOR ||
192
+ r.roles === RoleEnum.S_SELF)),
193
+ );
194
+
195
+ if (!hasCreatorOrSelf) {
196
+ // No per-item ownership checks needed — validate one sample, apply to all.
197
+ // The sample check recurses into properties. With checkObjectItself=true, it
198
+ // also validates the class-level restriction as a standalone gate. With the
199
+ // default checkObjectItself=false, class restrictions are merged into each
200
+ // property's restrictions (properties get stripped if the class restriction denies).
201
+ const sampleResult = checkRestricted(sample, user, config, processedObjects);
202
+ if (sampleResult === undefined || sampleResult === null) {
203
+ // Class-level restriction blocks access → entire array is blocked
204
+ if (config.throwError) {
205
+ return data; // Exception was already thrown in sampleResult
206
+ }
207
+ return config.removeUndefinedFromResultArray ? [] : data.map(() => undefined);
208
+ }
209
+ // Sample passed — process remaining items with the same (cached) restriction lookups.
210
+ // Since getRestricted() uses a WeakMap cache, subsequent calls for the same class
211
+ // are O(1) lookups, but we still need to recurse into nested properties per item.
212
+ const result = [sampleResult];
213
+ for (let i = 1; i < data.length; i++) {
214
+ result.push(checkRestricted(data[i], user, config, processedObjects));
215
+ }
216
+ if (!config.throwError && config.removeUndefinedFromResultArray) {
217
+ return result.filter((item) => item !== undefined);
218
+ }
219
+ return result;
220
+ }
221
+ }
222
+ }
223
+
224
+ // Fallback: plain objects, mixed types, or S_CREATOR/S_SELF checks needed
159
225
  let result = data.map((item) => checkRestricted(item, user, config, processedObjects));
160
226
  if (!config.throwError && config.removeUndefinedFromResultArray) {
161
227
  result = result.filter((item) => item !== undefined);
@@ -243,7 +309,7 @@ export const checkRestricted = (
243
309
  const items = config.dbObject?.[property];
244
310
  if (items) {
245
311
  if (Array.isArray(items)) {
246
- members.concat(items);
312
+ members.push(...items);
247
313
  } else {
248
314
  members.push(items);
249
315
  }
@@ -13,6 +13,13 @@ import { ConfigService } from '../services/config.service';
13
13
  import { getStringIds } from './db.helper';
14
14
  import { clone, plainToInstanceClean, processDeep } from './input.helper';
15
15
 
16
+ // Secret field names for recursive removal in prepareOutput — allocated once, never change.
17
+ // Only fields that are NEVER needed internally (hashed passwords, one-time tokens).
18
+ // Fields like refreshTokens/tempTokens are kept — they are needed for token validation
19
+ // and process flows (password reset, email verification). The CheckSecurityInterceptor
20
+ // removes those from HTTP responses as a separate layer.
21
+ const SECRET_FIELD_NAMES = Object.freeze(['password', 'verificationToken', 'passwordResetToken']);
22
+
16
23
  /**
17
24
  * Helper class for services
18
25
  * @deprecated use functions directly
@@ -84,7 +91,6 @@ export async function prepareInput<T = any>(
84
91
  clone: false,
85
92
  convertObjectIdsToString: true,
86
93
  create: false,
87
- getNewArray: false,
88
94
  proto: false,
89
95
  removeUndefined: true,
90
96
  sha256: ConfigService.configFastButReadOnly.sha256,
@@ -98,14 +104,14 @@ export async function prepareInput<T = any>(
98
104
 
99
105
  // Process array
100
106
  if (Array.isArray(input)) {
101
- const processedArray = config.getNewArray ? ([] as any[] & T) : input;
102
- for (let i = 0; i <= input.length - 1; i++) {
103
- processedArray[i] = await prepareInput(input[i], currentUser, options);
104
- if (processedArray[i] === undefined && config.removeUndefined) {
105
- processedArray.splice(i, 1);
107
+ const result = [] as any[] & T;
108
+ for (let i = 0; i < input.length; i++) {
109
+ const processed = await prepareInput(input[i], currentUser, options);
110
+ if (processed !== undefined || !config.removeUndefined) {
111
+ result.push(processed);
106
112
  }
107
113
  }
108
- return processedArray;
114
+ return result;
109
115
  }
110
116
 
111
117
  // Clone input
@@ -197,7 +203,6 @@ export async function prepareOutput<T = { [key: string]: any; map: (...args: any
197
203
  const config = {
198
204
  circles: false,
199
205
  clone: false,
200
- getNewArray: false,
201
206
  objectIdsToStrings: true,
202
207
  proto: false,
203
208
  removeSecrets: true,
@@ -213,14 +218,14 @@ export async function prepareOutput<T = { [key: string]: any; map: (...args: any
213
218
 
214
219
  // Process array
215
220
  if (Array.isArray(output)) {
216
- const processedArray = config.getNewArray ? [] : output;
217
- for (let i = 0; i <= output.length - 1; i++) {
218
- processedArray[i] = await prepareOutput(output[i], options);
219
- if (processedArray[i] === undefined && config.removeUndefined) {
220
- processedArray.splice(i, 1);
221
+ const result = [];
222
+ for (let i = 0; i < output.length; i++) {
223
+ const processed = await prepareOutput(output[i], options);
224
+ if (processed !== undefined || !config.removeUndefined) {
225
+ result.push(processed);
221
226
  }
222
227
  }
223
- return processedArray;
228
+ return result;
224
229
  }
225
230
 
226
231
  // Clone output
@@ -241,21 +246,6 @@ export async function prepareOutput<T = { [key: string]: any; map: (...args: any
241
246
  }
242
247
  }
243
248
 
244
- // Remove password if exists
245
- if (config.removeSecrets && output.password) {
246
- output.password = undefined;
247
- }
248
-
249
- // Remove verification token if exists
250
- if (config.removeSecrets && output.verificationToken) {
251
- output.verificationToken = undefined;
252
- }
253
-
254
- // Remove password reset token if exists
255
- if (config.removeSecrets && output.passwordResetToken) {
256
- output.passwordResetToken = undefined;
257
- }
258
-
259
249
  // Remove undefined properties to avoid unwanted overwrites
260
250
  if (config.removeUndefined) {
261
251
  for (const [key, value] of Object.entries(output)) {
@@ -263,13 +253,29 @@ export async function prepareOutput<T = { [key: string]: any; map: (...args: any
263
253
  }
264
254
  }
265
255
 
266
- // Convert ObjectIds into strings
267
- if (config.objectIdsToStrings) {
268
- for (const [key, value] of Object.entries(output)) {
269
- if (value instanceof Types.ObjectId) {
270
- output[key] = value.toHexString();
271
- }
272
- }
256
+ // Single-pass DFS: convert ObjectIds to strings AND remove secret fields recursively.
257
+ // Merging both operations into one processDeep traversal halves the object graph walk
258
+ // compared to separate removeSecretsDeep + processDeep calls.
259
+ if (config.objectIdsToStrings || config.removeSecrets) {
260
+ const doSecrets = config.removeSecrets;
261
+ const doObjectIds = config.objectIdsToStrings;
262
+ output = processDeep(
263
+ output,
264
+ (property) => {
265
+ if (doObjectIds && property instanceof Types.ObjectId) {
266
+ return property.toHexString();
267
+ }
268
+ if (doSecrets && property && typeof property === 'object' && !Array.isArray(property)) {
269
+ for (const field of SECRET_FIELD_NAMES) {
270
+ if (field in property && property[field] !== undefined) {
271
+ property[field] = undefined;
272
+ }
273
+ }
274
+ }
275
+ return property;
276
+ },
277
+ { specialClasses: ['ObjectId'] },
278
+ );
273
279
  }
274
280
 
275
281
  // Add translated values of current selected language if _translations object exists
@@ -7,6 +7,7 @@ export interface PrepareInputOptions {
7
7
  clone?: boolean;
8
8
  convertObjectIdsToString?: boolean;
9
9
  create?: boolean;
10
+ /** @deprecated No longer used — array processing always returns a new array to prevent mutation bugs */
10
11
  getNewArray?: boolean;
11
12
  removeUndefined?: boolean;
12
13
  targetModel?: new (...args: any[]) => any;
@@ -4,6 +4,7 @@
4
4
  export interface PrepareOutputOptions {
5
5
  [key: string]: any;
6
6
  clone?: boolean;
7
+ /** @deprecated No longer used — array processing always returns a new array to prevent mutation bugs */
7
8
  getNewArray?: boolean;
8
9
  removeSecrets?: boolean;
9
10
  removeUndefined?: boolean;
@@ -181,7 +181,13 @@ function isRoleChangeAllowed(): boolean {
181
181
  }
182
182
 
183
183
  function hasRolesInUpdate(update: any): boolean {
184
- return !!(update?.roles || update?.$set?.roles || update?.$push?.roles || update?.$addToSet?.roles);
184
+ return !!(
185
+ update?.roles ||
186
+ update?.$set?.roles ||
187
+ update?.$push?.roles ||
188
+ update?.$addToSet?.roles ||
189
+ update?.$pull?.roles
190
+ );
185
191
  }
186
192
 
187
193
  function handleUpdateRoleGuard(update: any) {
@@ -189,7 +195,8 @@ function handleUpdateRoleGuard(update: any) {
189
195
  return;
190
196
  }
191
197
 
192
- const hasRolesUpdate = update.roles || update.$set?.roles || update.$push?.roles || update.$addToSet?.roles;
198
+ const hasRolesUpdate =
199
+ update.roles || update.$set?.roles || update?.$push?.roles || update.$addToSet?.roles || update.$pull?.roles;
193
200
  if (!hasRolesUpdate) {
194
201
  return;
195
202
  }
@@ -211,4 +218,7 @@ function handleUpdateRoleGuard(update: any) {
211
218
  if (update.$addToSet?.roles) {
212
219
  delete update.$addToSet.roles;
213
220
  }
221
+ if (update.$pull?.roles) {
222
+ delete update.$pull.roles;
223
+ }
214
224
  }