vanta-api 1.4.4 → 1.4.5

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 +510 -115
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "vanta-api",
3
- "version": "1.4.4",
3
+ "version": "1.4.5",
4
4
  "description": "Advanced API features and security configuration for Node.js/MongoDB.",
5
5
  "main": "index.js",
6
6
  "scripts": {
@@ -32,6 +32,7 @@ export class ApiFeatures {
32
32
 
33
33
  filter() {
34
34
  const queryFilters = this._parseQueryFilters();
35
+
35
36
  const normalizedManualFilters = this._normalizeLogicalOperators(
36
37
  this.manualFilters
37
38
  );
@@ -54,6 +55,7 @@ export class ApiFeatures {
54
55
  addManualFilters(filters = {}) {
55
56
  if (filters && typeof filters === "object" && !Array.isArray(filters)) {
56
57
  const normalizedFilters = this._normalizeLogicalOperators(filters);
58
+
57
59
  this.manualFilters = this._deepMergeFilters(
58
60
  this.manualFilters,
59
61
  normalizedFilters
@@ -208,6 +210,7 @@ export class ApiFeatures {
208
210
  });
209
211
 
210
212
  data = [];
213
+
211
214
  for await (const doc of cursor) {
212
215
  data.push(doc);
213
216
  }
@@ -227,40 +230,487 @@ export class ApiFeatures {
227
230
  }
228
231
  }
229
232
 
230
- _normalizeLogicalOperators(filters = {}) {
231
- if (Array.isArray(filters)) {
232
- return filters.map((item) => this._normalizeLogicalOperators(item));
233
+ _addPopulateStages({
234
+ populateItem,
235
+ parentPath = "",
236
+ parentIsArray = false,
237
+ schema = null,
238
+ allowedPopulate = [],
239
+ }) {
240
+ if (!populateItem || !populateItem.path) return;
241
+
242
+ const path = populateItem.path;
243
+ const fullPath = parentPath ? `${parentPath}.${path}` : path;
244
+
245
+ if (!this._isPopulateAllowed(fullPath, allowedPopulate)) {
246
+ return;
233
247
  }
234
248
 
235
- if (!filters || typeof filters !== "object") return filters;
249
+ const info = this._getPopulateInfo({
250
+ schema,
251
+ path,
252
+ fullPath,
253
+ parentPath,
254
+ parentIsArray,
255
+ populateItem,
256
+ });
236
257
 
237
- if (
238
- filters instanceof mongoose.Types.ObjectId ||
239
- filters instanceof ObjectId ||
240
- filters instanceof Date
241
- ) {
242
- return filters;
258
+ if (!parentIsArray) {
259
+ this.pipeline.push({
260
+ $lookup: {
261
+ from: info.collection,
262
+ localField: info.localField,
263
+ foreignField: "_id",
264
+ as: info.as,
265
+ },
266
+ });
267
+
268
+ if (!info.isArray) {
269
+ this.pipeline.push({
270
+ $unwind: {
271
+ path: `$${info.as}`,
272
+ preserveNullAndEmptyArrays: true,
273
+ },
274
+ });
275
+ }
276
+
277
+ if (populateItem.populate) {
278
+ const nestedList = this._normalizeNestedPopulate(populateItem.populate);
279
+
280
+ for (const nested of nestedList) {
281
+ this._addPopulateStages({
282
+ populateItem: nested,
283
+ parentPath: info.as,
284
+ parentIsArray: info.isArray,
285
+ schema: info.refSchema,
286
+ allowedPopulate,
287
+ });
288
+ }
289
+ }
290
+
291
+ if (populateItem.select) {
292
+ this._applyPopulateSelect({
293
+ path: info.as,
294
+ select: populateItem.select,
295
+ isArray: info.isArray,
296
+ });
297
+ }
298
+
299
+ return;
300
+ }
301
+
302
+ const tempLookupName = this._makeTempLookupName(fullPath);
303
+
304
+ this.pipeline.push({
305
+ $lookup: {
306
+ from: info.collection,
307
+ localField: info.localField,
308
+ foreignField: "_id",
309
+ as: tempLookupName,
310
+ },
311
+ });
312
+
313
+ this.pipeline.push({
314
+ $set: {
315
+ [parentPath]: {
316
+ $map: {
317
+ input: { $ifNull: [`$${parentPath}`, []] },
318
+ as: "item",
319
+ in: {
320
+ $mergeObjects: [
321
+ "$$item",
322
+ {
323
+ [path]: {
324
+ $first: {
325
+ $filter: {
326
+ input: `$${tempLookupName}`,
327
+ as: "joined",
328
+ cond: {
329
+ $eq: ["$$joined._id", `$$item.${path}`],
330
+ },
331
+ },
332
+ },
333
+ },
334
+ },
335
+ ],
336
+ },
337
+ },
338
+ },
339
+ },
340
+ });
341
+
342
+ this.pipeline.push({ $unset: tempLookupName });
343
+
344
+ if (populateItem.populate) {
345
+ const nestedList = this._normalizeNestedPopulate(populateItem.populate);
346
+
347
+ for (const nested of nestedList) {
348
+ this._addNestedPopulateInsideArrayItem({
349
+ arrayPath: parentPath,
350
+ objectPath: path,
351
+ populateItem: nested,
352
+ allowedPopulate,
353
+ schema: info.refSchema,
354
+ });
355
+ }
356
+ }
357
+
358
+ if (populateItem.select) {
359
+ this._applyNestedObjectSelectInsideArray({
360
+ arrayPath: parentPath,
361
+ objectPath: path,
362
+ select: populateItem.select,
363
+ });
364
+ }
365
+ }
366
+
367
+ _addNestedPopulateInsideArrayItem({
368
+ arrayPath,
369
+ objectPath,
370
+ populateItem,
371
+ allowedPopulate = [],
372
+ schema = null,
373
+ }) {
374
+ if (!populateItem || !populateItem.path) return;
375
+
376
+ const childPath = populateItem.path;
377
+ const fullPath = `${arrayPath}.${objectPath}.${childPath}`;
378
+
379
+ if (!this._isPopulateAllowed(fullPath, allowedPopulate)) {
380
+ return;
381
+ }
382
+
383
+ const info = this._getPopulateInfo({
384
+ schema,
385
+ path: childPath,
386
+ fullPath,
387
+ parentPath: `${arrayPath}.${objectPath}`,
388
+ parentIsArray: true,
389
+ populateItem,
390
+ });
391
+
392
+ const tempLookupName = this._makeTempLookupName(fullPath);
393
+
394
+ this.pipeline.push({
395
+ $lookup: {
396
+ from: info.collection,
397
+ localField: `${arrayPath}.${objectPath}.${childPath}`,
398
+ foreignField: "_id",
399
+ as: tempLookupName,
400
+ },
401
+ });
402
+
403
+ this.pipeline.push({
404
+ $set: {
405
+ [arrayPath]: {
406
+ $map: {
407
+ input: { $ifNull: [`$${arrayPath}`, []] },
408
+ as: "item",
409
+ in: {
410
+ $mergeObjects: [
411
+ "$$item",
412
+ {
413
+ [objectPath]: {
414
+ $cond: [
415
+ { $ne: [`$$item.${objectPath}`, null] },
416
+ {
417
+ $mergeObjects: [
418
+ `$$item.${objectPath}`,
419
+ {
420
+ [childPath]: {
421
+ $first: {
422
+ $filter: {
423
+ input: `$${tempLookupName}`,
424
+ as: "joined",
425
+ cond: {
426
+ $eq: [
427
+ "$$joined._id",
428
+ `$$item.${objectPath}.${childPath}`,
429
+ ],
430
+ },
431
+ },
432
+ },
433
+ },
434
+ },
435
+ ],
436
+ },
437
+ `$$item.${objectPath}`,
438
+ ],
439
+ },
440
+ },
441
+ ],
442
+ },
443
+ },
444
+ },
445
+ },
446
+ });
447
+
448
+ this.pipeline.push({ $unset: tempLookupName });
449
+
450
+ if (populateItem.populate) {
451
+ const nestedList = this._normalizeNestedPopulate(populateItem.populate);
452
+
453
+ for (const nested of nestedList) {
454
+ this._addNestedPopulateInsideArrayItem({
455
+ arrayPath,
456
+ objectPath: `${objectPath}.${childPath}`,
457
+ populateItem: nested,
458
+ allowedPopulate,
459
+ schema: info.refSchema,
460
+ });
461
+ }
462
+ }
463
+
464
+ if (populateItem.select) {
465
+ this._applyNestedObjectSelectInsideArray({
466
+ arrayPath,
467
+ objectPath: `${objectPath}.${childPath}`,
468
+ select: populateItem.select,
469
+ });
470
+ }
471
+ }
472
+
473
+ _getPopulateInfo({
474
+ schema = null,
475
+ path,
476
+ fullPath,
477
+ parentPath = "",
478
+ parentIsArray = false,
479
+ populateItem = {},
480
+ }) {
481
+ const schemaPath = schema?.path?.(path);
482
+
483
+ const isArray =
484
+ populateItem.isArray === true ||
485
+ schemaPath?.instance === "Array" ||
486
+ Array.isArray(schemaPath?.options?.type);
487
+
488
+ const refModelName =
489
+ populateItem.ref ||
490
+ populateItem.modelName ||
491
+ schemaPath?.options?.ref ||
492
+ schemaPath?.caster?.options?.ref ||
493
+ (Array.isArray(schemaPath?.options?.type)
494
+ ? schemaPath.options.type[0]?.ref
495
+ : undefined) ||
496
+ this._inferModelNameFromPath(path);
497
+
498
+ const collection =
499
+ populateItem.collection ||
500
+ populateItem.from ||
501
+ this._resolveCollectionName(refModelName);
502
+
503
+ const refSchema = this._resolveRegisteredSchema(refModelName);
504
+
505
+ return {
506
+ path,
507
+ fullPath,
508
+ refModelName,
509
+ collection,
510
+ refSchema,
511
+ isArray,
512
+ localField: parentIsArray ? fullPath : fullPath,
513
+ as: fullPath,
514
+ parentPath,
515
+ parentIsArray,
516
+ };
517
+ }
518
+
519
+ _applyPopulateSelect({ path, select, isArray }) {
520
+ const parsed = this._parseSelect(select);
521
+ if (!parsed.fields.length) return;
522
+
523
+ if (parsed.mode === "exclude") {
524
+ this.pipeline.push({
525
+ $unset: parsed.fields.map((field) => `${path}.${field}`),
526
+ });
527
+
528
+ return;
529
+ }
530
+
531
+ const includeFields = this._ensureIdField(parsed.fields);
532
+
533
+ if (isArray) {
534
+ const selectedObject = {};
535
+
536
+ for (const field of includeFields) {
537
+ selectedObject[field] = `$$item.${field}`;
538
+ }
539
+
540
+ this.pipeline.push({
541
+ $set: {
542
+ [path]: {
543
+ $map: {
544
+ input: { $ifNull: [`$${path}`, []] },
545
+ as: "item",
546
+ in: selectedObject,
547
+ },
548
+ },
549
+ },
550
+ });
551
+
552
+ return;
553
+ }
554
+
555
+ const selectedObject = {};
556
+
557
+ for (const field of includeFields) {
558
+ selectedObject[field] = `$${path}.${field}`;
559
+ }
560
+
561
+ this.pipeline.push({
562
+ $set: {
563
+ [path]: {
564
+ $cond: [{ $ne: [`$${path}`, null] }, selectedObject, `$${path}`],
565
+ },
566
+ },
567
+ });
568
+ }
569
+
570
+ _applyNestedObjectSelectInsideArray({ arrayPath, objectPath, select }) {
571
+ const parsed = this._parseSelect(select);
572
+ if (!parsed.fields.length) return;
573
+
574
+ if (parsed.mode === "exclude") {
575
+ this.pipeline.push({
576
+ $unset: parsed.fields.map(
577
+ (field) => `${arrayPath}.${objectPath}.${field}`
578
+ ),
579
+ });
580
+
581
+ return;
582
+ }
583
+
584
+ const includeFields = this._ensureIdField(parsed.fields);
585
+ const selectedObject = {};
586
+
587
+ for (const field of includeFields) {
588
+ selectedObject[field] = `$$item.${objectPath}.${field}`;
589
+ }
590
+
591
+ this.pipeline.push({
592
+ $set: {
593
+ [arrayPath]: {
594
+ $map: {
595
+ input: { $ifNull: [`$${arrayPath}`, []] },
596
+ as: "item",
597
+ in: {
598
+ $mergeObjects: [
599
+ "$$item",
600
+ {
601
+ [objectPath]: {
602
+ $cond: [
603
+ { $ne: [`$$item.${objectPath}`, null] },
604
+ selectedObject,
605
+ `$$item.${objectPath}`,
606
+ ],
607
+ },
608
+ },
609
+ ],
610
+ },
611
+ },
612
+ },
613
+ },
614
+ });
615
+ }
616
+
617
+ _parseSelect(select = "") {
618
+ const fields = String(select)
619
+ .split(/\s+/)
620
+ .map((field) => field.trim())
621
+ .filter(Boolean)
622
+ .filter((field) => {
623
+ const cleanField = field.replace(/^-/, "");
624
+ return !this._isForbiddenField(cleanField);
625
+ });
626
+
627
+ const hasInclude = fields.some((field) => !field.startsWith("-"));
628
+ const hasExclude = fields.some((field) => field.startsWith("-"));
629
+
630
+ if (hasInclude && hasExclude) {
631
+ throw new HandleERROR(
632
+ "Cannot mix include and exclude in populate select",
633
+ 400
634
+ );
243
635
  }
244
636
 
637
+ return {
638
+ mode: hasExclude ? "exclude" : "include",
639
+ fields: fields.map((field) => field.replace(/^-/, "")),
640
+ };
641
+ }
642
+
643
+ _ensureIdField(fields = []) {
644
+ return fields.includes("_id") ? fields : ["_id", ...fields];
645
+ }
646
+
647
+ _sanitization() {
648
+ for (const key of Object.keys(this.query)) {
649
+ if (
650
+ key.startsWith("$") ||
651
+ ["$where", "$accumulator", "$function"].includes(key)
652
+ ) {
653
+ delete this.query[key];
654
+ }
655
+ }
656
+
657
+ ["page", "limit"].forEach((field) => {
658
+ if (this.query[field] && !/^[0-9]+$/.test(String(this.query[field]))) {
659
+ throw new HandleERROR(`Invalid ${field}`, 400);
660
+ }
661
+ });
662
+ }
663
+
664
+ _parseQueryFilters() {
665
+ const obj = { ...this.query };
666
+ RESERVED_QUERY_KEYS.forEach((key) => delete obj[key]);
667
+
245
668
  const out = {};
246
669
 
247
- for (const [key, value] of Object.entries(filters)) {
248
- const normalizedKey =
249
- key === "and" ? "$and" : key === "or" ? "$or" : key === "nor" ? "$nor" : key;
670
+ for (const [rawKey, rawVal] of Object.entries(obj)) {
671
+ const bracketMatch = rawKey.match(/^(.+)\[\$?(\w+)\]$/);
250
672
 
251
- out[normalizedKey] = this._normalizeLogicalOperators(value);
673
+ if (bracketMatch) {
674
+ const [, field, op] = bracketMatch;
675
+ const cleanOp = op.replace(/^\$/, "");
676
+
677
+ if (securityConfig.allowedOperators?.includes(cleanOp)) {
678
+ out[field] = {
679
+ ...(out[field] || {}),
680
+ [`$${cleanOp}`]: rawVal,
681
+ };
682
+ }
683
+
684
+ continue;
685
+ }
686
+
687
+ if (rawVal && typeof rawVal === "object" && !Array.isArray(rawVal)) {
688
+ out[rawKey] = out[rawKey] || {};
689
+
690
+ for (const [op, val] of Object.entries(rawVal)) {
691
+ const cleanOp = op.replace(/^\$/, "");
692
+
693
+ if (securityConfig.allowedOperators?.includes(cleanOp)) {
694
+ out[rawKey][`$${cleanOp}`] = val;
695
+ }
696
+ }
697
+
698
+ continue;
699
+ }
700
+
701
+ if (typeof rawVal === "string" && rawVal.includes(",")) {
702
+ out[rawKey] = rawVal.split(",").map((v) => v.trim());
703
+ } else {
704
+ out[rawKey] = rawVal;
705
+ }
252
706
  }
253
707
 
254
708
  return out;
255
709
  }
256
710
 
257
711
  _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
- ) {
712
+ const sanitizeNode = (node, key = "") => {
713
+ if (node instanceof mongoose.Types.ObjectId || node instanceof ObjectId) {
264
714
  return node;
265
715
  }
266
716
 
@@ -269,24 +719,21 @@ export class ApiFeatures {
269
719
  if (node === "false") return false;
270
720
 
271
721
  if (Array.isArray(node)) {
272
- return node.map((item) => sanitizeNode(item, key, parentKey));
722
+ return node.map((item) => sanitizeNode(item, key));
273
723
  }
274
724
 
275
725
  if (node && typeof node === "object") {
276
726
  const result = {};
277
727
 
278
728
  for (const [childKey, childVal] of Object.entries(node)) {
279
- result[childKey] = sanitizeNode(childVal, childKey, key);
729
+ result[childKey] = sanitizeNode(childVal, childKey);
280
730
  }
281
731
 
282
732
  return result;
283
733
  }
284
734
 
285
735
  if (typeof node === "string") {
286
- if (
287
- this.#isStrictObjectId(node) &&
288
- this._shouldConvertToObjectId(key, parentKey)
289
- ) {
736
+ if (this.#isStrictObjectId(node) && this._shouldConvertToObjectId(key)) {
290
737
  return new ObjectId(node);
291
738
  }
292
739
 
@@ -303,27 +750,51 @@ export class ApiFeatures {
303
750
  return sanitizeNode(filters);
304
751
  }
305
752
 
306
- _shouldConvertToObjectId(key = "", parentKey = "") {
753
+ _shouldConvertToObjectId(key = "") {
307
754
  const cleanKey = String(key).replace(/^\$/, "").toLowerCase();
308
- const cleanParentKey = String(parentKey).replace(/^\$/, "").toLowerCase();
309
755
 
310
- if (
756
+ return (
311
757
  cleanKey === "_id" ||
312
758
  cleanKey === "id" ||
313
759
  cleanKey.endsWith("id") ||
314
- cleanParentKey === "_id" ||
315
- cleanParentKey === "id" ||
316
- cleanParentKey.endsWith("id")
760
+ cleanKey === "eq" ||
761
+ cleanKey === "ne" ||
762
+ cleanKey === "in" ||
763
+ cleanKey === "nin"
764
+ );
765
+ }
766
+
767
+ _normalizeLogicalOperators(filters = {}) {
768
+ if (Array.isArray(filters)) {
769
+ return filters.map((item) => this._normalizeLogicalOperators(item));
770
+ }
771
+
772
+ if (
773
+ !filters ||
774
+ typeof filters !== "object" ||
775
+ filters instanceof mongoose.Types.ObjectId ||
776
+ filters instanceof ObjectId ||
777
+ filters instanceof Date
317
778
  ) {
318
- return true;
779
+ return filters;
319
780
  }
320
781
 
321
- return (
322
- ["eq", "ne", "in", "nin"].includes(cleanKey) &&
323
- (cleanParentKey === "_id" ||
324
- cleanParentKey === "id" ||
325
- cleanParentKey.endsWith("id"))
326
- );
782
+ const out = {};
783
+
784
+ for (const [key, value] of Object.entries(filters)) {
785
+ const normalizedKey =
786
+ key === "and"
787
+ ? "$and"
788
+ : key === "or"
789
+ ? "$or"
790
+ : key === "nor"
791
+ ? "$nor"
792
+ : key;
793
+
794
+ out[normalizedKey] = this._normalizeLogicalOperators(value);
795
+ }
796
+
797
+ return out;
327
798
  }
328
799
 
329
800
  _deepMergeFilters(a = {}, b = {}) {
@@ -350,14 +821,6 @@ export class ApiFeatures {
350
821
 
351
822
  _applySecurityFilters(filters = {}) {
352
823
  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
-
361
824
  if (Array.isArray(node)) {
362
825
  return node.map(cleanNode);
363
826
  }
@@ -379,74 +842,6 @@ export class ApiFeatures {
379
842
  return cleanNode(filters);
380
843
  }
381
844
 
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
-
450
845
  _normalizePopulateInput(input = "") {
451
846
  const raw = [];
452
847