vanta-api 1.4.3 → 1.4.4

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.
Files changed (2) hide show
  1. package/package.json +1 -1
  2. package/src/api-features.js +134 -495
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "vanta-api",
3
- "version": "1.4.3",
3
+ "version": "1.4.4",
4
4
  "description": "Advanced API features and security configuration for Node.js/MongoDB.",
5
5
  "main": "index.js",
6
6
  "scripts": {
@@ -24,7 +24,6 @@ export class ApiFeatures {
24
24
  this.pipeline = [];
25
25
  this.manualFilters = {};
26
26
  this.useCursor = false;
27
-
28
27
  this.userRole =
29
28
  userRole && securityConfig.accessLevels?.[userRole] ? userRole : "guest";
30
29
 
@@ -33,10 +32,15 @@ export class ApiFeatures {
33
32
 
34
33
  filter() {
35
34
  const queryFilters = this._parseQueryFilters();
35
+ const normalizedManualFilters = this._normalizeLogicalOperators(
36
+ this.manualFilters
37
+ );
38
+
36
39
  const mergedFilters = this._deepMergeFilters(
37
40
  queryFilters,
38
- this.manualFilters
41
+ normalizedManualFilters
39
42
  );
43
+
40
44
  const sanitizedFilters = this._sanitizeFilters(mergedFilters);
41
45
  const safeFilters = this._applySecurityFilters(sanitizedFilters);
42
46
 
@@ -49,7 +53,11 @@ export class ApiFeatures {
49
53
 
50
54
  addManualFilters(filters = {}) {
51
55
  if (filters && typeof filters === "object" && !Array.isArray(filters)) {
52
- this.manualFilters = this._deepMergeFilters(this.manualFilters, filters);
56
+ const normalizedFilters = this._normalizeLogicalOperators(filters);
57
+ this.manualFilters = this._deepMergeFilters(
58
+ this.manualFilters,
59
+ normalizedFilters
60
+ );
53
61
  }
54
62
 
55
63
  return this;
@@ -57,11 +65,9 @@ export class ApiFeatures {
57
65
 
58
66
  search(fields = []) {
59
67
  const q = this.query.q;
60
-
61
68
  if (!q || !Array.isArray(fields) || !fields.length) return this;
62
69
 
63
70
  const safeQ = this._escapeRegex(String(q).trim());
64
-
65
71
  if (!safeQ) return this;
66
72
 
67
73
  const conditions = fields
@@ -109,7 +115,6 @@ export class ApiFeatures {
109
115
 
110
116
  limitFields(input = "") {
111
117
  const rawFields = [input, this.query.fields].filter(Boolean).join(",");
112
-
113
118
  if (!rawFields) return this;
114
119
 
115
120
  const fields = rawFields
@@ -128,9 +133,7 @@ export class ApiFeatures {
128
133
 
129
134
  for (const field of fields) {
130
135
  const cleanField = field.replace(/^-/, "");
131
-
132
136
  if (this._isForbiddenField(cleanField)) continue;
133
-
134
137
  project[cleanField] = field.startsWith("-") ? 0 : 1;
135
138
  }
136
139
 
@@ -205,7 +208,6 @@ export class ApiFeatures {
205
208
  });
206
209
 
207
210
  data = [];
208
-
209
211
  for await (const doc of cursor) {
210
212
  data.push(doc);
211
213
  }
@@ -225,440 +227,164 @@ export class ApiFeatures {
225
227
  }
226
228
  }
227
229
 
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;
230
+ _normalizeLogicalOperators(filters = {}) {
231
+ if (Array.isArray(filters)) {
232
+ return filters.map((item) => this._normalizeLogicalOperators(item));
242
233
  }
243
234
 
244
- const info = this._getPopulateInfo({
245
- schema,
246
- path,
247
- fullPath,
248
- parentPath,
249
- parentIsArray,
250
- populateItem,
251
- });
235
+ if (!filters || typeof filters !== "object") return filters;
252
236
 
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;
237
+ if (
238
+ filters instanceof mongoose.Types.ObjectId ||
239
+ filters instanceof ObjectId ||
240
+ filters instanceof Date
241
+ ) {
242
+ return filters;
295
243
  }
296
244
 
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 });
245
+ const out = {};
338
246
 
339
- if (populateItem.populate) {
340
- const nestedList = this._normalizeNestedPopulate(populateItem.populate);
247
+ for (const [key, value] of Object.entries(filters)) {
248
+ const normalizedKey =
249
+ key === "and" ? "$and" : key === "or" ? "$or" : key === "nor" ? "$nor" : key;
341
250
 
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
- }
251
+ out[normalizedKey] = this._normalizeLogicalOperators(value);
351
252
  }
352
253
 
353
- if (populateItem.select) {
354
- this._applyNestedObjectSelectInsideArray({
355
- arrayPath: parentPath,
356
- objectPath: path,
357
- select: populateItem.select,
358
- });
359
- }
254
+ return out;
360
255
  }
361
256
 
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
- }
257
+ _sanitizeFilters(filters = {}) {
258
+ const sanitizeNode = (node, key = "", parentKey = "") => {
259
+ if (
260
+ node instanceof mongoose.Types.ObjectId ||
261
+ node instanceof ObjectId ||
262
+ node instanceof Date
263
+ ) {
264
+ return node;
265
+ }
377
266
 
378
- const info = this._getPopulateInfo({
379
- schema,
380
- path: childPath,
381
- fullPath,
382
- parentPath: `${arrayPath}.${objectPath}`,
383
- parentIsArray: true,
384
- populateItem,
385
- });
267
+ if (node === null || node === "null") return null;
268
+ if (node === "true") return true;
269
+ if (node === "false") return false;
386
270
 
387
- const tempLookupName = this._makeTempLookupName(fullPath);
271
+ if (Array.isArray(node)) {
272
+ return node.map((item) => sanitizeNode(item, key, parentKey));
273
+ }
388
274
 
389
- this.pipeline.push({
390
- $lookup: {
391
- from: info.collection,
392
- localField: `${arrayPath}.${objectPath}.${childPath}`,
393
- foreignField: "_id",
394
- as: tempLookupName,
395
- },
396
- });
275
+ if (node && typeof node === "object") {
276
+ const result = {};
397
277
 
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
- });
278
+ for (const [childKey, childVal] of Object.entries(node)) {
279
+ result[childKey] = sanitizeNode(childVal, childKey, key);
280
+ }
442
281
 
443
- this.pipeline.push({ $unset: tempLookupName });
282
+ return result;
283
+ }
444
284
 
445
- if (populateItem.populate) {
446
- const nestedList = this._normalizeNestedPopulate(populateItem.populate);
285
+ if (typeof node === "string") {
286
+ if (
287
+ this.#isStrictObjectId(node) &&
288
+ this._shouldConvertToObjectId(key, parentKey)
289
+ ) {
290
+ return new ObjectId(node);
291
+ }
447
292
 
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
- });
293
+ if (/^[0-9]+$/.test(node)) {
294
+ return node.length > 1 && node.startsWith("0")
295
+ ? node
296
+ : parseInt(node, 10);
297
+ }
456
298
  }
457
- }
458
-
459
- if (populateItem.select) {
460
- this._applyNestedObjectSelectInsideArray({
461
- arrayPath,
462
- objectPath: `${objectPath}.${childPath}`,
463
- select: populateItem.select,
464
- });
465
- }
466
- }
467
299
 
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,
300
+ return node;
511
301
  };
512
- }
513
302
 
514
- _applyPopulateSelect({ path, select, isArray }) {
515
- const parsed = this._parseSelect(select);
303
+ return sanitizeNode(filters);
304
+ }
516
305
 
517
- if (!parsed.fields.length) return;
306
+ _shouldConvertToObjectId(key = "", parentKey = "") {
307
+ const cleanKey = String(key).replace(/^\$/, "").toLowerCase();
308
+ const cleanParentKey = String(parentKey).replace(/^\$/, "").toLowerCase();
518
309
 
519
- if (parsed.mode === "exclude") {
520
- this.pipeline.push({
521
- $unset: parsed.fields.map((field) => `${path}.${field}`),
522
- });
523
- return;
310
+ if (
311
+ cleanKey === "_id" ||
312
+ cleanKey === "id" ||
313
+ cleanKey.endsWith("id") ||
314
+ cleanParentKey === "_id" ||
315
+ cleanParentKey === "id" ||
316
+ cleanParentKey.endsWith("id")
317
+ ) {
318
+ return true;
524
319
  }
525
320
 
526
- const includeFields = this._ensureIdField(parsed.fields);
321
+ return (
322
+ ["eq", "ne", "in", "nin"].includes(cleanKey) &&
323
+ (cleanParentKey === "_id" ||
324
+ cleanParentKey === "id" ||
325
+ cleanParentKey.endsWith("id"))
326
+ );
327
+ }
527
328
 
528
- if (isArray) {
529
- const selectedObject = {};
329
+ _deepMergeFilters(a = {}, b = {}) {
330
+ const out = { ...a };
530
331
 
531
- for (const field of includeFields) {
532
- selectedObject[field] = `$$item.${field}`;
332
+ for (const [key, value] of Object.entries(b)) {
333
+ if (
334
+ value &&
335
+ typeof value === "object" &&
336
+ !Array.isArray(value) &&
337
+ out[key] &&
338
+ typeof out[key] === "object" &&
339
+ !Array.isArray(out[key]) &&
340
+ !LOGICAL_OPERATORS.includes(key)
341
+ ) {
342
+ out[key] = this._deepMergeFilters(out[key], value);
343
+ } else {
344
+ out[key] = value;
533
345
  }
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
346
  }
549
347
 
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
- });
348
+ return out;
563
349
  }
564
350
 
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
- }
351
+ _applySecurityFilters(filters = {}) {
352
+ const cleanNode = (node) => {
353
+ if (
354
+ node instanceof mongoose.Types.ObjectId ||
355
+ node instanceof ObjectId ||
356
+ node instanceof Date
357
+ ) {
358
+ return node;
359
+ }
585
360
 
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
- }
361
+ if (Array.isArray(node)) {
362
+ return node.map(cleanNode);
363
+ }
611
364
 
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
- });
365
+ if (!node || typeof node !== "object") {
366
+ return node;
367
+ }
621
368
 
