@classytic/mongokit 3.6.3 → 3.7.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 (33) hide show
  1. package/dist/{PaginationEngine-CunXkR9p.mjs → PaginationEngine-CPmZv7Ds.mjs} +8 -5
  2. package/dist/actions/index.d.mts +1 -1
  3. package/dist/actions/index.mjs +1 -1
  4. package/dist/ai/index.d.mts +23 -1
  5. package/dist/ai/index.mjs +194 -12
  6. package/dist/{cursor-CHToazHy.mjs → cursor-DZ9JSoMM.mjs} +11 -7
  7. package/dist/error-Lw9GEAro.mjs +46 -0
  8. package/dist/{index-BSRSsRRx.d.mts → index-C-qZUqv8.d.mts} +1 -1
  9. package/dist/index.d.mts +31 -9
  10. package/dist/index.mjs +53 -12
  11. package/dist/logger-DJ7egMnd.mjs +30 -0
  12. package/dist/{mongooseToJsonSchema-MlaD3C0B.mjs → mongooseToJsonSchema-DUwXPStK.mjs} +25 -21
  13. package/dist/{mongooseToJsonSchema-D2YjHo7W.d.mts → mongooseToJsonSchema-Du-UopRK.d.mts} +18 -2
  14. package/dist/pagination/PaginationEngine.d.mts +3 -1
  15. package/dist/pagination/PaginationEngine.mjs +1 -1
  16. package/dist/plugins/index.d.mts +2 -2
  17. package/dist/plugins/index.mjs +2 -2
  18. package/dist/query/primitives/coercion.d.mts +1 -1
  19. package/dist/query/primitives/coercion.mjs +1 -1
  20. package/dist/query/primitives/indexes.d.mts +1 -1
  21. package/dist/{sort-C-BJEWUZ.mjs → sort-ffe-9JBg.mjs} +19 -4
  22. package/dist/{types-DiQfDesD.d.mts → types-BgdeBXI1.d.mts} +68 -0
  23. package/dist/{update-AVfKWNGt.mjs → update-BDsQ3cdy.mjs} +2 -1
  24. package/dist/utils/index.d.mts +2 -2
  25. package/dist/utils/index.mjs +5 -4
  26. package/dist/{validation-chain.plugin-BsFzKVnO.d.mts → validation-chain.plugin-Be9rG04B.d.mts} +25 -2
  27. package/dist/{validation-chain.plugin-7A5qGEQq.mjs → validation-chain.plugin-CEyJssZl.mjs} +129 -17
  28. package/package.json +8 -3
  29. package/dist/error-Bpbi_NKo.mjs +0 -63
  30. /package/dist/{coercion-B19MD38s.d.mts → coercion-oM8G4b6m.d.mts} +0 -0
  31. /package/dist/{field-selection-C5o7jyyA.mjs → field-selection-dI0m4RBs.mjs} +0 -0
  32. /package/dist/{id-resolution-LZ-cCwR5.mjs → id-resolution-e4BT9zAV.mjs} +0 -0
  33. /package/dist/{indexes-qIBCZ3mY.d.mts → indexes-C26GNZRU.d.mts} +0 -0
@@ -1,6 +1,7 @@
1
- import { a as warn, t as createError } from "./error-Bpbi_NKo.mjs";
2
- import { n as encodeCursor, r as resolveCursorFilter } from "./cursor-CHToazHy.mjs";
3
- import { r as validateKeysetSort, t as getPrimaryField } from "./sort-C-BJEWUZ.mjs";
1
+ import { r as warn } from "./logger-DJ7egMnd.mjs";
2
+ import { t as createError } from "./error-Lw9GEAro.mjs";
3
+ import { n as encodeCursor, r as resolveCursorFilter } from "./cursor-DZ9JSoMM.mjs";
4
+ import { r as validateKeysetSort, t as getPrimaryField } from "./sort-ffe-9JBg.mjs";
4
5
  //#region src/pagination/utils/index-hint.ts
