@classytic/mongokit 3.23.0 → 3.25.0

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 (40) hide show
  1. package/README.md +11 -2
  2. package/dist/{PaginationEngine-BnZ24ehC.d.mts → PaginationEngine-CBS1XGzp.d.mts} +2 -1
  3. package/dist/{PaginationEngine-BT1ywu9k.mjs → PaginationEngine-CczbvCAq.mjs} +64 -3
  4. package/dist/{Repository-CLQA3sEz.mjs → Repository-KKR8jxfD.mjs} +74 -45
  5. package/dist/actions/index.d.mts +1 -1
  6. package/dist/adapter/index.d.mts +1 -1
  7. package/dist/adapter/index.mjs +1 -1
  8. package/dist/{adapter-BWgJ_Jdc.mjs → adapter-CLvUHiTW.mjs} +1 -1
  9. package/dist/ai/index.d.mts +1 -1
  10. package/dist/better-auth/index.mjs +2 -2
  11. package/dist/core-62oo3N1C.d.mts +50 -0
  12. package/dist/{create-repository-Bx1PpcdF.mjs → create-repository-DA5VJh-3.mjs} +2 -2
  13. package/dist/{create-repository-Cn1LkvL7.d.mts → create-repository-HjMD9h9_.d.mts} +18 -5
  14. package/dist/{id-resolution-P2OzcyRl.mjs → id-resolution-BqDrk0S2.mjs} +12 -1
  15. package/dist/{index-DLQ6pjal.d.mts → index-DceInCTX.d.mts} +3 -1
  16. package/dist/index.d.mts +53 -213
  17. package/dist/index.mjs +1072 -923
  18. package/dist/{mongooseToJsonSchema-CVnrKFDq.mjs → mongooseToJsonSchema-CByt1VhQ.mjs} +1 -1
  19. package/dist/{multi-tenant.plugin-BjXXuc_g.d.mts → multi-tenant.plugin-DVyVGREb.d.mts} +117 -18
  20. package/dist/operations-C8IcCHF9.d.mts +463 -0
  21. package/dist/pagination/PaginationEngine.d.mts +1 -1
  22. package/dist/pagination/PaginationEngine.mjs +1 -1
  23. package/dist/pagination-jSUdrACK.d.mts +158 -0
  24. package/dist/plugin-options-vFufQZMZ.d.mts +125 -0
  25. package/dist/plugins/index.d.mts +4 -4
  26. package/dist/plugins/index.mjs +3 -3
  27. package/dist/query/primitives/coercion.mjs +1 -1
  28. package/dist/query/primitives/geo.d.mts +1 -1
  29. package/dist/repository-DCHRWRXw.d.mts +511 -0
  30. package/dist/testkit/index.d.mts +1 -1
  31. package/dist/testkit/index.mjs +1 -1
  32. package/dist/{timestamp.plugin-BnuuIDqJ.mjs → timestamp.plugin-B8252GuQ.mjs} +192 -46
  33. package/dist/{to-plain-g8-gsVxZ.d.mts → to-plain-DkF5px0R.d.mts} +12 -16
  34. package/dist/type-utils-CLzRFtIA.d.mts +163 -0
  35. package/dist/utils/index.d.mts +2 -2
  36. package/dist/utils/index.mjs +3 -3
  37. package/dist/{validation-chain.plugin-ZFDHxXLn.mjs → validation-chain.plugin-C3Vwp-Rk.mjs} +1 -1
  38. package/dist/{validation-chain.plugin-CFJbr964.d.mts → validation-chain.plugin-DxD15h-2.d.mts} +3 -1
  39. package/package.json +5 -3
  40. package/dist/types-BtMuhoX5.d.mts +0 -1830
package/dist/index.mjs CHANGED
@@ -1,19 +1,19 @@
1
- import { n as idVariants } from "./id-resolution-P2OzcyRl.mjs";
2
- import { a as withTransaction, c as MUTATING_OPERATIONS, d as operationsByPolicyKey, f as MONGOKIT_CAPABILITIES, i as isTransactionUnsupported, l as OP_REGISTRY, n as Repository, o as AggregationBuilder, p as createMongoPurgePort, r as batchTransaction, s as ALL_OPERATIONS, t as HOOK_PRIORITY, u as READ_OPERATIONS } from "./Repository-CLQA3sEz.mjs";
1
+ import { n as idVariants } from "./id-resolution-BqDrk0S2.mjs";
2
+ import { a as withTransaction, c as MUTATING_OPERATIONS, d as operationsByPolicyKey, f as MONGOKIT_CAPABILITIES, i as isTransactionUnsupported, l as OP_REGISTRY, n as Repository, o as AggregationBuilder, p as createMongoPurgePort, r as batchTransaction, s as ALL_OPERATIONS, t as HOOK_PRIORITY, u as READ_OPERATIONS } from "./Repository-KKR8jxfD.mjs";
3
3
  import { b as LookupBuilder, x as compileFilterToMongo } from "./update-1Lq9OMD2.mjs";
4
4
  import { n as warn, t as configureLogger } from "./logger-K7dPmu2U.mjs";
5
5
  import { n as isDuplicateKeyError, r as parseDuplicateKeyError, t as createError } from "./error-BuByZwKU.mjs";
6
6
  import { t as actions_exports } from "./actions/index.mjs";
7
- import { a as methodRegistryPlugin, c as getNextSequence, d as cachePlugin, f as batchOperationsPlugin, h as auditLogPlugin, i as multiTenantPlugin, l as prefixedId, m as auditTrailPlugin, n as softDeletePlugin, o as customIdPlugin, p as AuditTrailQuery, r as adminBypass, s as dateSequentialId, t as timestampPlugin, u as sequentialId } from "./timestamp.plugin-BnuuIDqJ.mjs";
8
- import { t as PaginationEngine } from "./PaginationEngine-BT1ywu9k.mjs";
7
+ import { a as methodRegistryPlugin, c as getNextSequence, d as cachePlugin, f as batchOperationsPlugin, g as auditLogPlugin, h as ensureAuditTrailReady, i as multiTenantPlugin, l as prefixedId, m as auditTrailPlugin, n as softDeletePlugin, o as customIdPlugin, p as AuditTrailQuery, r as adminBypass, s as dateSequentialId, t as timestampPlugin, u as sequentialId } from "./timestamp.plugin-B8252GuQ.mjs";
8
+ import { t as PaginationEngine } from "./PaginationEngine-CczbvCAq.mjs";
9
9
  import { isGeoOperator, parseGeoFilter } from "./query/primitives/geo.mjs";
10
- import { t as createRepository } from "./create-repository-Bx1PpcdF.mjs";
11
- import { _ as appendOnlyPlugin, a as uniqueField, c as subdocumentPlugin, d as mongoOperationsPlugin, f as leasePlugin, g as cascadePlugin, h as elasticSearchPlugin, i as requireField, l as observabilityPlugin, m as fieldFilterPlugin, n as blockIf, o as validationChainPlugin, p as immutableStatesPlugin, r as immutableField, s as createTenantContext, t as autoInject, u as MONGOKIT_PLUGIN_NAMES, v as aggregateHelpersPlugin } from "./validation-chain.plugin-ZFDHxXLn.mjs";
10
+ import { t as createRepository } from "./create-repository-DA5VJh-3.mjs";
11
+ import { _ as appendOnlyPlugin, a as uniqueField, c as subdocumentPlugin, d as mongoOperationsPlugin, f as leasePlugin, g as cascadePlugin, h as elasticSearchPlugin, i as requireField, l as observabilityPlugin, m as fieldFilterPlugin, n as blockIf, o as validationChainPlugin, p as immutableStatesPlugin, r as immutableField, s as createTenantContext, t as autoInject, u as MONGOKIT_PLUGIN_NAMES, v as aggregateHelpersPlugin } from "./validation-chain.plugin-C3Vwp-Rk.mjs";
12
12
  import { i as getMongooseProjection, n as filterResponseData, r as getFieldsForUser, t as createFieldPreset } from "./field-selection-FSQFLpsU.mjs";
13
13
  import { buildFieldTypeMap, coerceFieldValue, coerceHeuristic } from "./query/primitives/coercion.mjs";
14
14
  import { extractSchemaIndexes } from "./query/primitives/indexes.mjs";
15
15
  import { n as createMemoryCache, t as toPlain } from "./to-plain-vksRrLtc.mjs";
16
- import { n as buildCrudSchemasFromMongooseSchema, t as buildCrudSchemasFromModel } from "./mongooseToJsonSchema-CVnrKFDq.mjs";
16
+ import { n as buildCrudSchemasFromMongooseSchema, t as buildCrudSchemasFromModel } from "./mongooseToJsonSchema-CByt1VhQ.mjs";
17
17
  import { runChunkedPurge } from "@classytic/repo-core/repository";
18
18
  import { isControlParam } from "@classytic/repo-core/query-parser";
19
19
  //#region src/actions/cascade-references.ts
@@ -145,87 +145,255 @@ async function cascadePurgeReferences(options) {
145
145
  return out;
146
146
  }
147
147
  //#endregion
