vanta-api 1.4.2 → 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 +132 -486
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "vanta-api",
3
- "version": "1.4.2",
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,507 +227,66 @@ 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;
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;
230
+ _normalizeLogicalOperators(filters = {}) {
231
+ if (Array.isArray(filters)) {
232
+ return filters.map((item) => this._normalizeLogicalOperators(item));
376
233
  }
377
234
 
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 });
235
+ if (!filters || typeof filters !== "object") return filters;
444
236
 
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
- }
237
+ if (
238
+ filters instanceof mongoose.Types.ObjectId ||
239
+ filters instanceof ObjectId ||
240
+ filters instanceof Date
241
+ ) {
242
+ return filters;
457
243
  }
458
244
 
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
- });
245
+ const out = {};
621
246
 
622
- const hasInclude = fields.some((field) => !field.startsWith("-"));
623
- const hasExclude = fields.some((field) => field.startsWith("-"));
247
+ for (const [key, value] of Object.entries(filters)) {
248
+ const normalizedKey =
249
+ key === "and" ? "$and" : key === "or" ? "$or" : key === "nor" ? "$nor" : key;
624
250
 
625
- if (hasInclude && hasExclude) {
626
- throw new HandleERROR(
627
- "Cannot mix include and exclude in populate select",
628
- 400
629
- );
251
+ out[normalizedKey] = this._normalizeLogicalOperators(value);
630
252
  }
631
253
 
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];
254
+ return out;
640
255
  }
641
256
 
