@anteros/core 0.0.19 → 0.0.21

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 (46) hide show
  1. package/README.md +90 -5
  2. package/database/decorator.ts +1 -1
  3. package/database/mongodbadapter.ts +64 -40
  4. package/index.ts +1 -1
  5. package/lib/define.ts +60 -3
  6. package/lib/files.ts +115 -32
  7. package/lib/mcp.ts +59 -0
  8. package/lib/scriptWorker.ts +76 -0
  9. package/lib/scripts.ts +77 -12
  10. package/package.json +6 -3
  11. package/probe-cache.ts +16 -0
  12. package/server/api.ts +35 -28
  13. package/server/boot.ts +78 -16
  14. package/server/cluster.ts +177 -0
  15. package/server/config.ts +0 -1
  16. package/server/env.ts +6 -0
  17. package/server/hono.ts +192 -18
  18. package/server/mcp.ts +232 -0
  19. package/server/metrics.ts +110 -0
  20. package/server/security.ts +13 -9
  21. package/tests/api.test.ts +14 -12
  22. package/tests/cache.test.ts +124 -0
  23. package/tests/crypto.test.ts +101 -0
  24. package/tests/fixtures/heavy/collections/items.model.ts +8 -0
  25. package/tests/fixtures/heavy/scripts/heavy-import.run.ts +14 -0
  26. package/tests/fixtures/mcp-tenant/mcp/resources/orders.summary.resource.ts +17 -0
  27. package/tests/fixtures/mcp-tenant/mcp/resources/users.id.resource.ts +18 -0
  28. package/tests/fixtures/mcp-tenant/mcp/tools/badge.tool.ts +16 -0
  29. package/tests/fixtures/mcp-tenant/mcp/tools/echo.tool.ts +19 -0
  30. package/tests/fixtures/metrics-worker.ts +21 -0
  31. package/tests/hono.test.ts +211 -0
  32. package/tests/mcp.test.ts +186 -0
  33. package/tests/metrics-cluster.test.ts +103 -0
  34. package/tests/scripts-heavy.test.ts +49 -0
  35. package/tests/security.test.ts +102 -0
  36. package/tests/ts1011-test.ts +15 -0
  37. package/tests/utils.test.ts +113 -0
  38. package/types/api.d.ts +11 -0
  39. package/types/collection.d.ts +20 -5
  40. package/types/config.d.ts +20 -3
  41. package/types/hook.d.ts +16 -13
  42. package/types/mcp.d.ts +81 -0
  43. package/types/scripts.d.ts +14 -1
  44. package/utils/cache.ts +20 -1
  45. package/utils/crypto.ts +25 -8
  46. package/utils/func.ts +178 -18
package/types/hook.d.ts CHANGED
@@ -1,7 +1,8 @@
1
1
  import type { MongoRest } from "../database/mongodbadapter";
2
2
  import type { Server as SocketIO } from "socket.io";
3
3
  import type { ActionsApiList } from "./api";
