vanta-api 1.4.0 → 1.4.2

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "vanta-api",
3
- "version": "1.4.0",
3
+ "version": "1.4.2",
4
4
  "description": "Advanced API features and security configuration for Node.js/MongoDB.",
5
5
  "main": "index.js",
6
6
  "scripts": {
@@ -15,6 +15,7 @@ const logger = winston.createLogger({
15
15
  });
16
16
 
17
17
  const RESERVED_QUERY_KEYS = ["page", "limit", "sort", "fields", "populate", "q"];
18
+ const LOGICAL_OPERATORS = ["$and", "$or", "$nor"];
18
19
 
19
20
  export class ApiFeatures {
20
21
  constructor(model, query = {}, userRole = "") {
@@ -32,7 +33,10 @@ export class ApiFeatures {
32
33
 
33
34
  filter() {
34
35
  const queryFilters = this._parseQueryFilters();
35
- const mergedFilters = this._deepMergeFilters(queryFilters, this.manualFilters);
36
+ const mergedFilters = this._deepMergeFilters(
37
+ queryFilters,
38
+ this.manualFilters
39
+ );
36
40
  const sanitizedFilters = this._sanitizeFilters(mergedFilters);
37
41
  const safeFilters = this._applySecurityFilters(sanitizedFilters);
38
42
 
@@ -60,15 +64,19 @@ export class ApiFeatures {
60
64
 
61
65
  if (!safeQ) return this;
62
66
 
63
- this.pipeline.push({
64
- $match: {
65
- $or: fields
66
- .filter((field) => typeof field === "string" && field.trim())
67
- .map((field) => ({
68
- [field]: { $regex: safeQ, $options: "i" },
69
- })),
70
- },
71
- });
67
+ const conditions = fields
68
+ .filter((field) => typeof field === "string" && field.trim())
69
+ .map((field) => ({
70
+ [field]: { $regex: safeQ, $options: "i" },
71
+ }));
72
+
73
+ if (conditions.length) {
74
+ this.pipeline.push({
75
+ $match: {
76
+ $or: conditions,
77
+ },
78
+ });
79
+ }
72
80
 
73
81
  return this;
74
82
  }
@@ -81,7 +89,7 @@ export class ApiFeatures {
81
89
 
82
90
  String(this.query.sort)
83
91
  .split(",")
84
- .map((p) => p.trim())
92
+ .map((part) => part.trim())
85
93
  .filter(Boolean)
86
94
  .forEach((part) => {
87
95
  const direction = part.startsWith("-") ? -1 : 1;
@@ -155,9 +163,10 @@ export class ApiFeatures {
155
163
 
156
164
  for (const populateItem of populateList) {
157
165
  this._addPopulateStages({
158
- model: this.model,
159
166
  populateItem,
160
- parentAlias: "",
167
+ parentPath: "",
168
+ parentIsArray: false,
169
+ schema: this.model.schema,
161
170
  allowedPopulate,
162
171
  });
163
172
  }
@@ -173,7 +182,7 @@ export class ApiFeatures {
173
182
  logger.info("Pipeline:", this.pipeline);
174
183
  }
175
184
 
176
- if (this.pipeline.length > (securityConfig.maxPipelineStages || 50)) {
185
+ if (this.pipeline.length > (securityConfig.maxPipelineStages || 80)) {
177
186
  throw new HandleERROR("Too many pipeline stages", 400);
178
187
  }
179
188
 
@@ -216,6 +225,420 @@ export class ApiFeatures {
216
225
  }
217
226
  }
218
227
 
228
+ _addPopulateStages({
229
+ populateItem,
230
+ parentPath = "",
231
+ parentIsArray = false,
232
+ schema = null,
233
+ allowedPopulate = [],
234
+ }) {
235
+ if (!populateItem || !populateItem.path) return;
236
+
237
+ const path = populateItem.path;
238
+ const fullPath = parentPath ? `${parentPath}.${path}` : path;
239
+
240
+ if (!this._isPopulateAllowed(fullPath, allowedPopulate)) {
241
+ return;
242
+ }
243
+
244
+ const info = this._getPopulateInfo({
245
+ schema,
246
+ path,
247
+ fullPath,
248
+ parentPath,
249
+ parentIsArray,
250
+ populateItem,
251
+ });
252
+
253
+ if (!parentIsArray) {
254
+ this.pipeline.push({
255
+ $lookup: {
256
+ from: info.collection,
257
+ localField: info.localField,
258
+ foreignField: "_id",
259
+ as: info.as,
260
+ },
261
+ });
262
+
263
+ if (!info.isArray) {
264
+ this.pipeline.push({
265
+ $unwind: {
266
+ path: `$${info.as}`,
267
+ preserveNullAndEmptyArrays: true,
268
+ },
269
+ });
270
+ }
271
+
272
+ if (populateItem.populate) {
273
+ const nestedList = this._normalizeNestedPopulate(populateItem.populate);
274
+
275
+ for (const nested of nestedList) {
276
+ this._addPopulateStages({
277
+ populateItem: nested,
278
+ parentPath: info.as,
279
+ parentIsArray: info.isArray,
280
+ schema: info.refSchema,
281
+ allowedPopulate,
282
+ });
283
+ }
284
+ }
285
+
286
+ if (populateItem.select) {
287
+ this._applyPopulateSelect({
288
+ path: info.as,
289
+ select: populateItem.select,
290
+ isArray: info.isArray,
291
+ });
292
+ }
293
+
294
+ return;
295
+ }
296
+
297
+ const tempLookupName = this._makeTempLookupName(fullPath);
298
+
299
+ this.pipeline.push({
300
+ $lookup: {
301
+ from: info.collection,
302
+ localField: info.localField,
303
+ foreignField: "_id",
304
+ as: tempLookupName,
305
+ },
306
+ });
307
+
308
+ this.pipeline.push({
309
+ $set: {
310
+ [parentPath]: {
311
+ $map: {
312
+ input: { $ifNull: [`$${parentPath}`, []] },
313
+ as: "item",
314
+ in: {
315
+ $mergeObjects: [
316
+ "$$item",
317
+ {
318
+ [path]: {
319
+ $first: {
320
+ $filter: {
321
+ input: `$${tempLookupName}`,
322
+ as: "joined",
323
+ cond: {
324
+ $eq: ["$$joined._id", `$$item.${path}`],
325
+ },
326
+ },
327
+ },
328
+ },
329
+ },
330
+ ],
331
+ },
332
+ },
333
+ },
334
+ },
335
+ });
336
+
337
+ this.pipeline.push({ $unset: tempLookupName });
338
+
339
+ if (populateItem.populate) {
340
+ const nestedList = this._normalizeNestedPopulate(populateItem.populate);
341
+
342
+ for (const nested of nestedList) {
343
+ this._addNestedPopulateInsideArrayItem({
344
+ arrayPath: parentPath,
345
+ objectPath: path,
346
+ populateItem: nested,
347
+ allowedPopulate,
348
+ schema: info.refSchema,
349
+ });
350
+ }
351
+ }
352
+
353
+ if (populateItem.select) {
354
+ this._applyNestedObjectSelectInsideArray({
355
+ arrayPath: parentPath,
356
+ objectPath: path,
357
+ select: populateItem.select,
358
+ });
359
+ }
360
+ }
361
+
362
+ _addNestedPopulateInsideArrayItem({
363
+ arrayPath,
364
+ objectPath,
365
+ populateItem,
366
+ allowedPopulate = [],
367
+ schema = null,
368
+ }) {
369
+ if (!populateItem || !populateItem.path) return;
370
+
371
+ const childPath = populateItem.path;
372
+ const fullPath = `${arrayPath}.${objectPath}.${childPath}`;
373
+
374
+ if (!this._isPopulateAllowed(fullPath, allowedPopulate)) {
375
+ return;
376
+ }
377
+
378
+ const info = this._getPopulateInfo({
379
+ schema,
380
+ path: childPath,
381
+ fullPath,
382
+ parentPath: `${arrayPath}.${objectPath}`,
383
+ parentIsArray: true,
384
+ populateItem,
385
+ });
386
+
387
+ const tempLookupName = this._makeTempLookupName(fullPath);
388
+
389
+ this.pipeline.push({
390
+ $lookup: {
391
+ from: info.collection,
392
+ localField: `${arrayPath}.${objectPath}.${childPath}`,
393
+ foreignField: "_id",
394
+ as: tempLookupName,
395
+ },
396
+ });
397
+
398
+ this.pipeline.push({
399
+ $set: {
400
+ [arrayPath]: {
401
+ $map: {
402
+ input: { $ifNull: [`$${arrayPath}`, []] },
403
+ as: "item",
404
+ in: {
405
+ $mergeObjects: [
406
+ "$$item",
407
+ {
408
+ [objectPath]: {
409
+ $cond: [
410
+ { $ne: [`$$item.${objectPath}`, null] },
411
+ {
412
+ $mergeObjects: [
413
+ `$$item.${objectPath}`,
414
+ {
415
+ [childPath]: {
416
+ $first: {
417
+ $filter: {
418
+ input: `$${tempLookupName}`,
419
+ as: "joined",
420
+ cond: {
421
+ $eq: [
422
+ "$$joined._id",
423
+ `$$item.${objectPath}.${childPath}`,
424
+ ],
425
+ },
426
+ },
427
+ },
428
+ },
429
+ },
430
+ ],
431
+ },
432
+ `$$item.${objectPath}`,
433
+ ],
434
+ },
435
+ },
436
+ ],
437
+ },
438
+ },
439
+ },
440
+ },
441
+ });
442
+
443
+ this.pipeline.push({ $unset: tempLookupName });
444
+
445
+ if (populateItem.populate) {
446
+ const nestedList = this._normalizeNestedPopulate(populateItem.populate);
447
+
448
+ for (const nested of nestedList) {
449
+ this._addNestedPopulateInsideArrayItem({
450
+ arrayPath,
451
+ objectPath: `${objectPath}.${childPath}`,
452
+ populateItem: nested,
453
+ allowedPopulate,
454
+ schema: info.refSchema,
455
+ });
456
+ }
457
+ }
458
+
459
+ if (populateItem.select) {
460
+ this._applyNestedObjectSelectInsideArray({
461
+ arrayPath,
462
+ objectPath: `${objectPath}.${childPath}`,
463
+ select: populateItem.select,
464
+ });
465
+ }
466
+ }
467
+
468
+ _getPopulateInfo({
469
+ schema = null,
470
+ path,
471
+ fullPath,
472
+ parentPath = "",
473
+ parentIsArray = false,
474
+ populateItem = {},
475
+ }) {
476
+ const schemaPath = schema?.path?.(path);
477
+
478
+ const isArray =
479
+ populateItem.isArray === true ||
480
+ schemaPath?.instance === "Array" ||
481
+ Array.isArray(schemaPath?.options?.type);
482
+
483
+ const refModelName =
484
+ populateItem.ref ||
485
+ populateItem.modelName ||
486
+ schemaPath?.options?.ref ||
487
+ schemaPath?.caster?.options?.ref ||
488
+ (Array.isArray(schemaPath?.options?.type)
489
+ ? schemaPath.options.type[0]?.ref
490
+ : undefined) ||
491
+ this._inferModelNameFromPath(path);
492
+
493
+ const collection =
494
+ populateItem.collection ||
495
+ populateItem.from ||
496
+ this._resolveCollectionName(refModelName);
497
+
498
+ const refSchema = this._resolveRegisteredSchema(refModelName);
499
+
500
+ return {
501
+ path,
502
+ fullPath,
503
+ refModelName,
504
+ collection,
505
+ refSchema,
506
+ isArray,
507
+ localField: parentIsArray ? fullPath : fullPath,
508
+ as: fullPath,
509
+ parentPath,
510
+ parentIsArray,
511
+ };
512
+ }
513
+
514
+ _applyPopulateSelect({ path, select, isArray }) {
515
+ const parsed = this._parseSelect(select);
516
+
517
+ if (!parsed.fields.length) return;
518
+
519
+ if (parsed.mode === "exclude") {
520
+ this.pipeline.push({
521
+ $unset: parsed.fields.map((field) => `${path}.${field}`),
522
+ });
523
+ return;
524
+ }
525
+
526
+ const includeFields = this._ensureIdField(parsed.fields);
527
+
528
+ if (isArray) {
529
+ const selectedObject = {};
530
+
531
+ for (const field of includeFields) {
532
+ selectedObject[field] = `$$item.${field}`;
533
+ }
534
+
535
+ this.pipeline.push({
536
+ $set: {
537
+ [path]: {
538
+ $map: {
539
+ input: { $ifNull: [`$${path}`, []] },
540
+ as: "item",
541
+ in: selectedObject,
542
+ },
543
+ },
544
+ },
545
+ });
546
+
547
+ return;
548
+ }
549
+
550
+ const selectedObject = {};
551
+
552
+ for (const field of includeFields) {
553
+ selectedObject[field] = `$${path}.${field}`;
554
+ }
555
+
556
+ this.pipeline.push({
557
+ $set: {
558
+ [path]: {
559
+ $cond: [{ $ne: [`$${path}`, null] }, selectedObject, `$${path}`],
560
+ },
561
+ },
562
+ });
563
+ }
564
+
565
+ _applyNestedObjectSelectInsideArray({ arrayPath, objectPath, select }) {
566
+ const parsed = this._parseSelect(select);
567
+
568
+ if (!parsed.fields.length) return;
569
+
570
+ if (parsed.mode === "exclude") {
571
+ this.pipeline.push({
572
+ $unset: parsed.fields.map(
573
+ (field) => `${arrayPath}.${objectPath}.${field}`
574
+ ),
575
+ });
576
+ return;
577
+ }
578
+
579
+ const includeFields = this._ensureIdField(parsed.fields);
580
+ const selectedObject = {};
581
+
582
+ for (const field of includeFields) {
583
+ selectedObject[field] = `$$item.${objectPath}.${field}`;
584
+ }
585
+
586
+ this.pipeline.push({
587
+ $set: {
588
+ [arrayPath]: {
589
+ $map: {
590
+ input: { $ifNull: [`$${arrayPath}`, []] },
591
+ as: "item",
592
+ in: {
593
+ $mergeObjects: [
594
+ "$$item",
595
+ {
596
+ [objectPath]: {
597
+ $cond: [
598
+ { $ne: [`$$item.${objectPath}`, null] },
599
+ selectedObject,
600
+ `$$item.${objectPath}`,
601
+ ],
602
+ },
603
+ },
604
+ ],
605
+ },
606
+ },
607
+ },
608
+ },
609
+ });
610
+ }
611
+
612
+ _parseSelect(select = "") {
613
+ const fields = String(select)
614
+ .split(/\s+/)
615
+ .map((field) => field.trim())
616
+ .filter(Boolean)
617
+ .filter((field) => {
618
+ const cleanField = field.replace(/^-/, "");
619
+ return !this._isForbiddenField(cleanField);
620
+ });
621
+
622
+ const hasInclude = fields.some((field) => !field.startsWith("-"));
623
+ const hasExclude = fields.some((field) => field.startsWith("-"));
624
+
625
+ if (hasInclude && hasExclude) {
626
+ throw new HandleERROR(
627
+ "Cannot mix include and exclude in populate select",
628
+ 400
629
+ );
630
+ }
631
+
632
+ return {
633
+ mode: hasExclude ? "exclude" : "include",
634
+ fields: fields.map((field) => field.replace(/^-/, "")),
635
+ };
636
+ }
637
+
638
+ _ensureIdField(fields = []) {
639
+ return fields.includes("_id") ? fields : ["_id", ...fields];
640
+ }
641
+
219
642
  _sanitization() {
220
643
  for (const key of Object.keys(this.query)) {
221
644
  if (
@@ -344,7 +767,7 @@ export class ApiFeatures {
344
767
  out[key] &&
345
768
  typeof out[key] === "object" &&
346
769
  !Array.isArray(out[key]) &&
347
- !["$and", "$or", "$nor"].includes(key)
770
+ !LOGICAL_OPERATORS.includes(key)
348
771
  ) {
349
772
  out[key] = this._deepMergeFilters(out[key], value);
350
773
  } else {
@@ -356,13 +779,27 @@ export class ApiFeatures {
356
779
  }
357
780
 
358
781
  _applySecurityFilters(filters = {}) {
359
- const clean = { ...filters };
782
+ const cleanNode = (node) => {
783
+ if (Array.isArray(node)) {
784
+ return node.map(cleanNode);
785
+ }
360
786
 
361
- for (const field of securityConfig.forbiddenFields || []) {
362
- delete clean[field];
363
- }
787
+ if (!node || typeof node !== "object") {
788
+ return node;
789
+ }
790
+
791
+ const result = {};
364
792
 
365
- return clean;
793
+ for (const [key, value] of Object.entries(node)) {
794
+ if (this._isForbiddenField(key)) continue;
795
+
796
+ result[key] = cleanNode(value);
797
+ }
798
+
799
+ return result;
800
+ };
801
+
802
+ return cleanNode(filters);
366
803
  }
367
804
 
368
805
  _normalizePopulateInput(input = "") {
@@ -388,16 +825,7 @@ export class ApiFeatures {
388
825
  if (!trimmed) return;
389
826
 
390
827
  if (trimmed.includes(".")) {
391
- const parts = trimmed.split(".").filter(Boolean);
392
- const root = { path: parts[0] };
393
- let current = root;
394
-
395
- for (const part of parts.slice(1)) {
396
- current.populate = { path: part };
397
- current = current.populate;
398
- }
399
-
400
- normalized.push(root);
828
+ normalized.push(this._dotPathToPopulate(trimmed));
401
829
  } else {
402
830
  normalized.push({ path: trimmed });
403
831
  }
@@ -411,7 +839,7 @@ export class ApiFeatures {
411
839
  }
412
840
 
413
841
  if (item && typeof item === "object" && item.path) {
414
- normalized.push(item);
842
+ normalized.push(this._normalizePopulateObject(item));
415
843
  }
416
844
  };
417
845
 
@@ -420,159 +848,105 @@ export class ApiFeatures {
420
848
  return this._dedupePopulate(normalized);
421
849
  }
422
850
 
423
- _dedupePopulate(items) {
424
- const map = new Map();
851
+ _normalizePopulateObject(item) {
852
+ const normalized = { ...item };
425
853
 
426
- for (const item of items) {
427
- map.set(item.path, item);
854
+ if (typeof normalized.path === "string") {
855
+ normalized.path = normalized.path.trim();
428
856
  }
429
857
 
430
- return [...map.values()];
431
- }
432
-
433
- _addPopulateStages({ model, populateItem, parentAlias, allowedPopulate }) {
434
- const path = populateItem.path;
435
- const fullPath = parentAlias ? `${parentAlias}.${path}` : path;
436
-
437
- if (!this._isPopulateAllowed(fullPath, allowedPopulate)) {
438
- return;
858
+ if (typeof normalized.select === "string") {
859
+ normalized.select = normalized.select.trim();
439
860
  }
440
861
 
441
- const info = this._getPopulateInfo(model, path, parentAlias);
442
-
443
- this.pipeline.push({
444
- $lookup: {
445
- from: info.collection,
446
- localField: info.localField,
447
- foreignField: "_id",
448
- as: info.as,
449
- },
450
- });
451
-
452
- if (!info.isArray) {
453
- this.pipeline.push({
454
- $unwind: {
455
- path: `$${info.as}`,
456
- preserveNullAndEmptyArrays: true,
457
- },
458
- });
862
+ if (normalized.populate) {
863
+ normalized.populate = this._normalizeNestedPopulate(normalized.populate);
459
864
  }
460
865
 
461
- if (populateItem.select) {
462
- const project = this._buildPopulateProjection(populateItem.select, info.as);
463
-
464
- if (Object.keys(project).length) {
465
- this.pipeline.push({ $project: project });
466
- }
467
- }
468
-
469
- if (populateItem.populate) {
470
- const nestedList = this._normalizeNestedPopulate(populateItem.populate);
471
-
472
- for (const nested of nestedList) {
473
- this._addPopulateStages({
474
- model: info.refModel,
475
- populateItem: nested,
476
- parentAlias: info.as,
477
- allowedPopulate,
478
- });
479
- }
480
- }
866
+ return normalized;
481
867
  }
482
868
 
483
869
  _normalizeNestedPopulate(input) {
484
870
  if (!input) return [];
485
871
 
486
- if (Array.isArray(input)) {
487
- return input.flatMap((item) => this._normalizeNestedPopulate(item));
488
- }
489
-
490
872
  if (typeof input === "string") {
491
873
  return this._normalizePopulateInput(input);
492
874
  }
493
875
 
876
+ if (Array.isArray(input)) {
877
+ return input
878
+ .flatMap((item) => {
879
+ if (typeof item === "string") return this._normalizePopulateInput(item);
880
+ if (item && typeof item === "object" && item.path) {
881
+ return [this._normalizePopulateObject(item)];
882
+ }
883
+ return [];
884
+ })
885
+ .filter(Boolean);
886
+ }
887
+
494
888
  if (typeof input === "object" && input.path) {
495
- return [input];
889
+ return [this._normalizePopulateObject(input)];
496
890
  }
497
891
 
498
892
  return [];
499
893
  }
500
894
 
501
- _getPopulateInfo(model, path, parentAlias = "") {
502
- const schemaPath = model.schema.path(path);
503
-
504
- if (!schemaPath) {
505
- throw new HandleERROR(`Invalid populate path: ${path}`, 400);
506
- }
895
+ _dotPathToPopulate(path) {
896
+ const parts = path
897
+ .split(".")
898
+ .map((part) => part.trim())
899
+ .filter(Boolean);
507
900
 
508
- const isArray = schemaPath.instance === "Array";
901
+ const root = { path: parts[0] };
902
+ let current = root;
509
903
 
510
- const refModelName =
511
- schemaPath.options?.ref ||
512
- schemaPath.caster?.options?.ref ||
513
- (Array.isArray(schemaPath.options?.type)
514
- ? schemaPath.options.type[0]?.ref
515
- : undefined);
516
-
517
- if (!refModelName) {
518
- throw new HandleERROR(`Populate path has no ref: ${path}`, 400);
904
+ for (const part of parts.slice(1)) {
905
+ current.populate = { path: part };
906
+ current = current.populate;
519
907
  }
520
908
 
521
- const refModel = this._resolveModel(refModelName, model);
522
-
523
- const as = parentAlias ? `${parentAlias}.${path}` : path;
524
-
525
- return {
526
- refModel,
527
- collection: this._resolveCollectionName(refModelName, refModel),
528
- localField: as,
529
- foreignField: "_id",
530
- as,
531
- isArray,
532
- };
909
+ return root;
533
910
  }
534
911
 
535
- _resolveModel(refModelName, currentModel) {
536
- const connection = currentModel?.db || this.model?.db || mongoose.connection;
912
+ _dedupePopulate(items) {
913
+ const map = new Map();
914
+
915
+ for (const item of items) {
916
+ if (!item?.path) continue;
537
917
 
538
- return connection.models?.[refModelName] || mongoose.models?.[refModelName] || null;
539
- }
918
+ if (!map.has(item.path)) {
919
+ map.set(item.path, item);
920
+ continue;
921
+ }
540
922
 
541
- _resolveCollectionName(refModelName, refModel) {
542
- if (refModel?.collection?.name) {
543
- return refModel.collection.name;
923
+ const existing = map.get(item.path);
924
+ map.set(item.path, this._mergePopulateOptions(existing, item));
544
925
  }
545
926
 
546
- return pluralize(String(refModelName).toLowerCase());
927
+ return [...map.values()];
547
928
  }
548
929
 
549
- _buildPopulateProjection(select, alias) {
550
- const fields = String(select)
551
- .split(" ")
552
- .map((field) => field.trim())
553
- .filter(Boolean);
554
-
555
- const hasInclude = fields.some((field) => !field.startsWith("-"));
556
- const hasExclude = fields.some((field) => field.startsWith("-"));
930
+ _mergePopulateOptions(a, b) {
931
+ const merged = { ...a, ...b };
557
932
 
558
- if (hasInclude && hasExclude) {
559
- throw new HandleERROR(
560
- "Cannot mix include and exclude in populate select",
561
- 400
562
- );
563
- }
933
+ if (a.populate || b.populate) {
934
+ const aList = Array.isArray(a.populate)
935
+ ? a.populate
936
+ : a.populate
937
+ ? [a.populate]
938
+ : [];
564
939
 
565
- const project = {};
940
+ const bList = Array.isArray(b.populate)
941
+ ? b.populate
942
+ : b.populate
943
+ ? [b.populate]
944
+ : [];
566
945
 
567
- for (const field of fields) {
568
- const cleanField = field.replace(/^-/, "");
569
-
570
- if (this._isForbiddenField(cleanField)) continue;
571
-
572
- project[`${alias}.${cleanField}`] = field.startsWith("-") ? 0 : 1;
946
+ merged.populate = this._dedupePopulate([...aList, ...bList]);
573
947
  }
574
948
 
575
- return project;
949
+ return merged;
576
950
  }
577
951
 
578
952
  _isPopulateAllowed(path, allowedPopulate = []) {
@@ -583,6 +957,36 @@ export class ApiFeatures {
583
957
  );
584
958
  }
585
959
 
960
+ _resolveCollectionName(refModelName = "") {
961
+ return pluralize(String(refModelName).toLowerCase());
962
+ }
963
+
964
+ _resolveRegisteredSchema(refModelName = "") {
965
+ const connection = this.model?.db || mongoose.connection;
966
+ const registeredModel =
967
+ connection.models?.[refModelName] || mongoose.models?.[refModelName];
968
+
969
+ return registeredModel?.schema || null;
970
+ }
971
+
972
+ _inferModelNameFromPath(path = "") {
973
+ let clean = String(path).split(".").pop();
974
+
975
+ clean = clean.replace(/Ids$/i, "");
976
+ clean = clean.replace(/Id$/i, "");
977
+ clean = pluralize.singular(clean);
978
+
979
+ return clean
980
+ .split(/[_-\s]+/)
981
+ .filter(Boolean)
982
+ .map((part) => part.charAt(0).toUpperCase() + part.slice(1))
983
+ .join("");
984
+ }
985
+
986
+ _makeTempLookupName(path = "") {
987
+ return `__vanta_lookup_${String(path).replace(/[^a-zA-Z0-9]/g, "_")}`;
988
+ }
989
+
586
990
  _isForbiddenField(field) {
587
991
  return (securityConfig.forbiddenFields || []).includes(field);
588
992
  }
@@ -593,7 +997,11 @@ export class ApiFeatures {
593
997
  "$skip" in stage ||
594
998
  "$limit" in stage ||
595
999
  "$sort" in stage ||
596
- "$project" in stage
1000
+ "$project" in stage ||
1001
+ "$lookup" in stage ||
1002
+ "$unwind" in stage ||
1003
+ "$set" in stage ||
1004
+ "$unset" in stage
597
1005
  );
598
1006
  });
599
1007
  }
@@ -1,8 +1,6 @@
1
1
  const catchError=(err,req,res,next)=>{
2
2
  err.statusCode=err.statusCode || 500
3
- err.status=err.status||'error'
4
3
  res.status(err.statusCode).json({
5
- status:err.status,
6
4
  success:false,
7
5
  message:err.message
8
6
  })