642
- _sanitization() {
643
- for (const key of Object.keys(this.query)) {
257
+ _sanitizeFilters(filters = {}) {
258
+ const sanitizeNode = (node, key = "", parentKey = "") => {
644
259
  if (
645
- key.startsWith("$") ||
646
- ["$where", "$accumulator", "$function"].includes(key)
260
+ node instanceof mongoose.Types.ObjectId ||
261
+ node instanceof ObjectId ||
262
+ node instanceof Date
647
263
  ) {
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
- });
657
- }
658
-
659
- _parseQueryFilters() {
660
- const obj = { ...this.query };
661
-
662
- RESERVED_QUERY_KEYS.forEach((key) => delete obj[key]);
663
-
664
- const out = {};
665
-
666
- for (const [rawKey, rawVal] of Object.entries(obj)) {
667
- const bracketMatch = rawKey.match(/^(.+)\[\$?(\w+)\]$/);
668
-
669
- if (bracketMatch) {
670
- const [, field, op] = bracketMatch;
671
- const cleanOp = op.replace(/^\$/, "");
672
-
673
- if (securityConfig.allowedOperators?.includes(cleanOp)) {
674
- out[field] = {
675
- ...(out[field] || {}),
676
- [`$${cleanOp}`]: rawVal,
677
- };
678
- }
679
-
680
- continue;
681
- }
682
-
683
- if (rawVal && typeof rawVal === "object" && !Array.isArray(rawVal)) {
684
- out[rawKey] = out[rawKey] || {};
685
-
686
- for (const [op, val] of Object.entries(rawVal)) {
687
- const cleanOp = op.replace(/^\$/, "");
688
-
689
- if (securityConfig.allowedOperators?.includes(cleanOp)) {
690
- out[rawKey][`$${cleanOp}`] = val;
691
- }
692
- }
693
-
694
- continue;
695
- }
696
-
697
- if (typeof rawVal === "string" && rawVal.includes(",")) {
698
- out[rawKey] = rawVal.split(",").map((v) => v.trim());
699
- } else {
700
- out[rawKey] = rawVal;
264
+ return node;
701
265
  }
702
- }
703
-
704
- return out;
705
- }
706
266
 
707
- _sanitizeFilters(filters = {}) {
708
- const sanitizeNode = (node, key = "") => {
709
267
  if (node === null || node === "null") return null;
710
268
  if (node === "true") return true;
711
269
  if (node === "false") return false;
712
270
 
713
271
  if (Array.isArray(node)) {
714
- return node.map((item) => sanitizeNode(item, key));
272
+ return node.map((item) => sanitizeNode(item, key, parentKey));
715
273
  }
716
274
 
717
275
  if (node && typeof node === "object") {
718
276
  const result = {};
719
277
 
720
278
  for (const [childKey, childVal] of Object.entries(node)) {
721
- result[childKey] = sanitizeNode(childVal, childKey);
279
+ result[childKey] = sanitizeNode(childVal, childKey, key);
722
280
  }
723
281
 
724
282
  return result;
725
283
  }
726
284
 
727
285
  if (typeof node === "string") {
728
- if (this.#isStrictObjectId(node) && this._shouldConvertToObjectId(key)) {
286
+ if (
287
+ this.#isStrictObjectId(node) &&
288
+ this._shouldConvertToObjectId(key, parentKey)
289
+ ) {
729
290
  return new ObjectId(node);
730
291
  }
731
292
 
@@ -742,17 +303,26 @@ export class ApiFeatures {
742
303
  return sanitizeNode(filters);
743
304
  }
744
305
 
745
- _shouldConvertToObjectId(key = "") {
306
+ _shouldConvertToObjectId(key = "", parentKey = "") {
746
307
  const cleanKey = String(key).replace(/^\$/, "").toLowerCase();
308
+ const cleanParentKey = String(parentKey).replace(/^\$/, "").toLowerCase();
747
309
 
748
- return (
310
+ if (
749
311
  cleanKey === "_id" ||
750
312
  cleanKey === "id" ||
751
313
  cleanKey.endsWith("id") ||
752
- cleanKey === "eq" ||
753
- cleanKey === "ne" ||
754
- cleanKey === "in" ||
755
- cleanKey === "nin"
314
+ cleanParentKey === "_id" ||
315
+ cleanParentKey === "id" ||
316
+ cleanParentKey.endsWith("id")
317
+ ) {
318
+ return true;
319
+ }
320
+
321
+ return (
322
+ ["eq", "ne", "in", "nin"].includes(cleanKey) &&
323
+ (cleanParentKey === "_id" ||
324
+ cleanParentKey === "id" ||
325
+ cleanParentKey.endsWith("id"))
756
326
  );
757
327
  }
758
328
 
@@ -780,6 +350,14 @@ export class ApiFeatures {
780
350
 
781
351
  _applySecurityFilters(filters = {}) {
782
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
+ }
360
+
783
361
  if (Array.isArray(node)) {
784
362
  return node.map(cleanNode);
785
363
  }
@@ -792,7 +370,6 @@ export class ApiFeatures {
792
370
 
793
371
  for (const [key, value] of Object.entries(node)) {
794
372
  if (this._isForbiddenField(key)) continue;
795
-
796
373
  result[key] = cleanNode(value);
797
374
  }
798
375
 
@@ -802,6 +379,74 @@ export class ApiFeatures {
802
379
  return cleanNode(filters);
803
380
  }
804
381
 
382
+ _addPopulateStages(args) {
383
+ return ApiFeatures.prototype.__proto__?._addPopulateStages?.call(this, args);
384
+ }
385
+
386
+ _parseQueryFilters() {
387
+ const obj = { ...this.query };
388
+ RESERVED_QUERY_KEYS.forEach((key) => delete obj[key]);
389
+
390
+ const out = {};
391
+
392
+ for (const [rawKey, rawVal] of Object.entries(obj)) {
393
+ const bracketMatch = rawKey.match(/^(.+)\[\$?(\w+)\]$/);
394
+
395
+ if (bracketMatch) {
396
+ const [, field, op] = bracketMatch;
397
+ const cleanOp = op.replace(/^\$/, "");
398
+
399
+ if (securityConfig.allowedOperators?.includes(cleanOp)) {
400
+ out[field] = {
401
+ ...(out[field] || {}),
402
+ [`$${cleanOp}`]: rawVal,
403
+ };
404
+ }
405
+
406
+ continue;
407
+ }
408
+
409
+ if (rawVal && typeof rawVal === "object" && !Array.isArray(rawVal)) {
410
+ out[rawKey] = out[rawKey] || {};
411
+
412
+ for (const [op, val] of Object.entries(rawVal)) {
413
+ const cleanOp = op.replace(/^\$/, "");
414
+
415
+ if (securityConfig.allowedOperators?.includes(cleanOp)) {
416
+ out[rawKey][`$${cleanOp}`] = val;
417
+ }
418
+ }
419
+
420
+ continue;
421
+ }
422
+
423
+ if (typeof rawVal === "string" && rawVal.includes(",")) {
424
+ out[rawKey] = rawVal.split(",").map((v) => v.trim());
425
+ } else {
426
+ out[rawKey] = rawVal;
427
+ }
428
+ }
429
+
430
+ return out;
431
+ }
432
+
433
+ _sanitization() {
434
+ for (const key of Object.keys(this.query)) {
435
+ if (
436
+ key.startsWith("$") ||
437
+ ["$where", "$accumulator", "$function"].includes(key)
438
+ ) {
439
+ delete this.query[key];
440
+ }
441
+ }
442
+
443
+ ["page", "limit"].forEach((field) => {
444
+ if (this.query[field] && !/^[0-9]+$/.test(String(this.query[field]))) {
445
+ throw new HandleERROR(`Invalid ${field}`, 400);
446
+ }
447
+ });
448
+ }
449
+
805
450
  _normalizePopulateInput(input = "") {
806
451
  const raw = [];
807
452
 
@@ -821,7 +466,6 @@ export class ApiFeatures {
821
466
 
822
467
  if (typeof item === "string") {
823
468
  const trimmed = item.trim();
824
-
825
469
  if (!trimmed) return;
826
470
 
827
471
  if (trimmed.includes(".")) {
@@ -877,9 +521,11 @@ export class ApiFeatures {
877
521
  return input
878
522
  .flatMap((item) => {
879
523
  if (typeof item === "string") return this._normalizePopulateInput(item);
524
+
880
525
  if (item && typeof item === "object" && item.path) {
881
526
  return [this._normalizePopulateObject(item)];
882
527
  }
528
+
883
529
  return [];
884
530
  })
885
531
  .filter(Boolean);