@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.
- package/CLAUDE.md +67 -13
- package/FRAMEWORK-API.md +3 -1
- package/dist/core/common/decorators/restricted.decorator.d.ts +1 -1
- package/dist/core/common/decorators/restricted.decorator.js +45 -4
- package/dist/core/common/decorators/restricted.decorator.js.map +1 -1
- package/dist/core/common/helpers/service.helper.js +28 -28
- package/dist/core/common/helpers/service.helper.js.map +1 -1
- package/dist/core/common/plugins/mongoose-role-guard.plugin.js +9 -2
- package/dist/core/common/plugins/mongoose-role-guard.plugin.js.map +1 -1
- package/dist/core/common/services/crud.service.d.ts +13 -1
- package/dist/core/common/services/crud.service.js +34 -0
- package/dist/core/common/services/crud.service.js.map +1 -1
- package/dist/tsconfig.build.tsbuildinfo +1 -1
- package/migration-guides/11.23.x-to-11.24.0.md +272 -0
- package/migration-guides/11.24.0-to-11.24.1.md +67 -0
- package/package.json +1 -1
- package/src/core/common/decorators/restricted.decorator.ts +72 -6
- package/src/core/common/helpers/service.helper.ts +42 -36
- package/src/core/common/interfaces/prepare-input-options.interface.ts +1 -0
- package/src/core/common/interfaces/prepare-output-options.interface.ts +1 -0
- package/src/core/common/plugins/mongoose-role-guard.plugin.ts +12 -2
- package/src/core/common/services/crud.service.ts +86 -0
|
@@ -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.
|
|
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:
|
|
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
|
|
151
|
-
if (processedObjects.
|
|
150
|
+
// Prevent infinite recursion
|
|
151
|
+
if (processedObjects.has(data)) {
|
|
152
152
|
return data;
|
|
153
153
|
}
|
|
154
|
-
processedObjects.
|
|
154
|
+
processedObjects.add(data);
|
|
155
155
|
|
|
156
156
|
// Array
|
|
157
157
|
if (Array.isArray(data)) {
|
|
158
|
-
|
|
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.
|
|
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
|
|
102
|
-
for (let i = 0; i
|
|
103
|
-
|
|
104
|
-
if (
|
|
105
|
-
|
|
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
|
|
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
|
|
217
|
-
for (let i = 0; i
|
|
218
|
-
|
|
219
|
-
if (
|
|
220
|
-
|
|
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
|
|
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
|
-
//
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
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 !!(
|
|
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 =
|
|
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
|
}
|