4
- type metaHook = {
4
+
5
+ export type HookMeta = {
5
6
  action: ActionsApiList;
6
7
  collection: string;
7
8
  data?: any;
@@ -14,17 +15,19 @@ type metaHook = {
14
15
  pipeline?: any[];
15
16
  options?: any;
16
17
  }
18
+
19
+ /** Context passed to a collection hook (`define.Hook`). */
20
+ export type HookContext = {
21
+ rest: MongoRest;
22
+ action: ActionsApiList;
23
+ meta: HookMeta;
24
+ io: SocketIO;
25
+ };
26
+
27
+ /** A collection hook — `define.Hook(fn)`, used as beforeOperation/afterOperation. */
28
+ export type CollectionHook = (ctx: HookContext) => Promise<void>;
29
+
17
30
  export type HooksCollection = {
18
- beforeOperation?: (ctx: {
19
- rest: MongoRest;
20
- action: ActionsApiList;
21
- meta: metaHook;
22
- io: SocketIO;
23
- }) => Promise<void>;
24
- afterOperation?: (ctx: {
25
- rest: MongoRest;
26
- action: ActionsApiList;
27
- meta: metaHook;
28
- io: SocketIO;
29
- }) => Promise<void>;
31
+ beforeOperation?: CollectionHook;
32
+ afterOperation?: CollectionHook;
30
33
  }
package/types/mcp.d.ts ADDED
@@ -0,0 +1,81 @@
1
+ import type Joi from "joi";
2
+ import type { ContentBlock } from "@modelcontextprotocol/sdk/types.js";
3
+ import type { Context } from "hono";
4
+ import type { useRest } from "../database/rest";
5
+
6
+ /**
7
+ * MCP tool result — the Model Context Protocol `CallToolResult` shape.
8
+ * `exec` must return this (or a Promise of it).
9
+ */
10
+ export type McpToolResult = {
11
+ content: ContentBlock[];
12
+ isError?: boolean;
13
+ structuredContent?: unknown;
14
+ };
15
+
16
+ /**
17
+ * MCP tool definition — auto-loaded from `{tenant.dir}/mcp/tools/**\/*.tool.ts`.
18
+ * Served over the MCP protocol at `GET/POST /mcp/:tenant_id` (Streamable HTTP).
19
+ */
20
+ export type McpTool = {
21
+ _isMcpTool_?: boolean;
22
+ _tenant_?: string;
23
+ /** Tool name — unique per tenant (how the LLM calls it) */
24
+ name: string;
25
+ /** Short description shown to the LLM */
26
+ description?: string;
27
+ enabled?: boolean;
28
+ /** Joi schema describing the tool arguments (same language as collections — `v`) */
29
+ inputSchema: Joi.Schema;
30
+ /** Handler executed when the LLM calls the tool (args are Joi-validated).
31
+ * Must return the MCP protocol result shape: `{ content: [{ type, text | data… }] }`. */
32
+ exec: (ctx: {
33
+ c: Context;
34
+ rest: InstanceType<typeof useRest>;
35
+ /** Parsed & validated arguments */
36
+ args: Record<string, any>;
37
+ }) => McpToolResult | Promise<McpToolResult>;
38
+ };
39
+
40
+ /**
41
+ * MCP resource read result — the protocol `ReadResourceResult` shape.
42
+ * `contents` is an array of text or blob (base64) content blocks.
43
+ */
44
+ export type McpResourceResult = {
45
+ contents: Array<{
46
+ uri: string;
47
+ mimeType?: string;
48
+ text?: string;
49
+ blob?: string;
50
+ }>;
51
+ };
52
+
53
+ /**
54
+ * MCP resource — read-only data exposed by URI (like a file the LLM can open).
55
+ * Auto-loaded from `{tenant.dir}/mcp/resources/**\/*.resource.ts`.
56
+ *
57
+ * The `uri` can be static (`orders://summary`) or a template
58
+ * (`users://{id}`) — template params are extracted and passed to `read`.
59
+ */
60
+ export type McpResource = {
61
+ _isMcpResource_?: boolean;
62
+ _tenant_?: string;
63
+ /** Resource name — unique per tenant */
64
+ name: string;
65
+ /** Short description shown to the LLM */
66
+ description?: string;
67
+ enabled?: boolean;
68
+ /** Static URI (e.g. `orders://summary`) or template (e.g. `users://{id}`) */
69
+ uri: string;
70
+ /** Content type of the resource (default: application/json) */
71
+ mimeType?: string;
72
+ /** Handler called when the LLM reads the resource */
73
+ read: (ctx: {
74
+ c: Context;
75
+ rest: InstanceType<typeof useRest>;
76
+ /** Params extracted from the URI template (e.g. `{ id: '123' }`) */
77
+ params: Record<string, string>;
78
+ /** The exact URI the client requested */
79
+ uri: string;
80
+ }) => McpResourceResult | Promise<McpResourceResult>;
81
+ };
@@ -1,10 +1,23 @@
1
1
  import { useRest } from "../database/rest"
2
+ import type { Tenant } from "./tenant"
2
3
 
3
4
 
4
5
  export type Script = {
5
6
  _isScript_?: boolean;
6
7
  enabled: boolean;
8
+ /**
9
+ * Run in a separate Bun process so heavy work (large JSON, long imports…)
10
+ * never blocks the HTTP server. The subprocess reconnects the tenant DB and
11
+ * rebuilds collection schemas automatically.
12
+ */
13
+ heavy?: boolean;
14
+ /** Max runtime in ms for heavy scripts (default: no timeout) */
15
+ timeout?: number;
7
16
  exec: (ctx: {
8
17
  rest: InstanceType<typeof useRest>
18
+ /** Report progress — only wired for heavy scripts (sent via IPC to the parent) */
19
+ progress?: (percent: number, info?: any) => void;
20
+ /** Tenant this script runs for */
21
+ tenant?: Tenant;
9
22
  }) => void
10
- }
23
+ }
package/utils/cache.ts CHANGED
@@ -50,6 +50,25 @@ class Cache {
50
50
  }
51
51
  })
