vanta-api 1.1.4 → 1.1.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 +87 -43
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "vanta-api",
3
- "version": "1.1.4",
3
+ "version": "1.1.5",
4
4
  "description": "Advanced API features and security configuration for Node.js/MongoDB.",
5
5
  "main": "index.js",
6
6
  "scripts": {
@@ -62,10 +62,12 @@ export class ApiFeatures {
62
62
 
63
63
  limitFields() {
64
64
  if (!this.query.fields) return this;
65
- const validFields = Object.keys(this.model.schema.paths).filter(f => !securityConfig.forbiddenFields.includes(f));
65
+ const validFields = Object.keys(this.model.schema.paths).filter(
66
+ (f) => !securityConfig.forbiddenFields.includes(f)
67
+ );
66
68
  const project = {};
67
69
 
68
- this.query.fields.split(",").forEach(f => {
70
+ this.query.fields.split(",").forEach((f) => {
69
71
  if (validFields.includes(f)) project[f] = 1;
70
72
  });
71
73
 
@@ -74,9 +76,14 @@ export class ApiFeatures {
74
76
  }
75
77
 
76
78
  paginate() {
77
- const { maxLimit } = securityConfig.accessLevels[this.userRole] || { maxLimit: 100 };
79
+ const { maxLimit } = securityConfig.accessLevels[this.userRole] || {
80
+ maxLimit: 100,
81
+ };
78
82
  const page = Math.max(parseInt(this.query.page, 10) || 1, 1);
79
- const lim = Math.min(Math.max(parseInt(this.query.limit, 10) || 10, 1), maxLimit);
83
+ const lim = Math.min(
84
+ Math.max(parseInt(this.query.limit, 10) || 10, 1),
85
+ maxLimit
86
+ );
80
87
 
81
88
  this.pipeline.push({ $skip: (page - 1) * lim }, { $limit: lim });
82
89
  return this;
@@ -88,39 +95,60 @@ export class ApiFeatures {
88
95
  const raw = Array.isArray(input) ? input : [input];
89
96
  if (this.query.populate) raw.push(...this.query.populate.split(","));
90
97
 
91
- raw.forEach(item => {
92
- if (typeof item === 'string' && item.trim()) list.push(item.trim());
98
+ raw.forEach((item) => {
99
+ if (typeof item === "string" && item.trim()) list.push(item.trim());
93
100
  else if (item?.path) list.push(item);
94
101
  });
95
102
 
96
103
  // Deduplicate
97
104
  const map = new Map();
98
- list.forEach(opt => {
99
- const key = typeof opt === 'string' ? opt : opt.path;
105
+ list.forEach((opt) => {
106
+ const key = typeof opt === "string" ? opt : opt.path;
100
107
  map.set(key, opt);
101
108
  });
102
109
 
103
110
  // Enforce role-based populate
104
- const allowed = securityConfig.accessLevels[this.userRole]?.allowedPopulate || [];
111
+ const allowed =
112
+ securityConfig.accessLevels[this.userRole]?.allowedPopulate || [];
105
113
  const final = [];
106
114
  map.forEach((opt, key) => {
107
- if (allowed.includes('*') || allowed.includes(key)) final.push(opt);
115
+ if (allowed.includes("*") || allowed.includes(key)) final.push(opt);
108
116
  });
109
117
 
110
118
  // Apply lookups
111
119
  for (const opt of final) {
112
- const field = typeof opt === 'string' ? opt : opt.path;
113
- const proj = typeof opt === 'object' && opt.select
114
- ? opt.select.split(' ').reduce((a, f) => { a[f]=1; return a; }, {})
115
- : {};
120
+ const field = typeof opt === 'string' ? opt.toLowerCase() : opt.path.toLowerCase();
121
+ const proj =
122
+ typeof opt === "object" && opt.select
123
+ ? opt.select.split(" ").reduce((a, f) => {
124
+ a[f] = 1;
125
+ return a;
126
+ }, {})
127
+ : {};
116
128
 
117
129
  const { collection } = this._getCollectionInfo(field);
118
- const lookup = proj && Object.keys(proj).length
119
- ? { from: collection, let: { id: `$${field}` }, pipeline: [ { $match: { $expr: { $eq: ['$_id','$$id'] } } }, { $project: proj } ], as: field }
120
- : { from: collection, localField: field, foreignField: '_id', as: field };
130
+ const lookup =
131
+ proj && Object.keys(proj).length
132
+ ? {
133
+ from: collection,
134
+ let: { id: `$${field}` },
135
+ pipeline: [
136
+ { $match: { $expr: { $eq: ["$_id", "$$id"] } } },
137
+ { $project: proj },
138
+ ],
139
+ as: field,
140
+ }
141
+ : {
142
+ from: collection,
143
+ localField: field,
144
+ foreignField: "_id",
145
+ as: field,
146
+ };
121
147
 
122
148
  this.pipeline.push({ $lookup: lookup });
123
- this.pipeline.push({ $unwind: { path: `$${field}`, preserveNullAndEmptyArrays: true } });
149
+ this.pipeline.push({
150
+ $unwind: { path: `$${field}`, preserveNullAndEmptyArrays: true },
151
+ });
124
152
  }
125
153
 
126
154
  return this;
@@ -134,16 +162,23 @@ export class ApiFeatures {
134
162
  async execute(options = {}) {
135
163
  try {
136
164
  if (options.useCursor) this.useCursor = true;
137
- if (options.debug) logger.info('Pipeline:', this.pipeline);
165
+ if (options.debug) logger.info("Pipeline:", this.pipeline);
138
166
  if (this.pipeline.length > (securityConfig.maxPipelineStages || 20)) {
139
- throw new HandleERROR('Too many pipeline stages', 400);
167
+ throw new HandleERROR("Too many pipeline stages", 400);
140
168
  }
141
169
 
142
- let agg = this.model.aggregate(this.pipeline).option({ maxTimeMS: 10000 });
143
- const [cnt] = await this.model.aggregate([...this.countPipeline, { $count: 'total' }]);
170
+ let agg = this.model
171
+ .aggregate(this.pipeline)
172
+ .option({ maxTimeMS: 10000 });
173
+ const [cnt] = await this.model.aggregate([
174
+ ...this.countPipeline,
175
+ { $count: "total" },
176
+ ]);
144
177
  const cursorOrData = this.useCursor
145
178
  ? agg.cursor({ batchSize: 100 }).exec()
146
- : agg.allowDiskUse(options.allowDiskUse || false).readConcern('majority');
179
+ : agg
180
+ .allowDiskUse(options.allowDiskUse || false)
181
+ .readConcern("majority");
147
182
 
148
183
  const data = this.useCursor
149
184
  ? await cursorOrData.toArray()
@@ -151,9 +186,9 @@ export class ApiFeatures {
151
186
 
152
187
  const result = { success: true, count: cnt?.total || 0, data };
153
188
  if (options.projection) {
154
- result.data = result.data.map(doc => {
189
+ result.data = result.data.map((doc) => {
155
190
  const projDoc = {};
156
- Object.keys(options.projection).forEach(f => {
191
+ Object.keys(options.projection).forEach((f) => {
157
192
  if (options.projection[f]) projDoc[f] = doc[f];
158
193
  });
159
194
  return projDoc;
@@ -169,30 +204,30 @@ export class ApiFeatures {
169
204
 
170
205
  _sanitization() {
171
206
  // Remove unsafe ops
172
- ['$', '$where', '$accumulator', '$function'].forEach(op => {
207
+ ["$", "$where", "$accumulator", "$function"].forEach((op) => {
173
208
  delete this.query[op];
174
209
  });
175
210
  // Validate numeric
176
- ['page','limit'].forEach(f => {
211
+ ["page", "limit"].forEach((f) => {
177
212
  if (this.query[f] && !/^[0-9]+$/.test(this.query[f])) {
178
- throw new HandleERROR(`Invalid ${f}`,400);
213
+ throw new HandleERROR(`Invalid ${f}`, 400);
179
214
  }
180
215
  });
181
216
  }
182
217
 
183
218
  _parseQueryFilters() {
184
219
  const obj = { ...this.query };
185
- ['page','limit','sort','fields','populate'].forEach(k => delete obj[k]);
220
+ ["page", "limit", "sort", "fields", "populate"].forEach(
221
+ (k) => delete obj[k]
222
+ );
186
223
 
187
224
  // Whitelist operators
188
225
  const out = {};
189
- for (const [k,v] of Object.entries(obj)) {
190
- if (['or','and'].includes(k)) {
226
+ for (const [k, v] of Object.entries(obj)) {
227
+ if (["or", "and"].includes(k)) {
191
228
  out[`$${k}`] = Array.isArray(v) ? v : [v];
192
229
  } else {
193
- out[k] = typeof v === 'string' && v.includes(',')
194
- ? v.split(',')
195
- : v;
230
+ out[k] = typeof v === "string" && v.includes(",") ? v.split(",") : v;
196
231
  }
197
232
  }
198
233
  return out;
@@ -200,19 +235,20 @@ export class ApiFeatures {
200
235
 
201
236
  _sanitizeFilters(filters) {
202
237
  // Simple deep clone with ObjectId and boolean parsing
203
- return JSON.parse(JSON.stringify(filters), (key,val) => {
204
- if (key.endsWith('Id') && mongoose.isValidObjectId(val)) return new mongoose.Types.ObjectId(val);
205
- if (val === 'true') return true;
206
- if (val === 'false') return false;
207
- if (/^[0-9]+$/.test(val)) return parseInt(val,10);
238
+ return JSON.parse(JSON.stringify(filters), (key, val) => {
239
+ if (key.endsWith("Id") && mongoose.isValidObjectId(val))
240
+ return new mongoose.Types.ObjectId(val);
241
+ if (val === "true") return true;
242
+ if (val === "false") return false;
243
+ if (/^[0-9]+$/.test(val)) return parseInt(val, 10);
208
244
  return val;
209
245
  });
210
246
  }
211
247
 
212
248
  _applySecurityFilters(filters) {
213
249
  let res = { ...filters };
214
- securityConfig.forbiddenFields.forEach(f => delete res[f]);
215
- if (this.userRole !== 'admin' && this.model.schema.path('isActive')) {
250
+ securityConfig.forbiddenFields.forEach((f) => delete res[f]);
251
+ if (this.userRole !== "admin" && this.model.schema.path("isActive")) {
216
252
  res.isActive = true;
217
253
  }
218
254
  return res;
@@ -220,8 +256,16 @@ export class ApiFeatures {
220
256
 
221
257
  _getCollectionInfo(field) {
222
258
  const path = this.model.schema.path(field);
223
- if (!path?.options?.ref) throw new HandleERROR(`Invalid populate: ${field}`,400);
224
- return { collection: pluralize(path.options.ref), isArray: path.instance==='Array' };
259
+ if (!path?.options?.ref)
260
+ throw new HandleERROR(`Invalid populate: ${field}`, 400);
261
+
262
+ const refModelName = path.options.ref.toLowerCase();
263
+ const collectionName = pluralize(refModelName);
264
+
265
+ return {
266
+ collection: collectionName,
267
+ isArray: path.instance === "Array",
268
+ };
225
269
  }
226
270
 
227
271
  _handleError(err) {