vanta-api 1.4.1 → 1.4.3

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 -119
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "vanta-api",
3
- "version": "1.4.1",
3
+ "version": "1.4.3",
4
4
  "description": "Advanced API features and security configuration for Node.js/MongoDB.",
5
5
  "main": "index.js",
6
6
  "scripts": {
@@ -1,5 +1,6 @@
1
1
  import mongoose from "mongoose";
2
2
  import winston from "winston";
3
+ import pluralize from "pluralize";
3
4
  import HandleERROR from "./handleError.js";
4
5
  import { securityConfig } from "./config.js";
5
6
  import { ObjectId } from "bson";
@@ -22,7 +23,6 @@ export class ApiFeatures {
22
23
  this.query = { ...query };
23
24
  this.pipeline = [];
24
25
  this.manualFilters = {};
25
- this.populateOptions = [];
26
26
  this.useCursor = false;
27
27
 
28
28
  this.userRole =
@@ -161,11 +161,15 @@ export class ApiFeatures {
161
161
  const allowedPopulate =
162
162
  securityConfig.accessLevels?.[this.userRole]?.allowedPopulate || [];
163
163
 
164
- const safePopulateList = populateList
165
- .map((item) => this._sanitizePopulateOption(item, "", allowedPopulate))
166
- .filter(Boolean);
167
-
168
- this.populateOptions.push(...safePopulateList);
164
+ for (const populateItem of populateList) {
165
+ this._addPopulateStages({
166
+ populateItem,
167
+ parentPath: "",
168
+ parentIsArray: false,
169
+ schema: this.model.schema,
170
+ allowedPopulate,
171
+ });
172
+ }
169
173
 
170
174
  return this;
171
175
  }
@@ -176,10 +180,9 @@ export class ApiFeatures {
176
180
 
177
181
  if (options.debug) {
178
182
  logger.info("Pipeline:", this.pipeline);
179
- logger.info("Populate:", this.populateOptions);
180
183
  }
181
184
 
182
- if (this.pipeline.length > (securityConfig.maxPipelineStages || 50)) {
185
+ if (this.pipeline.length > (securityConfig.maxPipelineStages || 80)) {
183
186
  throw new HandleERROR("Too many pipeline stages", 400);
184
187
  }
185
188
 
@@ -212,10 +215,6 @@ export class ApiFeatures {
212
215
  .readConcern(options.readConcern || "majority");
213
216
  }
214
217
 
215
- if (this.populateOptions.length) {
216
- data = await this.model.populate(data, this.populateOptions);
217
- }
218
-
219
218
  return {
220
219
  success: true,
221
220
  count: countResult?.total || 0,
@@ -226,6 +225,420 @@ export class ApiFeatures {
226
225
  }
227
226
  }
228
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
+
229
642
  _sanitization() {
230
643
  for (const key of Object.keys(this.query)) {
231
644
  if (
@@ -291,43 +704,50 @@ export class ApiFeatures {
291
704
  return out;
292
705
  }
293
706
 
294
- _sanitizeFilters(filters = {}) {
295
- const sanitizeNode = (node, key = "") => {
296
- if (node === null || node === "null") return null;
297
- if (node === "true") return true;
298
- if (node === "false") return false;
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
+ }
299
715
 
300
- if (Array.isArray(node)) {
301
- return node.map((item) => sanitizeNode(item, key));
302
- }
716
+ if (node === null || node === "null") return null;
717
+ if (node === "true") return true;
718
+ if (node === "false") return false;
303
719
 
304
- if (node && typeof node === "object") {
305
- const result = {};
720
+ if (Array.isArray(node)) {
721
+ return node.map((item) => sanitizeNode(item, key));
722
+ }
306
723
 
307
- for (const [childKey, childVal] of Object.entries(node)) {
308
- result[childKey] = sanitizeNode(childVal, childKey);
309
- }
724
+ if (node && typeof node === "object") {
725
+ const result = {};
310
726
 
311
- return result;
727
+ for (const [childKey, childVal] of Object.entries(node)) {
728
+ result[childKey] = sanitizeNode(childVal, childKey);
312
729
  }
313
730
 
314
- if (typeof node === "string") {
315
- if (this.#isStrictObjectId(node) && this._shouldConvertToObjectId(key)) {
316
- return new ObjectId(node);
317
- }
731
+ return result;
732
+ }
318
733
 
319
- if (/^[0-9]+$/.test(node)) {
320
- return node.length > 1 && node.startsWith("0")
321
- ? node
322
- : parseInt(node, 10);
323
- }
734
+ if (typeof node === "string") {
735
+ if (this.#isStrictObjectId(node) && this._shouldConvertToObjectId(key)) {
736
+ return new ObjectId(node);
324
737
  }
325
738
 
326
- return node;
327
- };
739
+ if (/^[0-9]+$/.test(node)) {
740
+ return node.length > 1 && node.startsWith("0")
741
+ ? node
742
+ : parseInt(node, 10);
743
+ }
744
+ }
328
745
 
329
- return sanitizeNode(filters);
330
- }
746
+ return node;
747
+ };
748
+
749
+ return sanitizeNode(filters);
750
+ }
331
751
 
332
752
  _shouldConvertToObjectId(key = "") {
333
753
  const cleanKey = String(key).replace(/^\$/, "").toLowerCase();
@@ -443,7 +863,7 @@ export class ApiFeatures {
443
863
  }
444
864
 
445
865
  if (typeof normalized.select === "string") {
446
- normalized.select = this._sanitizeSelectString(normalized.select);
866
+ normalized.select = normalized.select.trim();
447
867
  }
448
868
 
449
869
  if (normalized.populate) {
@@ -454,7 +874,7 @@ export class ApiFeatures {
454
874
  }
455
875
 
456
876
  _normalizeNestedPopulate(input) {
457
- if (!input) return undefined;
877
+ if (!input) return [];
458
878
 
459
879
  if (typeof input === "string") {
460
880
  return this._normalizePopulateInput(input);
@@ -473,14 +893,17 @@ export class ApiFeatures {
473
893
  }
474
894
 
475
895
  if (typeof input === "object" && input.path) {
476
- return this._normalizePopulateObject(input);
896
+ return [this._normalizePopulateObject(input)];
477
897
  }
478
898
 
479
- return undefined;
899
+ return [];
480
900
  }
481
901
 
482
902
  _dotPathToPopulate(path) {
483
- const parts = path.split(".").map((p) => p.trim()).filter(Boolean);
903
+ const parts = path
904
+ .split(".")
905
+ .map((part) => part.trim())
906
+ .filter(Boolean);
484
907
 
485
908
  const root = { path: parts[0] };
486
909
  let current = root;
@@ -515,96 +938,60 @@ export class ApiFeatures {
515
938
  const merged = { ...a, ...b };
516
939
 
517
940
  if (a.populate || b.populate) {
518
- const aList = this._populateToArray(a.populate);
519
- const bList = this._populateToArray(b.populate);
941
+ const aList = Array.isArray(a.populate)
942
+ ? a.populate
943
+ : a.populate
944
+ ? [a.populate]
945
+ : [];
946
+
947
+ const bList = Array.isArray(b.populate)
948
+ ? b.populate
949
+ : b.populate
950
+ ? [b.populate]
951
+ : [];
952
+
520
953
  merged.populate = this._dedupePopulate([...aList, ...bList]);
521
954
  }
522
955
 
523
956
  return merged;
524
957
  }
525
958
 
526
- _populateToArray(populate) {
527
- if (!populate) return [];
528
- return Array.isArray(populate) ? populate : [populate];
959
+ _isPopulateAllowed(path, allowedPopulate = []) {
960
+ return (
961
+ allowedPopulate.includes("*") ||
962
+ allowedPopulate.includes(path) ||
963
+ allowedPopulate.includes(path.split(".")[0])
964
+ );
529
965
  }
530
966
 
531
- _sanitizePopulateOption(item, parentPath = "", allowedPopulate = []) {
532
- if (!item || typeof item !== "object" || !item.path) return null;
533
-
534
- const fullPath = parentPath ? `${parentPath}.${item.path}` : item.path;
535
-
536
- if (!this._isPopulateAllowed(fullPath, allowedPopulate)) {
537
- return null;
538
- }
539
-
540
- const sanitized = {
541
- path: item.path,
542
- };
543
-
544
- if (item.select) {
545
- sanitized.select = this._sanitizeSelectString(item.select);
546
- }
547
-
548
- if (item.match && typeof item.match === "object") {
549
- sanitized.match = this._applySecurityFilters(
550
- this._sanitizeFilters(item.match)
551
- );
552
- }
553
-
554
- if (item.options && typeof item.options === "object") {
555
- sanitized.options = item.options;
556
- }
557
-
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);
967
+ _resolveCollectionName(refModelName = "") {
968
+ return pluralize(String(refModelName).toLowerCase());
969
+ }
568
970
 
569
- if (nested.length === 1) {
570
- sanitized.populate = nested[0];
571
- } else if (nested.length > 1) {
572
- sanitized.populate = nested;
573
- }
574
- }
971
+ _resolveRegisteredSchema(refModelName = "") {
972
+ const connection = this.model?.db || mongoose.connection;
973
+ const registeredModel =
974
+ connection.models?.[refModelName] || mongoose.models?.[refModelName];
575
975
 
576
- return sanitized;
976
+ return registeredModel?.schema || null;
577
977
  }
578
978
 
579
- _sanitizeSelectString(select = "") {
580
- const fields = String(select)
581
- .split(/\s+/)
582
- .map((field) => field.trim())
583
- .filter(Boolean)
584
- .filter((field) => {
585
- const cleanField = field.replace(/^-/, "");
586
- return !this._isForbiddenField(cleanField);
587
- });
979
+ _inferModelNameFromPath(path = "") {
980
+ let clean = String(path).split(".").pop();
588
981
 
589
- const hasInclude = fields.some((field) => !field.startsWith("-"));
590
- const hasExclude = fields.some((field) => field.startsWith("-"));
982
+ clean = clean.replace(/Ids$/i, "");
983
+ clean = clean.replace(/Id$/i, "");
984
+ clean = pluralize.singular(clean);
591
985
 
592
- if (hasInclude && hasExclude) {
593
- throw new HandleERROR(
594
- "Cannot mix include and exclude in populate select",
595
- 400
596
- );
597
- }
598
-
599
- return fields.join(" ");
986
+ return clean
987
+ .split(/[_-\s]+/)
988
+ .filter(Boolean)
989
+ .map((part) => part.charAt(0).toUpperCase() + part.slice(1))
990
+ .join("");
600
991
  }
601
992
 
602
- _isPopulateAllowed(path, allowedPopulate = []) {
603
- return (
604
- allowedPopulate.includes("*") ||
605
- allowedPopulate.includes(path) ||
606
- allowedPopulate.includes(path.split(".")[0])
607
- );
993
+ _makeTempLookupName(path = "") {
994
+ return `__vanta_lookup_${String(path).replace(/[^a-zA-Z0-9]/g, "_")}`;
608
995
  }
609
996
 
610
997
  _isForbiddenField(field) {
@@ -617,7 +1004,11 @@ export class ApiFeatures {
617
1004
  "$skip" in stage ||
618
1005
  "$limit" in stage ||
619
1006
  "$sort" in stage ||
620
- "$project" in stage
1007
+ "$project" in stage ||
1008
+ "$lookup" in stage ||
1009
+ "$unwind" in stage ||
1010
+ "$set" in stage ||
1011
+ "$unset" in stage
621
1012
  );
622
1013
  });
623
1014
  }