52
52
  }
53
+
54
+ // ─── BentoCache provider API (delegated to the underlying instance) ───
55
+
56
+ set(options: any): Promise<boolean> { return this.#cache.set(options); }
57
+ setForever(options: any): Promise<boolean> { return this.#cache.setForever(options); }
58
+ get(options: any): Promise<unknown> { return this.#cache.get(options); }
59
+ getOrSet(options: any): Promise<unknown> { return this.#cache.getOrSet(options); }
60
+ getOrSetForever(options: any): Promise<unknown> { return this.#cache.getOrSetForever(options); }
61
+ has(options: any): Promise<boolean> { return this.#cache.has(options); }
62
+ missing(options: any): Promise<boolean> { return this.#cache.missing(options); }
63
+ pull(key: string): Promise<unknown> { return this.#cache.pull(key); }
64
+ delete(options: any): Promise<boolean> { return this.#cache.delete(options); }
65
+ deleteMany(options: any): Promise<boolean> { return this.#cache.deleteMany(options); }
66
+ deleteByTag(options: any): Promise<boolean> { return this.#cache.deleteByTag(options); }
67
+ expire(options: any): Promise<boolean> { return this.#cache.expire(options); }
68
+ clear(options?: any): Promise<void> { return this.#cache.clear(options); }
69
+ prune(): Promise<void> { return this.#cache.prune(); }
70
+ namespace(namespace: string): any { return this.#cache.namespace(namespace); }
71
+ disconnect(): Promise<void> { return this.#cache.disconnect(); }
53
72
  }
54
73
 
55
74
 
@@ -93,4 +112,4 @@ export {
93
112
  useFilesystemCache,
94
113
  useMemoryCache,
95
114
  useRedisCache
96
- }
115
+ }
package/utils/crypto.ts CHANGED
@@ -2,6 +2,20 @@ const IV_LENGTH = 12;
2
2
  const TAG_LENGTH = 16;
3
3
  const RSA_KEY_SIZE = 256;
4
4
 
5
+ /**
6
+ * Build AES-GCM parameters with optional Additional Authenticated Data.
7
+ * AAD binds the ciphertext to its context (field/record/tenant) so a valid
8
+ * ciphertext cannot be swapped into another context (substitution attack).
9
+ */
10
+ function gcmParams(iv: Uint8Array, aad?: string) {
11
+ return {
12
+ name: "AES-GCM" as const,
13
+ iv,
14
+ tagLength: TAG_LENGTH * 8,
15
+ ...(aad ? { additionalData: new TextEncoder().encode(aad) } : {}),
16
+ };
17
+ }
18
+
5
19
  interface JsonWebKey {
6
20
  kty?: string;
7
21
  alg?: string;
@@ -43,12 +57,12 @@ class CryptSym {
43
57
  return this.#key;
44
58
  }
45
59
 
46
- async encrypt(data: string | object): Promise<string> {
60
+ async encrypt(data: string | object, aad?: string): Promise<string> {
47
61
  const text = typeof data === "object" ? JSON.stringify(data) : data;
48
62
  const key = await this.#getKey();
49
63
  const iv = crypto.getRandomValues(new Uint8Array(IV_LENGTH));
50
64
  const encrypted = await crypto.subtle.encrypt(
51
- { name: "AES-GCM", iv, tagLength: TAG_LENGTH * 8 },
65
+ gcmParams(iv, aad),
52
66
  key,
53
67
  new TextEncoder().encode(text)
54
68
  );
@@ -58,14 +72,17 @@ class CryptSym {
58
72
  return Buffer.from(buffer).toString("base64");
59
73
  }
60
74
 
61
- async decrypt<T = string>(cipher: string, parseJson?: boolean): Promise<T> {
75
+ async decrypt<T = string>(cipher: string, parseJson?: boolean, aad?: string): Promise<T> {
62
76
  try {
63
77
  const buffer = new Uint8Array(Buffer.from(cipher, "base64"));
78
+ if (buffer.length < IV_LENGTH + TAG_LENGTH) {
79
+ throw new Error("ciphertext too short");
80
+ }
64
81
  const iv = buffer.slice(0, IV_LENGTH);
65
82
  const data = buffer.slice(IV_LENGTH);
66
83
  const key = await this.#getKey();
67
84
  const decrypted = await crypto.subtle.decrypt(
68
- { name: "AES-GCM", iv, tagLength: TAG_LENGTH * 8 },
85
+ gcmParams(iv, aad),
69
86
  key,
70
87
  data
71
88
  );
@@ -131,7 +148,7 @@ class CryptAsym {
131
148
  return this.#keyPair;
132
149
  }
133
150
 
134
- async encrypt(data: string | object, publicKey?: CryptoKey): Promise<string> {
151
+ async encrypt(data: string | object, publicKey?: CryptoKey, aad?: string): Promise<string> {
135
152
  const text = typeof data === "object" ? JSON.stringify(data) : data;
136
153
  const rsaKey = publicKey ?? (await this.#getKeyPair()).publicKey;
137
154
 
@@ -143,7 +160,7 @@ class CryptAsym {
143
160
 
144
161
  const iv = crypto.getRandomValues(new Uint8Array(IV_LENGTH));
145
162
  const encrypted = await crypto.subtle.encrypt(
146
- { name: "AES-GCM", iv, tagLength: TAG_LENGTH * 8 },
163
+ gcmParams(iv, aad),
147
164
  aesKey,
148
165
  new TextEncoder().encode(text)
149
166
  );
@@ -162,7 +179,7 @@ class CryptAsym {
162
179
  return Buffer.from(result).toString("base64");
163
180
  }
164
181
 
165
- async decrypt<T = string>(cipher: string, parseJson?: boolean): Promise<T> {
182
+ async decrypt<T = string>(cipher: string, parseJson?: boolean, aad?: string): Promise<T> {
166
183
  try {
167
184
  const buffer = new Uint8Array(Buffer.from(cipher, "base64"));
168
185
  if (buffer.length < RSA_KEY_SIZE + IV_LENGTH + TAG_LENGTH) {
@@ -189,7 +206,7 @@ class CryptAsym {
189
206
  );
190
207
 
191
208
  const decrypted = await crypto.subtle.decrypt(
192
- { name: "AES-GCM", iv, tagLength: TAG_LENGTH * 8 },
209
+ gcmParams(iv, aad),
193
210
  aesKey,
194
211
  ciphertext
195
212
  );
package/utils/func.ts CHANGED
@@ -19,15 +19,16 @@ const jwt = {
19
19
  audience?: string | string[];
20
20
  subject?: string;
21
21
  }) => {
22
- let SECRET_ = cfg.server.jwt?.secret || Bun.env.JWT_SECRET
22
+ const SECRET_ = cfg.server.jwt?.secret || Bun.env.JWT_SECRET
23
23
  if (!SECRET_) throw new Error('JWT_SECRET is not set');
24
- let EXPIRES_IN = cfg.server.jwt?.expiresIn || '1h'
24
+ // Per-call expiresIn wins over the global config (default: 7d)
25
+ const EXPIRES_IN = options?.expiresIn || cfg.server.jwt?.expiresIn || '7d'
25
26
 
26
27
  const secret = new TextEncoder().encode(SECRET_);
27
28
  const signer = new Jose.SignJWT(payload)
28
29
  .setProtectedHeader({ alg: 'HS256' })
29
30
  .setIssuedAt()
30
- .setExpirationTime(EXPIRES_IN || '1h')
31
+ .setExpirationTime(EXPIRES_IN)
31
32
 
32
33
  if (options?.issuer) signer.setIssuer(options.issuer)
33
34
  if (options?.audience) signer.setAudience(options.audience)
@@ -36,7 +37,7 @@ const jwt = {
36
37
  const token = await signer.sign(secret);
37
38
  return token;
38
39
  },
39
- verify: async (token: string): Promise<{ value: Record<string, unknown> | null, error: string | null }> => {
40
+ verify: async (token: string): Promise<{ value: Record<string, unknown> | null, error: string | null, expired: boolean }> => {
40
41
  try {
41
42
  if (!token) throw new Error('Token is required');
42
43
  let SECRET_ = cfg.server.jwt?.secret || Bun.env.JWT_SECRET
@@ -45,12 +46,17 @@ const jwt = {
45
46
  const { payload, protectedHeader } = await Jose.jwtVerify(token, secret);
46
47
  return {
47
48
  value: payload,
48
- error: null
49
+ error: null,
50
+ expired: false,
49
51
  }
50
- } catch (error) {
52
+ } catch (error: any) {
53
+ // Reliable expiry detection via jose error code (not string matching)
54
+ const isExpired = error?.code === 'ERR_JWT_EXPIRED'
55
+ || /expired|expiration/i.test(error?.message ?? '');
51
56
  return {
52
57
  value: null,
53
- error: error instanceof Error ? error.message : 'Invalid token'
58
+ error: error instanceof Error ? error.message : 'Invalid token',
59
+ expired: isExpired,
54
60
  }
55
61
  }
56
62
  }
@@ -152,6 +158,48 @@ function pick<T extends DeepRecord>(data: T | T[], keys: string[]): Partial<T> |
152
158
  return result as Partial<T>;
153
159
  }
154
160
 
161
+ /**
162
+ * Convertit la dot-notation MongoDB en structure imbriquée pour la validation Joi.
163
+ * Ex: { "address.zip": "BP28", "items.0.name": "x" } → { address: { zip }, items: [{ name }] }
164
+ * Les clés opérateurs ($inc, $push…) sont laissées telles quelles.
165
+ */
166
+ function unflattenKeys(input: Record<string, unknown>): Record<string, unknown> {
167
+ const result: Record<string, unknown> = {};
168
+ for (const [key, value] of Object.entries(input)) {
169
+ // Opérateurs Mongo → laisser tel quel (interdits dans $set de toute façon)
170
+ if (key.startsWith('$')) {
171
+ result[key] = value;
172
+ continue;
173
+ }
174
+ const parts = key.split('.');
175
+ let target: any = result;
176
+ for (let i = 0; i < parts.length - 1; i++) {
177
+ const part = parts[i]!;
178
+ const nextPart = parts[i + 1]!;
179
+ const isArrayIndex = /^\d+$/.test(nextPart);
180
+ if (typeof target[part] !== 'object' || target[part] === null) {
181
+ target[part] = isArrayIndex ? [] : {};
182
+ }
183
+ target = target[part];
184
+ }
185
+ const last = parts[parts.length - 1]!;
186
+ // Fusion si les deux côtés sont des objets (ex. "a.b" + a: { c })
187
+ if (
188
+ target[last] &&
189
+ typeof target[last] === 'object' &&
190
+ !Array.isArray(target[last]) &&
191
+ typeof value === 'object' &&
192
+ value !== null &&
193
+ !Array.isArray(value)
194
+ ) {
195
+ target[last] = { ...(target[last] as object), ...(value as object) };
196
+ } else {
197
+ target[last] = value;
198
+ }
199
+ }
200
+ return result;
201
+ }
202
+
155
203
  function toJson<T>(data: any): T {
156
204
  if (data === null || data === undefined) return data;
157
205
  try {
@@ -254,7 +302,14 @@ function isEpochNumber(value: number): boolean {
254
302
  }
255
303
 
256
304
  function deepCopy<T>(value: T): T {
257
- return clone(value);
305
+ // structuredClone preserves Date / RegExp / Map / Set / typed arrays
306
+ // (clone() uses toJson, which JSON-serializes and loses those types).
307
+ // Falls back to clone() for values structuredClone cannot handle.
308
+ try {
309
+ return structuredClone(value);
310
+ } catch {
311
+ return clone(value);
312
+ }
258
313
  }
259
314
 
260
315
  type FormatToDateResult<T> = T extends string
@@ -645,12 +700,34 @@ function buildPipeline(p: FindOptions, options?: {
645
700
  status: 400
646
701
  });
647
702
  }
703
+ // Faille 13: bound $regex (ReDoS) and $in/$nin (memory DoS)
704
+ boundMatchOperators(p.$match);
648
705
  pipeline.push({
649
706
  $match: p.$match
650
707
  })
651
708
  }
652
709
 
653
- // $sort
710
+ // $sort — Faille 14: values must be 1 or -1
711
+ if (p?.$sort) {
712
+ for (const [key, dir] of Object.entries(p.$sort)) {
713
+ if (dir !== 1 && dir !== -1) {
714
+ throw new AppError(`Invalid $sort direction for '${key}' (expected 1 or -1)`, {
715
+ code: 'INVALID_SORT_DIRECTION',
716
+ status: 400
717
+ });
718
+ }
719
+ }
720
+ }
721
+
722
+ // $skip — Faille 14: must be a safe non-negative integer
723
+ if (p?.$skip !== undefined && p?.$skip !== null) {
724
+ if (!Number.isSafeInteger(p.$skip) || p.$skip < 0) {
725
+ throw new AppError('Invalid $skip (expected a non-negative integer)', {
726
+ code: 'INVALID_SKIP',
727
+ status: 400
728
+ });
729
+ }
730
+ }
654
731
  if (!p?.$sort?.createdAt) {
655
732
  pipeline.push({
656
733
  $sort: {
@@ -664,20 +741,25 @@ function buildPipeline(p: FindOptions, options?: {
664
741
  })
665
742
  }
666
743
 
667
- // $skip
744
+ // $skip (validated above)
668
745
  if (p?.$skip) {
669
746
  pipeline.push({
670
747
  $skip: p.$skip
671
748
  })
672
749
  }
673
750
 
674
- // $limit
751
+ // $limit — Faille 14: must be a safe integer in (0, MAX_LIMIT]; 0 would mean "no limit"
752
+ const MAX_LIMIT = 1000;
675
753
  p.$limit = p?.$limit ?? 100
676
- if (p?.$limit > 0) {
677
- pipeline.push({
678
- $limit: p.$limit
679
- })
754
+ if (!Number.isSafeInteger(p.$limit) || p.$limit <= 0 || p.$limit > MAX_LIMIT) {
755
+ throw new AppError(`Invalid $limit (expected an integer between 1 and ${MAX_LIMIT})`, {
756
+ code: 'INVALID_LIMIT',
757
+ status: 400
758
+ });
680
759
  }
760
+ pipeline.push({
761
+ $limit: p.$limit
762
+ })
681
763
 
682
764
  // $include
683
765
  if (p?.$include && Array.isArray(p?.$include)) {
@@ -742,6 +824,17 @@ function buildPipeline(p: FindOptions, options?: {
742
824
 
743
825
  let as = include.as || include.localField
744
826
 
827
+ // Faille 3: never trust client-supplied $lookup sub-pipeline — scan it
828
+ if (include.pipeline && Array.isArray(include.pipeline)) {
829
+ const check = isSafeAggregatePipeline(include.pipeline as Array<Record<string, unknown>>)
830
+ if (!check.isSafe) {
831
+ throw check.error ?? new AppError('Unauthorized $include pipeline', {
832
+ code: 'UNAUTHORIZED_PIPELINE_KEYS',
833
+ status: 400
834
+ })
835
+ }
836
+ }
837
+
745
838
  pipeline.push({
746
839
  $lookup: {
747
840
  from: include.from,
@@ -807,6 +900,46 @@ function clone<T>(data: T): T {
807
900
  return data_
808
901
  }
809
902
 
903
+ /**
904
+ * Faille 13: bounds dangerous $match operators to prevent ReDoS ($regex) and memory DoS ($in).
905
+ * Walks nested objects/arrays ($and, $or, $nor, dotted fields).
906
+ */
907
+ const MAX_REGEX_LENGTH = 1000;
908
+ const MAX_IN_ARRAY_SIZE = 10000;
909
+
910
+ function boundMatchOperators(node: unknown): void {
911
+ if (node === null || node === undefined) return;
912
+
913
+ if (Array.isArray(node)) {
914
+ for (const item of node) {
915
+ boundMatchOperators(item);
916
+ }
917
+ return;
918
+ }
919
+
920
+ if (typeof node === 'object') {
921
+ const obj = node as Record<string, unknown>;
922
+ for (const [key, value] of Object.entries(obj)) {
923
+ if (key === '$regex' && typeof value === 'string') {
924
+ if (value.length > MAX_REGEX_LENGTH) {
925
+ throw new AppError(`$regex pattern too long (max ${MAX_REGEX_LENGTH} chars)`, {
926
+ code: 'REGEX_TOO_LONG',
927
+ status: 400
928
+ });
929
+ }
930
+ } else if ((key === '$in' || key === '$nin') && Array.isArray(value)) {
931
+ if (value.length > MAX_IN_ARRAY_SIZE) {
932
+ throw new AppError(`$in/$nin too large (max ${MAX_IN_ARRAY_SIZE} items)`, {
933
+ code: 'ARRAY_OPERATOR_TOO_LARGE',
934
+ status: 400
935
+ });
936
+ }
937
+ }
938
+ boundMatchOperators(value);
939
+ }
940
+ }
941
+ }
942
+
810
943
 
811
944
 
812
945
 
@@ -1007,14 +1140,18 @@ function countAggregatePipelineStages(pipeline: unknown): number {
1007
1140
  }
1008
1141
 
1009
1142
 
1010
- function isSafeAggregatePipeline(pipeline: Array<Record<string, unknown>>): {
1143
+ function isSafeAggregatePipeline(pipeline: Array<Record<string, unknown>>, allowedCollections?: string[]): {
1011
1144
  isSafe: boolean;
1012
1145
  forbiddenKeys: string[];
1013
1146
  error: AppError | null;
1014
1147
  } {
1015
1148
 
1016
- // Unauthorized pipeline keys
1017
- const UNAUTHORIZED_PIPELINE_KEYS = ['$out', '$merge', '$function', '$where', '$accumulator'];
1149
+ // Unauthorized pipeline keys — Faille 12: include cross-collection exfiltration stages
1150
+ const UNAUTHORIZED_PIPELINE_KEYS = [
1151
+ '$out', '$merge', '$function', '$where', '$accumulator',
1152
+ '$unionWith', '$collStats', '$indexStats', '$planCacheStats',
1153
+ '$currentOp', '$listLocalSessions', '$listSessions', '$changeStream',
1154
+ ];
1018
1155
  const forbiddenFound = hasUnauthorizedKeys(pipeline, UNAUTHORIZED_PIPELINE_KEYS);
1019
1156
  if (forbiddenFound.length > 0) {
1020
1157
  return {
@@ -1024,6 +1161,28 @@ function isSafeAggregatePipeline(pipeline: Array<Record<string, unknown>>): {
1024
1161
  }
1025
1162
  }
1026
1163
 
1164
+ // Faille 12: restrict $lookup / $graphLookup targets to declared tenant collections
1165
+ if (allowedCollections && allowedCollections.length > 0) {
1166
+ const badTargets: string[] = [];
1167
+ walkAggregatePipeline(pipeline, (stage) => {
1168
+ const lookup = (stage as any).$lookup;
1169
+ if (lookup?.from && !allowedCollections.includes(lookup.from)) {
1170
+ badTargets.push(lookup.from);
1171
+ }
1172
+ const graph = (stage as any).$graphLookup;
1173
+ if (graph?.from && !allowedCollections.includes(graph.from)) {
1174
+ badTargets.push(graph.from);
1175
+ }
1176
+ });
1177
+ if (badTargets.length > 0) {
1178
+ return {
1179
+ isSafe: false,
1180
+ forbiddenKeys: badTargets,
1181
+ error: new AppError('Unauthorized $lookup target collection: ' + [...new Set(badTargets)].join(', '), { code: 'UNAUTHORIZED_LOOKUP_TARGET', status: 400 })
1182
+ };
1183
+ }
1184
+ }
1185
+
1027
1186
  // $project avec renommage : racine + imbriqué ($facet, $lookup.pipeline, …)
1028
1187
  let renameForbiddenKeys: string[] = [];
1029
1188
  let renameError: AppError | null = null;
@@ -1066,6 +1225,7 @@ export {
1066
1225
  reorder,
1067
1226
  omit,
1068
1227
  pick,
1228
+ unflattenKeys,
1069
1229
  toJson,
1070
1230
  stringToBoolean,
1071
1231
  cleanDeep,