@classytic/mongokit 3.39.0 → 3.40.1
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.
- package/README.md +71 -0
- package/dist/{PaginationEngine-D-P19Y9m.d.mts → PaginationEngine-CRHyuUtw.d.mts} +17 -3
- package/dist/{PaginationEngine-DQ7beTCR.mjs → PaginationEngine-CfT2Cxkj.mjs} +320 -145
- package/dist/{Repository-D7fDpyFx.mjs → Repository-CYhSC9ut.mjs} +844 -794
- package/dist/{Repository-IxI983_m.d.mts → Repository-pnLw1rG5.d.mts} +113 -47
- package/dist/actions/index.d.mts +1 -1
- package/dist/actions/index.mjs +1 -1
- package/dist/adapter/index.mjs +1 -1
- package/dist/{adapter-BrVfPFSW.mjs → adapter-CkMoMF83.mjs} +1 -1
- package/dist/ai/index.d.mts +1 -1
- package/dist/better-auth/index.mjs +2 -2
- package/dist/{create-repository-CRXjDa3N.d.mts → create-repository-BUx9vfG9.d.mts} +5 -5
- package/dist/{create-repository-DOe9E_5F.mjs → create-repository-BYyWzLbt.mjs} +2 -2
- package/dist/idempotency/index.d.mts +23 -0
- package/dist/idempotency/index.mjs +134 -0
- package/dist/{index-CmjNyulu.d.mts → index-SYF8N28D.d.mts} +37 -19
- package/dist/index.d.mts +105 -39
- package/dist/index.mjs +332 -676
- package/dist/indexes/verify-indexes.d.mts +14 -1
- package/dist/indexes/verify-indexes.mjs +54 -7
- package/dist/join-scope-DSAznE8E.mjs +168 -0
- package/dist/{mongooseToJsonSchema-waUVQwK4.mjs → mongooseToJsonSchema-C3k4ide0.mjs} +5 -1
- package/dist/{multi-tenant.plugin-B-tzNeYL.d.mts → multi-tenant.plugin-a3UhQARj.d.mts} +19 -3
- package/dist/{operations-CrRIoc8Z.d.mts → operations-XiFgG5US.d.mts} +36 -13
- package/dist/outbox/index.d.mts +1 -1
- package/dist/outbox/index.mjs +6 -4
- package/dist/pagination/PaginationEngine.d.mts +2 -2
- package/dist/pagination/PaginationEngine.mjs +1 -1
- package/dist/pagination/utils/limits.d.mts +1 -1
- package/dist/{pagination-BpIv2bdS.d.mts → pagination-D6psses0.d.mts} +116 -13
- package/dist/{plugin-options-BuvtOSZf.d.mts → plugin-options-CRhIvSzx.d.mts} +16 -2
- package/dist/plugins/index.d.mts +8 -3
- package/dist/plugins/index.mjs +98 -25
- package/dist/query-defaults-BxUcSOoF.mjs +208 -0
- package/dist/read-model/index.d.mts +66 -0
- package/dist/read-model/index.mjs +193 -0
- package/dist/{repository-ZbLtL7Nk.d.mts → repository-DNHfjAZX.d.mts} +36 -4
- package/dist/sync/index.d.mts +53 -0
- package/dist/sync/index.mjs +371 -0
- package/dist/tenant/index.mjs +2 -0
- package/dist/testkit/index.d.mts +40 -3
- package/dist/testkit/index.mjs +113 -2
- package/dist/{timestamp.plugin-B5eiaItQ.mjs → timestamp.plugin-zfdLYZiO.mjs} +135 -32
- package/dist/{to-plain-B8ufk3Qz.d.mts → to-plain-CjgEInWV.d.mts} +2 -2
- package/dist/transaction-BrRYtH_b.mjs +655 -0
- package/dist/{update-c_01A0if.mjs → update-WgeAUJ_I.mjs} +212 -33
- package/dist/utils/index.d.mts +1 -1
- package/dist/utils/index.mjs +1 -1
- package/dist/{validation-chain.plugin-Bju1D1Sn.mjs → validation-chain.plugin-CHUHWMtk.mjs} +204 -279
- package/dist/{validation-chain.plugin-DvrLJIbJ.d.mts → validation-chain.plugin-CTcNJCmV.d.mts} +27 -5
- package/package.json +18 -4
package/dist/index.mjs
CHANGED
|
@@ -1,23 +1,27 @@
|
|
|
1
1
|
import { n as idVariants } from "./id-resolution-BqDrk0S2.mjs";
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
2
|
+
import { a as MUTATING_OPERATIONS, c as operationsByPolicyKey, i as ALL_OPERATIONS, l as createMongoPurgePort, n as Repository, o as OP_REGISTRY, r as AggregationBuilder, s as READ_OPERATIONS, t as HOOK_PRIORITY } from "./Repository-CYhSC9ut.mjs";
|
|
3
|
+
import { C as isCreateManyPartialError, g as LookupBuilder, v as compileFilterToMongo } from "./update-WgeAUJ_I.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
|
+
import { a as assertQueryDefaultsConfigured, c as getQueryDefaults, l as resetQueryDefaults, o as configureAggregateDefaults, s as configureQueryDefaults, t as QUERY_DEFAULTS_ERROR_CODES, u as resolveQueryOptions } from "./query-defaults-BxUcSOoF.mjs";
|
|
6
7
|
import { t as actions_exports } from "./actions/index.mjs";
|
|
7
|
-
import {
|
|
8
|
+
import { c as classifyHelloReply, d as resolveMongoCapabilities, f as resolveTransactionSupport, i as withTransaction, l as probeMongoCapabilities, n as isTransactionUnsupported, o as MONGOKIT_CAPABILITIES, p as transactionResolutionOf, r as supportsTransactions, s as capabilitiesForSupport, t as batchTransaction, u as resetCapabilityProbeCache } from "./transaction-BrRYtH_b.mjs";
|
|
9
|
+
import { _ as AuditTrailQuery, a as declareTenantContextKey, b as auditLogPlugin, c as tenantContextKeysOf, d as ensureSequenceAtLeast, f as getNextSequence, g as cachePlugin, h as sequentialId, i as multiTenantPlugin, l as customIdPlugin, m as readSequence, n as softDeletePlugin, o as forwardScope, p as prefixedId, r as adminBypass, s as isScopeKey, t as timestampPlugin, u as dateSequentialId, v as auditTrailPlugin, y as ensureAuditTrailReady } from "./timestamp.plugin-zfdLYZiO.mjs";
|
|
8
10
|
import { n as batchOperationsPlugin, t as methodRegistryPlugin } from "./method-registry.plugin-C63pGn1d.mjs";
|
|
9
|
-
import {
|
|
10
|
-
import {
|
|
11
|
-
import { t as
|
|
11
|
+
import { c as PAGINATION_ERROR_CODES, h as resetPaginationDefaults, m as getPaginationDefaults, p as configurePaginationDefaults, t as PaginationEngine } from "./PaginationEngine-CfT2Cxkj.mjs";
|
|
12
|
+
import { GEO_OPERATORS, parseGeoFilter } from "./query/primitives/geo.mjs";
|
|
13
|
+
import { n as declareCollectionScope, t as JOIN_ERROR_CODES } from "./join-scope-DSAznE8E.mjs";
|
|
14
|
+
import { t as createRepository } from "./create-repository-BYyWzLbt.mjs";
|
|
12
15
|
import { n as defineModel, r as isModelRegistered, t as ModelCollisionError } from "./model-registry-ClKtWiHa.mjs";
|
|
13
|
-
import { _ as cascadePlugin, a as uniqueField, c as subdocumentPlugin, d as mongoOperationsPlugin, f as leasePlugin, g as elasticSearchPlugin, h as fieldFilterPlugin, i as requireField, l as observabilityPlugin, m as idempotencyStorePlugin, 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 appendOnlyPlugin, y as aggregateHelpersPlugin } from "./validation-chain.plugin-
|
|
16
|
+
import { _ as cascadePlugin, a as uniqueField, c as subdocumentPlugin, d as mongoOperationsPlugin, f as leasePlugin, g as elasticSearchPlugin, h as fieldFilterPlugin, i as requireField, l as observabilityPlugin, m as idempotencyStorePlugin, 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 appendOnlyPlugin, y as aggregateHelpersPlugin } from "./validation-chain.plugin-CHUHWMtk.mjs";
|
|
14
17
|
import { i as getMongooseProjection, n as filterResponseData, r as getFieldsForUser, t as createFieldPreset } from "./field-selection-FSQFLpsU.mjs";
|
|
15
|
-
import { buildFieldTypeMap,
|
|
18
|
+
import { buildFieldTypeMap, coerceHeuristic } from "./query/primitives/coercion.mjs";
|
|
16
19
|
import { extractSchemaIndexes } from "./query/primitives/indexes.mjs";
|
|
17
20
|
import { n as createMemoryCache, t as toPlain } from "./to-plain-vksRrLtc.mjs";
|
|
18
|
-
import { n as buildCrudSchemasFromMongooseSchema, t as buildCrudSchemasFromModel } from "./mongooseToJsonSchema-
|
|
21
|
+
import { n as buildCrudSchemasFromMongooseSchema, t as buildCrudSchemasFromModel } from "./mongooseToJsonSchema-C3k4ide0.mjs";
|
|
19
22
|
import { runChunkedPurge } from "@classytic/repo-core/repository";
|
|
20
|
-
import {
|
|
23
|
+
import { stableStringify } from "@classytic/repo-core/hash";
|
|
24
|
+
import { OPERATOR_DESCRIPTIONS, QueryGrammarError, URL_OPERATORS, clausesToFilter, escapeRegex, isQueryGrammarError, readFilterClauses, readPageRequest, readSearch, readSelect, readSort } from "@classytic/repo-core/query-parser";
|
|
21
25
|
//#region src/actions/cascade-references.ts
|
|
22
26
|
/**
|
|
23
27
|
* cascadePurgeReferences — imperative, multi-relation "purge by reference".
|
|
@@ -218,6 +222,45 @@ function defineModels(spec) {
|
|
|
218
222
|
};
|
|
219
223
|
}
|
|
220
224
|
//#endregion
|
|
225
|
+
//#region src/pagination/keyset-source.ts
|
|
226
|
+
/**
|
|
227
|
+
* `keysetSource(repo, { name, sort, filters, ...scope })` — a mongokit repository as a repo-core
|
|
228
|
+
* `KeysetSource`, for `mergeKeysetPages`. Pages are `getAll({ mode: 'keyset' })` under the given
|
|
229
|
+
* scope; the per-row cursor is `repo.keysetCursor` (scope-bound like every mongokit cursor); the
|
|
230
|
+
* source's `scope` string binds the composite cursor to (collection, filters, tenant values).
|
|
231
|
+
*/
|
|
232
|
+
function keysetSource(repo, options) {
|
|
233
|
+
const { name, sort, filters = {}, ...scope } = options;
|
|
234
|
+
const { session: _session, user: _user, ...tenant } = forwardScope(scope);
|
|
235
|
+
return {
|
|
236
|
+
name,
|
|
237
|
+
scope: `${repo.Model.collection.collectionName}:${stableStringify({
|
|
238
|
+
filters,
|
|
239
|
+
tenant
|
|
240
|
+
})}`,
|
|
241
|
+
async page({ after, limit }) {
|
|
242
|
+
const page = await repo.getAll({
|
|
243
|
+
...scope,
|
|
244
|
+
filters,
|
|
245
|
+
sort,
|
|
246
|
+
limit,
|
|
247
|
+
mode: "keyset",
|
|
248
|
+
...after ? { after } : {}
|
|
249
|
+
});
|
|
250
|
+
if (page.method !== "keyset") throw new Error("[mongokit] keysetSource: getAll did not return a keyset page");
|
|
251
|
+
return {
|
|
252
|
+
data: page.data,
|
|
253
|
+
hasMore: page.hasMore
|
|
254
|
+
};
|
|
255
|
+
},
|
|
256
|
+
cursorOf: (row) => repo.keysetCursor(row, {
|
|
257
|
+
...scope,
|
|
258
|
+
sort,
|
|
259
|
+
filters
|
|
260
|
+
})
|
|
261
|
+
};
|
|
262
|
+
}
|
|
263
|
+
//#endregion
|
|
221
264
|
//#region src/query/parser/pipeline-sanitizer.ts
|
|
222
265
|
/**
|
|
223
266
|
* Sanitize $match configuration to prevent dangerous operators.
|
|
@@ -357,410 +400,134 @@ function parseAggregation(rt, aggregate) {
|
|
|
357
400
|
return pipeline.length > 0 ? pipeline : void 0;
|
|
358
401
|
}
|
|
359
402
|
//#endregion
|
|
360
|
-
//#region src/query/parser/regex-safety.ts
|
|
361
|
-
/**
|
|
362
|
-
* Regex safety — ReDoS protection for user-supplied patterns.
|
|
363
|
-
*
|
|
364
|
-
* Two independent detectors feed the decision:
|
|
365
|
-
* 1. A heuristic pattern for classic catastrophic-backtracking shapes
|
|
366
|
-
* (nested quantifiers, possessive quantifiers, backreferences, …).
|
|
367
|
-
* 2. A deterministic static complexity budget, so patterns that evade the
|
|
368
|
-
* heuristic still fail over when their unbounded-quantifier density is
|
|
369
|
-
* pathological.
|
|
370
|
-
*
|
|
371
|
-
* Unsafe input is never executed as a regex: depending on the caller's
|
|
372
|
-
* intent it is either escaped to a literal match or rejected through the
|
|
373
|
-
* `invalidInput` policy.
|
|
374
|
-
*/
|
|
375
|
-
/**
|
|
376
|
-
* Regex patterns that can cause catastrophic backtracking (ReDoS attacks).
|
|
377
|
-
* Detects common footguns:
|
|
378
|
-
* - Quantifiers: {n,m}
|
|
379
|
-
* - Possessive quantifiers: *+, ++, ?+
|
|
380
|
-
* - Nested quantifiers: (a+)+, (a*)*
|
|
381
|
-
* - Backreferences: \1, \2, etc.
|
|
382
|
-
* - Complex character classes: [...]...[...]
|
|
383
|
-
*/
|
|
384
|
-
const DANGEROUS_REGEX_PATTERNS = /(\{[0-9,]+\}|\*\+|\+\+|\?\+|(\(.+\))\+|\(\?:|\\[0-9]|(\[.+\]).+(\[.+\]))/;
|
|
385
|
-
/**
|
|
386
|
-
* Static complexity budget for user-supplied regex patterns.
|
|
387
|
-
* Returns `true` if the pattern crosses a budget that empirically correlates
|
|
388
|
-
* with ReDoS risk — independent of the heuristic regex detector.
|
|
389
|
-
*
|
|
390
|
-
* Budget components (any single threshold crossed → over budget):
|
|
391
|
-
* - unbounded quantifiers (`*`, `+`, `?`) — hard cap 20
|
|
392
|
-
* - nested groups `(` — hard cap 8
|
|
393
|
-
* - alternations `|` — hard cap 10
|
|
394
|
-
* - combined: (quantifiers * groups) — hard cap 40
|
|
395
|
-
*/
|
|
396
|
-
function regexComplexityExceedsBudget(patternStr) {
|
|
397
|
-
const stripped = patternStr.replace(/\\[^\\]/g, "");
|
|
398
|
-
const unboundedQuantifiers = (stripped.match(/[*+?]/g) ?? []).length;
|
|
399
|
-
const groups = (stripped.match(/\(/g) ?? []).length;
|
|
400
|
-
const alternations = (stripped.match(/\|/g) ?? []).length;
|
|
401
|
-
if (unboundedQuantifiers > 20) return true;
|
|
402
|
-
if (groups > 8) return true;
|
|
403
|
-
if (alternations > 10) return true;
|
|
404
|
-
if (unboundedQuantifiers * groups > 40) return true;
|
|
405
|
-
return false;
|
|
406
|
-
}
|
|
407
|
-
/** Escape special regex characters (backslash first, then other metas). */
|
|
408
|
-
function escapeRegex(str) {
|
|
409
|
-
return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
410
|
-
}
|
|
411
|
-
/**
|
|
412
|
-
* Build a safe RegExp from user input.
|
|
413
|
-
*
|
|
414
|
-
* `onUnsafe` selects what happens when the pattern is over-long,
|
|
415
|
-
* pathological, or syntactically invalid:
|
|
416
|
-
* - `'reject'`: route through the `invalidInput` policy — HTTP 400 in
|
|
417
|
-
* `'throw'` mode, warn + escape-to-literal in `'drop'` mode. Used for
|
|
418
|
-
* the explicit `regex` operator, where the caller claims to be sending
|
|
419
|
-
* a real regex.
|
|
420
|
-
* - `'escape'`: always warn + escape-to-literal, never throw. Used for
|
|
421
|
-
* `search` terms and the `like`/`contains` operators, whose documented
|
|
422
|
-
* semantic is a literal substring match — `?search=c++` must not 400.
|
|
423
|
-
*/
|
|
424
|
-
function createSafeRegex(rt, pattern, flags = "i", onUnsafe = "reject") {
|
|
425
|
-
if (pattern === null || pattern === void 0) return null;
|
|
426
|
-
const flag = (message, meta) => {
|
|
427
|
-
if (onUnsafe === "reject") rt.reject(message, meta);
|
|
428
|
-
else warn(`[mongokit] ${message}`);
|
|
429
|
-
};
|
|
430
|
-
const patternStr = String(pattern);
|
|
431
|
-
if (patternStr.length > rt.options.maxRegexLength) {
|
|
432
|
-
flag(`Regex pattern too long, truncating`, {
|
|
433
|
-
length: patternStr.length,
|
|
434
|
-
maxRegexLength: rt.options.maxRegexLength
|
|
435
|
-
});
|
|
436
|
-
return new RegExp(escapeRegex(patternStr.substring(0, rt.options.maxRegexLength)), flags);
|
|
437
|
-
}
|
|
438
|
-
if (DANGEROUS_REGEX_PATTERNS.test(patternStr)) {
|
|
439
|
-
flag("Potentially dangerous regex pattern, escaping");
|
|
440
|
-
return new RegExp(escapeRegex(patternStr), flags);
|
|
441
|
-
}
|
|
442
|
-
if (regexComplexityExceedsBudget(patternStr)) {
|
|
443
|
-
flag("Regex complexity budget exceeded, escaping");
|
|
444
|
-
return new RegExp(escapeRegex(patternStr), flags);
|
|
445
|
-
}
|
|
446
|
-
try {
|
|
447
|
-
return new RegExp(patternStr, flags);
|
|
448
|
-
} catch {
|
|
449
|
-
flag("Invalid regex pattern syntax, escaping to literal match");
|
|
450
|
-
return new RegExp(escapeRegex(patternStr), flags);
|
|
451
|
-
}
|
|
452
|
-
}
|
|
453
|
-
//#endregion
|
|
454
403
|
//#region src/query/parser/filter-compiler.ts
|
|
455
404
|
/**
|
|
456
|
-
*
|
|
457
|
-
*
|
|
458
|
-
*
|
|
459
|
-
* `
|
|
460
|
-
* and the `between` range expander. Every invalid or disallowed fragment
|
|
461
|
-
* routes through the runtime's `invalidInput` policy.
|
|
405
|
+
* URL filters → a MongoDB filter. The grammar (keys, operators, value coercion, refusals) is
|
|
406
|
+
* repo-core's `readFilterClauses`; its clauses compile through the Filter IR compiler every
|
|
407
|
+
* repository query uses, so URL and programmatic filters mean the same thing. Only mongokit's own
|
|
408
|
+
* operators (`size`, `type`, geo) are emitted here, natively.
|
|
462
409
|
*/
|
|
410
|
+
/** mongokit-native operators, beyond the shared grammar. */
|
|
411
|
+
const MONGOKIT_EXTENSION_OPERATORS = [
|
|
412
|
+
"size",
|
|
413
|
+
"type",
|
|
414
|
+
...GEO_OPERATORS
|
|
415
|
+
];
|
|
416
|
+
/** Group params — `or[0][status]=a&or[1][qty][gte]=5` — compiled branch by branch. */
|
|
417
|
+
const GROUP_PARAMS = [
|
|
418
|
+
"or",
|
|
419
|
+
"OR",
|
|
420
|
+
"$or",
|
|
421
|
+
"$and"
|
|
422
|
+
];
|
|
423
|
+
/** mongokit control params that are not filters. */
|
|
424
|
+
const MONGOKIT_RESERVED_PARAMS = [
|
|
425
|
+
"lean",
|
|
426
|
+
"includeDeleted",
|
|
427
|
+
"lookup",
|
|
428
|
+
"aggregate",
|
|
429
|
+
"cursor",
|
|
430
|
+
...GROUP_PARAMS
|
|
431
|
+
];
|
|
432
|
+
const BSON_TYPE_RE = /^(?:[a-zA-Z]+|\d{1,3})$/;
|
|
433
|
+
/** Compile every filter in `query` (group params excluded — see {@link compileGroups}). */
|
|
434
|
+
function compileUrlFilters(rt, query) {
|
|
435
|
+
const { clauses, extensions } = readFilterClauses(query, grammarOptions(rt));
|
|
436
|
+
return conjoin([compileFilterToMongo(clausesToFilter(clauses)), ...extensions.map((clause) => compileExtension(rt, clause)).filter((part) => part !== void 0)]);
|
|
437
|
+
}
|
|
463
438
|
/**
|
|
464
|
-
*
|
|
465
|
-
*
|
|
466
|
-
* caller wrote `?filters[limit]=5` instead of `?limit=5` and the qs parser
|
|
467
|
-
* nested the key under a filter object — the value is silently dropped
|
|
468
|
-
* without this guard. Kept as a Set for O(1) lookup; values mirror the
|
|
469
|
-
* reserved-key list at the top of `parseFilters`.
|
|
439
|
+
* `or` / `$or` and `$and` groups. Each branch is a full filter through the same grammar. An empty
|
|
440
|
+
* branch is refused — inside an OR it would match every document — and groups do not nest.
|
|
470
441
|
*/
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
"
|
|
475
|
-
|
|
476
|
-
]);
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
return op.startsWith("$") ? op : `$${op}`;
|
|
442
|
+
function compileGroups(rt, query) {
|
|
443
|
+
const out = {};
|
|
444
|
+
const orRaw = query?.or ?? query?.OR ?? query?.$or;
|
|
445
|
+
const orBranches = orRaw === void 0 ? [] : compileBranches(rt, "or", orRaw);
|
|
446
|
+
if (orBranches.length > 0) out.$or = orBranches;
|
|
447
|
+
const andBranches = query?.$and === void 0 ? [] : compileBranches(rt, "$and", query.$and);
|
|
448
|
+
if (andBranches.length > 0) out.$and = andBranches;
|
|
449
|
+
return out;
|
|
480
450
|
}
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
if (
|
|
484
|
-
rt.reject(
|
|
485
|
-
|
|
486
|
-
maxFilterDepth: rt.options.maxFilterDepth
|
|
487
|
-
});
|
|
488
|
-
return {};
|
|
451
|
+
function compileBranches(rt, param, raw) {
|
|
452
|
+
const items = Array.isArray(raw) ? raw : isRecord(raw) ? Object.values(raw) : void 0;
|
|
453
|
+
if (!items) {
|
|
454
|
+
rt.reject(`${param} takes branches: ${param}[0][field]=value`, { param });
|
|
455
|
+
return [];
|
|
489
456
|
}
|
|
490
|
-
const
|
|
491
|
-
const
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
rt.reject(`Blocked dangerous operator: ${key}`, { key });
|
|
495
|
-
continue;
|
|
496
|
-
}
|
|
497
|
-
if (isControlParam(key) || [
|
|
498
|
-
"lean",
|
|
499
|
-
"includeDeleted",
|
|
500
|
-
"lookup",
|
|
501
|
-
"aggregate",
|
|
502
|
-
"or",
|
|
503
|
-
"OR",
|
|
504
|
-
"$or"
|
|
505
|
-
].includes(key)) continue;
|
|
506
|
-
const operatorMatch = key.match(/^(.+)\[(.+)\]$/);
|
|
507
|
-
const baseField = operatorMatch ? operatorMatch[1] : key;
|
|
508
|
-
if (rt.options.allowedFilterFields && !rt.options.allowedFilterFields.includes(baseField)) {
|
|
509
|
-
rt.reject(`Blocked filter field not in allowlist: ${baseField}`, { field: baseField });
|
|
457
|
+
const branches = [];
|
|
458
|
+
for (const item of items) {
|
|
459
|
+
if (!isRecord(item)) {
|
|
460
|
+
rt.reject(`each ${param} branch is a set of filters`, { param });
|
|
510
461
|
continue;
|
|
511
462
|
}
|
|
512
|
-
if (
|
|
513
|
-
|
|
514
|
-
if (branches.length > 0) parsedFilters.$and = branches;
|
|
463
|
+
if (GROUP_PARAMS.some((group) => group in item)) {
|
|
464
|
+
rt.reject(`${param} groups do not nest`, { param });
|
|
515
465
|
continue;
|
|
516
466
|
}
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
rt.reject(`Blocked dangerous operator: ${operator}`, { operator });
|
|
521
|
-
continue;
|
|
522
|
-
}
|
|
523
|
-
handleOperatorSyntax(rt, parsedFilters, regexFields, operatorMatch, value);
|
|
467
|
+
const branch = compileUrlFilters(rt, item);
|
|
468
|
+
if (Object.keys(branch).length === 0) {
|
|
469
|
+
rt.reject(`an empty ${param} branch would match every document`, { param });
|
|
524
470
|
continue;
|
|
525
471
|
}
|
|
526
|
-
|
|
527
|
-
else parsedFilters[key] = coerceFieldValue(key, value, rt.fieldTypes);
|
|
472
|
+
branches.push(branch);
|
|
528
473
|
}
|
|
529
|
-
return
|
|
474
|
+
return branches;
|
|
530
475
|
}
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
}
|
|
542
|
-
if (operator.toLowerCase() === "options" && regexFields[field]) {
|
|
543
|
-
const fieldValue = filters[field];
|
|
544
|
-
if (typeof fieldValue === "object" && fieldValue !== null && "$regex" in fieldValue) {
|
|
545
|
-
if (typeof value === "string" && /^[imsx]+$/.test(value)) fieldValue.$options = value;
|
|
546
|
-
else rt.reject(`Blocked invalid regex $options value: ${String(value)}. Allowed flags: i, m, s, x`, {
|
|
547
|
-
field,
|
|
548
|
-
value: String(value)
|
|
549
|
-
});
|
|
550
|
-
}
|
|
551
|
-
return;
|
|
552
|
-
}
|
|
553
|
-
if (operator.toLowerCase() === "between") {
|
|
554
|
-
if (typeof filters[field] !== "object" || filters[field] === null || Array.isArray(filters[field])) filters[field] = {};
|
|
555
|
-
filters[field].between = value;
|
|
556
|
-
return;
|
|
557
|
-
}
|
|
558
|
-
if (operator.toLowerCase() === "contains" || operator.toLowerCase() === "like") {
|
|
559
|
-
const safeRegex = createSafeRegex(rt, value, "i", "escape");
|
|
560
|
-
if (safeRegex) {
|
|
561
|
-
filters[field] = { $regex: safeRegex };
|
|
562
|
-
regexFields[field] = true;
|
|
563
|
-
}
|
|
564
|
-
return;
|
|
565
|
-
}
|
|
566
|
-
if (isGeoOperator(operator)) {
|
|
567
|
-
const geoFilter = parseGeoFilter(operator, value);
|
|
568
|
-
if (geoFilter) filters[field] = geoFilter;
|
|
569
|
-
else rt.reject(`Invalid geo operator value for ${field}[${operator}]; dropping filter`, {
|
|
570
|
-
field,
|
|
571
|
-
operator
|
|
572
|
-
});
|
|
573
|
-
return;
|
|
574
|
-
}
|
|
575
|
-
const mongoOperator = toMongoOperator(operator);
|
|
576
|
-
if (rt.dangerousOperators.includes(mongoOperator)) {
|
|
577
|
-
rt.reject(`Blocked dangerous operator: ${mongoOperator}`, { operator: mongoOperator });
|
|
578
|
-
return;
|
|
579
|
-
}
|
|
580
|
-
if (mongoOperator === "$eq") filters[field] = coerceFieldValue(field, value, rt.fieldTypes);
|
|
581
|
-
else if (mongoOperator === "$regex") {
|
|
582
|
-
const safeRegex = createSafeRegex(rt, value);
|
|
583
|
-
if (safeRegex) {
|
|
584
|
-
filters[field] = { $regex: safeRegex };
|
|
585
|
-
regexFields[field] = true;
|
|
586
|
-
}
|
|
587
|
-
} else {
|
|
588
|
-
let processedValue;
|
|
589
|
-
const op = operator.toLowerCase();
|
|
590
|
-
if (op === "size") {
|
|
591
|
-
processedValue = parseFloat(String(value));
|
|
592
|
-
if (Number.isNaN(processedValue)) {
|
|
593
|
-
rt.reject(`Non-numeric value for ${field}[${op}]: ${String(value)}`, {
|
|
594
|
-
field,
|
|
595
|
-
op
|
|
596
|
-
});
|
|
597
|
-
return;
|
|
598
|
-
}
|
|
599
|
-
} else if ([
|
|
600
|
-
"gt",
|
|
601
|
-
"gte",
|
|
602
|
-
"lt",
|
|
603
|
-
"lte"
|
|
604
|
-
].includes(op)) {
|
|
605
|
-
if (rt.fieldTypes.has(field)) {
|
|
606
|
-
processedValue = coerceFieldValue(field, value, rt.fieldTypes);
|
|
607
|
-
if (typeof processedValue === "number" && Number.isNaN(processedValue)) {
|
|
608
|
-
rt.reject(`Invalid value for ${field}[${op}]: ${String(value)}`, {
|
|
609
|
-
field,
|
|
610
|
-
op
|
|
611
|
-
});
|
|
612
|
-
return;
|
|
613
|
-
}
|
|
614
|
-
} else {
|
|
615
|
-
processedValue = parseFloat(String(value));
|
|
616
|
-
if (Number.isNaN(processedValue)) {
|
|
617
|
-
rt.reject(`Non-numeric value for ${field}[${op}]: ${String(value)}`, {
|
|
618
|
-
field,
|
|
619
|
-
op
|
|
620
|
-
});
|
|
621
|
-
return;
|
|
622
|
-
}
|
|
623
|
-
}
|
|
624
|
-
} 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));
|
|
625
|
-
else processedValue = coerceFieldValue(field, value, rt.fieldTypes);
|
|
626
|
-
if (typeof filters[field] !== "object" || filters[field] === null || Array.isArray(filters[field])) filters[field] = {};
|
|
627
|
-
filters[field][mongoOperator] = processedValue;
|
|
628
|
-
}
|
|
476
|
+
function grammarOptions(rt) {
|
|
477
|
+
return {
|
|
478
|
+
allowedFilterFields: rt.options.allowedFilterFields,
|
|
479
|
+
allowedOperators: rt.options.allowedOperators,
|
|
480
|
+
fieldTypes: rt.grammarFieldTypes,
|
|
481
|
+
extensionOperators: MONGOKIT_EXTENSION_OPERATORS,
|
|
482
|
+
reservedParams: MONGOKIT_RESERVED_PARAMS,
|
|
483
|
+
maxTextLength: rt.options.maxRegexLength,
|
|
484
|
+
onInvalid: rt.options.invalidInput === "drop" ? (error) => rt.reject(error.message) : void 0
|
|
485
|
+
};
|
|
629
486
|
}
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
}
|
|
647
|
-
if (isGeoOperator(operator)) {
|
|
648
|
-
const geoFilter = parseGeoFilter(operator, value);
|
|
649
|
-
if (geoFilter) parsedFilters[field] = geoFilter;
|
|
650
|
-
else {
|
|
651
|
-
rt.reject(`Invalid geo operator value for ${field}[${operator}]; dropping filter`, {
|
|
652
|
-
field,
|
|
653
|
-
operator
|
|
654
|
-
});
|
|
655
|
-
delete parsedFilters[field];
|
|
487
|
+
function compileExtension(rt, clause) {
|
|
488
|
+
const param = `${clause.field}[${clause.op}]`;
|
|
489
|
+
try {
|
|
490
|
+
switch (clause.op) {
|
|
491
|
+
case "size":
|
|
492
|
+
if (!/^\d+$/.test(clause.raw)) throw new QueryGrammarError(param, "size takes a whole number");
|
|
493
|
+
return { [clause.field]: { $size: Number(clause.raw) } };
|
|
494
|
+
case "type": {
|
|
495
|
+
if (!BSON_TYPE_RE.test(clause.raw)) throw new QueryGrammarError(param, "not a BSON type");
|
|
496
|
+
const code = Number(clause.raw);
|
|
497
|
+
return { [clause.field]: { $type: Number.isInteger(code) ? code : clause.raw } };
|
|
498
|
+
}
|
|
499
|
+
default: {
|
|
500
|
+
const geo = parseGeoFilter(clause.op, clause.raw);
|
|
501
|
+
if (!geo) throw new QueryGrammarError(param, `invalid ${clause.op} coordinates`);
|
|
502
|
+
return { [clause.field]: geo };
|
|
656
503
|
}
|
|
657
|
-
continue;
|
|
658
|
-
}
|
|
659
|
-
if (rt.options.allowedOperators && !rt.options.allowedOperators.includes(operator)) {
|
|
660
|
-
rt.reject(`Operator not in allowlist: ${operator}`, {
|
|
661
|
-
field,
|
|
662
|
-
operator
|
|
663
|
-
});
|
|
664
|
-
continue;
|
|
665
|
-
}
|
|
666
|
-
if (rt.operators[operator]) {
|
|
667
|
-
const mongoOperator = rt.operators[operator];
|
|
668
|
-
let processedValue;
|
|
669
|
-
if (operator === "size") {
|
|
670
|
-
processedValue = parseFloat(String(value));
|
|
671
|
-
if (Number.isNaN(processedValue)) {
|
|
672
|
-
rt.reject(`Non-numeric value for ${field}[${operator}]: ${String(value)}`, {
|
|
673
|
-
field,
|
|
674
|
-
operator
|
|
675
|
-
});
|
|
676
|
-
continue;
|
|
677
|
-
}
|
|
678
|
-
} else if ([
|
|
679
|
-
"gt",
|
|
680
|
-
"gte",
|
|
681
|
-
"lt",
|
|
682
|
-
"lte"
|
|
683
|
-
].includes(operator)) {
|
|
684
|
-
if (rt.fieldTypes.has(field)) {
|
|
685
|
-
processedValue = coerceFieldValue(field, value, rt.fieldTypes);
|
|
686
|
-
if (typeof processedValue === "number" && Number.isNaN(processedValue)) {
|
|
687
|
-
rt.reject(`Invalid value for ${field}[${operator}]: ${String(value)}`, {
|
|
688
|
-
field,
|
|
689
|
-
operator
|
|
690
|
-
});
|
|
691
|
-
continue;
|
|
692
|
-
}
|
|
693
|
-
} else {
|
|
694
|
-
processedValue = parseFloat(String(value));
|
|
695
|
-
if (Number.isNaN(processedValue)) {
|
|
696
|
-
rt.reject(`Non-numeric value for ${field}[${operator}]: ${String(value)}`, {
|
|
697
|
-
field,
|
|
698
|
-
operator
|
|
699
|
-
});
|
|
700
|
-
continue;
|
|
701
|
-
}
|
|
702
|
-
}
|
|
703
|
-
} 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));
|
|
704
|
-
else if (operator === "like" || operator === "contains" || operator === "regex") {
|
|
705
|
-
const safeRegex = createSafeRegex(rt, value, "i", operator === "regex" ? "reject" : "escape");
|
|
706
|
-
if (!safeRegex) continue;
|
|
707
|
-
processedValue = safeRegex;
|
|
708
|
-
} else processedValue = coerceFieldValue(field, value, rt.fieldTypes);
|
|
709
|
-
parsedFilters[field][mongoOperator] = processedValue;
|
|
710
504
|
}
|
|
505
|
+
} catch (error) {
|
|
506
|
+
if (!(error instanceof QueryGrammarError) || rt.options.invalidInput !== "drop") throw error;
|
|
507
|
+
rt.reject(error.message);
|
|
508
|
+
return;
|
|
711
509
|
}
|
|
712
|
-
if (typeof parsedFilters[field] === "object" && Object.keys(parsedFilters[field]).length === 0) delete parsedFilters[field];
|
|
713
510
|
}
|
|
714
|
-
/**
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
*/
|
|
723
|
-
function parseCompoundBranches(rt, raw, depth) {
|
|
724
|
-
const items = Array.isArray(raw) ? raw : typeof raw === "object" && raw ? Object.values(raw) : [];
|
|
725
|
-
const branches = [];
|
|
726
|
-
for (const item of items) {
|
|
727
|
-
if (typeof item !== "object" || !item) continue;
|
|
728
|
-
const parsed = parseFilters(rt, item, depth + 1);
|
|
729
|
-
if (Object.keys(parsed).length > 0) branches.push(parsed);
|
|
511
|
+
/** AND compiled parts: one object when fields don't collide, `$and` when they do. */
|
|
512
|
+
function conjoin(parts) {
|
|
513
|
+
const nonEmpty = parts.filter((part) => Object.keys(part).length > 0);
|
|
514
|
+
if (nonEmpty.length <= 1) return { ...nonEmpty[0] ?? {} };
|
|
515
|
+
const merged = {};
|
|
516
|
+
for (const part of nonEmpty) {
|
|
517
|
+
for (const key of Object.keys(part)) if (key in merged) return { $and: nonEmpty };
|
|
518
|
+
Object.assign(merged, part);
|
|
730
519
|
}
|
|
731
|
-
return
|
|
520
|
+
return merged;
|
|
732
521
|
}
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
const
|
|
736
|
-
if (
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
const parsedBranch = parseFilters(rt, item, 1);
|
|
740
|
-
if (Object.keys(parsedBranch).length > 0) orArray.push(parsedBranch);
|
|
741
|
-
}
|
|
742
|
-
return orArray.length ? orArray : void 0;
|
|
522
|
+
/** mongokit's schema-derived field types, narrowed to the grammar's portable set. */
|
|
523
|
+
function toGrammarFieldTypes(types) {
|
|
524
|
+
const out = {};
|
|
525
|
+
for (const [path, type] of types) if (type === "string" || type === "objectid") out[path] = "string";
|
|
526
|
+
else if (type === "number" || type === "boolean" || type === "date") out[path] = type;
|
|
527
|
+
return out;
|
|
743
528
|
}
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
const output = { ...filters };
|
|
747
|
-
for (const [key, value] of Object.entries(filters || {})) if (value && typeof value === "object" && "between" in value) {
|
|
748
|
-
const between = value.between;
|
|
749
|
-
const [from, to] = String(between).split(",").map((s) => s.trim());
|
|
750
|
-
const fromDate = from ? new Date(from) : void 0;
|
|
751
|
-
const toDate = to ? new Date(to) : void 0;
|
|
752
|
-
const range = {};
|
|
753
|
-
if (fromDate && !Number.isNaN(fromDate.getTime())) range.$gte = fromDate;
|
|
754
|
-
if (toDate && !Number.isNaN(toDate.getTime())) range.$lte = toDate;
|
|
755
|
-
if (Object.keys(range).length === 0) {
|
|
756
|
-
rt.reject(`Invalid 'between' value for ${key}: ${String(between)}`, {
|
|
757
|
-
field: key,
|
|
758
|
-
value: String(between)
|
|
759
|
-
});
|
|
760
|
-
delete output[key];
|
|
761
|
-
} else output[key] = range;
|
|
762
|
-
}
|
|
763
|
-
return output;
|
|
529
|
+
function isRecord(value) {
|
|
530
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
764
531
|
}
|
|
765
532
|
//#endregion
|
|
766
533
|
//#region src/query/parser/lookup.ts
|
|
@@ -838,97 +605,70 @@ function parseSingleLookup(rt, collectionName, config) {
|
|
|
838
605
|
return null;
|
|
839
606
|
}
|
|
840
607
|
//#endregion
|
|
841
|
-
//#region src/query/parser/
|
|
608
|
+
//#region src/query/parser/runtime.ts
|
|
842
609
|
/**
|
|
843
|
-
*
|
|
844
|
-
*
|
|
845
|
-
*
|
|
610
|
+
* Parser runtime — the shared context every parser module receives.
|
|
611
|
+
*
|
|
612
|
+
* The `QueryParser` facade resolves options once at construction and builds a
|
|
613
|
+
* single `ParserRuntime`; the extracted modules (filter compiler, sanitizers,
|
|
614
|
+
* populate/lookup/sort parsers, …) are pure functions over it. This keeps the
|
|
615
|
+
* modules independently testable and the facade thin, without threading six
|
|
616
|
+
* separate arguments through every call.
|
|
846
617
|
*/
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
}
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
618
|
+
/** Always-blocked MongoDB operators (extended via `additionalDangerousOperators`). */
|
|
619
|
+
const BASE_DANGEROUS_OPERATORS = [
|
|
620
|
+
"$where",
|
|
621
|
+
"$function",
|
|
622
|
+
"$accumulator",
|
|
623
|
+
"$expr"
|
|
624
|
+
];
|
|
625
|
+
function createReject(mode) {
|
|
626
|
+
return (message, meta) => {
|
|
627
|
+
if (mode === "throw") throw createError(400, `[mongokit] ${message}`, {
|
|
628
|
+
code: "INVALID_QUERY_INPUT",
|
|
629
|
+
meta
|
|
630
|
+
});
|
|
631
|
+
warn(`[mongokit] ${message}`);
|
|
632
|
+
};
|
|
633
|
+
}
|
|
634
|
+
/**
|
|
635
|
+
* Run a grammar reader under the `invalidInput` policy: `'throw'` lets its 400 through untouched;
|
|
636
|
+
* `'drop'` warns and returns `fallback`.
|
|
637
|
+
*/
|
|
638
|
+
function guarded(rt, read, fallback) {
|
|
639
|
+
try {
|
|
640
|
+
return read();
|
|
641
|
+
} catch (error) {
|
|
642
|
+
if (!isQueryGrammarError(error) || rt.options.invalidInput !== "drop") throw error;
|
|
643
|
+
rt.reject(error.message);
|
|
644
|
+
return fallback;
|
|
862
645
|
}
|
|
863
|
-
return n;
|
|
864
646
|
}
|
|
865
647
|
//#endregion
|
|
866
648
|
//#region src/query/parser/sort-select.ts
|
|
867
649
|
/**
|
|
868
|
-
*
|
|
869
|
-
*
|
|
870
|
-
* Exported so `parse()` can ask whether it is PERMITTED before applying it,
|
|
871
|
-
* rather than substituting it and then validating it like caller input.
|
|
650
|
+
* `?sort=-createdAt,name` and `?select=name,-password` — read through the shared grammar
|
|
651
|
+
* (`readSort` / `readSelect`), so an invalid or disallowed field is refused like any filter.
|
|
872
652
|
*/
|
|
873
|
-
const DEFAULT_PARSER_SORT = "-createdAt";
|
|
874
653
|
/**
|
|
875
|
-
*
|
|
876
|
-
*
|
|
877
|
-
* ask about a default the caller did not send.
|
|
654
|
+
* The sort applied when a request names none. Exported so `parse()` can ask whether it is
|
|
655
|
+
* PERMITTED before applying it, rather than validating it like caller input.
|
|
878
656
|
*/
|
|
657
|
+
const DEFAULT_PARSER_SORT = "-createdAt";
|
|
658
|
+
/** Is every field of `sort` inside `allowedSortFields`? Pure — never rejects. */
|
|
879
659
|
function isSortFieldAllowed(rt, sort) {
|
|
880
660
|
const allowed = rt.options.allowedSortFields;
|
|
881
661
|
if (!allowed || allowed.length === 0) return true;
|
|
882
|
-
return sort.split(",").map((s) => s.trim()).filter(Boolean).every((field) => allowed.includes(field.
|
|
662
|
+
return sort.split(",").map((s) => s.trim()).filter(Boolean).every((field) => allowed.includes(field.replace(/^[+-]/, "")));
|
|
883
663
|
}
|
|
884
|
-
/** Parse a sort spec (string or object form), honoring `allowedSortFields`. */
|
|
885
664
|
function parseSort(rt, sort) {
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
if (rt.options.allowedSortFields && !rt.options.allowedSortFields.includes(key)) {
|
|
891
|
-
rt.reject(`Blocked sort field not in allowlist: ${key}`, { field: key });
|
|
892
|
-
continue;
|
|
893
|
-
}
|
|
894
|
-
const strVal = String(value).toLowerCase();
|
|
895
|
-
sortObj[key] = strVal === "desc" || strVal === "-1" || value === -1 ? -1 : 1;
|
|
896
|
-
}
|
|
897
|
-
return Object.keys(sortObj).length > 0 ? sortObj : void 0;
|
|
898
|
-
}
|
|
899
|
-
const sortObj = {};
|
|
900
|
-
const fields = sort.split(",").map((s) => s.trim());
|
|
901
|
-
for (const field of fields) {
|
|
902
|
-
if (!field) continue;
|
|
903
|
-
const cleanField = field.startsWith("-") ? field.substring(1) : field;
|
|
904
|
-
if (rt.options.allowedSortFields && !rt.options.allowedSortFields.includes(cleanField)) {
|
|
905
|
-
rt.reject(`Blocked sort field not in allowlist: ${cleanField}`, { field: cleanField });
|
|
906
|
-
continue;
|
|
907
|
-
}
|
|
908
|
-
if (field.startsWith("-")) sortObj[field.substring(1)] = -1;
|
|
909
|
-
else sortObj[field] = 1;
|
|
910
|
-
}
|
|
911
|
-
return Object.keys(sortObj).length > 0 ? sortObj : void 0;
|
|
665
|
+
return readSort(sort, {
|
|
666
|
+
allowedSortFields: rt.options.allowedSortFields,
|
|
667
|
+
onInvalid: rt.options.invalidInput === "drop" ? (error) => rt.reject(error.message) : void 0
|
|
668
|
+
});
|
|
912
669
|
}
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
*
|
|
916
|
-
* @example
|
|
917
|
-
* ```typescript
|
|
918
|
-
* // URL: ?select=name,email,-password
|
|
919
|
-
* // Returns: { name: 1, email: 1, password: 0 }
|
|
920
|
-
* ```
|
|
921
|
-
*/
|
|
922
|
-
function parseSelect(select) {
|
|
923
|
-
if (!select) return void 0;
|
|
924
|
-
if (typeof select === "string") {
|
|
925
|
-
const projection = {};
|
|
926
|
-
const fields = select.split(",").map((f) => f.trim());
|
|
927
|
-
for (const field of fields) if (field.startsWith("-")) projection[field.substring(1)] = 0;
|
|
928
|
-
else projection[field] = 1;
|
|
929
|
-
return projection;
|
|
930
|
-
}
|
|
931
|
-
if (typeof select === "object" && select !== null) return select;
|
|
670
|
+
function parseSelect(rt, select) {
|
|
671
|
+
return guarded(rt, () => readSelect(select), void 0);
|
|
932
672
|
}
|
|
933
673
|
//#endregion
|
|
934
674
|
//#region src/query/parser/populate.ts
|
|
@@ -992,7 +732,10 @@ function parseSinglePopulate(rt, path, config, depth = 0) {
|
|
|
992
732
|
const opts = config;
|
|
993
733
|
const option = { path };
|
|
994
734
|
if (opts.select && typeof opts.select === "string") option.select = opts.select.split(",").map((s) => s.trim()).join(" ");
|
|
995
|
-
if (opts.match && typeof opts.match === "object")
|
|
735
|
+
if (opts.match && typeof opts.match === "object") {
|
|
736
|
+
const match = convertPopulateMatch(rt, path, opts.match);
|
|
737
|
+
if (match) option.match = match;
|
|
738
|
+
}
|
|
996
739
|
if (opts.limit !== void 0) {
|
|
997
740
|
const limit = parseInt(String(opts.limit), 10);
|
|
998
741
|
if (!Number.isNaN(limit) && limit > 0) {
|
|
@@ -1027,112 +770,56 @@ function parseSinglePopulate(rt, path, config, depth = 0) {
|
|
|
1027
770
|
}
|
|
1028
771
|
return null;
|
|
1029
772
|
}
|
|
1030
|
-
/**
|
|
1031
|
-
|
|
773
|
+
/**
|
|
774
|
+
* A populate `match` is plain field EQUALITY: a `$` key (`$where`) or a nested object — the same
|
|
775
|
+
* operator-smuggling route — is refused through the invalid-input policy, never passed to mongoose.
|
|
776
|
+
*/
|
|
777
|
+
function convertPopulateMatch(rt, path, match) {
|
|
1032
778
|
const converted = {};
|
|
1033
|
-
for (const [key, value] of Object.entries(match))
|
|
779
|
+
for (const [key, value] of Object.entries(match)) {
|
|
780
|
+
if (!/^[a-zA-Z_][a-zA-Z0-9_.]*$/.test(key) || value !== null && typeof value === "object") {
|
|
781
|
+
rt.reject(`Populate match on "${path}" accepts field equality only: "${key}"`, {
|
|
782
|
+
path,
|
|
783
|
+
key
|
|
784
|
+
});
|
|
785
|
+
return null;
|
|
786
|
+
}
|
|
787
|
+
converted[key] = coerceHeuristic(value);
|
|
788
|
+
}
|
|
1034
789
|
return converted;
|
|
1035
790
|
}
|
|
1036
791
|
//#endregion
|
|
1037
|
-
//#region src/query/parser/
|
|
792
|
+
//#region src/query/parser/schema-docs.ts
|
|
1038
793
|
/**
|
|
1039
|
-
*
|
|
1040
|
-
*
|
|
1041
|
-
*
|
|
1042
|
-
* single `ParserRuntime`; the extracted modules (filter compiler, sanitizers,
|
|
1043
|
-
* populate/lookup/sort parsers, …) are pure functions over it. This keeps the
|
|
1044
|
-
* modules independently testable and the facade thin, without threading six
|
|
1045
|
-
* separate arguments through every call.
|
|
794
|
+
* OpenAPI query-schema generation — Arc's `defineResource()` auto-detects
|
|
795
|
+
* `getQuerySchema()` on the parser and uses it to document list-endpoint
|
|
796
|
+
* query parameters in OpenAPI/Swagger.
|
|
1046
797
|
*/
|
|
1047
|
-
/**
|
|
1048
|
-
const
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
in: "$in",
|
|
1056
|
-
nin: "$nin",
|
|
1057
|
-
like: "$regex",
|
|
1058
|
-
contains: "$regex",
|
|
1059
|
-
regex: "$regex",
|
|
1060
|
-
exists: "$exists",
|
|
1061
|
-
size: "$size",
|
|
1062
|
-
type: "$type"
|
|
798
|
+
/** mongokit's own operators, described alongside the shared grammar's. */
|
|
799
|
+
const EXTENSION_DESCRIPTIONS = {
|
|
800
|
+
size: "Array has exactly N elements",
|
|
801
|
+
type: "Field is of a BSON type (name or number)",
|
|
802
|
+
near: "Nearest to lng,lat[,maxDistanceMeters]",
|
|
803
|
+
nearSphere: "Nearest on a sphere to lng,lat[,maxDistanceMeters]",
|
|
804
|
+
geoWithin: "Inside the box minLng,minLat,maxLng,maxLat",
|
|
805
|
+
withinRadius: "Within lng,lat,radiusMeters"
|
|
1063
806
|
};
|
|
1064
|
-
|
|
1065
|
-
|
|
1066
|
-
|
|
1067
|
-
"$function",
|
|
1068
|
-
"$accumulator",
|
|
1069
|
-
"$expr"
|
|
1070
|
-
];
|
|
1071
|
-
function createReject(mode) {
|
|
1072
|
-
return (message, meta) => {
|
|
1073
|
-
if (mode === "throw") throw createError(400, `[mongokit] ${message}`, {
|
|
1074
|
-
code: "INVALID_QUERY_INPUT",
|
|
1075
|
-
meta
|
|
1076
|
-
});
|
|
1077
|
-
warn(`[mongokit] ${message}`);
|
|
1078
|
-
};
|
|
807
|
+
function describeOperator(op) {
|
|
808
|
+
if (op in OPERATOR_DESCRIPTIONS) return OPERATOR_DESCRIPTIONS[op];
|
|
809
|
+
return EXTENSION_DESCRIPTIONS[op] ?? op;
|
|
1079
810
|
}
|
|
1080
|
-
//#endregion
|
|
1081
|
-
//#region src/query/parser/schema-docs.ts
|
|
1082
811
|
function availableOperators(rt) {
|
|
1083
|
-
|
|
812
|
+
const allowed = rt.options.allowedOperators;
|
|
813
|
+
return allowed ? rt.urlOperators.filter((op) => allowed.includes(op)) : rt.urlOperators;
|
|
1084
814
|
}
|
|
1085
|
-
/**
|
|
815
|
+
/** JSON Schema type of an operator's value. */
|
|
1086
816
|
function operatorSchemaType(op) {
|
|
1087
|
-
if (
|
|
1088
|
-
|
|
1089
|
-
"gte",
|
|
1090
|
-
"lt",
|
|
1091
|
-
"lte",
|
|
1092
|
-
"size"
|
|
1093
|
-
].includes(op)) return "number";
|
|
1094
|
-
if (["exists"].includes(op)) return "boolean";
|
|
817
|
+
if (op === "size") return "integer";
|
|
818
|
+
if (op === "exists") return "boolean";
|
|
1095
819
|
return "string";
|
|
1096
820
|
}
|
|
1097
|
-
/** Get a human-readable description for a filter operator. */
|
|
1098
|
-
function operatorDescription(op, field, mongoOp) {
|
|
1099
|
-
return {
|
|
1100
|
-
ne: `${field} not equal to value (${mongoOp})`,
|
|
1101
|
-
gt: `${field} greater than value (${mongoOp})`,
|
|
1102
|
-
gte: `${field} greater than or equal to value (${mongoOp})`,
|
|
1103
|
-
lt: `${field} less than value (${mongoOp})`,
|
|
1104
|
-
lte: `${field} less than or equal to value (${mongoOp})`,
|
|
1105
|
-
in: `${field} in comma-separated list (${mongoOp}). Example: value1,value2`,
|
|
1106
|
-
nin: `${field} not in comma-separated list (${mongoOp})`,
|
|
1107
|
-
like: `${field} matches pattern (case-insensitive regex)`,
|
|
1108
|
-
contains: `${field} contains substring (case-insensitive regex)`,
|
|
1109
|
-
regex: `${field} matches regex pattern (${mongoOp})`,
|
|
1110
|
-
exists: `Field ${field} exists (true/false)`,
|
|
1111
|
-
size: `Array field ${field} has exactly N elements (${mongoOp})`,
|
|
1112
|
-
type: `Field ${field} is of BSON type (${mongoOp})`
|
|
1113
|
-
}[op] || `Filter ${field} with ${mongoOp}`;
|
|
1114
|
-
}
|
|
1115
|
-
/** Build a summary description of all available filter operators. */
|
|
1116
821
|
function buildOperatorSummary(operators) {
|
|
1117
|
-
|
|
1118
|
-
const desc = {
|
|
1119
|
-
eq: "Equal (default when no operator specified)",
|
|
1120
|
-
ne: "Not equal",
|
|
1121
|
-
gt: "Greater than",
|
|
1122
|
-
gte: "Greater than or equal",
|
|
1123
|
-
lt: "Less than",
|
|
1124
|
-
lte: "Less than or equal",
|
|
1125
|
-
in: "In list (comma-separated values)",
|
|
1126
|
-
nin: "Not in list",
|
|
1127
|
-
like: "Pattern match (case-insensitive)",
|
|
1128
|
-
contains: "Contains substring (case-insensitive)",
|
|
1129
|
-
regex: "Regex pattern",
|
|
1130
|
-
exists: "Field exists (true/false)",
|
|
1131
|
-
size: "Array size equals",
|
|
1132
|
-
type: "BSON type check"
|
|
1133
|
-
};
|
|
1134
|
-
for (const [op, mongoOp] of operators) lines.push(` ${op} → ${mongoOp}: ${desc[op] || op}`);
|
|
1135
|
-
return lines.join("\n");
|
|
822
|
+
return ["Available filter operators (use as field[operator]=value):", ...operators.map((op) => ` ${op}: ${describeOperator(op)}`)].join("\n");
|
|
1136
823
|
}
|
|
1137
824
|
/**
|
|
1138
825
|
* Generate OpenAPI-compatible JSON Schema for query parameters.
|
|
@@ -1216,11 +903,11 @@ function buildQuerySchema(rt) {
|
|
|
1216
903
|
type: "string",
|
|
1217
904
|
description: `Filter by ${field} (exact match)`
|
|
1218
905
|
};
|
|
1219
|
-
for (const
|
|
906
|
+
for (const op of operators) {
|
|
1220
907
|
if (op === "eq") continue;
|
|
1221
908
|
properties[`${field}[${op}]`] = {
|
|
1222
909
|
type: operatorSchemaType(op),
|
|
1223
|
-
description:
|
|
910
|
+
description: `${field}: ${describeOperator(op)}`
|
|
1224
911
|
};
|
|
1225
912
|
}
|
|
1226
913
|
}
|
|
@@ -1246,39 +933,17 @@ function buildOpenAPIQuerySchema(rt) {
|
|
|
1246
933
|
//#endregion
|
|
1247
934
|
//#region src/query/parser/search.ts
|
|
1248
935
|
/**
|
|
1249
|
-
*
|
|
1250
|
-
*
|
|
1251
|
-
* patterns: special characters are escaped, not rejected.
|
|
936
|
+
* `?search=` — length-checked through the shared grammar. Regex mode matches the term as LITERAL
|
|
937
|
+
* text across `searchFields` (`c++` and `a.b` mean exactly those characters).
|
|
1252
938
|
*/
|
|
1253
|
-
/** Trim + length-cap the raw search term (reject over-long input per policy). */
|
|
1254
939
|
function sanitizeSearch(rt, search) {
|
|
1255
|
-
|
|
1256
|
-
let searchStr = String(search).trim();
|
|
1257
|
-
if (!searchStr) return void 0;
|
|
1258
|
-
if (searchStr.length > rt.options.maxSearchLength) {
|
|
1259
|
-
rt.reject(`Search query too long, truncating`, {
|
|
1260
|
-
length: searchStr.length,
|
|
1261
|
-
maxSearchLength: rt.options.maxSearchLength
|
|
1262
|
-
});
|
|
1263
|
-
searchStr = searchStr.substring(0, rt.options.maxSearchLength);
|
|
1264
|
-
}
|
|
1265
|
-
return searchStr;
|
|
940
|
+
return guarded(rt, () => readSearch(search, { maxSearchLength: rt.options.maxSearchLength }), void 0);
|
|
1266
941
|
}
|
|
1267
|
-
/**
|
|
1268
|
-
* Build regex-based multi-field search filters: an $or with case-insensitive
|
|
1269
|
-
* regex across all searchFields.
|
|
1270
|
-
*
|
|
1271
|
-
* @example
|
|
1272
|
-
* // searchFields: ['name', 'description', 'sku'], search: 'azure'
|
|
1273
|
-
* // Returns: [{ name: {$regex:/azure/i} }, { description: {...} }, { sku: {...} }]
|
|
1274
|
-
*/
|
|
1275
942
|
function buildRegexSearch(rt, searchTerm) {
|
|
1276
|
-
|
|
1277
|
-
|
|
1278
|
-
|
|
1279
|
-
|
|
1280
|
-
for (const field of rt.options.searchFields) orConditions.push({ [field]: { $regex: safeRegex } });
|
|
1281
|
-
return orConditions.length > 0 ? orConditions : null;
|
|
943
|
+
const fields = rt.options.searchFields;
|
|
944
|
+
if (!fields || fields.length === 0) return null;
|
|
945
|
+
const pattern = new RegExp(escapeRegex(searchTerm), "i");
|
|
946
|
+
return fields.map((field) => ({ [field]: { $regex: pattern } }));
|
|
1282
947
|
}
|
|
1283
948
|
//#endregion
|
|
1284
949
|
//#region src/query/QueryParser.ts
|
|
@@ -1293,8 +958,7 @@ function buildRegexSearch(rt, searchTerm) {
|
|
|
1293
958
|
* orchestration, and the OpenAPI schema surface. The implementation lives in
|
|
1294
959
|
* focused modules under `./parser/`:
|
|
1295
960
|
*
|
|
1296
|
-
* - `parser/filter-compiler` — filters
|
|
1297
|
-
* - `parser/regex-safety` — ReDoS protection (escape/reject)
|
|
961
|
+
* - `parser/filter-compiler` — filters (repo-core's shared grammar → Filter IR → Mongo), groups
|
|
1298
962
|
* - `parser/pipeline-sanitizer` — $match / $lookup-pipeline / expression sanitizing
|
|
1299
963
|
* - `parser/lookup` — ?lookup[...] parsing + collection allowlist
|
|
1300
964
|
* - `parser/aggregation` — ?aggregate[...] parsing (opt-in)
|
|
@@ -1319,9 +983,9 @@ function buildRegexSearch(rt, searchTerm) {
|
|
|
1319
983
|
* being silently dropped (which broadens the result set). Opt into
|
|
1320
984
|
* `'drop'` only for trusted compat tooling.
|
|
1321
985
|
* - Dangerous operators blocked everywhere ($where, $function, $accumulator, $expr).
|
|
1322
|
-
* -
|
|
1323
|
-
*
|
|
1324
|
-
* -
|
|
986
|
+
* - Filters, paging, sort and select follow repo-core's shared query grammar — the same
|
|
987
|
+
* contract as arc's parser and `parseUrl` (`runQueryGrammarConformance` holds all three).
|
|
988
|
+
* - Text operators and search are literal text; only `regex` is a pattern, checked for ReDoS.
|
|
1325
989
|
* - Lookup pipelines sanitized; `enableAggregations` is opt-in — keep it off
|
|
1326
990
|
* for public endpoints or pair with per-route allowlists.
|
|
1327
991
|
*
|
|
@@ -1366,11 +1030,13 @@ var QueryParser = class {
|
|
|
1366
1030
|
warn("[mongokit] searchMode \"regex\" requires searchFields to be specified. Falling back to \"text\" mode.");
|
|
1367
1031
|
resolved.searchMode = "text";
|
|
1368
1032
|
}
|
|
1033
|
+
const fieldTypes = buildFieldTypeMap(options.schema, options.fieldTypes);
|
|
1369
1034
|
this.rt = {
|
|
1370
1035
|
options: resolved,
|
|
1371
|
-
|
|
1036
|
+
urlOperators: [...URL_OPERATORS, ...MONGOKIT_EXTENSION_OPERATORS],
|
|
1372
1037
|
dangerousOperators: [...BASE_DANGEROUS_OPERATORS, ...resolved.additionalDangerousOperators],
|
|
1373
|
-
fieldTypes
|
|
1038
|
+
fieldTypes,
|
|
1039
|
+
grammarFieldTypes: toGrammarFieldTypes(fieldTypes),
|
|
1374
1040
|
reject: createReject(resolved.invalidInput)
|
|
1375
1041
|
};
|
|
1376
1042
|
this._schemaIndexes = extractSchemaIndexes(options.schema);
|
|
@@ -1434,67 +1100,45 @@ var QueryParser = class {
|
|
|
1434
1100
|
*/
|
|
1435
1101
|
parse(query) {
|
|
1436
1102
|
const rt = this.rt;
|
|
1437
|
-
const
|
|
1438
|
-
|
|
1439
|
-
|
|
1440
|
-
|
|
1441
|
-
|
|
1442
|
-
|
|
1443
|
-
|
|
1444
|
-
|
|
1445
|
-
|
|
1446
|
-
|
|
1447
|
-
* became `-createdAt` and was then validated exactly like caller input. With
|
|
1448
|
-
* `allowedSortFields` set and `createdAt` not among them, the parser
|
|
1449
|
-
* REJECTED ITS OWN DEFAULT — and since 3.25 rejection throws, so `parse({})`
|
|
1450
|
-
* threw on an empty query. Every list call on such a resource answered 400
|
|
1451
|
-
* (`Blocked sort field not in allowlist: createdAt`), REST and MCP alike,
|
|
1452
|
-
* and no request the caller could make would avoid it.
|
|
1453
|
-
*
|
|
1454
|
-
* A default the resource forbade must simply not be applied: it is not a
|
|
1455
|
-
* caller error, and there is nobody to report it to. A sort the caller DID
|
|
1456
|
-
* send still rejects — that is the fail-closed behaviour 3.25 added, and it
|
|
1457
|
-
* is deliberately unchanged.
|
|
1458
|
-
*/
|
|
1103
|
+
const q = query ?? {};
|
|
1104
|
+
const { page, limit, sort, populate, search, after, cursor, before, mode, select, lookup, aggregate } = q;
|
|
1105
|
+
const pageRequest = guarded(rt, () => readPageRequest({
|
|
1106
|
+
page,
|
|
1107
|
+
limit,
|
|
1108
|
+
after,
|
|
1109
|
+
cursor,
|
|
1110
|
+
before,
|
|
1111
|
+
mode
|
|
1112
|
+
}, { maxLimit: rt.options.maxLimit }), readPageRequest({}, { maxLimit: rt.options.maxLimit }));
|
|
1459
1113
|
const effectiveSort = sort === void 0 && isSortFieldAllowed(rt, "-createdAt") ? DEFAULT_PARSER_SORT : sort;
|
|
1460
1114
|
const sanitizedSearch = sanitizeSearch(rt, search);
|
|
1461
1115
|
const { simplePopulate, populateOptions } = parsePopulate(rt, populate);
|
|
1462
1116
|
const parsed = {
|
|
1463
|
-
filters:
|
|
1464
|
-
limit:
|
|
1117
|
+
filters: compileUrlFilters(rt, q),
|
|
1118
|
+
limit: pageRequest.limit,
|
|
1465
1119
|
sort: parseSort(rt, effectiveSort),
|
|
1466
1120
|
populate: simplePopulate,
|
|
1467
1121
|
populateOptions,
|
|
1468
1122
|
search: sanitizedSearch
|
|
1469
1123
|
};
|
|
1470
1124
|
if (sanitizedSearch && rt.options.searchMode === "regex" && rt.options.searchFields) {
|
|
1471
|
-
const
|
|
1472
|
-
if (
|
|
1473
|
-
|
|
1474
|
-
parsed.filters = {
|
|
1475
|
-
...parsed.filters,
|
|
1476
|
-
$and: [{ $or: parsed.filters.$or }, { $or: regexSearchFilters }]
|
|
1477
|
-
};
|
|
1478
|
-
delete parsed.filters.$or;
|
|
1479
|
-
} else parsed.filters.$or = regexSearchFilters;
|
|
1125
|
+
const regexSearch = buildRegexSearch(rt, sanitizedSearch);
|
|
1126
|
+
if (regexSearch) {
|
|
1127
|
+
parsed.filters = attachCondition(parsed.filters, { $or: regexSearch });
|
|
1480
1128
|
parsed.search = void 0;
|
|
1481
1129
|
}
|
|
1482
1130
|
}
|
|
1483
|
-
|
|
1131
|
+
const groups = compileGroups(rt, q);
|
|
1132
|
+
if (groups.$or) parsed.filters = attachCondition(parsed.filters, { $or: groups.$or });
|
|
1133
|
+
if (groups.$and) parsed.filters = attachCondition(parsed.filters, { $and: groups.$and });
|
|
1134
|
+
const projection = parseSelect(rt, select);
|
|
1135
|
+
if (projection) parsed.select = projection;
|
|
1484
1136
|
if (rt.options.enableLookups && lookup) parsed.lookups = parseLookups(rt, lookup);
|
|
1485
1137
|
if (rt.options.enableAggregations && aggregate) parsed.aggregation = parseAggregation(rt, aggregate);
|
|
1486
|
-
if (after
|
|
1487
|
-
|
|
1488
|
-
if (
|
|
1489
|
-
|
|
1490
|
-
if (orGroup) {
|
|
1491
|
-
if (parsed.filters.$or) {
|
|
1492
|
-
const existingOr = parsed.filters.$or;
|
|
1493
|
-
delete parsed.filters.$or;
|
|
1494
|
-
parsed.filters.$and = [{ $or: existingOr }, { $or: orGroup }];
|
|
1495
|
-
} else parsed.filters.$or = orGroup;
|
|
1496
|
-
}
|
|
1497
|
-
parsed.filters = enhanceWithBetween(rt, parsed.filters);
|
|
1138
|
+
if (pageRequest.after !== void 0) parsed.after = pageRequest.after;
|
|
1139
|
+
if (pageRequest.before !== void 0) parsed.before = pageRequest.before;
|
|
1140
|
+
if (pageRequest.mode !== void 0) parsed.mode = pageRequest.mode;
|
|
1141
|
+
if (pageRequest.page !== void 0) parsed.page = pageRequest.page;
|
|
1498
1142
|
return parsed;
|
|
1499
1143
|
}
|
|
1500
1144
|
/**
|
|
@@ -1515,6 +1159,29 @@ var QueryParser = class {
|
|
|
1515
1159
|
return buildOpenAPIQuerySchema(this.rt);
|
|
1516
1160
|
}
|
|
1517
1161
|
};
|
|
1162
|
+
/**
|
|
1163
|
+
* AND a top-level condition (`{ $or }` / `{ $and }`) into `filters`: alongside when its key is
|
|
1164
|
+
* free, otherwise both go under one `$and` so neither overwrites the other.
|
|
1165
|
+
*/
|
|
1166
|
+
function attachCondition(filters, condition) {
|
|
1167
|
+
const [key] = Object.keys(condition);
|
|
1168
|
+
if (!(key in filters)) return {
|
|
1169
|
+
...filters,
|
|
1170
|
+
...condition
|
|
1171
|
+
};
|
|
1172
|
+
const { [key]: existing, ...rest } = filters;
|
|
1173
|
+
const prior = key === "$and" ? existing : [{ [key]: existing }];
|
|
1174
|
+
const next = key === "$and" ? condition.$and : [condition];
|
|
1175
|
+
const and = rest.$and ? [
|
|
1176
|
+
...rest.$and,
|
|
1177
|
+
...prior,
|
|
1178
|
+
...next
|
|
1179
|
+
] : [...prior, ...next];
|
|
1180
|
+
return {
|
|
1181
|
+
...rest,
|
|
1182
|
+
$and: and
|
|
1183
|
+
};
|
|
1184
|
+
}
|
|
1518
1185
|
//#endregion
|
|
1519
1186
|
//#region src/utils/repo-options.ts
|
|
1520
1187
|
/**
|
|
@@ -1562,31 +1229,21 @@ var QueryParser = class {
|
|
|
1562
1229
|
* });
|
|
1563
1230
|
* ```
|
|
1564
1231
|
*
|
|
1565
|
-
*
|
|
1566
|
-
*
|
|
1232
|
+
* It is `forwardScope` (see `./scope.ts`), the same rule every internal
|
|
1233
|
+
* hop uses, so a request context and a cascade forward identically:
|
|
1567
1234
|
*
|
|
1568
|
-
* - `organizationId` —
|
|
1569
|
-
*
|
|
1570
|
-
*
|
|
1571
|
-
* - `
|
|
1572
|
-
*
|
|
1573
|
-
*
|
|
1574
|
-
* - `
|
|
1235
|
+
* - the tenant (`organizationId`, `tenantId`) — `multiTenantPlugin` casts
|
|
1236
|
+
* to `ObjectId` itself when configured with `fieldType: 'objectId'`;
|
|
1237
|
+
* do NOT pre-cast;
|
|
1238
|
+
* - `bypassTenant`, only when literally `true` AND no tenant is present
|
|
1239
|
+
* (the tenant plugin honours a bypass before a tenant, so carrying both
|
|
1240
|
+
* would widen a scoped call to every tenant);
|
|
1241
|
+
* - `session`, and `user` / `userId` / `requestId` for attribution.
|
|
1575
1242
|
*
|
|
1576
|
-
*
|
|
1577
|
-
* `repoOptionsFromCtx` deliberately stays narrow so adding a new
|
|
1578
|
-
* convention to the canonical set is a deliberate API decision, not
|
|
1579
|
-
* a quiet drift.
|
|
1243
|
+
* Domain fields beyond this set belong to `createOptionsExtractor`.
|
|
1580
1244
|
*/
|
|
1581
1245
|
function repoOptionsFromCtx(ctx) {
|
|
1582
|
-
|
|
1583
|
-
const out = {};
|
|
1584
|
-
if (ctx.organizationId !== void 0) out.organizationId = ctx.organizationId;
|
|
1585
|
-
if (ctx.userId !== void 0) out.userId = ctx.userId;
|
|
1586
|
-
if (ctx.user !== void 0) out.user = ctx.user;
|
|
1587
|
-
if (ctx.session !== void 0) out.session = ctx.session;
|
|
1588
|
-
if (ctx.requestId !== void 0) out.requestId = ctx.requestId;
|
|
1589
|
-
return out;
|
|
1246
|
+
return forwardScope(ctx);
|
|
1590
1247
|
}
|
|
1591
1248
|
/**
|
|
1592
1249
|
* Build a typed extractor for **consumer-defined** ctx → options
|
|
@@ -1608,12 +1265,11 @@ function repoOptionsFromCtx(ctx) {
|
|
|
1608
1265
|
* output keys are inferred from the input field tuple — you can
|
|
1609
1266
|
* destructure with full autocomplete.
|
|
1610
1267
|
*
|
|
1611
|
-
* **
|
|
1612
|
-
*
|
|
1613
|
-
* `
|
|
1614
|
-
*
|
|
1615
|
-
*
|
|
1616
|
-
* `idempotencyKey`, `sagaRunId`, ...) that should also forward.
|
|
1268
|
+
* **Scope is always included, and never from `fields`.** The extractor
|
|
1269
|
+
* returns `forwardScope(ctx)` plus the declared DOMAIN fields. Listing a
|
|
1270
|
+
* scope key (`organizationId`, `bypassTenant`, `session`, …) in `fields`
|
|
1271
|
+
* changes nothing: those follow the one scope rule, so an extractor cannot
|
|
1272
|
+
* forget the bypass, or carry a bypass beside a tenant.
|
|
1617
1273
|
*
|
|
1618
1274
|
* @example Per-package canonical extractor
|
|
1619
1275
|
* ```ts
|
|
@@ -1628,12 +1284,12 @@ function repoOptionsFromCtx(ctx) {
|
|
|
1628
1284
|
* idempotencyKey?: string;
|
|
1629
1285
|
* };
|
|
1630
1286
|
*
|
|
1287
|
+
* // Domain fields only: the scope (organizationId, session, bypass) is
|
|
1288
|
+
* // always included by the one scope rule.
|
|
1631
1289
|
* export const repoOptionsFromCtx = createOptionsExtractor<CommissionCtx>([
|
|
1632
|
-
* 'organizationId',
|
|
1633
1290
|
* 'actorRef',
|
|
1634
1291
|
* 'actorKind',
|
|
1635
1292
|
* 'correlationId',
|
|
1636
|
-
* 'session',
|
|
1637
1293
|
* 'idempotencyKey',
|
|
1638
1294
|
* ]);
|
|
1639
1295
|
*
|
|
@@ -1649,11 +1305,11 @@ function repoOptionsFromCtx(ctx) {
|
|
|
1649
1305
|
* options bag never erases inherited values).
|
|
1650
1306
|
*/
|
|
1651
1307
|
function createOptionsExtractor(fields) {
|
|
1652
|
-
const
|
|
1308
|
+
const domainFields = Object.freeze(fields.filter((field) => !isScopeKey(field)));
|
|
1653
1309
|
return function extractRepoOptions(ctx) {
|
|
1654
1310
|
if (!ctx) return {};
|
|
1655
|
-
const out =
|
|
1656
|
-
for (const field of
|
|
1311
|
+
const out = forwardScope(ctx);
|
|
1312
|
+
for (const field of domainFields) {
|
|
1657
1313
|
const v = ctx[field];
|
|
1658
1314
|
if (v !== void 0) out[field] = v;
|
|
1659
1315
|
}
|
|
@@ -1781,4 +1437,4 @@ function systemContext(extra) {
|
|
|
1781
1437
|
*/
|
|
1782
1438
|
var src_default = Repository;
|
|
1783
1439
|
//#endregion
|
|
1784
|
-
export { ALL_OPERATIONS, AggregationBuilder, AuditTrailQuery, HOOK_PRIORITY, LookupBuilder, MONGOKIT_CAPABILITIES, MONGOKIT_PLUGIN_NAMES, MUTATING_OPERATIONS, ModelCollisionError, OP_REGISTRY, PaginationEngine, QueryParser, READ_OPERATIONS, Repository, actions_exports as actions, adminBypass, aggregateHelpersPlugin, appendOnlyPlugin, auditLogPlugin, auditTrailPlugin, autoInject, batchOperationsPlugin, batchTransaction, blockIf, buildCrudSchemasFromModel, buildCrudSchemasFromMongooseSchema, cachePlugin, capabilitiesForSupport, cascadePlugin, cascadePurgeReferences, classifyHelloReply, compileFilterToMongo, configureLogger, configurePaginationDefaults, createError, createFieldPreset, createMemoryCache, createOptionsExtractor, createRepository, createTenantContext, customIdPlugin, dateSequentialId, src_default as default, defineModel, defineModels, elasticSearchPlugin, ensureAuditTrailReady, fieldFilterPlugin, filterResponseData, getFieldsForUser, getMongooseProjection, getNextSequence, getPaginationDefaults, idVariants, idempotencyStorePlugin, immutableField, immutableStatesPlugin, isCreateManyPartialError, isDuplicateKeyError, isModelRegistered, isTransactionUnsupported, leasePlugin, methodRegistryPlugin, mongoOperationsPlugin, multiTenantPlugin, observabilityPlugin, operationsByPolicyKey, parseDuplicateKeyError, prefixedId, probeMongoCapabilities, repoOptionsFromCtx, requireField, resetCapabilityProbeCache, resetPaginationDefaults, resolveMongoCapabilities, resolveTransactionSupport, sequentialId, softDeletePlugin, subdocumentPlugin, supportsTransactions, systemContext, timestampPlugin, toPlain, transactionResolutionOf, uniqueField, validationChainPlugin, withTransaction };
|
|
1440
|
+
export { ALL_OPERATIONS, AggregationBuilder, AuditTrailQuery, HOOK_PRIORITY, JOIN_ERROR_CODES, LookupBuilder, MONGOKIT_CAPABILITIES, MONGOKIT_PLUGIN_NAMES, MUTATING_OPERATIONS, ModelCollisionError, OP_REGISTRY, PAGINATION_ERROR_CODES, PaginationEngine, QUERY_DEFAULTS_ERROR_CODES, QueryParser, READ_OPERATIONS, Repository, actions_exports as actions, adminBypass, aggregateHelpersPlugin, appendOnlyPlugin, assertQueryDefaultsConfigured, auditLogPlugin, auditTrailPlugin, autoInject, batchOperationsPlugin, batchTransaction, blockIf, buildCrudSchemasFromModel, buildCrudSchemasFromMongooseSchema, cachePlugin, capabilitiesForSupport, cascadePlugin, cascadePurgeReferences, classifyHelloReply, compileFilterToMongo, configureAggregateDefaults, configureLogger, configurePaginationDefaults, configureQueryDefaults, createError, createFieldPreset, createMemoryCache, createOptionsExtractor, createRepository, createTenantContext, customIdPlugin, dateSequentialId, declareCollectionScope, declareTenantContextKey, src_default as default, defineModel, defineModels, elasticSearchPlugin, ensureAuditTrailReady, ensureSequenceAtLeast, fieldFilterPlugin, filterResponseData, forwardScope, getFieldsForUser, getMongooseProjection, getNextSequence, getPaginationDefaults, getQueryDefaults, idVariants, idempotencyStorePlugin, immutableField, immutableStatesPlugin, isCreateManyPartialError, isDuplicateKeyError, isModelRegistered, isTransactionUnsupported, keysetSource, leasePlugin, methodRegistryPlugin, mongoOperationsPlugin, multiTenantPlugin, observabilityPlugin, operationsByPolicyKey, parseDuplicateKeyError, prefixedId, probeMongoCapabilities, readSequence, repoOptionsFromCtx, requireField, resetCapabilityProbeCache, resetPaginationDefaults, resetQueryDefaults, resolveMongoCapabilities, resolveQueryOptions, resolveTransactionSupport, sequentialId, softDeletePlugin, subdocumentPlugin, supportsTransactions, systemContext, tenantContextKeysOf, timestampPlugin, toPlain, transactionResolutionOf, uniqueField, validationChainPlugin, withTransaction };
|