622
- const hasInclude = fields.some((field) => !field.startsWith("-"));
623
- const hasExclude = fields.some((field) => field.startsWith("-"));
369
+ const result = {};
624
370
 
625
- if (hasInclude && hasExclude) {
626
- throw new HandleERROR(
627
- "Cannot mix include and exclude in populate select",
628
- 400
629
- );
630
- }
371
+ for (const [key, value] of Object.entries(node)) {
372
+ if (this._isForbiddenField(key)) continue;
373
+ result[key] = cleanNode(value);
374
+ }
631
375
 
632
- return {
633
- mode: hasExclude ? "exclude" : "include",
634
- fields: fields.map((field) => field.replace(/^-/, "")),
376
+ return result;
635
377
  };
636
- }
637
378
 
638
- _ensureIdField(fields = []) {
639
- return fields.includes("_id") ? fields : ["_id", ...fields];
379
+ return cleanNode(filters);
640
380
  }
641
381
 
642
- _sanitization() {
643
- for (const key of Object.keys(this.query)) {
644
- if (
645
- key.startsWith("$") ||
646
- ["$where", "$accumulator", "$function"].includes(key)
647
- ) {
648
- delete this.query[key];
649
- }
650
- }
651
-
652
- ["page", "limit"].forEach((field) => {
653
- if (this.query[field] && !/^[0-9]+$/.test(String(this.query[field]))) {
654
- throw new HandleERROR(`Invalid ${field}`, 400);
655
- }
656
- });
382
+ _addPopulateStages(args) {
383
+ return ApiFeatures.prototype.__proto__?._addPopulateStages?.call(this, args);
657
384
  }
658
385
 
659
386
  _parseQueryFilters() {
660
387
  const obj = { ...this.query };
661
-
662
388
  RESERVED_QUERY_KEYS.forEach((key) => delete obj[key]);
663
389
 
664
390
  const out = {};
@@ -704,109 +430,21 @@ export class ApiFeatures {
704
430
  return out;
705
431
  }
706
432
 
707
- _sanitizeFilters(filters = {}) {
708
- const sanitizeNode = (node, key = "") => {
709
- if (
710
- node instanceof mongoose.Types.ObjectId ||
711
- node instanceof ObjectId
712
- ) {
713
- return node;
714
- }
715
-
716
- if (node === null || node === "null") return null;
717
- if (node === "true") return true;
718
- if (node === "false") return false;
719
-
720
- if (Array.isArray(node)) {
721
- return node.map((item) => sanitizeNode(item, key));
722
- }
723
-
724
- if (node && typeof node === "object") {
725
- const result = {};
726
-
727
- for (const [childKey, childVal] of Object.entries(node)) {
728
- result[childKey] = sanitizeNode(childVal, childKey);
729
- }
730
-
731
- return result;
732
- }
733
-
734
- if (typeof node === "string") {
735
- if (this.#isStrictObjectId(node) && this._shouldConvertToObjectId(key)) {
736
- return new ObjectId(node);
737
- }
738
-
739
- if (/^[0-9]+$/.test(node)) {
740
- return node.length > 1 && node.startsWith("0")
741
- ? node
742
- : parseInt(node, 10);
743
- }
744
- }
745
-
746
- return node;
747
- };
748
-
749
- return sanitizeNode(filters);
750
- }
751
-
752
- _shouldConvertToObjectId(key = "") {
753
- const cleanKey = String(key).replace(/^\$/, "").toLowerCase();
754
-
755
- return (
756
- cleanKey === "_id" ||
757
- cleanKey === "id" ||
758
- cleanKey.endsWith("id") ||
759
- cleanKey === "eq" ||
760
- cleanKey === "ne" ||
761
- cleanKey === "in" ||
762
- cleanKey === "nin"
763
- );
764
- }
765
-
766
- _deepMergeFilters(a = {}, b = {}) {
767
- const out = { ...a };
768
-
769
- for (const [key, value] of Object.entries(b)) {
433
+ _sanitization() {
434
+ for (const key of Object.keys(this.query)) {
770
435
  if (
771
- value &&
772
- typeof value === "object" &&
773
- !Array.isArray(value) &&
774
- out[key] &&
775
- typeof out[key] === "object" &&
776
- !Array.isArray(out[key]) &&
777
- !LOGICAL_OPERATORS.includes(key)
436
+ key.startsWith("$") ||
437
+ ["$where", "$accumulator", "$function"].includes(key)
778
438
  ) {
779
- out[key] = this._deepMergeFilters(out[key], value);
780
- } else {
781
- out[key] = value;
439
+ delete this.query[key];
782
440
  }
783
441
  }
784
442
 
785
- return out;
786
- }
787
-
788
- _applySecurityFilters(filters = {}) {
789
- const cleanNode = (node) => {
790
- if (Array.isArray(node)) {
791
- return node.map(cleanNode);
792
- }
793
-
794
- if (!node || typeof node !== "object") {
795
- return node;
796
- }
797
-
798
- const result = {};
799
-
800
- for (const [key, value] of Object.entries(node)) {
801
- if (this._isForbiddenField(key)) continue;
802
-
803
- result[key] = cleanNode(value);
443
+ ["page", "limit"].forEach((field) => {
444
+ if (this.query[field] && !/^[0-9]+$/.test(String(this.query[field]))) {
445
+ throw new HandleERROR(`Invalid ${field}`, 400);
804
446
  }
805
-
806
- return result;
807
- };
808
-
809
- return cleanNode(filters);
447
+ });
810
448
  }
811
449
 
812
450
  _normalizePopulateInput(input = "") {
@@ -828,7 +466,6 @@ _sanitizeFilters(filters = {}) {
828
466
 
829
467
  if (typeof item === "string") {
830
468
  const trimmed = item.trim();
831
-
832
469
  if (!trimmed) return;
833
470
 
834
471
  if (trimmed.includes(".")) {
@@ -884,9 +521,11 @@ _sanitizeFilters(filters = {}) {
884
521
  return input
885
522
  .flatMap((item) => {
886
523
  if (typeof item === "string") return this._normalizePopulateInput(item);
524
+
887
525
  if (item && typeof item === "object" && item.path) {
888
526
  return [this._normalizePopulateObject(item)];
889
527
  }
528
+
890
529
  return [];
891
530
  })
892
531
  .filter(Boolean);