vanta-api 1.4.1 → 1.4.2

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 +476 -92
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "vanta-api",
3
- "version": "1.4.1",
3
+ "version": "1.4.2",
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 (
@@ -443,7 +856,7 @@ export class ApiFeatures {
443
856
  }
444
857
 
445
858
  if (typeof normalized.select === "string") {
446
- normalized.select = this._sanitizeSelectString(normalized.select);
859
+ normalized.select = normalized.select.trim();
447
860
  }
448
861
 
449
862
  if (normalized.populate) {
@@ -454,7 +867,7 @@ export class ApiFeatures {
454
867
  }
455
868
 
456
869
  _normalizeNestedPopulate(input) {
457
- if (!input) return undefined;
870
+ if (!input) return [];
458
871
 
459
872
  if (typeof input === "string") {
460
873
  return this._normalizePopulateInput(input);
@@ -473,14 +886,17 @@ export class ApiFeatures {
473
886
  }
474
887
 
475
888
  if (typeof input === "object" && input.path) {
476
- return this._normalizePopulateObject(input);
889
+ return [this._normalizePopulateObject(input)];
477
890
  }
478
891
 
479
- return undefined;
892
+ return [];
480
893
  }
481
894
 
482
895
  _dotPathToPopulate(path) {
483
- const parts = path.split(".").map((p) => p.trim()).filter(Boolean);
896
+ const parts = path
897
+ .split(".")
898
+ .map((part) => part.trim())
899
+ .filter(Boolean);
484
900
 
485
901
  const root = { path: parts[0] };
486
902
  let current = root;
@@ -515,96 +931,60 @@ export class ApiFeatures {
515
931
  const merged = { ...a, ...b };
516
932
 
517
933
  if (a.populate || b.populate) {
518
- const aList = this._populateToArray(a.populate);
519
- const bList = this._populateToArray(b.populate);
934
+ const aList = Array.isArray(a.populate)
935
+ ? a.populate
936
+ : a.populate
937
+ ? [a.populate]
938
+ : [];
939
+
940
+ const bList = Array.isArray(b.populate)
941
+ ? b.populate
942
+ : b.populate
943
+ ? [b.populate]
944
+ : [];
945
+
520
946
  merged.populate = this._dedupePopulate([...aList, ...bList]);
521
947
  }
522
948
 
523
949
  return merged;
524
950
  }
525
951
 
526
- _populateToArray(populate) {
527
- if (!populate) return [];
528
- return Array.isArray(populate) ? populate : [populate];
952
+ _isPopulateAllowed(path, allowedPopulate = []) {
953
+ return (
954
+ allowedPopulate.includes("*") ||
955
+ allowedPopulate.includes(path) ||
956
+ allowedPopulate.includes(path.split(".")[0])
957
+ );
529
958
  }
530
959
 
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);
960
+ _resolveCollectionName(refModelName = "") {
961
+ return pluralize(String(refModelName).toLowerCase());
962
+ }
568
963
 
569
- if (nested.length === 1) {
570
- sanitized.populate = nested[0];
571
- } else if (nested.length > 1) {
572
- sanitized.populate = nested;
573
- }
574
- }
964
+ _resolveRegisteredSchema(refModelName = "") {
965
+ const connection = this.model?.db || mongoose.connection;
966
+ const registeredModel =
967
+ connection.models?.[refModelName] || mongoose.models?.[refModelName];
575
968
 
576
- return sanitized;
969
+ return registeredModel?.schema || null;
577
970
  }
578
971
 
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
- });
972
+ _inferModelNameFromPath(path = "") {
973
+ let clean = String(path).split(".").pop();
588
974
 
589
- const hasInclude = fields.some((field) => !field.startsWith("-"));
590
- const hasExclude = fields.some((field) => field.startsWith("-"));
591
-
592
- if (hasInclude && hasExclude) {
593
- throw new HandleERROR(
594
- "Cannot mix include and exclude in populate select",
595
- 400
596
- );
597
- }
975
+ clean = clean.replace(/Ids$/i, "");
976
+ clean = clean.replace(/Id$/i, "");
977
+ clean = pluralize.singular(clean);
598
978
 
599
- return fields.join(" ");
979
+ return clean
980
+ .split(/[_-\s]+/)
981
+ .filter(Boolean)
982
+ .map((part) => part.charAt(0).toUpperCase() + part.slice(1))
983
+ .join("");
600
984
  }
601
985
 
602
- _isPopulateAllowed(path, allowedPopulate = []) {
603
- return (
604
- allowedPopulate.includes("*") ||
605
- allowedPopulate.includes(path) ||
606
- allowedPopulate.includes(path.split(".")[0])
607
- );
986
+ _makeTempLookupName(path = "") {
987
+ return `__vanta_lookup_${String(path).replace(/[^a-zA-Z0-9]/g, "_")}`;
608
988
  }
609
989
 
610
990
  _isForbiddenField(field) {
@@ -617,7 +997,11 @@ export class ApiFeatures {
617
997
  "$skip" in stage ||
618
998
  "$limit" in stage ||
619
999
  "$sort" in stage ||
620
- "$project" in stage
1000
+ "$project" in stage ||
1001
+ "$lookup" in stage ||
1002
+ "$unwind" in stage ||
1003
+ "$set" in stage ||
1004
+ "$unset" in stage
621
1005
  );
622
1006
  });
623
1007
  }