vanta-api 1.4.0 → 1.4.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "vanta-api",
3
- "version": "1.4.0",
3
+ "version": "1.4.1",
4
4
  "description": "Advanced API features and security configuration for Node.js/MongoDB.",
5
5
  "main": "index.js",
6
6
  "scripts": {
@@ -1,6 +1,5 @@
1
1
  import mongoose from "mongoose";
2
2
  import winston from "winston";
3
- import pluralize from "pluralize";
4
3
  import HandleERROR from "./handleError.js";
5
4
  import { securityConfig } from "./config.js";
6
5
  import { ObjectId } from "bson";
@@ -15,6 +14,7 @@ const logger = winston.createLogger({
15
14
  });
16
15
 
17
16
  const RESERVED_QUERY_KEYS = ["page", "limit", "sort", "fields", "populate", "q"];
17
+ const LOGICAL_OPERATORS = ["$and", "$or", "$nor"];
18
18
 
19
19
  export class ApiFeatures {
20
20
  constructor(model, query = {}, userRole = "") {
@@ -22,6 +22,7 @@ export class ApiFeatures {
22
22
  this.query = { ...query };
23
23
  this.pipeline = [];
24
24
  this.manualFilters = {};
25
+ this.populateOptions = [];
25
26
  this.useCursor = false;
26
27
 
27
28
  this.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;
@@ -153,14 +161,11 @@ export class ApiFeatures {
153
161
  const allowedPopulate =
154
162
  securityConfig.accessLevels?.[this.userRole]?.allowedPopulate || [];
155
163
 
156
- for (const populateItem of populateList) {
157
- this._addPopulateStages({
158
- model: this.model,
159
- populateItem,
160
- parentAlias: "",
161
- allowedPopulate,
162
- });
163
- }
164
+ const safePopulateList = populateList
165
+ .map((item) => this._sanitizePopulateOption(item, "", allowedPopulate))
166
+ .filter(Boolean);
167
+
168
+ this.populateOptions.push(...safePopulateList);
164
169
 
165
170
  return this;
166
171
  }
@@ -171,6 +176,7 @@ export class ApiFeatures {
171
176
 
172
177
  if (options.debug) {
173
178
  logger.info("Pipeline:", this.pipeline);
179
+ logger.info("Populate:", this.populateOptions);
174
180
  }
175
181
 
176
182
  if (this.pipeline.length > (securityConfig.maxPipelineStages || 50)) {
@@ -206,6 +212,10 @@ export class ApiFeatures {
206
212
  .readConcern(options.readConcern || "majority");
207
213
  }
208
214
 
215
+ if (this.populateOptions.length) {
216
+ data = await this.model.populate(data, this.populateOptions);
217
+ }
218
+
209
219
  return {
210
220
  success: true,
211
221
  count: countResult?.total || 0,
@@ -344,7 +354,7 @@ export class ApiFeatures {
344
354
  out[key] &&
345
355
  typeof out[key] === "object" &&
346
356
  !Array.isArray(out[key]) &&
347
- !["$and", "$or", "$nor"].includes(key)
357
+ !LOGICAL_OPERATORS.includes(key)
348
358
  ) {
349
359
  out[key] = this._deepMergeFilters(out[key], value);
350
360
  } else {
@@ -356,13 +366,27 @@ export class ApiFeatures {
356
366
  }
357
367
 
358
368
  _applySecurityFilters(filters = {}) {
359
- const clean = { ...filters };
369
+ const cleanNode = (node) => {
370
+ if (Array.isArray(node)) {
371
+ return node.map(cleanNode);
372
+ }
360
373
 
361
- for (const field of securityConfig.forbiddenFields || []) {
362
- delete clean[field];
363
- }
374
+ if (!node || typeof node !== "object") {
375
+ return node;
376
+ }
377
+
378
+ const result = {};
364
379
 
365
- return clean;
380
+ for (const [key, value] of Object.entries(node)) {
381
+ if (this._isForbiddenField(key)) continue;
382
+
383
+ result[key] = cleanNode(value);
384
+ }
385
+
386
+ return result;
387
+ };
388
+
389
+ return cleanNode(filters);
366
390
  }
367
391
 
368
392
  _normalizePopulateInput(input = "") {
@@ -388,16 +412,7 @@ export class ApiFeatures {
388
412
  if (!trimmed) return;
389
413
 
390
414
  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);
415
+ normalized.push(this._dotPathToPopulate(trimmed));
401
416
  } else {
402
417
  normalized.push({ path: trimmed });
403
418
  }
@@ -411,7 +426,7 @@ export class ApiFeatures {
411
426
  }
412
427
 
413
428
  if (item && typeof item === "object" && item.path) {
414
- normalized.push(item);
429
+ normalized.push(this._normalizePopulateObject(item));
415
430
  }
416
431
  };
417
432
 
@@ -420,137 +435,156 @@ export class ApiFeatures {
420
435
  return this._dedupePopulate(normalized);
421
436
  }
422
437
 
423
- _dedupePopulate(items) {
424
- const map = new Map();
438
+ _normalizePopulateObject(item) {
439
+ const normalized = { ...item };
425
440
 
426
- for (const item of items) {
427
- map.set(item.path, item);
441
+ if (typeof normalized.path === "string") {
442
+ normalized.path = normalized.path.trim();
428
443
  }
429
444
 
430
- return [...map.values()];
431
- }
432
-
433
- _addPopulateStages({ model, populateItem, parentAlias, allowedPopulate }) {
434
- const path = populateItem.path;
435
- const fullPath = parentAlias ? `${parentAlias}.${path}` : path;
445
+ if (typeof normalized.select === "string") {
446
+ normalized.select = this._sanitizeSelectString(normalized.select);
447
+ }
436
448
 
437
- if (!this._isPopulateAllowed(fullPath, allowedPopulate)) {
438
- return;
449
+ if (normalized.populate) {
450
+ normalized.populate = this._normalizeNestedPopulate(normalized.populate);
439
451
  }
440
452
 
441
- const info = this._getPopulateInfo(model, path, parentAlias);
453
+ return normalized;
454
+ }
442
455
 
443
- this.pipeline.push({
444
- $lookup: {
445
- from: info.collection,
446
- localField: info.localField,
447
- foreignField: "_id",
448
- as: info.as,
449
- },
450
- });
456
+ _normalizeNestedPopulate(input) {
457
+ if (!input) return undefined;
451
458
 
452
- if (!info.isArray) {
453
- this.pipeline.push({
454
- $unwind: {
455
- path: `$${info.as}`,
456
- preserveNullAndEmptyArrays: true,
457
- },
458
- });
459
+ if (typeof input === "string") {
460
+ return this._normalizePopulateInput(input);
459
461
  }
460
462
 
461
- if (populateItem.select) {
462
- const project = this._buildPopulateProjection(populateItem.select, info.as);
463
+ if (Array.isArray(input)) {
464
+ return input
465
+ .flatMap((item) => {
466
+ if (typeof item === "string") return this._normalizePopulateInput(item);
467
+ if (item && typeof item === "object" && item.path) {
468
+ return [this._normalizePopulateObject(item)];
469
+ }
470
+ return [];
471
+ })
472
+ .filter(Boolean);
473
+ }
463
474
 
464
- if (Object.keys(project).length) {
465
- this.pipeline.push({ $project: project });
466
- }
475
+ if (typeof input === "object" && input.path) {
476
+ return this._normalizePopulateObject(input);
467
477
  }
468
478
 
469
- if (populateItem.populate) {
470
- const nestedList = this._normalizeNestedPopulate(populateItem.populate);
479
+ return undefined;
480
+ }
471
481
 
472
- for (const nested of nestedList) {
473
- this._addPopulateStages({
474
- model: info.refModel,
475
- populateItem: nested,
476
- parentAlias: info.as,
477
- allowedPopulate,
478
- });
479
- }
482
+ _dotPathToPopulate(path) {
483
+ const parts = path.split(".").map((p) => p.trim()).filter(Boolean);
484
+
485
+ const root = { path: parts[0] };
486
+ let current = root;
487
+
488
+ for (const part of parts.slice(1)) {
489
+ current.populate = { path: part };
490
+ current = current.populate;
480
491
  }
492
+
493
+ return root;
481
494
  }
482
495
 
483
- _normalizeNestedPopulate(input) {
484
- if (!input) return [];
496
+ _dedupePopulate(items) {
497
+ const map = new Map();
485
498
 
486
- if (Array.isArray(input)) {
487
- return input.flatMap((item) => this._normalizeNestedPopulate(item));
488
- }
499
+ for (const item of items) {
500
+ if (!item?.path) continue;
489
501
 
490
- if (typeof input === "string") {
491
- return this._normalizePopulateInput(input);
492
- }
502
+ if (!map.has(item.path)) {
503
+ map.set(item.path, item);
504
+ continue;
505
+ }
493
506
 
494
- if (typeof input === "object" && input.path) {
495
- return [input];
507
+ const existing = map.get(item.path);
508
+ map.set(item.path, this._mergePopulateOptions(existing, item));
496
509
  }
497
510
 
498
- return [];
511
+ return [...map.values()];
499
512
  }
500
513
 
501
- _getPopulateInfo(model, path, parentAlias = "") {
502
- const schemaPath = model.schema.path(path);
514
+ _mergePopulateOptions(a, b) {
515
+ const merged = { ...a, ...b };
503
516
 
504
- if (!schemaPath) {
505
- throw new HandleERROR(`Invalid populate path: ${path}`, 400);
517
+ if (a.populate || b.populate) {
518
+ const aList = this._populateToArray(a.populate);
519
+ const bList = this._populateToArray(b.populate);
520
+ merged.populate = this._dedupePopulate([...aList, ...bList]);
506
521
  }
507
522
 
508
- const isArray = schemaPath.instance === "Array";
523
+ return merged;
524
+ }
509
525
 
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);
526
+ _populateToArray(populate) {
527
+ if (!populate) return [];
528
+ return Array.isArray(populate) ? populate : [populate];
529
+ }
516
530
 
517
- if (!refModelName) {
518
- throw new HandleERROR(`Populate path has no ref: ${path}`, 400);
519
- }
531
+ _sanitizePopulateOption(item, parentPath = "", allowedPopulate = []) {
532
+ if (!item || typeof item !== "object" || !item.path) return null;
520
533
 
521
- const refModel = this._resolveModel(refModelName, model);
534
+ const fullPath = parentPath ? `${parentPath}.${item.path}` : item.path;
522
535
 
523
- const as = parentAlias ? `${parentAlias}.${path}` : path;
536
+ if (!this._isPopulateAllowed(fullPath, allowedPopulate)) {
537
+ return null;
538
+ }
524
539
 
525
- return {
526
- refModel,
527
- collection: this._resolveCollectionName(refModelName, refModel),
528
- localField: as,
529
- foreignField: "_id",
530
- as,
531
- isArray,
540
+ const sanitized = {
541
+ path: item.path,
532
542
  };
533
- }
534
543
 
535
- _resolveModel(refModelName, currentModel) {
536
- const connection = currentModel?.db || this.model?.db || mongoose.connection;
544
+ if (item.select) {
545
+ sanitized.select = this._sanitizeSelectString(item.select);
546
+ }
537
547
 
538
- return connection.models?.[refModelName] || mongoose.models?.[refModelName] || null;
539
- }
548
+ if (item.match && typeof item.match === "object") {
549
+ sanitized.match = this._applySecurityFilters(
550
+ this._sanitizeFilters(item.match)
551
+ );
552
+ }
540
553
 
541
- _resolveCollectionName(refModelName, refModel) {
542
- if (refModel?.collection?.name) {
543
- return refModel.collection.name;
554
+ if (item.options && typeof item.options === "object") {
555
+ sanitized.options = item.options;
544
556
  }
545
557
 
546
- return pluralize(String(refModelName).toLowerCase());
558
+ if (item.model) {
559
+ sanitized.model = item.model;
560
+ }
561
+
562
+ if (item.populate) {
563
+ const nested = this._populateToArray(item.populate)
564
+ .map((child) =>
565
+ this._sanitizePopulateOption(child, fullPath, allowedPopulate)
566
+ )
567
+ .filter(Boolean);
568
+
569
+ if (nested.length === 1) {
570
+ sanitized.populate = nested[0];
571
+ } else if (nested.length > 1) {
572
+ sanitized.populate = nested;
573
+ }
574
+ }
575
+
576
+ return sanitized;
547
577
  }
548
578
 
549
- _buildPopulateProjection(select, alias) {
579
+ _sanitizeSelectString(select = "") {
550
580
  const fields = String(select)
551
- .split(" ")
581
+ .split(/\s+/)
552
582
  .map((field) => field.trim())
553
- .filter(Boolean);
583
+ .filter(Boolean)
584
+ .filter((field) => {
585
+ const cleanField = field.replace(/^-/, "");
586
+ return !this._isForbiddenField(cleanField);
587
+ });
554
588
 
555
589
  const hasInclude = fields.some((field) => !field.startsWith("-"));
556
590
  const hasExclude = fields.some((field) => field.startsWith("-"));
@@ -562,17 +596,7 @@ export class ApiFeatures {
562
596
  );
563
597
  }
564
598
 
565
- const project = {};
566
-
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;
573
- }
574
-
575
- return project;
599
+ return fields.join(" ");
576
600
  }
577
601
 
578
602
  _isPopulateAllowed(path, allowedPopulate = []) {
@@ -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
  })