148
- //#region src/query/QueryParser.ts
148
+ //#region src/query/parser/pipeline-sanitizer.ts
149
149
  /**
150
- * Modern Query Parser - URL to MongoDB Query Transpiler
150
+ * Sanitize $match configuration to prevent dangerous operators.
151
+ * Recursively filters out operators like $where, $function, $accumulator.
152
+ */
153
+ function sanitizeMatchConfig(rt, config, depth = 0) {
154
+ if (depth > rt.options.maxFilterDepth) {
155
+ rt.reject(`$match sanitize depth ${depth} exceeds maximum ${rt.options.maxFilterDepth}, truncating branch`, {
156
+ depth,
157
+ maxFilterDepth: rt.options.maxFilterDepth
158
+ });
159
+ return {};
160
+ }
161
+ const sanitized = {};
162
+ const logicalArrayOps = /* @__PURE__ */ new Set([
163
+ "$or",
164
+ "$and",
165
+ "$nor"
166
+ ]);
167
+ for (const [key, value] of Object.entries(config)) {
168
+ if (rt.dangerousOperators.includes(key)) {
169
+ rt.reject(`Blocked dangerous operator in aggregation: ${key}`, { key });
170
+ continue;
171
+ }
172
+ if (value && typeof value === "object" && !Array.isArray(value)) sanitized[key] = sanitizeMatchConfig(rt, value, depth + 1);
173
+ else if (Array.isArray(value)) {
174
+ const sanitizedArray = value.map((item) => {
175
+ if (item && typeof item === "object" && !Array.isArray(item)) return sanitizeMatchConfig(rt, item, depth + 1);
176
+ return item;
177
+ });
178
+ if (logicalArrayOps.has(key)) {
179
+ const filtered = sanitizedArray.filter((item) => !(item && typeof item === "object" && !Array.isArray(item) && Object.keys(item).length === 0));
180
+ if (filtered.length === 0) {
181
+ rt.reject(`All branches of ${key} were blocked by sanitization; dropping the operator`, { key });
182
+ continue;
183
+ }
184
+ sanitized[key] = filtered;
185
+ } else sanitized[key] = sanitizedArray;
186
+ } else sanitized[key] = value;
187
+ }
188
+ return sanitized;
189
+ }
190
+ /**
191
+ * Sanitize pipeline stages for use in $lookup.
192
+ * Blocks dangerous stages ($out, $merge, etc.) and recursively sanitizes
193
+ * operator expressions within $match, $addFields, and $set stages.
194
+ */
195
+ function sanitizePipeline(rt, stages) {
196
+ const blockedStages = [
197
+ "$out",
198
+ "$merge",
199
+ "$unionWith",
200
+ "$collStats",
201
+ "$currentOp",
202
+ "$listSessions"
203
+ ];
204
+ const sanitized = [];
205
+ for (const stage of stages) {
206
+ if (!stage || typeof stage !== "object") {
207
+ rt.reject(`Malformed pipeline stage in lookup (not an object): ${String(stage)}`);
208
+ continue;
209
+ }
210
+ const entries = Object.entries(stage);
211
+ if (entries.length !== 1) {
212
+ rt.reject(`Malformed pipeline stage in lookup (expected exactly one operator, got ${entries.length})`);
213
+ continue;
214
+ }
215
+ const [op, config] = entries[0];
216
+ if (blockedStages.includes(op)) {
217
+ rt.reject(`Blocked dangerous pipeline stage in lookup: ${op}`, { stage: op });
218
+ continue;
219
+ }
220
+ if (op === "$match" && typeof config === "object" && config !== null) sanitized.push({ $match: sanitizeMatchConfig(rt, config) });
221
+ else if ((op === "$addFields" || op === "$set") && typeof config === "object" && config !== null) sanitized.push({ [op]: sanitizeExpressions(rt, config) });
222
+ else sanitized.push(stage);
223
+ }
224
+ return sanitized;
225
+ }
226
+ /**
227
+ * Recursively sanitize expression objects, blocking dangerous operators
228
+ * like $where, $function, $accumulator inside $addFields/$set stages.
151
229
  *
152
- * Next-generation query parser that converts URL parameters to MongoDB aggregation pipelines.
153
- * Smarter than Prisma/tRPC for MongoDB with support for:
154
- * - Custom field lookups ($lookup)
155
- * - Complex filtering with operators
156
- * - Full-text search
157
- * - Aggregations via URL
158
- * - Security hardening
230
+ * Depth-guarded — same protection `sanitizeMatchConfig` ships, since
231
+ * a hostile `$cond`/`$switch` chain can recurse arbitrarily deep and
232
+ * exhaust JS stack.
233
+ */
234
+ function sanitizeExpressions(rt, config, depth = 0) {
235
+ if (depth > rt.options.maxFilterDepth) {
236
+ rt.reject(`pipeline expression sanitize depth ${depth} exceeds maximum ${rt.options.maxFilterDepth}, truncating branch`, {
237
+ depth,
238
+ maxFilterDepth: rt.options.maxFilterDepth
239
+ });
240
+ return {};
241
+ }
242
+ const sanitized = {};
243
+ for (const [key, value] of Object.entries(config)) {
244
+ if (rt.dangerousOperators.includes(key)) {
245
+ rt.reject(`Blocked dangerous operator in pipeline expression: ${key}`, { key });
246
+ continue;
247
+ }
248
+ if (value && typeof value === "object" && !Array.isArray(value)) sanitized[key] = sanitizeExpressions(rt, value, depth + 1);
249
+ else if (Array.isArray(value)) sanitized[key] = value.map((item) => {
250
+ if (item && typeof item === "object" && !Array.isArray(item)) return sanitizeExpressions(rt, item, depth + 1);
251
+ return item;
252
+ });
253
+ else sanitized[key] = value;
254
+ }
255
+ return sanitized;
256
+ }
257
+ //#endregion
258
+ //#region src/query/parser/aggregation.ts
259
+ /**
260
+ * Parse aggregation pipeline from URL (advanced feature).
159
261
  *
160
262
  * @example
161
263
  * ```typescript
162
- * // Simple usage
163
- * const parser = new QueryParser();
164
- * const query = parser.parse(req.query);
165
- *
166
- * // URL: ?status=active&lookup[department]=slug&sort=-createdAt&page=1&limit=20
167
- * // Result: Complete MongoDB query with $lookup, filters, sort, pagination
264
+ * // URL: ?aggregate[group][_id]=$status&aggregate[group][count]=$sum:1
265
+ * parseAggregation(rt, { group: { _id: '$status', count: '$sum:1' } });
168
266
  * ```
267
+ */
268
+ function parseAggregation(rt, aggregate) {
269
+ if (!aggregate || typeof aggregate !== "object") return void 0;
270
+ const pipeline = [];
271
+ const aggObj = aggregate;
272
+ for (const [stage, config] of Object.entries(aggObj)) try {
273
+ if (stage === "group" && typeof config === "object") pipeline.push({ $group: config });
274
+ else if (stage === "match" && typeof config === "object") {
275
+ const sanitizedMatch = sanitizeMatchConfig(rt, config);
276
+ if (Object.keys(sanitizedMatch).length > 0) pipeline.push({ $match: sanitizedMatch });
277
+ } else if (stage === "sort" && typeof config === "object") pipeline.push({ $sort: config });
278
+ else if (stage === "project" && typeof config === "object") pipeline.push({ $project: config });
279
+ else rt.reject(`Unsupported aggregation stage: ${stage}`, { stage });
280
+ } catch (error) {
281
+ if (error.code === "INVALID_QUERY_INPUT") throw error;
282
+ rt.reject(`Invalid aggregation stage ${stage}: ${error instanceof Error ? error.message : String(error)}`, { stage });
283
+ }
284
+ return pipeline.length > 0 ? pipeline : void 0;
285
+ }
286
+ //#endregion
287
+ //#region src/query/parser/regex-safety.ts
288
+ /**
289
+ * Regex safety — ReDoS protection for user-supplied patterns.
169
290
  *
170
- * ## SECURITY CONSIDERATIONS FOR PRODUCTION
171
- *
172
- * ### Aggregation Security (enableAggregations option)
173
- *
174
- * **IMPORTANT:** The `enableAggregations` option exposes powerful MongoDB aggregation
175
- * pipeline capabilities via URL parameters. While this feature includes sanitization
176
- * (blocks $where, $function, $accumulator), it should be used with caution:
177
- *
178
- * **Recommended security practices:**
179
- * 1. **Disable by default for public endpoints:**
180
- * ```typescript
181
- * const parser = new QueryParser({
182
- * enableAggregations: false // Default: disabled
183
- * });
184
- * ```
185
- *
186
- * 2. **Use per-route allowlists for trusted clients:**
187
- * ```typescript
188
- * // Admin/internal routes only
189
- * if (req.user?.role === 'admin') {
190
- * const allowedStages = ['$match', '$project', '$sort', '$limit'];
191
- * // Validate aggregate parameter against allowlist
192
- * }
193
- * ```
194
- *
195
- * 3. **Validate stage structure:** Even with sanitization, complex pipelines can
196
- * cause performance issues. Consider limiting:
197
- * - Number of pipeline stages (e.g., max 5)
198
- * - Specific allowed operators per stage
199
- * - Allowed fields in $project/$match
200
- *
201
- * 4. **Monitor resource usage:** Aggregation pipelines can be expensive.
202
- * Use MongoDB profiling to track slow operations.
203
- *
204
- * ### Lookup Security
291
+ * Two independent detectors feed the decision:
292
+ * 1. A heuristic pattern for classic catastrophic-backtracking shapes
293
+ * (nested quantifiers, possessive quantifiers, backreferences, …).
294
+ * 2. A deterministic static complexity budget, so patterns that evade the
295
+ * heuristic still fail over when their unbounded-quantifier density is
296
+ * pathological.
205
297
  *
206
- * Lookup pipelines are sanitized by default:
207
- * - Dangerous stages blocked ($out, $merge, $unionWith, $collStats, $currentOp, $listSessions)
208
- * - Dangerous operators blocked inside $match/$addFields/$set ($where, $function, $accumulator, $expr)
209
- * - Optional collection whitelist via `allowedLookupCollections`
210
- * For maximum security, use per-collection field allowlists in your controller layer.
298
+ * Unsafe input is never executed as a regex: depending on the caller's
299
+ * intent it is either escaped to a literal match or rejected through the
300
+ * `invalidInput` policy.
301
+ */
302
+ /**
303
+ * Regex patterns that can cause catastrophic backtracking (ReDoS attacks).
304
+ * Detects common footguns:
305
+ * - Quantifiers: {n,m}
306
+ * - Possessive quantifiers: *+, ++, ?+
307
+ * - Nested quantifiers: (a+)+, (a*)*
308
+ * - Backreferences: \1, \2, etc.
309
+ * - Complex character classes: [...]...[...]
310
+ */
311
+ const DANGEROUS_REGEX_PATTERNS = /(\{[0-9,]+\}|\*\+|\+\+|\?\+|(\(.+\))\+|\(\?:|\\[0-9]|(\[.+\]).+(\[.+\]))/;
312
+ /**
313
+ * Static complexity budget for user-supplied regex patterns.
314
+ * Returns `true` if the pattern crosses a budget that empirically correlates
315
+ * with ReDoS risk — independent of the heuristic regex detector.
211
316
  *
212
- * ### Filter Security
317
+ * Budget components (any single threshold crossed → over budget):
318
+ * - unbounded quantifiers (`*`, `+`, `?`) — hard cap 20
319
+ * - nested groups `(` — hard cap 8
320
+ * - alternations `|` — hard cap 10
321
+ * - combined: (quantifiers * groups) — hard cap 40
322
+ */
323
+ function regexComplexityExceedsBudget(patternStr) {
324
+ const stripped = patternStr.replace(/\\[^\\]/g, "");
325
+ const unboundedQuantifiers = (stripped.match(/[*+?]/g) ?? []).length;
326
+ const groups = (stripped.match(/\(/g) ?? []).length;
327
+ const alternations = (stripped.match(/\|/g) ?? []).length;
328
+ if (unboundedQuantifiers > 20) return true;
329
+ if (groups > 8) return true;
330
+ if (alternations > 10) return true;
331
+ if (unboundedQuantifiers * groups > 40) return true;
332
+ return false;
333
+ }
334
+ /** Escape special regex characters (backslash first, then other metas). */
335
+ function escapeRegex(str) {
336
+ return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
337
+ }
338
+ /**
339
+ * Build a safe RegExp from user input.
213
340
  *
214
- * All filters are sanitized:
215
- * - Dangerous operators blocked ($where, $function, $accumulator, $expr)
216
- * - Regex patterns validated (ReDoS protection)
217
- * - Max filter depth enforced (prevents filter bombs)
218
- * - Max limit enforced (prevents resource exhaustion)
341
+ * `onUnsafe` selects what happens when the pattern is over-long,
342
+ * pathological, or syntactically invalid:
343
+ * - `'reject'`: route through the `invalidInput` policy — HTTP 400 in
344
+ * `'throw'` mode, warn + escape-to-literal in `'drop'` mode. Used for
345
+ * the explicit `regex` operator, where the caller claims to be sending
346
+ * a real regex.
347
+ * - `'escape'`: always warn + escape-to-literal, never throw. Used for
348
+ * `search` terms and the `like`/`contains` operators, whose documented
349
+ * semantic is a literal substring match — `?search=c++` must not 400.
350
+ */
351
+ function createSafeRegex(rt, pattern, flags = "i", onUnsafe = "reject") {
352
+ if (pattern === null || pattern === void 0) return null;
353
+ const flag = (message, meta) => {
354
+ if (onUnsafe === "reject") rt.reject(message, meta);
355
+ else warn(`[mongokit] ${message}`);
356
+ };
357
+ const patternStr = String(pattern);
358
+ if (patternStr.length > rt.options.maxRegexLength) {
359
+ flag(`Regex pattern too long, truncating`, {
360
+ length: patternStr.length,
361
+ maxRegexLength: rt.options.maxRegexLength
362
+ });
363
+ return new RegExp(escapeRegex(patternStr.substring(0, rt.options.maxRegexLength)), flags);
364
+ }
365
+ if (DANGEROUS_REGEX_PATTERNS.test(patternStr)) {
366
+ flag("Potentially dangerous regex pattern, escaping");
367
+ return new RegExp(escapeRegex(patternStr), flags);
368
+ }
369
+ if (regexComplexityExceedsBudget(patternStr)) {
370
+ flag("Regex complexity budget exceeded, escaping");
371
+ return new RegExp(escapeRegex(patternStr), flags);
372
+ }
373
+ try {
374
+ return new RegExp(patternStr, flags);
375
+ } catch {
376
+ flag("Invalid regex pattern syntax, escaping to literal match");
377
+ return new RegExp(escapeRegex(patternStr), flags);
378
+ }
379
+ }
380
+ //#endregion
381
+ //#region src/query/parser/filter-compiler.ts
382
+ /**
383
+ * Filter compiler — URL filter parameters → MongoDB filter query.
219
384
  *
220
- * @see {@link https://github.com/classytic/mongokit/blob/main/docs/SECURITY.md}
385
+ * Owns the three entry shapes (flat `field=value`, flat operator syntax
386
+ * `field[gte]=10`, nested bracket objects from qs), the `$or` group router,
387
+ * and the `between` range expander. Every invalid or disallowed fragment
388
+ * routes through the runtime's `invalidInput` policy.
221
389
  */
222
390
  /**
223
391
  * Top-level query parameters that mongokit handles outside the filter
224
- * pipeline. Used by `_handleBracketSyntax` to detect a common typo where a
392
+ * pipeline. Used by `handleBracketSyntax` to detect a common typo where a
225
393
  * caller wrote `?filters[limit]=5` instead of `?limit=5` and the qs parser
226
394
  * nested the key under a filter object — the value is silently dropped
227
395
  * without this guard. Kept as a Set for O(1) lookup; values mirror the
228
- * reserved-key list at the top of `_parseFilters`.
396
+ * reserved-key list at the top of `parseFilters`.
229
397
  */
230
398
  const RESERVED_PAGINATION_KEYS = /* @__PURE__ */ new Set([
231
399
  "page",
@@ -233,88 +401,817 @@ const RESERVED_PAGINATION_KEYS = /* @__PURE__ */ new Set([
233
401
  "sort",
234
402
  "select"
235
403
  ]);
404
+ function toMongoOperator(operator) {
405
+ const op = operator.toLowerCase();
406
+ return op.startsWith("$") ? op : `$${op}`;
407
+ }
408
+ /** Parse filter parameters into a MongoDB filter query. */
409
+ function parseFilters(rt, filters, depth = 0) {
410
+ if (depth > rt.options.maxFilterDepth) {
411
+ rt.reject(`Filter depth ${depth} exceeds maximum ${rt.options.maxFilterDepth}, truncating`, {
412
+ depth,
413
+ maxFilterDepth: rt.options.maxFilterDepth
414
+ });
415
+ return {};
416
+ }
417
+ const parsedFilters = {};
418
+ const regexFields = {};
419
+ for (const [key, value] of Object.entries(filters)) {
420
+ if (rt.dangerousOperators.includes(key) || key.startsWith("$") && !["$or", "$and"].includes(key)) {
421
+ rt.reject(`Blocked dangerous operator: ${key}`, { key });
422
+ continue;
423
+ }
424
+ if (isControlParam(key) || [
425
+ "lean",
426
+ "includeDeleted",
427
+ "lookup",
428
+ "aggregate",
429
+ "or",
430
+ "OR",
431
+ "$or"
432
+ ].includes(key)) continue;
433
+ const operatorMatch = key.match(/^(.+)\[(.+)\]$/);
434
+ const baseField = operatorMatch ? operatorMatch[1] : key;
435
+ if (rt.options.allowedFilterFields && !rt.options.allowedFilterFields.includes(baseField)) {
436
+ rt.reject(`Blocked filter field not in allowlist: ${baseField}`, { field: baseField });
437
+ continue;
438
+ }
439
+ if (operatorMatch) {
440
+ const [, , operator] = operatorMatch;
441
+ if (rt.dangerousOperators.includes(`$${operator}`)) {
442
+ rt.reject(`Blocked dangerous operator: ${operator}`, { operator });
443
+ continue;
444
+ }
445
+ handleOperatorSyntax(rt, parsedFilters, regexFields, operatorMatch, value);
446
+ continue;
447
+ }
448
+ if (typeof value === "object" && value !== null && !Array.isArray(value)) handleBracketSyntax(rt, key, value, parsedFilters, depth + 1);
449
+ else parsedFilters[key] = coerceFieldValue(key, value, rt.fieldTypes);
450
+ }
451
+ return parsedFilters;
452
+ }
453
+ /** Handle flat operator syntax: field[operator]=value */
454
+ function handleOperatorSyntax(rt, filters, regexFields, operatorMatch, value) {
455
+ const [, field, operator] = operatorMatch;
456
+ if (value === "" || value === null || value === void 0) return;
457
+ if (rt.options.allowedOperators && !rt.options.allowedOperators.includes(operator.toLowerCase())) {
458
+ rt.reject(`Operator not in allowlist: ${operator}`, {
459
+ field,
460
+ operator
461
+ });
462
+ return;
463
+ }
464
+ if (operator.toLowerCase() === "options" && regexFields[field]) {
465
+ const fieldValue = filters[field];
466
+ if (typeof fieldValue === "object" && fieldValue !== null && "$regex" in fieldValue) if (typeof value === "string" && /^[imsx]+$/.test(value)) fieldValue.$options = value;
467
+ else rt.reject(`Blocked invalid regex $options value: ${String(value)}. Allowed flags: i, m, s, x`, {
468
+ field,
469
+ value: String(value)
470
+ });
471
+ return;
472
+ }
473
+ if (operator.toLowerCase() === "between") {
474
+ if (typeof filters[field] !== "object" || filters[field] === null || Array.isArray(filters[field])) filters[field] = {};
475
+ filters[field].between = value;
476
+ return;
477
+ }
478
+ if (operator.toLowerCase() === "contains" || operator.toLowerCase() === "like") {
479
+ const safeRegex = createSafeRegex(rt, value, "i", "escape");
480
+ if (safeRegex) {
481
+ filters[field] = { $regex: safeRegex };
482
+ regexFields[field] = true;
483
+ }
484
+ return;
485
+ }
486
+ if (isGeoOperator(operator)) {
487
+ const geoFilter = parseGeoFilter(operator, value);
488
+ if (geoFilter) filters[field] = geoFilter;
489
+ else rt.reject(`Invalid geo operator value for ${field}[${operator}]; dropping filter`, {
490
+ field,
491
+ operator
492
+ });
493
+ return;
494
+ }
495
+ const mongoOperator = toMongoOperator(operator);
496
+ if (rt.dangerousOperators.includes(mongoOperator)) {
497
+ rt.reject(`Blocked dangerous operator: ${mongoOperator}`, { operator: mongoOperator });
498
+ return;
499
+ }
500
+ if (mongoOperator === "$eq") filters[field] = coerceFieldValue(field, value, rt.fieldTypes);
501
+ else if (mongoOperator === "$regex") {
502
+ const safeRegex = createSafeRegex(rt, value);
503
+ if (safeRegex) {
504
+ filters[field] = { $regex: safeRegex };
505
+ regexFields[field] = true;
506
+ }
507
+ } else {
508
+ let processedValue;
509
+ const op = operator.toLowerCase();
510
+ if (op === "size") {
511
+ processedValue = parseFloat(String(value));
512
+ if (Number.isNaN(processedValue)) {
513
+ rt.reject(`Non-numeric value for ${field}[${op}]: ${String(value)}`, {
514
+ field,
515
+ op
516
+ });
517
+ return;
518
+ }
519
+ } else if ([
520
+ "gt",
521
+ "gte",
522
+ "lt",
523
+ "lte"
524
+ ].includes(op)) if (rt.fieldTypes.has(field)) {
525
+ processedValue = coerceFieldValue(field, value, rt.fieldTypes);
526
+ if (typeof processedValue === "number" && Number.isNaN(processedValue)) {
527
+ rt.reject(`Invalid value for ${field}[${op}]: ${String(value)}`, {
528
+ field,
529
+ op
530
+ });
531
+ return;
532
+ }
533
+ } else {
534
+ processedValue = parseFloat(String(value));
535
+ if (Number.isNaN(processedValue)) {
536
+ rt.reject(`Non-numeric value for ${field}[${op}]: ${String(value)}`, {
537
+ field,
538
+ op
539
+ });
540
+ return;
541
+ }
542
+ }
543
+ else if (op === "in" || op === "nin") processedValue = (Array.isArray(value) ? value : String(value).split(",").map((v) => v.trim())).map((elem) => coerceFieldValue(field, elem, rt.fieldTypes));
544
+ else processedValue = coerceFieldValue(field, value, rt.fieldTypes);
545
+ if (typeof filters[field] !== "object" || filters[field] === null || Array.isArray(filters[field])) filters[field] = {};
546
+ filters[field][mongoOperator] = processedValue;
547
+ }
548
+ }
549
+ /** Handle bracket syntax with object value (qs-parsed nested operators). */
550
+ function handleBracketSyntax(rt, field, operators, parsedFilters, depth = 0) {
551
+ if (depth > rt.options.maxFilterDepth) {
552
+ rt.reject(`Nested filter depth exceeds maximum, skipping field: ${field}`, {
553
+ field,
554
+ maxFilterDepth: rt.options.maxFilterDepth
555
+ });
556
+ return;
557
+ }
558
+ for (const op of Object.keys(operators)) if (RESERVED_PAGINATION_KEYS.has(op)) warn(`[mongokit] Nested filter contains reserved key '${op}' at '${field}.${op}' — did you mean ?${op}=... at the top level? The nested value is ignored.`);
559
+ if (!parsedFilters[field]) parsedFilters[field] = {};
560
+ for (const [operator, value] of Object.entries(operators)) {
561
+ if (value === "" || value === null || value === void 0) continue;
562
+ if (operator === "between") {
563
+ parsedFilters[field].between = value;
564
+ continue;
565
+ }
566
+ if (isGeoOperator(operator)) {
567
+ const geoFilter = parseGeoFilter(operator, value);
568
+ if (geoFilter) parsedFilters[field] = geoFilter;
569
+ else {
570
+ rt.reject(`Invalid geo operator value for ${field}[${operator}]; dropping filter`, {
571
+ field,
572
+ operator
573
+ });
574
+ delete parsedFilters[field];
575
+ }
576
+ continue;
577
+ }
578
+ if (rt.options.allowedOperators && !rt.options.allowedOperators.includes(operator)) {
579
+ rt.reject(`Operator not in allowlist: ${operator}`, {
580
+ field,
581
+ operator
582
+ });
583
+ continue;
584
+ }
585
+ if (rt.operators[operator]) {
586
+ const mongoOperator = rt.operators[operator];
587
+ let processedValue;
588
+ if (operator === "size") {
589
+ processedValue = parseFloat(String(value));
590
+ if (Number.isNaN(processedValue)) {
591
+ rt.reject(`Non-numeric value for ${field}[${operator}]: ${String(value)}`, {
592
+ field,
593
+ operator
594
+ });
595
+ continue;
596
+ }
597
+ } else if ([
598
+ "gt",
599
+ "gte",
600
+ "lt",
601
+ "lte"
602
+ ].includes(operator)) if (rt.fieldTypes.has(field)) {
603
+ processedValue = coerceFieldValue(field, value, rt.fieldTypes);
604
+ if (typeof processedValue === "number" && Number.isNaN(processedValue)) {
605
+ rt.reject(`Invalid value for ${field}[${operator}]: ${String(value)}`, {
606
+ field,
607
+ operator
608
+ });
609
+ continue;
610
+ }
611
+ } else {
612
+ processedValue = parseFloat(String(value));
613
+ if (Number.isNaN(processedValue)) {
614
+ rt.reject(`Non-numeric value for ${field}[${operator}]: ${String(value)}`, {
615
+ field,
616
+ operator
617
+ });
618
+ continue;
619
+ }
620
+ }
621
+ else if (operator === "in" || operator === "nin") processedValue = (Array.isArray(value) ? value : String(value).split(",").map((v) => v.trim())).map((elem) => coerceFieldValue(field, elem, rt.fieldTypes));
622
+ else if (operator === "like" || operator === "contains" || operator === "regex") {
623
+ const safeRegex = createSafeRegex(rt, value, "i", operator === "regex" ? "reject" : "escape");
624
+ if (!safeRegex) continue;
625
+ processedValue = safeRegex;
626
+ } else processedValue = coerceFieldValue(field, value, rt.fieldTypes);
627
+ parsedFilters[field][mongoOperator] = processedValue;
628
+ }
629
+ }
630
+ if (typeof parsedFilters[field] === "object" && Object.keys(parsedFilters[field]).length === 0) delete parsedFilters[field];
631
+ }
632
+ /** Parse `?or=[...]` / `?OR=[...]` / `?$or=[...]` groups into an $or array. */
633
+ function parseOr(rt, query) {
634
+ const orArray = [];
635
+ const raw = query?.or || query?.OR || query?.$or;
636
+ if (!raw) return void 0;
637
+ const items = Array.isArray(raw) ? raw : typeof raw === "object" ? Object.values(raw) : [];
638
+ for (const item of items) if (typeof item === "object" && item) {
639
+ const parsedBranch = parseFilters(rt, item, 1);
640
+ if (Object.keys(parsedBranch).length > 0) orArray.push(parsedBranch);
641
+ }
642
+ return orArray.length ? orArray : void 0;
643
+ }
644
+ /** Expand `{ field: { between: 'a,b' } }` markers into `$gte`/`$lte` date ranges. */
645
+ function enhanceWithBetween(rt, filters) {
646
+ const output = { ...filters };
647
+ for (const [key, value] of Object.entries(filters || {})) if (value && typeof value === "object" && "between" in value) {
648
+ const between = value.between;
649
+ const [from, to] = String(between).split(",").map((s) => s.trim());
650
+ const fromDate = from ? new Date(from) : void 0;
651
+ const toDate = to ? new Date(to) : void 0;
652
+ const range = {};
653
+ if (fromDate && !Number.isNaN(fromDate.getTime())) range.$gte = fromDate;
654
+ if (toDate && !Number.isNaN(toDate.getTime())) range.$lte = toDate;
655
+ if (Object.keys(range).length === 0) {
656
+ rt.reject(`Invalid 'between' value for ${key}: ${String(between)}`, {
657
+ field: key,
658
+ value: String(between)
659
+ });
660
+ delete output[key];
661
+ } else output[key] = range;
662
+ }
663
+ return output;
664
+ }
665
+ //#endregion
666
+ //#region src/query/parser/lookup.ts
667
+ function pluralize(str) {
668
+ if (str.endsWith("y")) return `${str.slice(0, -1)}ies`;
669
+ if (str.endsWith("s")) return str;
670
+ return `${str}s`;
671
+ }
672
+ function capitalize(str) {
673
+ return str.charAt(0).toUpperCase() + str.slice(1);
674
+ }
675
+ /**
676
+ * Parse lookup configurations from URL parameters.
677
+ *
678
+ * Supported formats:
679
+ * 1. Simple: ?lookup[department]=slug
680
+ * → Join with 'departments' collection on slug field
681
+ * 2. Detailed: ?lookup[department][localField]=deptSlug&lookup[department][foreignField]=slug
682
+ * → Full control over join configuration
683
+ * 3. Multiple: ?lookup[department]=slug&lookup[category]=categorySlug
684
+ */
685
+ function parseLookups(rt, lookup) {
686
+ if (!lookup || typeof lookup !== "object") return [];
687
+ const lookups = [];
688
+ const lookupObj = lookup;
689
+ for (const [collectionName, config] of Object.entries(lookupObj)) try {
690
+ const lookupConfig = parseSingleLookup(rt, collectionName, config);
691
+ if (lookupConfig) lookups.push(lookupConfig);
692
+ } catch (error) {
693
+ if (error.code === "INVALID_QUERY_INPUT") throw error;
694
+ rt.reject(`Invalid lookup config for ${collectionName}: ${error instanceof Error ? error.message : String(error)}`, { collection: collectionName });
695
+ }
696
+ return lookups;
697
+ }
698
+ /** Parse a single lookup configuration. */
699
+ function parseSingleLookup(rt, collectionName, config) {
700
+ if (!config) return null;
701
+ if (typeof config === "string") {
702
+ const from = pluralize(collectionName);
703
+ if (rt.options.allowedLookupCollections && !rt.options.allowedLookupCollections.includes(from)) {
704
+ rt.reject(`Blocked lookup to disallowed collection: ${from}`, { collection: from });
705
+ return null;
706
+ }
707
+ return {
708
+ from,
709
+ localField: `${collectionName}${capitalize(config)}`,
710
+ foreignField: config,
711
+ as: collectionName,
712
+ single: true
713
+ };
714
+ }
715
+ if (typeof config === "object" && config !== null) {
716
+ const opts = config;
717
+ const from = opts.from || pluralize(collectionName);
718
+ const localField = opts.localField;
719
+ const foreignField = opts.foreignField;
720
+ if (rt.options.allowedLookupCollections && !rt.options.allowedLookupCollections.includes(from)) {
721
+ rt.reject(`Blocked lookup to disallowed collection: ${from}`, { collection: from });
722
+ return null;
723
+ }
724
+ if (!localField || !foreignField) {
725
+ rt.reject(`Lookup requires localField and foreignField for ${collectionName}`, { collection: collectionName });
726
+ return null;
727
+ }
728
+ return {
729
+ from,
730
+ localField,
731
+ foreignField,
732
+ as: opts.as || collectionName,
733
+ single: opts.single === true || opts.single === "true",
734
+ ...opts.select ? { select: String(opts.select) } : {},
735
+ ...opts.pipeline && Array.isArray(opts.pipeline) ? { pipeline: sanitizePipeline(rt, opts.pipeline) } : {}
736
+ };
737
+ }
738
+ return null;
739
+ }
740
+ //#endregion
741
+ //#region src/query/parser/pagination-input.ts
742
+ /**
743
+ * Parse a pagination input to a positive integer, or `undefined` when the
744
+ * value is absent, empty, or rejected under `'drop'` mode. Rejects (per
745
+ * policy) on non-integers, negatives, zero, decimals, and trailing garbage.
746
+ */
747
+ function parsePositiveInt(rt, raw, label) {
748
+ if (raw === void 0 || raw === null || raw === "") return void 0;
749
+ let n;
750
+ if (typeof raw === "number") n = raw;
751
+ else {
752
+ const s = String(raw).trim();
753
+ if (!/^\d+$/.test(s)) {
754
+ rt.reject(`Invalid ${label} value: ${String(raw)} (expected a positive integer)`, { [label]: String(raw) });
755
+ return;
756
+ }
757
+ n = Number(s);
758
+ }
759
+ if (!Number.isInteger(n) || n < 1) {
760
+ rt.reject(`Invalid ${label} value: ${String(raw)} (expected a positive integer)`, { [label]: String(raw) });
761
+ return;
762
+ }
763
+ return n;
764
+ }
765
+ //#endregion
766
+ //#region src/query/parser/sort-select.ts
767
+ /** Parse a sort spec (string or object form), honoring `allowedSortFields`. */
768
+ function parseSort(rt, sort) {
769
+ if (!sort) return void 0;
770
+ if (typeof sort === "object") {
771
+ const sortObj = {};
772
+ for (const [key, value] of Object.entries(sort)) {
773
+ if (rt.options.allowedSortFields && !rt.options.allowedSortFields.includes(key)) {
774
+ rt.reject(`Blocked sort field not in allowlist: ${key}`, { field: key });
775
+ continue;
776
+ }
777
+ const strVal = String(value).toLowerCase();
778
+ sortObj[key] = strVal === "desc" || strVal === "-1" || value === -1 ? -1 : 1;
779
+ }
780
+ return Object.keys(sortObj).length > 0 ? sortObj : void 0;
781
+ }
782
+ const sortObj = {};
783
+ const fields = sort.split(",").map((s) => s.trim());
784
+ for (const field of fields) {
785
+ if (!field) continue;
786
+ const cleanField = field.startsWith("-") ? field.substring(1) : field;
787
+ if (rt.options.allowedSortFields && !rt.options.allowedSortFields.includes(cleanField)) {
788
+ rt.reject(`Blocked sort field not in allowlist: ${cleanField}`, { field: cleanField });
789
+ continue;
790
+ }
791
+ if (field.startsWith("-")) sortObj[field.substring(1)] = -1;
792
+ else sortObj[field] = 1;
793
+ }
794
+ return Object.keys(sortObj).length > 0 ? sortObj : void 0;
795
+ }
796
+ /**
797
+ * Parse select/project fields.
798
+ *
799
+ * @example
800
+ * ```typescript
801
+ * // URL: ?select=name,email,-password
802
+ * // Returns: { name: 1, email: 1, password: 0 }
803
+ * ```
804
+ */
805
+ function parseSelect(select) {
806
+ if (!select) return void 0;
807
+ if (typeof select === "string") {
808
+ const projection = {};
809
+ const fields = select.split(",").map((f) => f.trim());
810
+ for (const field of fields) if (field.startsWith("-")) projection[field.substring(1)] = 0;
811
+ else projection[field] = 1;
812
+ return projection;
813
+ }
814
+ if (typeof select === "object" && select !== null) return select;
815
+ }
816
+ //#endregion
817
+ //#region src/query/parser/populate.ts
818
+ /**
819
+ * Populate parsing — simple string form (`?populate=author,category`) and
820
+ * qs-nested advanced form (`?populate[author][select]=name,email`), with
821
+ * nested populate, match conditions, and per-path options.
822
+ */
823
+ /**
824
+ * Parse populate parameter - handles both simple string and advanced object format
825
+ *
826
+ * @example
827
+ * ```typescript
828
+ * // Simple: ?populate=author,category
829
+ * // Returns: { simplePopulate: 'author,category', populateOptions: [{path:'author'},{path:'category'}] }
830
+ *
831
+ * // Advanced: ?populate[author][select]=name,email
832
+ * // Returns: { simplePopulate: undefined, populateOptions: [{ path: 'author', select: 'name email' }] }
833
+ * ```
834
+ */
835
+ function parsePopulate(rt, populate) {
836
+ if (!populate) return {};
837
+ if (typeof populate === "string") {
838
+ const paths = populate.split(",").map((p) => p.trim()).filter(Boolean);
839
+ if (paths.length > 0) return {
840
+ simplePopulate: populate,
841
+ populateOptions: paths.map((path) => ({ path }))
842
+ };
843
+ return {};
844
+ }
845
+ if (typeof populate === "object" && populate !== null) {
846
+ const populateObj = populate;
847
+ if (Object.keys(populateObj).length === 0) return {};
848
+ const populateOptions = [];
849
+ for (const [path, config] of Object.entries(populateObj)) {
850
+ if (path.startsWith("$") || rt.dangerousOperators.includes(path)) {
851
+ rt.reject(`Blocked dangerous populate path: ${path}`, { path });
852
+ continue;
853
+ }
854
+ const option = parseSinglePopulate(rt, path, config);
855
+ if (option) populateOptions.push(option);
856
+ }
857
+ return populateOptions.length > 0 ? { populateOptions } : {};
858
+ }
859
+ return {};
860
+ }
861
+ /** Parse a single populate configuration (recursive for nested populate). */
862
+ function parseSinglePopulate(rt, path, config, depth = 0) {
863
+ if (depth > 5) {
864
+ rt.reject(`Populate depth exceeds maximum (5), truncating at path: ${path}`, { path });
865
+ return { path };
866
+ }
867
+ if (typeof config === "string") {
868
+ if (config === "true" || config === "1") return { path };
869
+ return {
870
+ path,
871
+ select: config.split(",").join(" ")
872
+ };
873
+ }
874
+ if (typeof config === "object" && config !== null) {
875
+ const opts = config;
876
+ const option = { path };
877
+ if (opts.select && typeof opts.select === "string") option.select = opts.select.split(",").map((s) => s.trim()).join(" ");
878
+ if (opts.match && typeof opts.match === "object") option.match = convertPopulateMatch(opts.match);
879
+ if (opts.limit !== void 0) {
880
+ const limit = parseInt(String(opts.limit), 10);
881
+ if (!Number.isNaN(limit) && limit > 0) {
882
+ option.options = option.options || {};
883
+ option.options.limit = limit;
884
+ }
885
+ }
886
+ if (opts.sort && typeof opts.sort === "string") {
887
+ const sortSpec = parseSort(rt, opts.sort);
888
+ if (sortSpec) {
889
+ option.options = option.options || {};
890
+ option.options.sort = sortSpec;
891
+ }
892
+ }
893
+ if (opts.skip !== void 0) {
894
+ const skip = parseInt(String(opts.skip), 10);
895
+ if (!Number.isNaN(skip) && skip >= 0) {
896
+ option.options = option.options || {};
897
+ option.options.skip = skip;
898
+ }
899
+ }
900
+ if (opts.populate && typeof opts.populate === "object") {
901
+ const nestedPopulate = opts.populate;
902
+ const nestedEntries = Object.entries(nestedPopulate);
903
+ if (nestedEntries.length > 0) {
904
+ const [nestedPath, nestedConfig] = nestedEntries[0];
905
+ const nestedOption = parseSinglePopulate(rt, nestedPath, nestedConfig, depth + 1);
906
+ if (nestedOption) option.populate = nestedOption;
907
+ }
908
+ }
909
+ return option;
910
+ }
911
+ return null;
912
+ }
913
+ /** Convert populate match values (handles boolean strings, etc.) */
914
+ function convertPopulateMatch(match) {
915
+ const converted = {};
916
+ for (const [key, value] of Object.entries(match)) converted[key] = coerceHeuristic(value);
917
+ return converted;
918
+ }
919
+ //#endregion
920
+ //#region src/query/parser/runtime.ts
921
+ /**
922
+ * Parser runtime — the shared context every parser module receives.
923
+ *
924
+ * The `QueryParser` facade resolves options once at construction and builds a
925
+ * single `ParserRuntime`; the extracted modules (filter compiler, sanitizers,
926
+ * populate/lookup/sort parsers, …) are pure functions over it. This keeps the
927
+ * modules independently testable and the facade thin, without threading six
928
+ * separate arguments through every call.
929
+ */
930
+ /** URL operator key → MongoDB operator. */
931
+ const OPERATOR_MAP = {
932
+ eq: "$eq",
933
+ ne: "$ne",
934
+ gt: "$gt",
935
+ gte: "$gte",
936
+ lt: "$lt",
937
+ lte: "$lte",
938
+ in: "$in",
939
+ nin: "$nin",
940
+ like: "$regex",
941
+ contains: "$regex",
942
+ regex: "$regex",
943
+ exists: "$exists",
944
+ size: "$size",
945
+ type: "$type"
946
+ };
947
+ /** Always-blocked MongoDB operators (extended via `additionalDangerousOperators`). */
948
+ const BASE_DANGEROUS_OPERATORS = [
949
+ "$where",
950
+ "$function",
951
+ "$accumulator",
952
+ "$expr"
953
+ ];
954
+ function createReject(mode) {
955
+ return (message, meta) => {
956
+ if (mode === "throw") throw createError(400, `[mongokit] ${message}`, {
957
+ code: "INVALID_QUERY_INPUT",
958
+ meta
959
+ });
960
+ warn(`[mongokit] ${message}`);
961
+ };
962
+ }
963
+ //#endregion
964
+ //#region src/query/parser/schema-docs.ts
965
+ function availableOperators(rt) {
966
+ return rt.options.allowedOperators ? Object.entries(rt.operators).filter(([key]) => rt.options.allowedOperators?.includes(key)) : Object.entries(rt.operators);
967
+ }
968
+ /** Get the JSON Schema type for a filter operator. */
969
+ function operatorSchemaType(op) {
970
+ if ([
971
+ "gt",
972
+ "gte",
973
+ "lt",
974
+ "lte",
975
+ "size"
976
+ ].includes(op)) return "number";
977
+ if (["exists"].includes(op)) return "boolean";
978
+ return "string";
979
+ }
980
+ /** Get a human-readable description for a filter operator. */
981
+ function operatorDescription(op, field, mongoOp) {
982
+ return {
983
+ ne: `${field} not equal to value (${mongoOp})`,
984
+ gt: `${field} greater than value (${mongoOp})`,
985
+ gte: `${field} greater than or equal to value (${mongoOp})`,
986
+ lt: `${field} less than value (${mongoOp})`,
987
+ lte: `${field} less than or equal to value (${mongoOp})`,
988
+ in: `${field} in comma-separated list (${mongoOp}). Example: value1,value2`,
989
+ nin: `${field} not in comma-separated list (${mongoOp})`,
990
+ like: `${field} matches pattern (case-insensitive regex)`,
991
+ contains: `${field} contains substring (case-insensitive regex)`,
992
+ regex: `${field} matches regex pattern (${mongoOp})`,
993
+ exists: `Field ${field} exists (true/false)`,
994
+ size: `Array field ${field} has exactly N elements (${mongoOp})`,
995
+ type: `Field ${field} is of BSON type (${mongoOp})`
996
+ }[op] || `Filter ${field} with ${mongoOp}`;
997
+ }
998
+ /** Build a summary description of all available filter operators. */
999
+ function buildOperatorSummary(operators) {
1000
+ const lines = ["Available filter operators (use as field[operator]=value):"];
1001
+ const desc = {
1002
+ eq: "Equal (default when no operator specified)",
1003
+ ne: "Not equal",
1004
+ gt: "Greater than",
1005
+ gte: "Greater than or equal",
1006
+ lt: "Less than",
1007
+ lte: "Less than or equal",
1008
+ in: "In list (comma-separated values)",
1009
+ nin: "Not in list",
1010
+ like: "Pattern match (case-insensitive)",
1011
+ contains: "Contains substring (case-insensitive)",
1012
+ regex: "Regex pattern",
1013
+ exists: "Field exists (true/false)",
1014
+ size: "Array size equals",
1015
+ type: "BSON type check"
1016
+ };
1017
+ for (const [op, mongoOp] of operators) lines.push(` ${op} → ${mongoOp}: ${desc[op] || op}`);
1018
+ return lines.join("\n");
1019
+ }
1020
+ /**
1021
+ * Generate OpenAPI-compatible JSON Schema for query parameters.
1022
+ *
1023
+ * The schema respects parser configuration:
1024
+ * - `allowedOperators`: only documents allowed operators
1025
+ * - `allowedFilterFields`: generates explicit field[op] entries
1026
+ * - `enableLookups` / `enableAggregations`: includes/excludes lookup/aggregate params
1027
+ * - `maxLimit` / `maxSearchLength`: reflected in schema constraints
1028
+ */
1029
+ function buildQuerySchema(rt) {
1030
+ const properties = {
1031
+ page: {
1032
+ type: "integer",
1033
+ description: "Page number for offset pagination",
1034
+ default: 1,
1035
+ minimum: 1
1036
+ },
1037
+ limit: {
1038
+ type: "integer",
1039
+ description: "Number of items per page",
1040
+ default: 20,
1041
+ minimum: 1,
1042
+ maximum: rt.options.maxLimit
1043
+ },
1044
+ sort: {
1045
+ type: "string",
1046
+ description: "Sort fields (comma-separated). Prefix with - for descending. Example: -createdAt,name"
1047
+ },
1048
+ search: {
1049
+ type: "string",
1050
+ description: rt.options.searchMode === "regex" ? `Search across fields${rt.options.searchFields ? ` (${rt.options.searchFields.join(", ")})` : ""} using case-insensitive regex` : "Full-text search query (requires text index)",
1051
+ maxLength: rt.options.maxSearchLength
1052
+ },
1053
+ select: {
1054
+ type: "string",
1055
+ description: "Fields to include/exclude (comma-separated). Prefix with - to exclude. Example: name,email,-password"
1056
+ },
1057
+ populate: {
1058
+ oneOf: [{ type: "string" }, {
1059
+ type: "object",
1060
+ additionalProperties: true
1061
+ }],
1062
+ description: "Fields to populate/join. Simple: comma-separated string (author,category). Advanced: bracket-notation object (populate[author][select]=name,email)"
1063
+ },
1064
+ after: {
1065
+ type: "string",
1066
+ description: "Cursor value for keyset pagination"
1067
+ }
1068
+ };
1069
+ if (rt.options.enableLookups) properties.lookup = {
1070
+ type: "object",
1071
+ description: "Custom field lookups ($lookup). Example: lookup[department]=slug or lookup[department][localField]=deptId&lookup[department][foreignField]=_id"
1072
+ };
1073
+ if (rt.options.enableAggregations) properties.aggregate = {
1074
+ type: "object",
1075
+ description: "Aggregation pipeline stages. Supports: group, match, sort, project. Example: aggregate[group][_id]=$status"
1076
+ };
1077
+ const operators = availableOperators(rt);
1078
+ if (rt.options.allowedFilterFields && rt.options.allowedFilterFields.length > 0) for (const field of rt.options.allowedFilterFields) {
1079
+ properties[field] = {
1080
+ type: "string",
1081
+ description: `Filter by ${field} (exact match)`
1082
+ };
1083
+ for (const [op, mongoOp] of operators) {
1084
+ if (op === "eq") continue;
1085
+ properties[`${field}[${op}]`] = {
1086
+ type: operatorSchemaType(op),
1087
+ description: operatorDescription(op, field, mongoOp)
1088
+ };
1089
+ }
1090
+ }
1091
+ return {
1092
+ type: "object",
1093
+ properties
1094
+ };
1095
+ }
1096
+ /**
1097
+ * Query schema with OpenAPI extensions — adds a documentary
1098
+ * `_filterOperators` property (marked `x-internal`) describing available
1099
+ * filter operators. For validation-only schemas use `buildQuerySchema`.
1100
+ */
1101
+ function buildOpenAPIQuerySchema(rt) {
1102
+ const schema = buildQuerySchema(rt);
1103
+ schema.properties._filterOperators = {
1104
+ type: "string",
1105
+ description: buildOperatorSummary(availableOperators(rt)),
1106
+ "x-internal": true
1107
+ };
1108
+ return schema;
1109
+ }
1110
+ //#endregion
1111
+ //#region src/query/parser/search.ts
1112
+ /**
1113
+ * Search-term handling — length-capped sanitization plus the regex-mode
1114
+ * multi-field `$or` builder. Search terms are literal text, never regex
1115
+ * patterns: special characters are escaped, not rejected.
1116
+ */
1117
+ /** Trim + length-cap the raw search term (reject over-long input per policy). */
1118
+ function sanitizeSearch(rt, search) {
1119
+ if (search === null || search === void 0 || search === "") return void 0;
1120
+ let searchStr = String(search).trim();
1121
+ if (!searchStr) return void 0;
1122
+ if (searchStr.length > rt.options.maxSearchLength) {
1123
+ rt.reject(`Search query too long, truncating`, {
1124
+ length: searchStr.length,
1125
+ maxSearchLength: rt.options.maxSearchLength
1126
+ });
1127
+ searchStr = searchStr.substring(0, rt.options.maxSearchLength);
1128
+ }
1129
+ return searchStr;
1130
+ }
1131
+ /**
1132
+ * Build regex-based multi-field search filters: an $or with case-insensitive
1133
+ * regex across all searchFields.
1134
+ *
1135
+ * @example
1136
+ * // searchFields: ['name', 'description', 'sku'], search: 'azure'
1137
+ * // Returns: [{ name: {$regex:/azure/i} }, { description: {...} }, { sku: {...} }]
1138
+ */
1139
+ function buildRegexSearch(rt, searchTerm) {
1140
+ if (!rt.options.searchFields || rt.options.searchFields.length === 0) return null;
1141
+ const safeRegex = createSafeRegex(rt, searchTerm, "i", "escape");
1142
+ if (!safeRegex) return null;
1143
+ const orConditions = [];
1144
+ for (const field of rt.options.searchFields) orConditions.push({ [field]: { $regex: safeRegex } });
1145
+ return orConditions.length > 0 ? orConditions : null;
1146
+ }
1147
+ //#endregion
1148
+ //#region src/query/QueryParser.ts
1149
+ /**
1150
+ * Modern Query Parser - URL to MongoDB Query Transpiler
1151
+ *
1152
+ * Converts URL parameters to MongoDB queries with support for custom field
1153
+ * lookups ($lookup), operator filtering, full-text search, URL aggregations,
1154
+ * and security hardening.
1155
+ *
1156
+ * This file is the public facade: options resolution, the `parse()`
1157
+ * orchestration, and the OpenAPI schema surface. The implementation lives in
1158
+ * focused modules under `./parser/`:
1159
+ *
1160
+ * - `parser/filter-compiler` — filters, operator syntax, $or, between
1161
+ * - `parser/regex-safety` — ReDoS protection (escape/reject)
1162
+ * - `parser/pipeline-sanitizer` — $match / $lookup-pipeline / expression sanitizing
1163
+ * - `parser/lookup` — ?lookup[...] parsing + collection allowlist
1164
+ * - `parser/aggregation` — ?aggregate[...] parsing (opt-in)
1165
+ * - `parser/populate` — ?populate parsing (simple + nested)
1166
+ * - `parser/sort-select` — ?sort / ?select parsing
1167
+ * - `parser/search` — search sanitization + regex-mode $or builder
1168
+ * - `parser/schema-docs` — OpenAPI query-schema generation
1169
+ * - `parser/runtime` — shared runtime + invalidInput policy
1170
+ *
1171
+ * @example
1172
+ * ```typescript
1173
+ * const parser = new QueryParser();
1174
+ * const query = parser.parse(req.query);
1175
+ * // URL: ?status=active&lookup[department]=slug&sort=-createdAt&page=1&limit=20
1176
+ * // Result: Complete MongoDB query with $lookup, filters, sort, pagination
1177
+ * ```
1178
+ *
1179
+ * ## SECURITY
1180
+ *
1181
+ * - `invalidInput` defaults to `'throw'` (fail-closed): invalid or blocked
1182
+ * query input raises HTTP 400 (`code: 'INVALID_QUERY_INPUT'`) instead of
1183
+ * being silently dropped (which broadens the result set). Opt into
1184
+ * `'drop'` only for trusted compat tooling.
1185
+ * - Dangerous operators blocked everywhere ($where, $function, $accumulator, $expr).
1186
+ * - Regex patterns validated (ReDoS protection); search/like/contains are
1187
+ * literal text (escaped, never rejected).
1188
+ * - Max filter depth + max limit enforced.
1189
+ * - Lookup pipelines sanitized; `enableAggregations` is opt-in — keep it off
1190
+ * for public endpoints or pair with per-route allowlists.
1191
+ *
1192
+ * @see {@link https://github.com/classytic/mongokit/blob/main/docs/SECURITY.md}
1193
+ */
236
1194
  /**
237
1195
  * Modern Query Parser
238
1196
  * Converts URL parameters to MongoDB queries with $lookup support
239
1197
  */
240
1198
  var QueryParser = class {
241
- options;
242
- operators = {
243
- eq: "$eq",
244
- ne: "$ne",
245
- gt: "$gt",
246
- gte: "$gte",
247
- lt: "$lt",
248
- lte: "$lte",
249
- in: "$in",
250
- nin: "$nin",
251
- like: "$regex",
252
- contains: "$regex",
253
- regex: "$regex",
254
- exists: "$exists",
255
- size: "$size",
256
- type: "$type"
257
- };
258
- dangerousOperators;
259
1199
  /**
260
- * Normalized dot-notation field-type map. Built once at construction from
261
- * `options.schema` (Mongoose) and `options.fieldTypes` (override map), with
262
- * `fieldTypes` taking precedence. An empty map means schema-aware coercion
263
- * is disabled and the heuristic in `_convertValue` runs unchanged — this
264
- * preserves backwards compatibility for callers that don't opt in.
1200
+ * Shared runtime handed to every parser module. `schema` and `fieldTypes`
1201
+ * are consumed once at construction to build the coercion map and are not
1202
+ * retained — the parser never holds a reference to the user's Mongoose
1203
+ * schema.
265
1204
  */
266
- _fieldTypes;
1205
+ rt;
267
1206
  /**
268
1207
  * Structured schema-index info (geo / text / other), built once from
269
- * `options.schema?.indexes()`. Used by the operator router to validate
270
- * geo operators against actual geo-indexed fields, and exposed publicly
271
- * via `parser.schemaIndexes` for downstream tools (Arc MCP, query planners).
1208
+ * `options.schema?.indexes()`. Exposed via `parser.schemaIndexes` for
1209
+ * downstream tools (Arc MCP, query planners).
272
1210
  */
273
1211
  _schemaIndexes;
274
- /**
275
- * Regex patterns that can cause catastrophic backtracking (ReDoS attacks).
276
- * Detects common footguns:
277
- * - Quantifiers: {n,m}
278
- * - Possessive quantifiers: *+, ++, ?+
279
- * - Nested quantifiers: (a+)+, (a*)*
280
- * - Backreferences: \1, \2, etc.
281
- * - Complex character classes: [...]...[...]
282
- *
283
- * This pattern detector is FAIL-SAFE (on match, the input is escaped), but
284
- * it is not exhaustive. {@link _regexComplexityExceedsBudget} complements
285
- * it with a deterministic static complexity budget so patterns that happen
286
- * to evade the detector still fail over when their unbounded-quantifier
287
- * density is pathological.
288
- */
289
- dangerousRegexPatterns = /(\{[0-9,]+\}|\*\+|\+\+|\?\+|(\(.+\))\+|\(\?:|\\[0-9]|(\[.+\]).+(\[.+\]))/;
290
- /**
291
- * Static complexity budget for user-supplied regex patterns.
292
- * Returns `true` if the pattern crosses a budget that empirically correlates
293
- * with ReDoS risk — independent of the heuristic regex detector.
294
- *
295
- * Budget components (any single threshold crossed → over budget):
296
- * - unbounded quantifiers (`*`, `+`, `?`) — hard cap 20
297
- * - nested groups `(` — hard cap 8
298
- * - alternations `|` — hard cap 10
299
- * - combined: (quantifiers * groups) — hard cap 40
300
- *
301
- * These thresholds are intentionally permissive for legitimate queries and
302
- * tight enough to refuse known-pathological patterns like `(a+)+b` with
303
- * heavy repetition.
304
- */
305
- _regexComplexityExceedsBudget(patternStr) {
306
- const stripped = patternStr.replace(/\\[^\\]/g, "");
307
- const unboundedQuantifiers = (stripped.match(/[*+?]/g) ?? []).length;
308
- const groups = (stripped.match(/\(/g) ?? []).length;
309
- const alternations = (stripped.match(/\|/g) ?? []).length;
310
- if (unboundedQuantifiers > 20) return true;
311
- if (groups > 8) return true;
312
- if (alternations > 10) return true;
313
- if (unboundedQuantifiers * groups > 40) return true;
314
- return false;
315
- }
316
1212
  constructor(options = {}) {
317
- this.options = {
1213
+ const resolved = {
1214
+ invalidInput: options.invalidInput ?? "throw",
318
1215
  maxRegexLength: options.maxRegexLength ?? 500,
319
1216
  maxSearchLength: options.maxSearchLength ?? 200,
320
1217
  maxFilterDepth: options.maxFilterDepth ?? 10,
@@ -329,28 +1226,23 @@ var QueryParser = class {
329
1226
  allowedSortFields: options.allowedSortFields,
330
1227
  allowedOperators: options.allowedOperators
331
1228
  };
332
- if (this.options.searchMode === "regex" && (!this.options.searchFields || this.options.searchFields.length === 0)) {
1229
+ if (resolved.searchMode === "regex" && (!resolved.searchFields || resolved.searchFields.length === 0)) {
333
1230
  warn("[mongokit] searchMode \"regex\" requires searchFields to be specified. Falling back to \"text\" mode.");
334
- this.options.searchMode = "text";
1231
+ resolved.searchMode = "text";
335
1232
  }
336
- this.dangerousOperators = [
337
- "$where",
338
- "$function",
339
- "$accumulator",
340
- "$expr",
341
- ...this.options.additionalDangerousOperators
342
- ];
343
- this._fieldTypes = buildFieldTypeMap(options.schema, options.fieldTypes);
1233
+ this.rt = {
1234
+ options: resolved,
1235
+ operators: OPERATOR_MAP,
1236
+ dangerousOperators: [...BASE_DANGEROUS_OPERATORS, ...resolved.additionalDangerousOperators],
1237
+ fieldTypes: buildFieldTypeMap(options.schema, options.fieldTypes),
1238
+ reject: createReject(resolved.invalidInput)
1239
+ };
344
1240
  this._schemaIndexes = extractSchemaIndexes(options.schema);
345
1241
  }
346
1242
  /**
347
1243
  * Structured view of the configured schema's indexes — geo fields, text
348
1244
  * fields, and other compound indexes. Empty arrays when no schema was
349
- * provided. Stable across the parser's lifetime; mutating the returned
350
- * object does not affect parser behavior.
351
- *
352
- * Useful for downstream tooling (Arc MCP, query planners, doc generators)
353
- * that needs to know which fields support which query types.
1245
+ * provided. Stable across the parser's lifetime.
354
1246
  */
355
1247
  get schemaIndexes() {
356
1248
  return this._schemaIndexes;
@@ -363,21 +1255,21 @@ var QueryParser = class {
363
1255
  * from the QueryParser when `schemaOptions.filterableFields` is not set.
364
1256
  */
365
1257
  get allowedFilterFields() {
366
- return this.options.allowedFilterFields;
1258
+ return this.rt.options.allowedFilterFields;
367
1259
  }
368
1260
  /**
369
1261
  * Get the configured allowed sort fields.
370
1262
  * Returns `undefined` if no whitelist is set (all fields allowed).
371
1263
  */
372
1264
  get allowedSortFields() {
373
- return this.options.allowedSortFields;
1265
+ return this.rt.options.allowedSortFields;
374
1266
  }
375
1267
  /**
376
1268
  * Get the configured allowed operators.
377
1269
  * Returns `undefined` if no whitelist is set (all built-in operators allowed).
378
1270
  */
379
1271
  get allowedOperators() {
380
- return this.options.allowedOperators;
1272
+ return this.rt.options.allowedOperators;
381
1273
  }
382
1274
  /**
383
1275
  * Parse URL query parameters into MongoDB query format
@@ -390,25 +1282,25 @@ var QueryParser = class {
390
1282
  * ```
391
1283
  */
392
1284
  parse(query) {
1285
+ const rt = this.rt;
393
1286
  const { page, limit = 20, sort = "-createdAt", populate, search, after, cursor, select, lookup, aggregate, ...filters } = query || {};
394
- let parsedLimit = parseInt(String(limit), 10);
395
- if (Number.isNaN(parsedLimit) || parsedLimit < 1) parsedLimit = 20;
396
- if (parsedLimit > this.options.maxLimit) {
397
- warn(`[mongokit] Limit ${parsedLimit} exceeds maximum ${this.options.maxLimit}, capping to max`);
398
- parsedLimit = this.options.maxLimit;
1287
+ let parsedLimit = parsePositiveInt(rt, limit, "limit") ?? 20;
1288
+ if (parsedLimit > rt.options.maxLimit) {
1289
+ warn(`[mongokit] Limit ${parsedLimit} exceeds maximum ${rt.options.maxLimit}, capping to max`);
1290
+ parsedLimit = rt.options.maxLimit;
399
1291
  }
400
- const sanitizedSearch = this._sanitizeSearch(search);
401
- const { simplePopulate, populateOptions } = this._parsePopulate(populate);
1292
+ const sanitizedSearch = sanitizeSearch(rt, search);
1293
+ const { simplePopulate, populateOptions } = parsePopulate(rt, populate);
402
1294
  const parsed = {
403
- filters: this._parseFilters(filters),
1295
+ filters: parseFilters(rt, filters),
404
1296
  limit: parsedLimit,
405
- sort: this._parseSort(sort),
1297
+ sort: parseSort(rt, sort),
406
1298
  populate: simplePopulate,
407
1299
  populateOptions,
408
1300
  search: sanitizedSearch
409
1301
  };
410
- if (sanitizedSearch && this.options.searchMode === "regex" && this.options.searchFields) {
411
- const regexSearchFilters = this._buildRegexSearch(sanitizedSearch);
1302
+ if (sanitizedSearch && rt.options.searchMode === "regex" && rt.options.searchFields) {
1303
+ const regexSearchFilters = buildRegexSearch(rt, sanitizedSearch);
412
1304
  if (regexSearchFilters) {
413
1305
  if (parsed.filters.$or) {
414
1306
  parsed.filters = {
@@ -420,97 +1312,28 @@ var QueryParser = class {
420
1312
  parsed.search = void 0;
421
1313
  }
422
1314
  }
423
- if (select) parsed.select = this._parseSelect(select);
424
- if (this.options.enableLookups && lookup) parsed.lookups = this._parseLookups(lookup);
425
- if (this.options.enableAggregations && aggregate) parsed.aggregation = this._parseAggregation(aggregate);
1315
+ if (select) parsed.select = parseSelect(select);
1316
+ if (rt.options.enableLookups && lookup) parsed.lookups = parseLookups(rt, lookup);
1317
+ if (rt.options.enableAggregations && aggregate) parsed.aggregation = parseAggregation(rt, aggregate);
426
1318
  if (after || cursor) parsed.after = String(after || cursor);
427
- if (page !== void 0) parsed.page = parseInt(String(page), 10);
428
- const orGroup = this._parseOr(query);
1319
+ const parsedPage = parsePositiveInt(rt, page, "page");
1320
+ if (parsedPage !== void 0) parsed.page = parsedPage;
1321
+ const orGroup = parseOr(rt, query);
429
1322
  if (orGroup) if (parsed.filters.$or) {
430
1323
  const existingOr = parsed.filters.$or;
431
1324
  delete parsed.filters.$or;
432
1325
  parsed.filters.$and = [{ $or: existingOr }, { $or: orGroup }];
433
1326
  } else parsed.filters.$or = orGroup;
434
- parsed.filters = this._enhanceWithBetween(parsed.filters);
1327
+ parsed.filters = enhanceWithBetween(rt, parsed.filters);
435
1328
  return parsed;
436
1329
  }
437
1330
  /**
438
1331
  * Generate OpenAPI-compatible JSON Schema for query parameters.
439
1332
  * Arc's defineResource() auto-detects this method and uses it
440
1333
  * to document list endpoint query parameters in OpenAPI/Swagger.
441
- *
442
- * The schema respects parser configuration:
443
- * - `allowedOperators`: only documents allowed operators
444
- * - `allowedFilterFields`: generates explicit field[op] entries
445
- * - `enableLookups` / `enableAggregations`: includes/excludes lookup/aggregate params
446
- * - `maxLimit` / `maxSearchLength`: reflected in schema constraints
447
1334
  */
448
1335
  getQuerySchema() {
449
- const properties = {
450
- page: {
451
- type: "integer",
452
- description: "Page number for offset pagination",
453
- default: 1,
454
- minimum: 1
455
- },
456
- limit: {
457
- type: "integer",
458
- description: "Number of items per page",
459
- default: 20,
460
- minimum: 1,
461
- maximum: this.options.maxLimit
462
- },
463
- sort: {
464
- type: "string",
465
- description: "Sort fields (comma-separated). Prefix with - for descending. Example: -createdAt,name"
466
- },
467
- search: {
468
- type: "string",
469
- description: this.options.searchMode === "regex" ? `Search across fields${this.options.searchFields ? ` (${this.options.searchFields.join(", ")})` : ""} using case-insensitive regex` : "Full-text search query (requires text index)",
470
- maxLength: this.options.maxSearchLength
471
- },
472
- select: {
473
- type: "string",
474
- description: "Fields to include/exclude (comma-separated). Prefix with - to exclude. Example: name,email,-password"
475
- },
476
- populate: {
477
- oneOf: [{ type: "string" }, {
478
- type: "object",
479
- additionalProperties: true
480
- }],
481
- description: "Fields to populate/join. Simple: comma-separated string (author,category). Advanced: bracket-notation object (populate[author][select]=name,email)"
482
- },
483
- after: {
484
- type: "string",
485
- description: "Cursor value for keyset pagination"
486
- }
487
- };
488
- if (this.options.enableLookups) properties.lookup = {
489
- type: "object",
490
- description: "Custom field lookups ($lookup). Example: lookup[department]=slug or lookup[department][localField]=deptId&lookup[department][foreignField]=_id"
491
- };
492
- if (this.options.enableAggregations) properties.aggregate = {
493
- type: "object",
494
- description: "Aggregation pipeline stages. Supports: group, match, sort, project. Example: aggregate[group][_id]=$status"
495
- };
496
- const availableOperators = this.options.allowedOperators ? Object.entries(this.operators).filter(([key]) => this.options.allowedOperators?.includes(key)) : Object.entries(this.operators);
497
- if (this.options.allowedFilterFields && this.options.allowedFilterFields.length > 0) for (const field of this.options.allowedFilterFields) {
498
- properties[field] = {
499
- type: "string",
500
- description: `Filter by ${field} (exact match)`
501
- };
502
- for (const [op, mongoOp] of availableOperators) {
503
- if (op === "eq") continue;
504
- properties[`${field}[${op}]`] = {
505
- type: this._getOperatorSchemaType(op),
506
- description: this._getOperatorDescription(op, field, mongoOp)
507
- };
508
- }
509
- }
510
- return {
511
- type: "object",
512
- properties
513
- };
1336
+ return buildQuerySchema(this.rt);
514
1337
  }
515
1338
  /**
516
1339
  * Get the query schema with OpenAPI extensions (x-internal metadata).
@@ -519,681 +1342,7 @@ var QueryParser = class {
519
1342
  * For validation-only schemas, use `getQuerySchema()` instead.
520
1343
  */
521
1344
  getOpenAPIQuerySchema() {
522
- const schema = this.getQuerySchema();
523
- const availableOperators = this.options.allowedOperators ? Object.entries(this.operators).filter(([key]) => this.options.allowedOperators?.includes(key)) : Object.entries(this.operators);
524
- schema.properties._filterOperators = {
525
- type: "string",
526
- description: this._buildOperatorDescription(availableOperators),
527
- "x-internal": true
528
- };
529
- return schema;
530
- }
531
- /**
532
- * Get the JSON Schema type for a filter operator
533
- */
534
- _getOperatorSchemaType(op) {
535
- if ([
536
- "gt",
537
- "gte",
538
- "lt",
539
- "lte",
540
- "size"
541
- ].includes(op)) return "number";
542
- if (["exists"].includes(op)) return "boolean";
543
- return "string";
544
- }
545
- /**
546
- * Get a human-readable description for a filter operator
547
- */
548
- _getOperatorDescription(op, field, mongoOp) {
549
- return {
550
- ne: `${field} not equal to value (${mongoOp})`,
551
- gt: `${field} greater than value (${mongoOp})`,
552
- gte: `${field} greater than or equal to value (${mongoOp})`,
553
- lt: `${field} less than value (${mongoOp})`,
554
- lte: `${field} less than or equal to value (${mongoOp})`,
555
- in: `${field} in comma-separated list (${mongoOp}). Example: value1,value2`,
556
- nin: `${field} not in comma-separated list (${mongoOp})`,
557
- like: `${field} matches pattern (case-insensitive regex)`,
558
- contains: `${field} contains substring (case-insensitive regex)`,
559
- regex: `${field} matches regex pattern (${mongoOp})`,
560
- exists: `Field ${field} exists (true/false)`,
561
- size: `Array field ${field} has exactly N elements (${mongoOp})`,
562
- type: `Field ${field} is of BSON type (${mongoOp})`
563
- }[op] || `Filter ${field} with ${mongoOp}`;
564
- }
565
- /**
566
- * Build a summary description of all available filter operators
567
- */
568
- _buildOperatorDescription(operators) {
569
- const lines = ["Available filter operators (use as field[operator]=value):"];
570
- for (const [op, mongoOp] of operators) lines.push(` ${op} → ${mongoOp}: ${{
571
- eq: "Equal (default when no operator specified)",
572
- ne: "Not equal",
573
- gt: "Greater than",
574
- gte: "Greater than or equal",
575
- lt: "Less than",
576
- lte: "Less than or equal",
577
- in: "In list (comma-separated values)",
578
- nin: "Not in list",
579
- like: "Pattern match (case-insensitive)",
580
- contains: "Contains substring (case-insensitive)",
581
- regex: "Regex pattern",
582
- exists: "Field exists (true/false)",
583
- size: "Array size equals",
584
- type: "BSON type check"
585
- }[op] || op}`);
586
- return lines.join("\n");
587
- }
588
- /**
589
- * Parse lookup configurations from URL parameters
590
- *
591
- * Supported formats:
592
- * 1. Simple: ?lookup[department]=slug
593
- * → Join with 'departments' collection on slug field
594
- *
595
- * 2. Detailed: ?lookup[department][localField]=deptSlug&lookup[department][foreignField]=slug
596
- * → Full control over join configuration
597
- *
598
- * 3. Multiple: ?lookup[department]=slug&lookup[category]=categorySlug
599
- * → Multiple lookups
600
- *
601
- * @example
602
- * ```typescript
603
- * // URL: ?lookup[department][localField]=deptSlug&lookup[department][foreignField]=slug&lookup[department][single]=true
604
- * const lookups = parser._parseLookups({
605
- * department: { localField: 'deptSlug', foreignField: 'slug', single: 'true' }
606
- * });
607
- * // Returns: [{ from: 'departments', localField: 'deptSlug', foreignField: 'slug', single: true }]
608
- * ```
609
- */
610
- _parseLookups(lookup) {
611
- if (!lookup || typeof lookup !== "object") return [];
612
- const lookups = [];
613
- const lookupObj = lookup;
614
- for (const [collectionName, config] of Object.entries(lookupObj)) try {
615
- const lookupConfig = this._parseSingleLookup(collectionName, config);
616
- if (lookupConfig) lookups.push(lookupConfig);
617
- } catch (error) {
618
- warn(`[mongokit] Invalid lookup config for ${collectionName}:`, error);
619
- }
620
- return lookups;
621
- }
622
- /**
623
- * Parse a single lookup configuration
624
- */
625
- _parseSingleLookup(collectionName, config) {
626
- if (!config) return null;
627
- if (typeof config === "string") {
628
- const from = this._pluralize(collectionName);
629
- if (this.options.allowedLookupCollections && !this.options.allowedLookupCollections.includes(from)) {
630
- warn(`[mongokit] Blocked lookup to disallowed collection: ${from}`);
631
- return null;
632
- }
633
- return {
634
- from,
635
- localField: `${collectionName}${this._capitalize(config)}`,
636
- foreignField: config,
637
- as: collectionName,
638
- single: true
639
- };
640
- }
641
- if (typeof config === "object" && config !== null) {
642
- const opts = config;
643
- const from = opts.from || this._pluralize(collectionName);
644
- const localField = opts.localField;
645
- const foreignField = opts.foreignField;
646
- if (this.options.allowedLookupCollections && !this.options.allowedLookupCollections.includes(from)) {
647
- warn(`[mongokit] Blocked lookup to disallowed collection: ${from}`);
648
- return null;
649
- }
650
- if (!localField || !foreignField) {
651
- warn(`[mongokit] Lookup requires localField and foreignField for ${collectionName}`);
652
- return null;
653
- }
654
- return {
655
- from,
656
- localField,
657
- foreignField,
658
- as: opts.as || collectionName,
659
- single: opts.single === true || opts.single === "true",
660
- ...opts.select ? { select: String(opts.select) } : {},
661
- ...opts.pipeline && Array.isArray(opts.pipeline) ? { pipeline: this._sanitizePipeline(opts.pipeline) } : {}
662
- };
663
- }
664
- return null;
665
- }
666
- /**
667
- * Parse aggregation pipeline from URL (advanced feature)
668
- *
669
- * @example
670
- * ```typescript
671
- * // URL: ?aggregate[group][_id]=$status&aggregate[group][count]=$sum:1
672
- * const pipeline = parser._parseAggregation({
673
- * group: { _id: '$status', count: '$sum:1' }
674
- * });
675
- * ```
676
- */
677
- _parseAggregation(aggregate) {
678
- if (!aggregate || typeof aggregate !== "object") return void 0;
679
- const pipeline = [];
680
- const aggObj = aggregate;
681
- for (const [stage, config] of Object.entries(aggObj)) try {
682
- if (stage === "group" && typeof config === "object") pipeline.push({ $group: config });
683
- else if (stage === "match" && typeof config === "object") {
684
- const sanitizedMatch = this._sanitizeMatchConfig(config);
685
- if (Object.keys(sanitizedMatch).length > 0) pipeline.push({ $match: sanitizedMatch });
686
- } else if (stage === "sort" && typeof config === "object") pipeline.push({ $sort: config });
687
- else if (stage === "project" && typeof config === "object") pipeline.push({ $project: config });
688
- } catch (error) {
689
- warn(`[mongokit] Invalid aggregation stage ${stage}:`, error);
690
- }
691
- return pipeline.length > 0 ? pipeline : void 0;
692
- }
693
- /**
694
- * Parse select/project fields
695
- *
696
- * @example
697
- * ```typescript
698
- * // URL: ?select=name,email,-password
699
- * // Returns: { name: 1, email: 1, password: 0 }
700
- * ```
701
- */
702
- _parseSelect(select) {
703
- if (!select) return void 0;
704
- if (typeof select === "string") {
705
- const projection = {};
706
- const fields = select.split(",").map((f) => f.trim());
707
- for (const field of fields) if (field.startsWith("-")) projection[field.substring(1)] = 0;
708
- else projection[field] = 1;
709
- return projection;
710
- }
711
- if (typeof select === "object" && select !== null) return select;
712
- }
713
- /**
714
- * Parse populate parameter - handles both simple string and advanced object format
715
- *
716
- * @example
717
- * ```typescript
718
- * // Simple: ?populate=author,category
719
- * // Returns: { simplePopulate: 'author,category', populateOptions: undefined }
720
- *
721
- * // Advanced: ?populate[author][select]=name,email
722
- * // Returns: { simplePopulate: undefined, populateOptions: [{ path: 'author', select: 'name email' }] }
723
- * ```
724
- */
725
- _parsePopulate(populate) {
726
- if (!populate) return {};
727
- if (typeof populate === "string") {
728
- const paths = populate.split(",").map((p) => p.trim()).filter(Boolean);
729
- if (paths.length > 0) return {
730
- simplePopulate: populate,
731
- populateOptions: paths.map((path) => ({ path }))
732
- };
733
- return {};
734
- }
735
- if (typeof populate === "object" && populate !== null) {
736
- const populateObj = populate;
737
- if (Object.keys(populateObj).length === 0) return {};
738
- const populateOptions = [];
739
- for (const [path, config] of Object.entries(populateObj)) {
740
- if (path.startsWith("$") || this.dangerousOperators.includes(path)) {
741
- warn(`[mongokit] Blocked dangerous populate path: ${path}`);
742
- continue;
743
- }
744
- const option = this._parseSinglePopulate(path, config);
745
- if (option) populateOptions.push(option);
746
- }
747
- return populateOptions.length > 0 ? { populateOptions } : {};
748
- }
749
- return {};
750
- }
751
- /**
752
- * Parse a single populate configuration
753
- */
754
- _parseSinglePopulate(path, config, depth = 0) {
755
- if (depth > 5) {
756
- warn(`[mongokit] Populate depth exceeds maximum (5), truncating at path: ${path}`);
757
- return { path };
758
- }
759
- if (typeof config === "string") {
760
- if (config === "true" || config === "1") return { path };
761
- return {
762
- path,
763
- select: config.split(",").join(" ")
764
- };
765
- }
766
- if (typeof config === "object" && config !== null) {
767
- const opts = config;
768
- const option = { path };
769
- if (opts.select && typeof opts.select === "string") option.select = opts.select.split(",").map((s) => s.trim()).join(" ");
770
- if (opts.match && typeof opts.match === "object") option.match = this._convertPopulateMatch(opts.match);
771
- if (opts.limit !== void 0) {
772
- const limit = parseInt(String(opts.limit), 10);
773
- if (!Number.isNaN(limit) && limit > 0) {
774
- option.options = option.options || {};
775
- option.options.limit = limit;
776
- }
777
- }
778
- if (opts.sort && typeof opts.sort === "string") {
779
- const sortSpec = this._parseSort(opts.sort);
780
- if (sortSpec) {
781
- option.options = option.options || {};
782
- option.options.sort = sortSpec;
783
- }
784
- }
785
- if (opts.skip !== void 0) {
786
- const skip = parseInt(String(opts.skip), 10);
787
- if (!Number.isNaN(skip) && skip >= 0) {
788
- option.options = option.options || {};
789
- option.options.skip = skip;
790
- }
791
- }
792
- if (opts.populate && typeof opts.populate === "object") {
793
- const nestedPopulate = opts.populate;
794
- const nestedEntries = Object.entries(nestedPopulate);
795
- if (nestedEntries.length > 0) {
796
- const [nestedPath, nestedConfig] = nestedEntries[0];
797
- const nestedOption = this._parseSinglePopulate(nestedPath, nestedConfig, depth + 1);
798
- if (nestedOption) option.populate = nestedOption;
799
- }
800
- }
801
- return option;
802
- }
803
- return null;
804
- }
805
- /**
806
- * Convert populate match values (handles boolean strings, etc.)
807
- */
808
- _convertPopulateMatch(match) {
809
- const converted = {};
810
- for (const [key, value] of Object.entries(match)) converted[key] = coerceHeuristic(value);
811
- return converted;
812
- }
813
- /**
814
- * Parse filter parameters
815
- */
816
- _parseFilters(filters, depth = 0) {
817
- if (depth > this.options.maxFilterDepth) {
818
- warn(`[mongokit] Filter depth ${depth} exceeds maximum ${this.options.maxFilterDepth}, truncating`);
819
- return {};
820
- }
821
- const parsedFilters = {};
822
- const regexFields = {};
823
- for (const [key, value] of Object.entries(filters)) {
824
- if (this.dangerousOperators.includes(key) || key.startsWith("$") && !["$or", "$and"].includes(key)) {
825
- warn(`[mongokit] Blocked dangerous operator: ${key}`);
826
- continue;
827
- }
828
- if (isControlParam(key) || [
829
- "lean",
830
- "includeDeleted",
831
- "lookup",
832
- "aggregate",
833
- "or",
834
- "OR",
835
- "$or"
836
- ].includes(key)) continue;
837
- const operatorMatch = key.match(/^(.+)\[(.+)\]$/);
838
- const baseField = operatorMatch ? operatorMatch[1] : key;
839
- if (this.options.allowedFilterFields && !this.options.allowedFilterFields.includes(baseField)) {
840
- warn(`[mongokit] Blocked filter field not in allowlist: ${baseField}`);
841
- continue;
842
- }
843
- if (operatorMatch) {
844
- const [, , operator] = operatorMatch;
845
- if (this.dangerousOperators.includes(`$${operator}`)) {
846
- warn(`[mongokit] Blocked dangerous operator: ${operator}`);
847
- continue;
848
- }
849
- this._handleOperatorSyntax(parsedFilters, regexFields, operatorMatch, value);
850
- continue;
851
- }
852
- if (typeof value === "object" && value !== null && !Array.isArray(value)) this._handleBracketSyntax(key, value, parsedFilters, depth + 1);
853
- else parsedFilters[key] = coerceFieldValue(key, value, this._fieldTypes);
854
- }
855
- return parsedFilters;
856
- }
857
- /**
858
- * Handle operator syntax: field[operator]=value
859
- */
860
- _handleOperatorSyntax(filters, regexFields, operatorMatch, value) {
861
- const [, field, operator] = operatorMatch;
862
- if (value === "" || value === null || value === void 0) return;
863
- if (this.options.allowedOperators && !this.options.allowedOperators.includes(operator.toLowerCase())) {
864
- warn(`[mongokit] Operator not in allowlist: ${operator}`);
865
- return;
866
- }
867
- if (operator.toLowerCase() === "options" && regexFields[field]) {
868
- const fieldValue = filters[field];
869
- if (typeof fieldValue === "object" && fieldValue !== null && "$regex" in fieldValue) if (typeof value === "string" && /^[imsx]+$/.test(value)) fieldValue.$options = value;
870
- else warn(`[mongokit] Blocked invalid regex $options value: ${String(value)}. Allowed flags: i, m, s, x`);
871
- return;
872
- }
873
- if (operator.toLowerCase() === "contains" || operator.toLowerCase() === "like") {
874
- const safeRegex = this._createSafeRegex(value);
875
- if (safeRegex) {
876
- filters[field] = { $regex: safeRegex };
877
- regexFields[field] = true;
878
- }
879
- return;
880
- }
881
- if (isGeoOperator(operator)) {
882
- const geoFilter = parseGeoFilter(operator, value);
883
- if (geoFilter) filters[field] = geoFilter;
884
- else warn(`[mongokit] Invalid geo operator value for ${field}[${operator}]; dropping filter`);
885
- return;
886
- }
887
- const mongoOperator = this._toMongoOperator(operator);
888
- if (this.dangerousOperators.includes(mongoOperator)) {
889
- warn(`[mongokit] Blocked dangerous operator: ${mongoOperator}`);
890
- return;
891
- }
892
- if (mongoOperator === "$eq") filters[field] = coerceFieldValue(field, value, this._fieldTypes);
893
- else if (mongoOperator === "$regex") {
894
- const safeRegex = this._createSafeRegex(value);
895
- if (safeRegex) {
896
- filters[field] = { $regex: safeRegex };
897
- regexFields[field] = true;
898
- }
899
- } else {
900
- let processedValue;
901
- const op = operator.toLowerCase();
902
- if (op === "size") {
903
- processedValue = parseFloat(String(value));
904
- if (Number.isNaN(processedValue)) return;
905
- } else if ([
906
- "gt",
907
- "gte",
908
- "lt",
909
- "lte"
910
- ].includes(op)) if (this._fieldTypes.has(field)) {
911
- processedValue = coerceFieldValue(field, value, this._fieldTypes);
912
- if (typeof processedValue === "number" && Number.isNaN(processedValue)) return;
913
- } else {
914
- processedValue = parseFloat(String(value));
915
- if (Number.isNaN(processedValue)) return;
916
- }
917
- else if (op === "in" || op === "nin") processedValue = (Array.isArray(value) ? value : String(value).split(",").map((v) => v.trim())).map((elem) => coerceFieldValue(field, elem, this._fieldTypes));
918
- else processedValue = coerceFieldValue(field, value, this._fieldTypes);
919
- if (typeof filters[field] !== "object" || filters[field] === null || Array.isArray(filters[field])) filters[field] = {};
920
- filters[field][mongoOperator] = processedValue;
921
- }
922
- }
923
- /**
924
- * Handle bracket syntax with object value
925
- */
926
- _handleBracketSyntax(field, operators, parsedFilters, depth = 0) {
927
- if (depth > this.options.maxFilterDepth) {
928
- warn(`[mongokit] Nested filter depth exceeds maximum, skipping field: ${field}`);
929
- return;
930
- }
931
- for (const op of Object.keys(operators)) if (RESERVED_PAGINATION_KEYS.has(op)) warn(`[mongokit] Nested filter contains reserved key '${op}' at '${field}.${op}' — did you mean ?${op}=... at the top level? The nested value is ignored.`);
932
- if (!parsedFilters[field]) parsedFilters[field] = {};
933
- for (const [operator, value] of Object.entries(operators)) {
934
- if (value === "" || value === null || value === void 0) continue;
935
- if (operator === "between") {
936
- parsedFilters[field].between = value;
937
- continue;
938
- }
939
- if (isGeoOperator(operator)) {
940
- const geoFilter = parseGeoFilter(operator, value);
941
- if (geoFilter) parsedFilters[field] = geoFilter;
942
- else {
943
- warn(`[mongokit] Invalid geo operator value for ${field}[${operator}]; dropping filter`);
944
- delete parsedFilters[field];
945
- }
946
- continue;
947
- }
948
- if (this.options.allowedOperators && !this.options.allowedOperators.includes(operator)) {
949
- warn(`[mongokit] Operator not in allowlist: ${operator}`);
950
- continue;
951
- }
952
- if (this.operators[operator]) {
953
- const mongoOperator = this.operators[operator];
954
- let processedValue;
955
- if (operator === "size") {
956
- processedValue = parseFloat(String(value));
957
- if (Number.isNaN(processedValue)) continue;
958
- } else if ([
959
- "gt",
960
- "gte",
961
- "lt",
962
- "lte"
963
- ].includes(operator)) if (this._fieldTypes.has(field)) {
964
- processedValue = coerceFieldValue(field, value, this._fieldTypes);
965
- if (typeof processedValue === "number" && Number.isNaN(processedValue)) continue;
966
- } else {
967
- processedValue = parseFloat(String(value));
968
- if (Number.isNaN(processedValue)) continue;
969
- }
970
- else if (operator === "in" || operator === "nin") processedValue = (Array.isArray(value) ? value : String(value).split(",").map((v) => v.trim())).map((elem) => coerceFieldValue(field, elem, this._fieldTypes));
971
- else if (operator === "like" || operator === "contains" || operator === "regex") {
972
- const safeRegex = this._createSafeRegex(value);
973
- if (!safeRegex) continue;
974
- processedValue = safeRegex;
975
- } else processedValue = coerceFieldValue(field, value, this._fieldTypes);
976
- parsedFilters[field][mongoOperator] = processedValue;
977
- }
978
- }
979
- if (typeof parsedFilters[field] === "object" && Object.keys(parsedFilters[field]).length === 0) delete parsedFilters[field];
980
- }
981
- _parseSort(sort) {
982
- if (!sort) return void 0;
983
- if (typeof sort === "object") {
984
- const sortObj = {};
985
- for (const [key, value] of Object.entries(sort)) {
986
- if (this.options.allowedSortFields && !this.options.allowedSortFields.includes(key)) {
987
- warn(`[mongokit] Blocked sort field not in allowlist: ${key}`);
988
- continue;
989
- }
990
- const strVal = String(value).toLowerCase();
991
- sortObj[key] = strVal === "desc" || strVal === "-1" || value === -1 ? -1 : 1;
992
- }
993
- return Object.keys(sortObj).length > 0 ? sortObj : void 0;
994
- }
995
- const sortObj = {};
996
- const fields = sort.split(",").map((s) => s.trim());
997
- for (const field of fields) {
998
- if (!field) continue;
999
- const cleanField = field.startsWith("-") ? field.substring(1) : field;
1000
- if (this.options.allowedSortFields && !this.options.allowedSortFields.includes(cleanField)) {
1001
- warn(`[mongokit] Blocked sort field not in allowlist: ${cleanField}`);
1002
- continue;
1003
- }
1004
- if (field.startsWith("-")) sortObj[field.substring(1)] = -1;
1005
- else sortObj[field] = 1;
1006
- }
1007
- return Object.keys(sortObj).length > 0 ? sortObj : void 0;
1008
- }
1009
- _toMongoOperator(operator) {
1010
- const op = operator.toLowerCase();
1011
- return op.startsWith("$") ? op : `$${op}`;
1012
- }
1013
- _createSafeRegex(pattern, flags = "i") {
1014
- if (pattern === null || pattern === void 0) return null;
1015
- const patternStr = String(pattern);
1016
- if (patternStr.length > this.options.maxRegexLength) {
1017
- warn(`[mongokit] Regex pattern too long, truncating`);
1018
- return new RegExp(this._escapeRegex(patternStr.substring(0, this.options.maxRegexLength)), flags);
1019
- }
1020
- if (this.dangerousRegexPatterns.test(patternStr)) {
1021
- warn("[mongokit] Potentially dangerous regex pattern, escaping");
1022
- return new RegExp(this._escapeRegex(patternStr), flags);
1023
- }
1024
- if (this._regexComplexityExceedsBudget(patternStr)) {
1025
- warn("[mongokit] Regex complexity budget exceeded, escaping");
1026
- return new RegExp(this._escapeRegex(patternStr), flags);
1027
- }
1028
- try {
1029
- return new RegExp(patternStr, flags);
1030
- } catch {
1031
- return new RegExp(this._escapeRegex(patternStr), flags);
1032
- }
1033
- }
1034
- _escapeRegex(str) {
1035
- return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
1036
- }
1037
- /**
1038
- * Sanitize $match configuration to prevent dangerous operators
1039
- * Recursively filters out operators like $where, $function, $accumulator
1040
- */
1041
- _sanitizeMatchConfig(config, depth = 0) {
1042
- if (depth > this.options.maxFilterDepth) {
1043
- warn(`[mongokit] $match sanitize depth ${depth} exceeds maximum ${this.options.maxFilterDepth}, truncating branch`);
1044
- return {};
1045
- }
1046
- const sanitized = {};
1047
- const logicalArrayOps = /* @__PURE__ */ new Set([
1048
- "$or",
1049
- "$and",
1050
- "$nor"
1051
- ]);
1052
- for (const [key, value] of Object.entries(config)) {
1053
- if (this.dangerousOperators.includes(key)) {
1054
- warn(`[mongokit] Blocked dangerous operator in aggregation: ${key}`);
1055
- continue;
1056
- }
1057
- if (value && typeof value === "object" && !Array.isArray(value)) sanitized[key] = this._sanitizeMatchConfig(value, depth + 1);
1058
- else if (Array.isArray(value)) {
1059
- const sanitizedArray = value.map((item) => {
1060
- if (item && typeof item === "object" && !Array.isArray(item)) return this._sanitizeMatchConfig(item, depth + 1);
1061
- return item;
1062
- });
1063
- if (logicalArrayOps.has(key)) {
1064
- const filtered = sanitizedArray.filter((item) => !(item && typeof item === "object" && !Array.isArray(item) && Object.keys(item).length === 0));
1065
- if (filtered.length === 0) {
1066
- warn(`[mongokit] All branches of ${key} were blocked by sanitization; dropping the operator`);
1067
- continue;
1068
- }
1069
- sanitized[key] = filtered;
1070
- } else sanitized[key] = sanitizedArray;
1071
- } else sanitized[key] = value;
1072
- }
1073
- return sanitized;
1074
- }
1075
- /**
1076
- * Sanitize pipeline stages for use in $lookup.
1077
- * Blocks dangerous stages ($out, $merge, etc.) and recursively sanitizes
1078
- * operator expressions within $match, $addFields, and $set stages.
1079
- */
1080
- _sanitizePipeline(stages) {
1081
- const blockedStages = [
1082
- "$out",
1083
- "$merge",
1084
- "$unionWith",
1085
- "$collStats",
1086
- "$currentOp",
1087
- "$listSessions"
1088
- ];
1089
- const sanitized = [];
1090
- for (const stage of stages) {
1091
- if (!stage || typeof stage !== "object") continue;
1092
- const entries = Object.entries(stage);
1093
- if (entries.length !== 1) continue;
1094
- const [op, config] = entries[0];
1095
- if (blockedStages.includes(op)) {
1096
- warn(`[mongokit] Blocked dangerous pipeline stage in lookup: ${op}`);
1097
- continue;
1098
- }
1099
- if (op === "$match" && typeof config === "object" && config !== null) sanitized.push({ $match: this._sanitizeMatchConfig(config) });
1100
- else if ((op === "$addFields" || op === "$set") && typeof config === "object" && config !== null) sanitized.push({ [op]: this._sanitizeExpressions(config) });
1101
- else sanitized.push(stage);
1102
- }
1103
- return sanitized;
1104
- }
1105
- /**
1106
- * Recursively sanitize expression objects, blocking dangerous operators
1107
- * like $where, $function, $accumulator inside $addFields/$set stages.
1108
- *
1109
- * Depth-guarded — same protection `_sanitizeMatchConfig` ships, since
1110
- * a hostile `$cond`/`$switch` chain can recurse arbitrarily deep and
1111
- * exhaust JS stack. Without the guard, this is a one-liner DoS against
1112
- * any host that exposes pipeline parsing to untrusted callers.
1113
- */
1114
- _sanitizeExpressions(config, depth = 0) {
1115
- if (depth > this.options.maxFilterDepth) {
1116
- warn(`[mongokit] pipeline expression sanitize depth ${depth} exceeds maximum ${this.options.maxFilterDepth}, truncating branch`);
1117
- return {};
1118
- }
1119
- const sanitized = {};
1120
- for (const [key, value] of Object.entries(config)) {
1121
- if (this.dangerousOperators.includes(key)) {
1122
- warn(`[mongokit] Blocked dangerous operator in pipeline expression: ${key}`);
1123
- continue;
1124
- }
1125
- if (value && typeof value === "object" && !Array.isArray(value)) sanitized[key] = this._sanitizeExpressions(value, depth + 1);
1126
- else if (Array.isArray(value)) sanitized[key] = value.map((item) => {
1127
- if (item && typeof item === "object" && !Array.isArray(item)) return this._sanitizeExpressions(item, depth + 1);
1128
- return item;
1129
- });
1130
- else sanitized[key] = value;
1131
- }
1132
- return sanitized;
1133
- }
1134
- _sanitizeSearch(search) {
1135
- if (search === null || search === void 0 || search === "") return void 0;
1136
- let searchStr = String(search).trim();
1137
- if (!searchStr) return void 0;
1138
- if (searchStr.length > this.options.maxSearchLength) {
1139
- warn(`[mongokit] Search query too long, truncating`);
1140
- searchStr = searchStr.substring(0, this.options.maxSearchLength);
1141
- }
1142
- return searchStr;
1143
- }
1144
- /**
1145
- * Build regex-based multi-field search filters
1146
- * Creates an $or query with case-insensitive regex across all searchFields
1147
- *
1148
- * @example
1149
- * // searchFields: ['name', 'description', 'sku']
1150
- * // search: 'azure'
1151
- * // Returns: [
1152
- * // { name: { $regex: /azure/i } },
1153
- * // { description: { $regex: /azure/i } },
1154
- * // { sku: { $regex: /azure/i } }
1155
- * // ]
1156
- */
1157
- _buildRegexSearch(searchTerm) {
1158
- if (!this.options.searchFields || this.options.searchFields.length === 0) return null;
1159
- const safeRegex = this._createSafeRegex(searchTerm, "i");
1160
- if (!safeRegex) return null;
1161
- const orConditions = [];
1162
- for (const field of this.options.searchFields) orConditions.push({ [field]: { $regex: safeRegex } });
1163
- return orConditions.length > 0 ? orConditions : null;
1164
- }
1165
- _parseOr(query) {
1166
- const orArray = [];
1167
- const raw = query?.or || query?.OR || query?.$or;
1168
- if (!raw) return void 0;
1169
- const items = Array.isArray(raw) ? raw : typeof raw === "object" ? Object.values(raw) : [];
1170
- for (const item of items) if (typeof item === "object" && item) {
1171
- const parsedBranch = this._parseFilters(item, 1);
1172
- if (Object.keys(parsedBranch).length > 0) orArray.push(parsedBranch);
1173
- }
1174
- return orArray.length ? orArray : void 0;
1175
- }
1176
- _enhanceWithBetween(filters) {
1177
- const output = { ...filters };
1178
- for (const [key, value] of Object.entries(filters || {})) if (value && typeof value === "object" && "between" in value) {
1179
- const between = value.between;
1180
- const [from, to] = String(between).split(",").map((s) => s.trim());
1181
- const fromDate = from ? new Date(from) : void 0;
1182
- const toDate = to ? new Date(to) : void 0;
1183
- const range = {};
1184
- if (fromDate && !Number.isNaN(fromDate.getTime())) range.$gte = fromDate;
1185
- if (toDate && !Number.isNaN(toDate.getTime())) range.$lte = toDate;
1186
- output[key] = range;
1187
- }
1188
- return output;
1189
- }
1190
- _pluralize(str) {
1191
- if (str.endsWith("y")) return `${str.slice(0, -1)}ies`;
1192
- if (str.endsWith("s")) return str;
1193
- return `${str}s`;
1194
- }
1195
- _capitalize(str) {
1196
- return str.charAt(0).toUpperCase() + str.slice(1);
1345
+ return buildOpenAPIQuerySchema(this.rt);
1197
1346
  }
1198
1347
  };
1199
1348
  //#endregion
@@ -1462,4 +1611,4 @@ function systemContext(extra) {
1462
1611
  */
1463
1612
  var src_default = Repository;
1464
1613
  //#endregion
1465
- export { ALL_OPERATIONS, AggregationBuilder, AuditTrailQuery, HOOK_PRIORITY, LookupBuilder, MONGOKIT_CAPABILITIES, MONGOKIT_PLUGIN_NAMES, MUTATING_OPERATIONS, OP_REGISTRY, PaginationEngine, QueryParser, READ_OPERATIONS, Repository, actions_exports as actions, adminBypass, aggregateHelpersPlugin, appendOnlyPlugin, auditLogPlugin, auditTrailPlugin, autoInject, batchOperationsPlugin, batchTransaction, blockIf, buildCrudSchemasFromModel, buildCrudSchemasFromMongooseSchema, cachePlugin, cascadePlugin, cascadePurgeReferences, compileFilterToMongo, configureLogger, createError, createFieldPreset, createMemoryCache, createOptionsExtractor, createRepository, createTenantContext, customIdPlugin, dateSequentialId, src_default as default, elasticSearchPlugin, fieldFilterPlugin, filterResponseData, getFieldsForUser, getMongooseProjection, getNextSequence, idVariants, immutableField, immutableStatesPlugin, isDuplicateKeyError, isTransactionUnsupported, leasePlugin, methodRegistryPlugin, mongoOperationsPlugin, multiTenantPlugin, observabilityPlugin, operationsByPolicyKey, parseDuplicateKeyError, prefixedId, repoOptionsFromCtx, requireField, sequentialId, softDeletePlugin, subdocumentPlugin, systemContext, timestampPlugin, toPlain, uniqueField, validationChainPlugin, withTransaction };
1614
+ export { ALL_OPERATIONS, AggregationBuilder, AuditTrailQuery, HOOK_PRIORITY, LookupBuilder, MONGOKIT_CAPABILITIES, MONGOKIT_PLUGIN_NAMES, MUTATING_OPERATIONS, OP_REGISTRY, PaginationEngine, QueryParser, READ_OPERATIONS, Repository, actions_exports as actions, adminBypass, aggregateHelpersPlugin, appendOnlyPlugin, auditLogPlugin, auditTrailPlugin, autoInject, batchOperationsPlugin, batchTransaction, blockIf, buildCrudSchemasFromModel, buildCrudSchemasFromMongooseSchema, cachePlugin, cascadePlugin, cascadePurgeReferences, compileFilterToMongo, configureLogger, createError, createFieldPreset, createMemoryCache, createOptionsExtractor, createRepository, createTenantContext, customIdPlugin, dateSequentialId, src_default as default, elasticSearchPlugin, ensureAuditTrailReady, fieldFilterPlugin, filterResponseData, getFieldsForUser, getMongooseProjection, getNextSequence, idVariants, immutableField, immutableStatesPlugin, isDuplicateKeyError, isTransactionUnsupported, leasePlugin, methodRegistryPlugin, mongoOperationsPlugin, multiTenantPlugin, observabilityPlugin, operationsByPolicyKey, parseDuplicateKeyError, prefixedId, repoOptionsFromCtx, requireField, sequentialId, softDeletePlugin, subdocumentPlugin, systemContext, timestampPlugin, toPlain, uniqueField, validationChainPlugin, withTransaction };