5
6
  /**
6
7
  * Read a Mongoose schema's declared indexes, defensively.
@@ -197,6 +198,8 @@ var PaginationEngine = class {
197
198
  maxPage: config.maxPage ?? 1e4,
198
199
  deepPageThreshold: config.deepPageThreshold ?? 100,
199
200
  cursorVersion: config.cursorVersion ?? 1,
201
+ minCursorVersion: config.minCursorVersion ?? 1,
202
+ strictKeysetSortFields: config.strictKeysetSortFields,
200
203
  useEstimatedCount: config.useEstimatedCount ?? false
201
204
  };
202
205
  }
@@ -298,7 +301,7 @@ var PaginationEngine = class {
298
301
  const { filters = {}, sort, after, limit = this.config.defaultLimit, select, populate = [], lean = true, session, hint, maxTimeMS, readPreference, collation } = options;
299
302
  if (!sort) throw createError(400, "sort is required for keyset pagination");
300
303
  const sanitizedLimit = validateLimit(limit, this.config);
301
- const normalizedSort = validateKeysetSort(sort);
304
+ const normalizedSort = validateKeysetSort(sort, this.config.strictKeysetSortFields);
302
305
  const sortWithoutIdTail = stripTrailingIdTiebreaker(normalizedSort);
303
306
  const filterKeys = Object.keys(filters).filter((k) => !k.startsWith("$"));
304
307
  const effectiveSortFields = Object.keys(sortWithoutIdTail);
@@ -307,7 +310,7 @@ var PaginationEngine = class {
307
310
  warn(`[mongokit] Keyset pagination with filters [${filterKeys.join(", ")}] and sort [${effectiveSortFields.join(", ")}] has no matching schema-declared compound index. For O(1) performance, declare: { ${indexFields.join(", ")} }. (Collection-level indexes created outside the schema are not visible here.)`);
308
311
  }
309
312
  let query = { ...filters };
310
- if (after) query = resolveCursorFilter(after, normalizedSort, this.config.cursorVersion, query);
313
+ if (after) query = resolveCursorFilter(after, normalizedSort, this.config.cursorVersion, query, this.config.minCursorVersion);
311
314
  const effectiveSelect = ensureKeysetSelectIncludesCursorFields(select, normalizedSort);
312
315
  let mongoQuery = this.Model.find(query);
313
316
  if (effectiveSelect) mongoQuery = mongoQuery.select(effectiveSelect);
@@ -1,2 +1,2 @@
1
- import { a as create_d_exports, i as delete_d_exports, n as update_d_exports, o as aggregate_d_exports, r as read_d_exports } from "../index-BSRSsRRx.mjs";
1
+ import { a as create_d_exports, i as delete_d_exports, n as update_d_exports, o as aggregate_d_exports, r as read_d_exports } from "../index-C-qZUqv8.mjs";
2
2
  export { aggregate_d_exports as aggregate, create_d_exports as create, delete_d_exports as deleteActions, read_d_exports as read, update_d_exports as update };
@@ -1,5 +1,5 @@
1
1
  import { t as __exportAll } from "../chunk-CfYAbeIz.mjs";
2
- import { _ as aggregate_exports, f as delete_exports, h as create_exports, l as read_exports, r as update_exports } from "../update-AVfKWNGt.mjs";
2
+ import { _ as aggregate_exports, f as delete_exports, h as create_exports, l as read_exports, r as update_exports } from "../update-BDsQ3cdy.mjs";
3
3
  //#region src/actions/index.ts
4
4
  var actions_exports = /* @__PURE__ */ __exportAll({
5
5
  aggregate: () => aggregate_exports,
@@ -1,4 +1,4 @@
1
- import { J as Plugin } from "../types-DiQfDesD.mjs";
1
+ import { J as Plugin } from "../types-BgdeBXI1.mjs";
2
2
  import { ClientSession, PipelineStage } from "mongoose";
3
3
 
4
4
  //#region src/ai/types.d.ts
@@ -106,6 +106,28 @@ interface VectorPluginOptions {
106
106
  * If not provided, the error propagates and blocks the write.
107
107
  */
108
108
  onEmbedError?: (error: Error, doc: unknown) => void;
109
+ /**
110
+ * SSRF defense for media URLs (`image`, `audio`, entries in `media`).
111
+ *
112
+ * If set, only URLs whose origin (`scheme://host[:port]`) matches one of
113
+ * these entries are forwarded to `embedFn`. Entries can be exact origins
114
+ * (`https://cdn.example.com`) or wildcard hosts (`https://*.example.com`).
115
+ * When the allowlist is active, URLs failing validation are dropped with a
116
+ * warning and the doc is embedded without them (never passed to embedFn).
117
+ *
118
+ * Undefined (default) = no allowlist check — same as prior behavior.
119
+ */
120
+ allowedMediaOrigins?: string[];
121
+ /**
122
+ * When true, reject URLs that resolve to a private, loopback, or
123
+ * link-local IP literal (e.g., `http://169.254.169.254/…` cloud metadata,
124
+ * `http://127.0.0.1`, RFC1918 addresses). Hostnames that later resolve to
125
+ * private IPs via DNS are NOT caught here — that rebinding class of SSRF
126
+ * must be handled in your network/egress layer.
127
+ *
128
+ * Default: `false` (legacy behavior). Flip to `true` for production setups.
129
+ */
130
+ blockPrivateIpUrls?: boolean;
109
131
  }
110
132
  //#endregion
111
133
  //#region src/ai/vector.plugin.d.ts
package/dist/ai/index.mjs CHANGED
@@ -1,3 +1,163 @@
1
+ import { r as warn } from "../logger-DJ7egMnd.mjs";
2
+ //#region src/ai/url-policy.ts
3
+ /**
4
+ * URL policy helpers for the vector plugin media pipeline.
5
+ *
6
+ * Protects callers from passing hostile document URLs straight into an
7
+ * embedding service that may fetch them. The policy is pure / side-effect-
8
+ * free so it can be reused by anyone composing their own embed pipeline.
9
+ */
10
+ /**
11
+ * Returns true when the string is a safe media URL under the given policy.
12
+ *
13
+ * - Non-URL strings (data URLs, base64 blobs, relative paths) are accepted —
14
+ * they never trigger an outbound HTTP fetch, so SSRF isn't a concern.
15
+ * - `data:` URLs are accepted.
16
+ * - `http` / `https` URLs must pass:
17
+ * a) origin allowlist (if `allowedOrigins` is set)
18
+ * b) private-IP rejection (if `blockPrivateIpUrls` is true)
19
+ *
20
+ * Any parse failure returns `true` only when strict mode is off — a string
21
+ * that doesn't parse as a URL cannot be used to SSRF either. In strict mode
22
+ * (either option set), unparseable http(s)-looking strings are rejected.
23
+ */
24
+ function isMediaUrlAllowed(value, policy = {}) {
25
+ if (typeof value !== "string" || value.length === 0) return false;
26
+ if (value.startsWith("data:")) return true;
27
+ if (!/^https?:\/\//i.test(value)) return true;
28
+ let url;
29
+ try {
30
+ url = new URL(value);
31
+ } catch {
32
+ return false;
33
+ }
34
+ if (policy.blockPrivateIpUrls && isPrivateOrLocalHost(url.hostname)) return false;
35
+ if (policy.allowedOrigins && policy.allowedOrigins.length > 0) {
36
+ const origin = `${url.protocol}//${url.host}`;
37
+ if (!policy.allowedOrigins.some((entry) => matchesOriginEntry(origin, entry, url))) return false;
38
+ }
39
+ return true;
40
+ }
41
+ function matchesOriginEntry(origin, entry, url) {
42
+ if (!entry) return false;
43
+ if (entry === origin) return true;
44
+ const wildcardMatch = entry.match(/^(https?:)\/\/\*\.(.+)$/i);
45
+ if (wildcardMatch) {
46
+ const [, entryProtocol, suffix] = wildcardMatch;
47
+ if (url.protocol.toLowerCase() !== entryProtocol.toLowerCase()) return false;
48
+ return url.hostname.toLowerCase().endsWith("." + suffix.toLowerCase());
49
+ }
50
+ return false;
51
+ }
52
+ /**
53
+ * Literal-IP private/loopback/link-local checks. Does NOT perform DNS
54
+ * resolution — callers who need rebinding-proof behavior must handle that
55
+ * at the HTTP layer.
56
+ */
57
+ function isPrivateOrLocalHost(hostname) {
58
+ const h = hostname.toLowerCase().replace(/^\[|\]$/g, "");
59
+ if (h === "::1" || h.startsWith("fe80:") || h.startsWith("fc") || h.startsWith("fd")) return true;
60
+ if (h === "localhost" || h === "localhost.localdomain") return true;
61
+ const ipv4 = h.match(/^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/);
62
+ if (!ipv4) return false;
63
+ const octets = ipv4.slice(1).map(Number);
64
+ if (octets.some((o) => o < 0 || o > 255)) return false;
65
+ const [a, b] = octets;
66
+ if (a === 10) return true;
67
+ if (a === 127) return true;
68
+ if (a === 169 && b === 254) return true;
69
+ if (a === 172 && b >= 16 && b <= 31) return true;
70
+ if (a === 192 && b === 168) return true;
71
+ if (a === 0) return true;
72
+ if (a === 100 && b >= 64 && b <= 127) return true;
73
+ return false;
74
+ }
75
+ //#endregion
76
+ //#region src/ai/vector-error-hints.ts
77
+ /**
78
+ * Pure classifier. Takes the raw error and the context the plugin knows
79
+ * (index name, dimensions, filter paths it tried to use) and returns a hint.
80
+ *
81
+ * Returns `{ code: 'UNKNOWN', ... }` when no pattern matches — callers
82
+ * should then fall back to rethrowing the original.
83
+ */
84
+ function classifyVectorSearchError(err, ctx) {
85
+ const lower = extractErrorMessage(err).toLowerCase();
86
+ if (lower.includes("unrecognized pipeline stage name: '$vectorsearch'") || lower.includes("unrecognized pipeline stage name: \"$vectorsearch\"") || lower.includes("$vectorsearch is only supported") || lower.includes("$vectorsearch stage is only allowed") || lower.includes("$vectorsearch is only allowed") || lower.includes("unknown top level operator: $vectorsearch") || lower.includes("$vectorsearch") && lower.includes("atlas") && (lower.includes("only") || lower.includes("require"))) return {
87
+ code: "NOT_ATLAS",
88
+ summary: "MongoDB did not recognize the `$vectorSearch` stage. This stage is Atlas-only — it doesn't exist on standalone MongoDB, self-hosted Community/Enterprise, or mongodb-memory-server.",
89
+ hints: [
90
+ "Run against MongoDB Atlas — create a cluster and point your app at its connection string.",
91
+ "If you're testing locally, use the integration suite that mocks `$vectorSearch` (see tests/integration/vector-*.test.ts) rather than calling `searchSimilar` directly.",
92
+ "Atlas Free tier (M0) supports Vector Search as of late 2024; older free tiers do not."
93
+ ]
94
+ };
95
+ if (lower.includes("$vectorsearch") && lower.includes("index") || lower.includes("couldn't find index") || lower.includes("search index") && lower.includes("not found") || lower.includes("no such index") || lower.includes("index") && lower.includes(ctx.indexName.toLowerCase())) return {
96
+ code: "INDEX_NOT_FOUND",
97
+ summary: `Atlas reported the vector search index "${ctx.indexName}" is not available on the target collection.`,
98
+ hints: [
99
+ `Verify an index named "${ctx.indexName}" exists on the collection in the Atlas UI (Search → Indexes) or via \`db.collection.listSearchIndexes()\`.`,
100
+ `If you just created the index, wait until it reports \`queryable: true\`. Builds typically take 1–3 minutes on new clusters.`,
101
+ `The index's \`numDimensions\` must match your vector config (${ctx.dimensions}), and \`path\` must match the field name.`
102
+ ]
103
+ };
104
+ if (lower.includes("not a supported filter field") || lower.includes("is not a filter field") || lower.includes("filter") && lower.includes("path") && lower.includes("not") || lower.includes("unknown filter path")) return {
105
+ code: "FILTER_FIELD_NOT_INDEXED",
106
+ summary: "Atlas rejected the `filter` clause because at least one of its paths is not declared as a `filter`-typed field in the vector search index.",
107
+ hints: [
108
+ `Add every path you filter on as \`{ type: "filter", path: "<field>" }\` in the index definition.`,
109
+ `Paths passed in this call: ${ctx.filterPaths?.length ? ctx.filterPaths.join(", ") : "(none declared)"}.`,
110
+ `Example for multi-tenant: index must include \`{ type: "filter", path: "tenantId" }\` when you call \`searchSimilar({ filter: { tenantId: "..." } })\`.`
111
+ ]
112
+ };
113
+ if (lower.includes("dimension") && (lower.includes("mismatch") || lower.includes("expected"))) return {
114
+ code: "DIMENSION_MISMATCH",
115
+ summary: "Atlas rejected the query vector — its length does not match the index definition.",
116
+ hints: [`Your vectorPlugin config says \`dimensions: ${ctx.dimensions}\`. The Atlas index for "${ctx.indexName}" must declare the same \`numDimensions\`.`, `If the index was created with a different dimension count, re-create it with the correct value (indexes are immutable for dimensions).`]
117
+ };
118
+ if (lower.includes("vectorsearch") && (lower.includes("requires") || lower.includes("expected") || lower.includes("missing"))) return {
119
+ code: "MALFORMED_PIPELINE",
120
+ summary: "The `$vectorSearch` stage is missing a required argument. This is almost always a mongokit bug — please file an issue with the full error text.",
121
+ hints: ["Capture the original error (`err.cause`) and attach it to the bug report."]
122
+ };
123
+ return {
124
+ code: "UNKNOWN",
125
+ summary: "",
126
+ hints: []
127
+ };
128
+ }
129
+ /**
130
+ * Wrap an error-producing promise so known-recognizable failures become
131
+ * mongokit errors with hint context. On unrecognized errors, rethrows the
132
+ * original untouched.
133
+ */
134
+ async function withVectorErrorHints(op, ctx) {
135
+ try {
136
+ return await op();
137
+ } catch (err) {
138
+ const hint = classifyVectorSearchError(err, ctx);
139
+ if (hint.code === "UNKNOWN") throw err;
140
+ const message = `[mongokit:vector] ${hint.summary}\n` + hint.hints.map((h) => ` • ${h}`).join("\n");
141
+ const wrapped = new Error(message, { cause: err });
142
+ wrapped.code = hint.code;
143
+ throw wrapped;
144
+ }
145
+ }
146
+ function extractErrorMessage(err) {
147
+ if (typeof err === "string") return err;
148
+ if (err && typeof err === "object") {
149
+ const e = err;
150
+ if (typeof e.message === "string") return e.message;
151
+ if (typeof e.errmsg === "string") return e.errmsg;
152
+ try {
153
+ return JSON.stringify(err);
154
+ } catch {
155
+ return String(err);
156
+ }
157
+ }
158
+ return String(err);
159
+ }
160
+ //#endregion
1
161
  //#region src/ai/vector.plugin.ts
2
162
  /** Maximum numCandidates allowed by Atlas Vector Search */
3
163
  const MAX_NUM_CANDIDATES = 1e4;
@@ -28,23 +188,35 @@ function getNestedValue(obj, path) {
28
188
  }, obj);
29
189
  }
30
190
  /**
31
- * Builds EmbeddingInput from document source fields
191
+ * Builds EmbeddingInput from document source fields.
192
+ *
193
+ * `urlPolicy` is applied to every media value that looks like an http(s)
194
+ * URL. Values that fail the policy are silently dropped (with a warn log)
195
+ * — never forwarded to the embedding service. Non-URL strings (base64,
196
+ * data URLs, file paths) are untouched.
32
197
  */
33
- function buildInputFromDoc(data, field) {
198
+ function buildInputFromDoc(data, field, urlPolicy = {}) {
34
199
  const input = {};
35
200
  if (field.sourceFields?.length) {
36
201
  const text = field.sourceFields.map((f) => getNestedValue(data, f)).filter(Boolean).join(" ");
37
202
  if (text.trim()) input.text = text;
38
203
  }
204
+ const policyActive = urlPolicy.allowedOrigins && urlPolicy.allowedOrigins.length > 0 || urlPolicy.blockPrivateIpUrls === true;
205
+ const screenMediaValue = (mf, value) => {
206
+ if (!policyActive) return value;
207
+ if (typeof value !== "string") return value;
208
+ if (isMediaUrlAllowed(value, urlPolicy)) return value;
209
+ warn(`[mongokit] vectorPlugin: blocked media URL in field "${mf}" by URL policy`);
210
+ };
39
211
  if (field.mediaFields?.length) {
40
212
  const firstImageField = field.mediaFields[0];
41
- const imageValue = getNestedValue(data, firstImageField);
42
- if (typeof imageValue === "string") input.image = imageValue;
213
+ const screenedImage = screenMediaValue(firstImageField, getNestedValue(data, firstImageField));
214
+ if (typeof screenedImage === "string") input.image = screenedImage;
43
215
  if (field.mediaFields.length > 1) {
44
216
  input.media = {};
45
217
  for (const mf of field.mediaFields) {
46
- const val = getNestedValue(data, mf);
47
- if (val != null) input.media[mf] = val;
218
+ const screened = screenMediaValue(mf, getNestedValue(data, mf));
219
+ if (screened != null) input.media[mf] = screened;
48
220
  }
49
221
  }
50
222
  }
@@ -88,7 +260,11 @@ function buildVectorSearchPipeline(field, queryVector, params) {
88
260
  function vectorPlugin(options) {
89
261
  const { fields, autoEmbed = false } = options;
90
262
  if (!fields?.length) throw new Error("[mongokit] vectorPlugin requires at least one field config");
91
- const { embedFn, batchEmbedFn } = options;
263
+ const { embedFn, batchEmbedFn, allowedMediaOrigins, blockPrivateIpUrls } = options;
264
+ const urlPolicy = {
265
+ allowedOrigins: allowedMediaOrigins,
266
+ blockPrivateIpUrls
267
+ };
92
268
  return {
93
269
  name: "vector",
94
270
  apply(repo) {
@@ -105,7 +281,11 @@ function vectorPlugin(options) {
105
281
  const pipeline = buildVectorSearchPipeline(field, queryVector, params);
106
282
  const agg = repo.Model.aggregate(pipeline);
107
283
  if (params.session) agg.session(params.session);
108
- return (await agg.exec()).map((doc) => {
284
+ return (await withVectorErrorHints(() => agg.exec(), {
285
+ indexName: field.index,
286
+ dimensions: field.dimensions,
287
+ filterPaths: params.filter ? Object.keys(params.filter) : void 0
288
+ })).map((doc) => {
109
289
  const score = doc._score ?? 0;
110
290
  const { _score, ...rest } = doc;
111
291
  return {
@@ -133,7 +313,7 @@ function vectorPlugin(options) {
133
313
  };
134
314
  const embedFromSource = async (data, field) => {
135
315
  if (data[field.path] && Array.isArray(data[field.path])) return;
136
- const input = buildInputFromDoc(data, field);
316
+ const input = buildInputFromDoc(data, field, urlPolicy);
137
317
  if (!hasContent(input)) return;
138
318
  const vector = await safeEmbed(input, data);
139
319
  if (vector) data[field.path] = vector;
@@ -143,7 +323,7 @@ function vectorPlugin(options) {
143
323
  for (let i = 0; i < dataArray.length; i++) {
144
324
  const data = dataArray[i];
145
325
  if (data[field.path] && Array.isArray(data[field.path])) continue;
146
- const input = buildInputFromDoc(data, field);
326
+ const input = buildInputFromDoc(data, field, urlPolicy);
147
327
  if (hasContent(input)) toEmbed.push({
148
328
  idx: i,
149
329
  input
@@ -181,7 +361,9 @@ function vectorPlugin(options) {
181
361
  return allFields.length > 0 && contextData && allFields.some((f) => f in contextData);
182
362
  });
183
363
  if (!fieldsToEmbed.length) return;
184
- const existing = await repo.Model.findById(context.id).lean().session(context.session ?? null);
364
+ const idField = repo.idField || "_id";
365
+ const lookupQuery = idField === "_id" ? { _id: context.id } : { [idField]: context.id };
366
+ const existing = await repo.Model.findOne(lookupQuery).lean().session(context.session ?? null);
185
367
  if (!existing) return;
186
368
  for (const field of fieldsToEmbed) {
187
369
  const merged = {
@@ -189,7 +371,7 @@ function vectorPlugin(options) {
189
371
  ...context.data
190
372
  };
191
373
  delete merged[field.path];
192
- const input = buildInputFromDoc(merged, field);
374
+ const input = buildInputFromDoc(merged, field, urlPolicy);
193
375
  if (!hasContent(input)) continue;
194
376
  const vector = await safeEmbed(input, merged);
195
377
  if (vector) context.data[field.path] = vector;
@@ -180,14 +180,18 @@ function validateCursorSort(cursorSort, currentSort) {
180
180
  if (JSON.stringify(cursorSort) !== JSON.stringify(currentSort)) throw new Error("Cursor sort does not match current query sort");
181
181
  }
182
182
  /**
183
- * Validates cursor version matches expected version
183
+ * Validates cursor version against the server's expected range.
184
184
  *
185
- * @param cursorVersion - Version from cursor
186
- * @param expectedVersion - Expected version from config
187
- * @throws Error if versions don't match
185
+ * - Cursors newer than `expectedVersion` are rejected (client ahead of server).
186
+ * - Cursors older than `minVersion` are rejected (client cached a cursor from
187
+ * a pre-breaking-change deploy). Default `minVersion = 1` keeps the legacy
188
+ * "accept anything <= expected" behavior; bump it when you ship a breaking
189
+ * cursor format change so old clients restart pagination cleanly instead of
190
+ * silently paginating from the wrong position.
188
191
  */
189
- function validateCursorVersion(cursorVersion, expectedVersion) {
192
+ function validateCursorVersion(cursorVersion, expectedVersion, minVersion = 1) {
190
193
  if (cursorVersion > expectedVersion) throw new Error(`Cursor version ${cursorVersion} is newer than expected version ${expectedVersion}. Please upgrade.`);
194
+ if (cursorVersion < minVersion) throw new Error(`Cursor version ${cursorVersion} is older than minimum supported ${minVersion}. Pagination must restart.`);
191
195
  }
192
196
  /**
193
197
  * Serializes a value for cursor storage
@@ -232,7 +236,7 @@ function rehydrateValue(serialized, type) {
232
236
  * - Base64-encoded cursor tokens (standard cursor)
233
237
  * - Cursor version and sort validation
234
238
  */
235
- function resolveCursorFilter(after, sort, cursorVersion, baseFilters = {}) {
239
+ function resolveCursorFilter(after, sort, cursorVersion, baseFilters = {}, minCursorVersion = 1) {
236
240
  if (/^[a-f0-9]{24}$/i.test(after)) {
237
241
  const objectId = new mongoose.Types.ObjectId(after);
238
242
  const idOperator = (sort._id || -1) === 1 ? "$gt" : "$lt";
@@ -242,7 +246,7 @@ function resolveCursorFilter(after, sort, cursorVersion, baseFilters = {}) {
242
246
  };
243
247
  }
244
248
  const cursor = decodeCursor(after);
245
- validateCursorVersion(cursor.version, cursorVersion);
249
+ validateCursorVersion(cursor.version, cursorVersion, minCursorVersion);
246
250
  validateCursorSort(cursor.sort, sort);
247
251
  return buildKeysetFilter(baseFilters, sort, cursor.value, cursor.id, cursor.values);
248
252
  }
@@ -0,0 +1,46 @@
1
+ //#region src/utils/error.ts
2
+ /**
3
+ * Creates an error with HTTP status code
4
+ *
5
+ * @param status - HTTP status code
6
+ * @param message - Error message
7
+ * @returns Error with status property
8
+ *
9
+ * @example
10
+ * throw createError(404, 'Document not found');
11
+ * throw createError(400, 'Invalid input');
12
+ * throw createError(403, 'Access denied');
13
+ */
14
+ function createError(status, message) {
15
+ const error = new Error(message);
16
+ error.status = status;
17
+ return error;
18
+ }
19
+ /**
20
+ * Detect and convert a MongoDB E11000 duplicate-key error into
21
+ * a 409 HttpError with an actionable message.
22
+ *
23
+ * PII-safe by default: the error message lists only the conflicting field
24
+ * names, never the values. Structured field names are attached under
25
+ * `error.duplicate.fields` for downstream handlers. Pass
26
+ * `{ exposeValues: true }` to opt into including the values (dev or trusted
27
+ * server-to-server contexts only).
28
+ *
29
+ * Returns `null` when the error is not a duplicate-key error.
30
+ */
31
+ function parseDuplicateKeyError(error, options = {}) {
32
+ if (!error || typeof error !== "object") return null;
33
+ const mongoErr = error;
34
+ if (mongoErr.code !== 11e3) return null;
35
+ const fields = mongoErr.keyPattern ? Object.keys(mongoErr.keyPattern) : [];
36
+ const exposed = options.exposeValues === true;
37
+ const valuesString = exposed && mongoErr.keyValue ? Object.entries(mongoErr.keyValue).map(([k, v]) => `${k}: ${JSON.stringify(v)}`).join(", ") : "";
38
+ const httpError = createError(409, fields.length ? `Duplicate value for ${fields.join(", ")}${valuesString ? ` (${valuesString})` : ""}` : "Duplicate key error");
39
+ httpError.duplicate = {
40
+ fields,
41
+ ...exposed && mongoErr.keyValue ? { values: { ...mongoErr.keyValue } } : {}
42
+ };
43
+ return httpError;
44
+ }
45
+ //#endregion
46
+ export { parseDuplicateKeyError as n, createError as t };
@@ -1,4 +1,4 @@
1
- import { $ as ReadPreferenceType, At as LookupOptions, D as GroupResult, V as ObjectId, W as OperationOptions, Z as PopulateSpec, _t as UpdateOptions, a as AnyDocument, b as DeleteResult, g as CreateOptions, gt as UpdateManyResult, pt as SortSpec, st as SelectSpec, vt as UpdateWithValidationResult, z as MinMaxResult } from "./types-DiQfDesD.mjs";
1
+ import { $ as ReadPreferenceType, At as LookupOptions, D as GroupResult, V as ObjectId, W as OperationOptions, Z as PopulateSpec, _t as UpdateOptions, a as AnyDocument, b as DeleteResult, g as CreateOptions, gt as UpdateManyResult, pt as SortSpec, st as SelectSpec, vt as UpdateWithValidationResult, z as MinMaxResult } from "./types-BgdeBXI1.mjs";
2
2
  import { ClientSession, Model, PipelineStage } from "mongoose";
3
3
 
4
4
  //#region src/actions/aggregate.d.ts
package/dist/index.d.mts CHANGED
@@ -1,10 +1,10 @@
1
- import { $ as ReadPreferenceType, A as InferDocument, At as LookupOptions, B as NonNullableFields, C as EventPayload, Ct as WithPlugins, D as GroupResult, Dt as IRequestContext, E as FieldRules, Et as IControllerResponse, F as KeysetPaginationResult, G as PaginationConfig, H as OffsetPaginationOptions, I as Logger, J as Plugin, K as PaginationResult, L as LookupPopulateOptions, M as JsonSchema, N as KeysOfType, O as HookMode, Ot as IResponseFormatter, P as KeysetPaginationOptions, Q as ReadOptions, R as LookupPopulateResult, S as EventHandlers, St as ValidatorDefinition, T as FieldPreset, Tt as IController, U as OffsetPaginationResult, V as ObjectId, W as OperationOptions, X as PluginType, Y as PluginFunction, Z as PopulateSpec, _ as CrudSchemas, _t as UpdateOptions, a as AnyDocument, at as RequiredBy, b as DeleteResult, bt as ValidationChainOptions, c as CacheOperationOptions, ct as SessionOptions, d as CacheableOptions, dt as SoftDeleteRepository, et as RepositoryContext, f as CascadeOptions, ft as SortDirection, g as CreateOptions, gt as UpdateManyResult, h as CreateInput, ht as UpdateInput, i as AllPluginMethods, it as RepositoryOptions, j as InferRawDoc, k as HttpError, kt as LookupBuilder, l as CacheOptions, lt as SoftDeleteFilterMode, m as CollationOptions, mt as Strict, n as AggregatePaginationOptions, nt as RepositoryInstance, o as AnyModel, ot as SchemaBuilderOptions, p as CascadeRelation, pt as SortSpec, q as PartialBy, r as AggregatePaginationResult, rt as RepositoryOperation, s as CacheAdapter, st as SelectSpec, t as AggregateOptions, tt as RepositoryEvent, u as CacheStats, ut as SoftDeleteOptions, v as DecodedCursor, vt as UpdateWithValidationResult, w as EventPhase, wt as WithTransactionOptions, x as DocField, xt as ValidationResult, y as DeepPartial, yt as UserContext, z as MinMaxResult } from "./types-DiQfDesD.mjs";
2
- import { t as index_d_exports } from "./index-BSRSsRRx.mjs";
1
+ import { $ as ReadPreferenceType, A as InferDocument, At as LookupOptions, B as NonNullableFields, C as EventPayload, Ct as WithPlugins, D as GroupResult, Dt as IRequestContext, E as FieldRules, Et as IControllerResponse, F as KeysetPaginationResult, G as PaginationConfig, H as OffsetPaginationOptions, I as Logger, J as Plugin, K as PaginationResult, L as LookupPopulateOptions, M as JsonSchema, N as KeysOfType, O as HookMode, Ot as IResponseFormatter, P as KeysetPaginationOptions, Q as ReadOptions, R as LookupPopulateResult, S as EventHandlers, St as ValidatorDefinition, T as FieldPreset, Tt as IController, U as OffsetPaginationResult, V as ObjectId, W as OperationOptions, X as PluginType, Y as PluginFunction, Z as PopulateSpec, _ as CrudSchemas, _t as UpdateOptions, a as AnyDocument, at as RequiredBy, b as DeleteResult, bt as ValidationChainOptions, c as CacheOperationOptions, ct as SessionOptions, d as CacheableOptions, dt as SoftDeleteRepository, et as RepositoryContext, f as CascadeOptions, ft as SortDirection, g as CreateOptions, gt as UpdateManyResult, h as CreateInput, ht as UpdateInput, i as AllPluginMethods, it as RepositoryOptions, j as InferRawDoc, k as HttpError, kt as LookupBuilder, l as CacheOptions, lt as SoftDeleteFilterMode, m as CollationOptions, mt as Strict, n as AggregatePaginationOptions, nt as RepositoryInstance, o as AnyModel, ot as SchemaBuilderOptions, p as CascadeRelation, pt as SortSpec, q as PartialBy, r as AggregatePaginationResult, rt as RepositoryOperation, s as CacheAdapter, st as SelectSpec, t as AggregateOptions, tt as RepositoryEvent, u as CacheStats, ut as SoftDeleteOptions, v as DecodedCursor, vt as UpdateWithValidationResult, w as EventPhase, wt as WithTransactionOptions, x as DocField, xt as ValidationResult, y as DeepPartial, yt as UserContext, z as MinMaxResult } from "./types-BgdeBXI1.mjs";
2
+ import { t as index_d_exports } from "./index-C-qZUqv8.mjs";
3
3
  import { PaginationEngine } from "./pagination/PaginationEngine.mjs";
4
- import { A as dateSequentialId, B as AuditEntry, C as elasticSearchPlugin, D as PrefixedIdOptions, E as IdGenerator, F as CacheMethods, G as AuditTrailOptions, H as AuditQueryOptions, I as cachePlugin, J as auditLogPlugin, K as AuditTrailQuery, L as BatchOperationsMethods, M as prefixedId, N as sequentialId, O as SequentialIdOptions, P as cascadePlugin, R as BulkWriteResult, S as ElasticSearchOptions, T as DateSequentialIdOptions, U as AuditQueryResult, V as AuditOperation, W as AuditTrailMethods, X as aggregateHelpersPlugin, Y as AggregateHelpersMethods, _ as MongoOperationsMethods, a as uniqueField, b as methodRegistryPlugin, c as SubdocumentMethods, d as softDeletePlugin, f as ObservabilityOptions, g as multiTenantPlugin, h as MultiTenantOptions, i as requireField, j as getNextSequence, k as customIdPlugin, l as subdocumentPlugin, m as observabilityPlugin, n as blockIf, o as validationChainPlugin, p as OperationMetric, q as auditTrailPlugin, r as immutableField, s as timestampPlugin, t as autoInject, u as SoftDeleteMethods, v as mongoOperationsPlugin, w as CustomIdOptions, x as fieldFilterPlugin, z as batchOperationsPlugin } from "./validation-chain.plugin-BsFzKVnO.mjs";
5
- import { n as SchemaPathsLike, t as FieldType$1 } from "./coercion-B19MD38s.mjs";
6
- import { n as SchemaIndexes } from "./indexes-qIBCZ3mY.mjs";
7
- import { a as isFieldUpdateAllowed, c as configureLogger, d as getFieldsForUser, f as getMongooseProjection, i as getSystemManagedFields, l as createFieldPreset, m as parseDuplicateKeyError, n as buildCrudSchemasFromMongooseSchema, o as validateUpdateBody, p as createError, r as getImmutableFields, s as createMemoryCache, t as buildCrudSchemasFromModel, u as filterResponseData } from "./mongooseToJsonSchema-D2YjHo7W.mjs";
4
+ import { $ as aggregateHelpersPlugin, A as PrefixedIdOptions, B as BatchOperationsMethods, C as methodRegistryPlugin, D as CustomIdOptions, E as elasticSearchPlugin, F as prefixedId, G as AuditQueryOptions, H as batchOperationsPlugin, I as sequentialId, J as AuditTrailOptions, K as AuditQueryResult, L as cascadePlugin, M as customIdPlugin, N as dateSequentialId, O as DateSequentialIdOptions, P as getNextSequence, Q as AggregateHelpersMethods, R as CacheMethods, T as ElasticSearchOptions, U as AuditEntry, V as BulkWriteResult, W as AuditOperation, X as auditTrailPlugin, Y as AuditTrailQuery, Z as auditLogPlugin, _ as observabilityPlugin, a as uniqueField, b as MongoOperationsMethods, c as TenantContext, d as SubdocumentMethods, f as subdocumentPlugin, g as OperationMetric, h as ObservabilityOptions, i as requireField, j as SequentialIdOptions, k as IdGenerator, l as TenantStore, m as softDeletePlugin, n as blockIf, o as validationChainPlugin, p as SoftDeleteMethods, q as AuditTrailMethods, r as immutableField, s as timestampPlugin, t as autoInject, u as createTenantContext, v as MultiTenantOptions, w as fieldFilterPlugin, x as mongoOperationsPlugin, y as multiTenantPlugin, z as cachePlugin } from "./validation-chain.plugin-Be9rG04B.mjs";
5
+ import { n as SchemaPathsLike, t as FieldType$1 } from "./coercion-oM8G4b6m.mjs";
6
+ import { n as SchemaIndexes } from "./indexes-C26GNZRU.mjs";
7
+ import { a as isFieldUpdateAllowed, c as configureLogger, d as getFieldsForUser, f as getMongooseProjection, i as getSystemManagedFields, l as createFieldPreset, m as parseDuplicateKeyError, n as buildCrudSchemasFromMongooseSchema, o as validateUpdateBody, p as createError, r as getImmutableFields, s as createMemoryCache, t as buildCrudSchemasFromModel, u as filterResponseData } from "./mongooseToJsonSchema-Du-UopRK.mjs";
8
8
  import * as _$mongoose from "mongoose";
9
9
  import { ClientSession, Expression, Model, PipelineStage, PopulateOptions } from "mongoose";
10
10
 
@@ -596,15 +596,37 @@ declare class QueryParser {
596
596
  */
597
597
  private readonly _schemaIndexes;
598
598
  /**
599
- * Regex patterns that can cause catastrophic backtracking (ReDoS attacks)
600
- * Detects:
599
+ * Regex patterns that can cause catastrophic backtracking (ReDoS attacks).
600
+ * Detects common footguns:
601
601
  * - Quantifiers: {n,m}
602
602
  * - Possessive quantifiers: *+, ++, ?+
603
603
  * - Nested quantifiers: (a+)+, (a*)*
604
604
  * - Backreferences: \1, \2, etc.
605
605
  * - Complex character classes: [...]...[...]
606
+ *
607
+ * This pattern detector is FAIL-SAFE (on match, the input is escaped), but
608
+ * it is not exhaustive. {@link _regexComplexityExceedsBudget} complements
609
+ * it with a deterministic static complexity budget so patterns that happen
610
+ * to evade the detector still fail over when their unbounded-quantifier
611
+ * density is pathological.
606
612
  */
607
613
  private readonly dangerousRegexPatterns;
614
+ /**
615
+ * Static complexity budget for user-supplied regex patterns.
616
+ * Returns `true` if the pattern crosses a budget that empirically correlates
617
+ * with ReDoS risk — independent of the heuristic regex detector.
618
+ *
619
+ * Budget components (any single threshold crossed → over budget):
620
+ * - unbounded quantifiers (`*`, `+`, `?`) — hard cap 20
621
+ * - nested groups `(` — hard cap 8
622
+ * - alternations `|` — hard cap 10
623
+ * - combined: (quantifiers * groups) — hard cap 40
624
+ *
625
+ * These thresholds are intentionally permissive for legitimate queries and
626
+ * tight enough to refuse known-pathological patterns like `(a+)+b` with
627
+ * heavy repetition.
628
+ */
629
+ private _regexComplexityExceedsBudget;
608
630
  constructor(options?: QueryParserOptions);
609
631
  /**
610
632
  * Structured view of the configured schema's indexes — geo fields, text
@@ -1210,4 +1232,4 @@ declare function isTransactionUnsupported(error: Error): boolean;
1210
1232
  */
1211
1233
  declare function createRepository<TDoc>(Model: _$mongoose.Model<TDoc, any, any, any>, plugins?: PluginType[], paginationConfig?: PaginationConfig, options?: RepositoryOptions): Repository<TDoc>;
1212
1234
  //#endregion
1213
- export { type AggregateHelpersMethods, type AggregateOptions, type AggregatePaginationOptions, type AggregatePaginationResult, AggregationBuilder, type AllPluginMethods, type AnyDocument, type AnyModel, type AuditEntry, type AuditOperation, type AuditQueryOptions, type AuditQueryResult, type AuditTrailMethods, type AuditTrailOptions, AuditTrailQuery, type BatchOperationsMethods, type BulkWriteResult, type CacheAdapter, type CacheMethods, type CacheOperationOptions, type CacheOptions, type CacheStats, type CacheableOptions, type CascadeOptions, type CascadeRelation, type CollationOptions, type CreateInput, type CreateOptions, type CrudSchemas, type CustomIdOptions, type DateSequentialIdOptions, type DecodedCursor, type DeepPartial, type DeleteResult, type DocField, type ElasticSearchOptions, type EventHandlers, type EventPayload, type EventPhase, type FieldPreset, type FieldRules, type FieldType, type FilterQuery, type GroupResult, HOOK_PRIORITY, type HookMode, type HttpError, type IController, type IControllerResponse, type IRequestContext, type IResponseFormatter, type IdGenerator, type InferDocument, type InferRawDoc, type JsonSchema, type KeysOfType, type KeysetPaginationOptions, type KeysetPaginationResult, type Logger, LookupBuilder, type LookupOptions, type LookupPopulateOptions, type LookupPopulateResult, type MinMaxResult, type MongoOperationsMethods, type MultiTenantOptions, type NonNullableFields, type ObjectId, type ObservabilityOptions, type OffsetPaginationOptions, type OffsetPaginationResult, type OperationMetric, type OperationOptions, type PaginationConfig, PaginationEngine, type PaginationResult, type ParsedQuery, type PartialBy, type Plugin, type PluginFunction, type PluginType, type PopulateOption, type PopulateSpec, type PrefixedIdOptions, QueryParser, type QueryParserOptions, type ReadOptions, type ReadPreferenceType, Repository, Repository as default, type RepositoryContext, type RepositoryEvent, type RepositoryInstance, type RepositoryOperation, type RepositoryOptions, type RequiredBy, type SchemaBuilderOptions, type SchemaLike, type SearchMode, type SelectSpec, type SequentialIdOptions, type SessionOptions, type SoftDeleteFilterMode, type SoftDeleteMethods, type SoftDeleteOptions, type SoftDeleteRepository, type SortDirection, type SortSpec, type Strict, type SubdocumentMethods, type UpdateInput, type UpdateManyResult, type UpdateOptions, type UpdateWithValidationResult, type UserContext, type ValidationChainOptions, type ValidationResult, type ValidatorDefinition, type WithPlugins, type WithTransactionOptions, index_d_exports as actions, aggregateHelpersPlugin, auditLogPlugin, auditTrailPlugin, autoInject, batchOperationsPlugin, blockIf, buildCrudSchemasFromModel, buildCrudSchemasFromMongooseSchema, cachePlugin, cascadePlugin, configureLogger, createError, createFieldPreset, createMemoryCache, createRepository, customIdPlugin, dateSequentialId, elasticSearchPlugin, fieldFilterPlugin, filterResponseData, getFieldsForUser, getImmutableFields, getMongooseProjection, getNextSequence, getSystemManagedFields, immutableField, isFieldUpdateAllowed, isTransactionUnsupported, methodRegistryPlugin, mongoOperationsPlugin, multiTenantPlugin, observabilityPlugin, parseDuplicateKeyError, prefixedId, requireField, sequentialId, softDeletePlugin, subdocumentPlugin, timestampPlugin, uniqueField, validateUpdateBody, validationChainPlugin, withTransaction };
1235
+ export { type AggregateHelpersMethods, type AggregateOptions, type AggregatePaginationOptions, type AggregatePaginationResult, AggregationBuilder, type AllPluginMethods, type AnyDocument, type AnyModel, type AuditEntry, type AuditOperation, type AuditQueryOptions, type AuditQueryResult, type AuditTrailMethods, type AuditTrailOptions, AuditTrailQuery, type BatchOperationsMethods, type BulkWriteResult, type CacheAdapter, type CacheMethods, type CacheOperationOptions, type CacheOptions, type CacheStats, type CacheableOptions, type CascadeOptions, type CascadeRelation, type CollationOptions, type CreateInput, type CreateOptions, type CrudSchemas, type CustomIdOptions, type DateSequentialIdOptions, type DecodedCursor, type DeepPartial, type DeleteResult, type DocField, type ElasticSearchOptions, type EventHandlers, type EventPayload, type EventPhase, type FieldPreset, type FieldRules, type FieldType, type FilterQuery, type GroupResult, HOOK_PRIORITY, type HookMode, type HttpError, type IController, type IControllerResponse, type IRequestContext, type IResponseFormatter, type IdGenerator, type InferDocument, type InferRawDoc, type JsonSchema, type KeysOfType, type KeysetPaginationOptions, type KeysetPaginationResult, type Logger, LookupBuilder, type LookupOptions, type LookupPopulateOptions, type LookupPopulateResult, type MinMaxResult, type MongoOperationsMethods, type MultiTenantOptions, type NonNullableFields, type ObjectId, type ObservabilityOptions, type OffsetPaginationOptions, type OffsetPaginationResult, type OperationMetric, type OperationOptions, type PaginationConfig, PaginationEngine, type PaginationResult, type ParsedQuery, type PartialBy, type Plugin, type PluginFunction, type PluginType, type PopulateOption, type PopulateSpec, type PrefixedIdOptions, QueryParser, type QueryParserOptions, type ReadOptions, type ReadPreferenceType, Repository, Repository as default, type RepositoryContext, type RepositoryEvent, type RepositoryInstance, type RepositoryOperation, type RepositoryOptions, type RequiredBy, type SchemaBuilderOptions, type SchemaLike, type SearchMode, type SelectSpec, type SequentialIdOptions, type SessionOptions, type SoftDeleteFilterMode, type SoftDeleteMethods, type SoftDeleteOptions, type SoftDeleteRepository, type SortDirection, type SortSpec, type Strict, type SubdocumentMethods, type TenantContext, type TenantStore, type UpdateInput, type UpdateManyResult, type UpdateOptions, type UpdateWithValidationResult, type UserContext, type ValidationChainOptions, type ValidationResult, type ValidatorDefinition, type WithPlugins, type WithTransactionOptions, index_d_exports as actions, aggregateHelpersPlugin, auditLogPlugin, auditTrailPlugin, autoInject, batchOperationsPlugin, blockIf, buildCrudSchemasFromModel, buildCrudSchemasFromMongooseSchema, cachePlugin, cascadePlugin, configureLogger, createError, createFieldPreset, createMemoryCache, createRepository, createTenantContext, customIdPlugin, dateSequentialId, elasticSearchPlugin, fieldFilterPlugin, filterResponseData, getFieldsForUser, getImmutableFields, getMongooseProjection, getNextSequence, getSystemManagedFields, immutableField, isFieldUpdateAllowed, isTransactionUnsupported, methodRegistryPlugin, mongoOperationsPlugin, multiTenantPlugin, observabilityPlugin, parseDuplicateKeyError, prefixedId, requireField, sequentialId, softDeletePlugin, subdocumentPlugin, timestampPlugin, uniqueField, validateUpdateBody, validationChainPlugin, withTransaction };