@anteros/core 0.0.40 → 0.0.41

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 (59) hide show
  1. package/database/audit.ts +193 -0
  2. package/database/collection.ts +3 -3
  3. package/database/deleteLog.ts +34 -0
  4. package/database/file.ts +3 -3
  5. package/database/mongodbadapter.ts +321 -147
  6. package/database/replication.ts +780 -0
  7. package/database/schema.ts +140 -125
  8. package/database/tenant.ts +4 -0
  9. package/database/vars.ts +405 -0
  10. package/database/workflow.ts +6 -6
  11. package/index.ts +10 -0
  12. package/lib/audit.ts +271 -0
  13. package/lib/auditResult.ts +120 -0
  14. package/lib/define.ts +20 -1
  15. package/lib/files.ts +64 -11
  16. package/lib/lifecycle.ts +141 -0
  17. package/lib/redact.ts +117 -0
  18. package/lib/workflow.ts +1 -1
  19. package/package.json +1 -1
  20. package/server/api.ts +109 -25
  21. package/server/boot.ts +185 -53
  22. package/server/flags.ts +136 -0
  23. package/server/hono.ts +1 -1
  24. package/tests/audit-file.test.ts +296 -0
  25. package/tests/audit-operations.test.ts +184 -0
  26. package/tests/audit-redact.test.ts +233 -0
  27. package/tests/audit-results.test.ts +188 -0
  28. package/tests/audit-retention-config.test.ts +76 -0
  29. package/tests/audit-run-action.test.ts +120 -0
  30. package/tests/audit.test.ts +320 -0
  31. package/tests/db-cache.test.ts +7 -7
  32. package/tests/file-collection-replication.test.ts +98 -0
  33. package/tests/fixtures/lc-disabled/lifecycle.ts +11 -0
  34. package/tests/fixtures/lc-hang/lifecycle.ts +13 -0
  35. package/tests/fixtures/lc-normal/lifecycle.ts +23 -0
  36. package/tests/fixtures/lc-throw/lifecycle.ts +6 -0
  37. package/tests/fixtures/repl-only/app.ts +26 -0
  38. package/tests/fixtures/repl-only/tenant/collections/orders.model.ts +14 -0
  39. package/tests/fixtures/repl-only/tenant/lifecycle.ts +14 -0
  40. package/tests/fixtures/repl-only/tenant/scripts/probe.run.ts +8 -0
  41. package/tests/fixtures/vars-tenant/vars/config.var.ts +23 -0
  42. package/tests/fixtures/vars-tenant/vars/private.var.ts +6 -0
  43. package/tests/lifecycle.test.ts +96 -0
  44. package/tests/replication-control.test.ts +128 -0
  45. package/tests/replication-initial-sync.test.ts +86 -0
  46. package/tests/replication-only.test.ts +358 -0
  47. package/tests/replication.test.ts +188 -0
  48. package/tests/upload.test.ts +102 -1
  49. package/tests/vars-api.test.ts +123 -0
  50. package/tests/vars-replication.test.ts +95 -0
  51. package/tests/vars.test.ts +216 -0
  52. package/types/collection.d.ts +3 -0
  53. package/types/config.d.ts +64 -0
  54. package/types/file.d.ts +4 -1
  55. package/types/lifecycle.d.ts +71 -0
  56. package/types/replication.d.ts +199 -0
  57. package/types/tenant.d.ts +23 -0
  58. package/types/vars.d.ts +147 -0
  59. package/utils/func.ts +67 -52
@@ -0,0 +1,147 @@
1
+ import type { Field } from "./field";
2
+ import type { useRest } from "../database/rest";
3
+ import type { fn } from "../lib/error";
4
+ import type { jwt } from "../utils/func";
5
+
6
+ /** Actions exposed by the variables HTTP API (`POST /vars/:tenant_id/:action`). */
7
+ export type VarsActions =
8
+ | "set"
9
+ | "setMany"
10
+ | "get"
11
+ | "entry"
12
+ | "entries"
13
+ | "all"
14
+ | "del"
15
+ | "has"
16
+ | "incr"
17
+ | "expire"
18
+ | "clear";
19
+
20
+ /** Access rule context for a vars action. */
21
+ export type VarsAccessContext = {
22
+ rest: InstanceType<typeof useRest>;
23
+ /** Namespace targeted by the request. */
24
+ namespace: string;
25
+ /** Raw request body. */
26
+ body: any;
27
+ error: typeof fn.error;
28
+ jwt: typeof jwt;
29
+ token: { value: string | null; decoded: Record<string, unknown> | null; provided: boolean; expired: boolean };
30
+ };
31
+
32
+ /** Per-action access rules — `'*'` wildcard, same pattern as collections. */
33
+ export type VarsAccess = {
34
+ [key in VarsActions | (string & {})]?: boolean | ((ctx: VarsAccessContext) => boolean | Promise<boolean>);
35
+ };
36
+
37
+ /** HTTP API configuration of a vars namespace (declared in `define.Vars`). */
38
+ export type VarsApi = {
39
+ /** Access rules per action — **no rules = denied** (secure by default). */
40
+ access?: VarsAccess;
41
+ };
42
+
43
+ /**
44
+ * Value spec for a variable — a `Field` (minus `name`) plus an optional default
45
+ * TTL. Use it when a variable needs a type / validation / a default value / a TTL:
46
+ *
47
+ * ```ts
48
+ * vars: {
49
+ * maxUsers: 100, // shorthand: raw default
50
+ * licence: { type: 'string', defaultValue: 'RDX00', ttl: '30d' },
51
+ * }
52
+ * ```
53
+ */
54
+ export type VarSpec = Omit<Field, "name"> & {
55
+ /** Default TTL for this variable — human string (`'30m'`, `'2h'`, `'1d'`) or ms. */
56
+ ttl?: string | number;
57
+ };
58
+
59
+ /** Per-namespace replication opt-in (`define.Vars({ replication: { enabled: true } })`). */
60
+ export type VarsReplicationConfig = {
61
+ /** Replicate the `_vars_` documents of THIS namespace. */
62
+ enabled: boolean;
63
+ };
64
+
65
+ /**
66
+ * A tenant variable definition — `{tenant.dir}/vars/**\/*.var.ts`.
67
+ *
68
+ * - `scope` — **one** optional identity field (often a `relationship`): its value
69
+ * is part of the entry key, so each company/branch/environment gets its own value.
70
+ * - `meta` — annotation fields: validated, stored alongside — **never** part of the key.
71
+ * - `vars` — values: a raw default, or a `VarSpec`.
72
+ */
73
+ export type VarDefinition = {
74
+ _isVars_?: boolean;
75
+ /** Injected by the loader — the tenant this definition belongs to. */
76
+ _tenant_?: string;
77
+ /** Namespace grouping these variables (required). */
78
+ namespace: string;
79
+ /** Optional identity field — its value is part of the entry key. */
80
+ scope?: Field;
81
+ /** Annotation fields — validated, never part of the key. */
82
+ meta?: Field[];
83
+ /** Variables — a raw default value, or a `VarSpec`. */
84
+ vars: Record<string, any>;
85
+ /** HTTP API access rules — required to expose this namespace over `POST /vars/:tenant_id/:action`. */
86
+ api?: VarsApi;
87
+ /** Opt this namespace into replication — like a collection, but scoped to this namespace. */
88
+ replication?: VarsReplicationConfig;
89
+ };
90
+
91
+ /** A stored variable entry, as returned by `rest.vars.entry()` / `.entries()`. */
92
+ export type VarEntry<T = any> = {
93
+ ns: string;
94
+ key: string;
95
+ scope: any;
96
+ value: T;
97
+ meta: Record<string, any>;
98
+ expiresAt: Date | null;
99
+ createdAt?: Date;
100
+ updatedAt: Date;
101
+ };
102
+
103
+ /** Scope option shared by every `rest.vars` method. */
104
+ export type VarsOptions = { scope?: any };
105
+
106
+ export type VarsSetOptions = VarsOptions & {
107
+ /** Annotation values — validated against the namespace `meta` fields. */
108
+ meta?: Record<string, any>;
109
+ /** Time-to-live — human string or ms. Pass `null` to clear an existing TTL. */
110
+ ttl?: string | number | null;
111
+ };
112
+
113
+ export type VarsAllOptions = VarsOptions & {
114
+ /** Filter on `meta` fields (e.g. `{ note: 'x' }` → `{ 'meta.note': 'x' }`). */
115
+ where?: Record<string, any>;
116
+ };
117
+
118
+ /**
119
+ * Tenant-scoped key/value store — `rest.vars` (like Redis, MongoDB-backed).
120
+ * The tenant is implicit (like every `rest.*` accessor).
121
+ */
122
+ export type TenantVars = {
123
+ /** Set a value. `ttl` replaces the entry's TTL (omit to store without expiry). */
124
+ set(ns: string, key: string, value: any, opts?: VarsSetOptions): Promise<void>;
125
+ /** Set several keys at once (shared `meta` / `ttl`). */
126
+ setMany(ns: string, entries: Record<string, any>, opts?: VarsSetOptions): Promise<void>;
127
+ /** Read a value — `undefined` when missing or expired. */
128
+ get<T = any>(ns: string, key: string, opts?: VarsOptions): Promise<T | undefined>;
129
+ /** Read the full entry (value + meta + expiry). */
130
+ entry<T = any>(ns: string, key: string, opts?: VarsOptions): Promise<VarEntry<T> | undefined>;
131
+ /** All entries of a namespace (current scope). */
132
+ entries(ns: string, opts?: VarsAllOptions): Promise<VarEntry[]>;
133
+ /** All key/value pairs of a namespace (current scope). */
134
+ all(ns: string, opts?: VarsAllOptions): Promise<Record<string, any>>;
135
+ /** Delete a key — `true` when a key was removed. */
136
+ del(ns: string, key: string, opts?: VarsOptions): Promise<boolean>;
137
+ /** Whether a (non-expired) key exists. */
138
+ has(ns: string, key: string, opts?: VarsOptions): Promise<boolean>;
139
+ /** Atomic increment (default `1`) — does not clear an existing TTL. */
140
+ incr(ns: string, key: string, by?: number, opts?: VarsSetOptions): Promise<number>;
141
+ /** Set / renew the TTL of an existing key. */
142
+ expire(ns: string, key: string, ttl: string | number, opts?: VarsOptions): Promise<boolean>;
143
+ /** Delete every key of the namespace in the current scope. */
144
+ clear(ns: string, opts?: VarsOptions): Promise<number>;
145
+ /** Bound store with the scope pre-filled — `rest.vars.scope(companyId)`. */
146
+ scope(value: any): TenantVars;
147
+ };
package/utils/func.ts CHANGED
@@ -159,14 +159,14 @@ function pick<T extends DeepRecord>(data: T | T[], keys: string[]): Partial<T> |
159
159
  }
160
160
 
161
161
  /**
162
- * Convertit la dot-notation MongoDB en structure imbriquée pour la validation Joi.
162
+ * Converts MongoDB dot-notation into a nested structure for Joi validation.
163
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.
164
+ * Operator keys ($inc, $push…) are left as-is.
165
165
  */
166
166
  function unflattenKeys(input: Record<string, unknown>): Record<string, unknown> {
167
167
  const result: Record<string, unknown> = {};
168
168
  for (const [key, value] of Object.entries(input)) {
169
- // Opérateurs Mongo → laisser tel quel (interdits dans $set de toute façon)
169
+ // Mongo operators → leave as-is (forbidden in $set anyway)
170
170
  if (key.startsWith('$')) {
171
171
  result[key] = value;
172
172
  continue;
@@ -183,7 +183,7 @@ function unflattenKeys(input: Record<string, unknown>): Record<string, unknown>
183
183
  target = target[part];
184
184
  }
185
185
  const last = parts[parts.length - 1]!;
186
- // Fusion si les deux côtés sont des objets (ex. "a.b" + a: { c })
186
+ // Merge when both sides are objects (e.g. "a.b" + a: { c })
187
187
  if (
188
188
  target[last] &&
189
189
  typeof target[last] === 'object' &&
@@ -257,15 +257,15 @@ function cleanDeep<T extends DeepRecord | DeepRecord[]>(data: T): Partial<T> {
257
257
  }
258
258
 
259
259
  /**
260
- * Vérifie si une string est une date ISO 8601 valide.
261
- * Les timestamps numériques (ex: "1704067200") ne sont PAS convertis ici —
262
- * ils passent par `isEpochNumber` quand la valeur est un `number`.
260
+ * Checks whether a string is a valid ISO 8601 date.
261
+ * Numeric timestamps (e.g. "1704067200") are NOT converted here —
262
+ * they go through `isEpochNumber` when the value is a `number`.
263
263
  */
264
264
  function isDate(value: string): boolean {
265
265
  if (!value || typeof value !== 'string') return false;
266
266
  const trimmed = value.trim();
267
267
 
268
- // ISO 8601 : YYYY-MM-DD avec heure optionnelle
268
+ // ISO 8601: YYYY-MM-DD with optional time
269
269
  if (!/^\d{4}-\d{2}-\d{2}(T\d{2}:\d{2}(:\d{2}(\.\d{1,3})?)?(Z|[+-]\d{2}:\d{2})?)?$/.test(trimmed)) {
270
270
  return false;
271
271
  }
@@ -274,14 +274,14 @@ function isDate(value: string): boolean {
274
274
  return !isNaN(date.getTime());
275
275
  }
276
276
 
277
- /** Convertit une string ISO 8601 en Date. */
277
+ /** Converts an ISO 8601 string to a Date. */
278
278
  function stringToDate(value: string): Date {
279
279
  return new Date(value.trim());
280
280
  }
281
281
 
282
282
  /**
283
- * Nombre entier type timestamp UNIX (s ou ms).
284
- * Restreint à ≥ an 2000 pour éviter les faux positifs (ex: numéros de téléphone).
283
+ * Integer number of UNIX timestamp type (s or ms).
284
+ * Restricted to ≥ year 2000 to avoid false positives (e.g. phone numbers).
285
285
  */
286
286
  function isEpochNumber(value: number): boolean {
287
287
  if (typeof value !== 'number' || !Number.isFinite(value)) return false;
@@ -289,12 +289,12 @@ function isEpochNumber(value: number): boolean {
289
289
  if (int !== value) return false;
290
290
  const s = String(Math.abs(int));
291
291
  if (s.length === 10) {
292
- // Secondes UNIX : ≥ 2000-01-01
292
+ // UNIX seconds: ≥ 2000-01-01
293
293
  if (int < 946684800) return false;
294
294
  return !isNaN(new Date(int * 1000).getTime());
295
295
  }
296
296
  if (s.length === 13) {
297
- // Millisecondes UNIX : ≥ 2000-01-01
297
+ // UNIX milliseconds: ≥ 2000-01-01
298
298
  if (int < 946684800000) return false;
299
299
  return !isNaN(new Date(int).getTime());
300
300
  }
@@ -320,11 +320,11 @@ type FormatToDateResult<T> = T extends string
320
320
  ? { [K in keyof T]: FormatToDateResult<T[K]> }
321
321
  : T;
322
322
 
323
- /** Options pour filtrer les chemins ; sans options, tout ce qui passe `isDate` est converti en `Date`. */
323
+ /** Options to filter paths; without options, everything that passes `isDate` is converted to `Date`. */
324
324
  type FormatToDateOptions = {
325
- /** Noms de champs (notation pointée) à ne pas convertir ; correspondance exacte ou suffixe (ex. `$set.createdAt`). */
325
+ /** Field names (dot notation) not to convert; exact match or suffix (e.g. `$set.createdAt`). */
326
326
  omitKeys?: string[];
327
- /** Si défini et non vide : seuls ces champs sont convertis (équivalent à « tout sauf les non-date »). Correspondance exacte ou suffixe de chemin. */
327
+ /** If set and non-empty: only these fields are converted (equivalent to "everything except the non-dates"). Exact match or path suffix. */
328
328
  includeKeys?: string[];
329
329
  };
330
330
 
@@ -334,9 +334,9 @@ function joinFormatToDatePath(parent: string, segment: string | number): string
334
334
  }
335
335
 
336
336
  /**
337
- * Correspond à un nom de champ schéma (`createdAt`, `user.birthDate`)
338
- * y compris en profondeur (`$set.createdAt`) ou derrière un opérateur
339
- * MongoDB (`createdAt.$lte`, `$match.createdAt.$gte`).
337
+ * Matches a schema field name (`createdAt`, `user.birthDate`)
338
+ * including at depth (`$set.createdAt`) or behind a MongoDB
339
+ * operator (`createdAt.$lte`, `$match.createdAt.$gte`).
340
340
  */
341
341
  function pathMatchesFieldKey(path: string, key: string): boolean {
342
342
  // Strip trailing MongoDB operator segments: createdAt.$lte → createdAt
@@ -345,16 +345,16 @@ function pathMatchesFieldKey(path: string, key: string): boolean {
345
345
  }
346
346
 
347
347
  function shouldFormatDateAtPath(path: string, options?: FormatToDateOptions): boolean {
348
- // Aucune option → convertir partout où `isDate` est vrai
348
+ // No options → convert everywhere `isDate` is true
349
349
  if (!options) return true;
350
350
 
351
- // Contexte d'opérateur MongoDB ($lte, $gte, $eq, $in, $nin, $exists, etc.)
352
- // → toujours convertir : un opérateur sur une date n'a de sens qu'avec un vrai Date
353
- // On vérifie si un segment du chemin commence par '$' (ex: createdAt.$lte, $in.0)
351
+ // MongoDB operator context ($lte, $gte, $eq, $in, $nin, $exists, etc.)
352
+ // → always convert: an operator on a date only makes sense with a real Date
353
+ // We check whether a path segment starts with '$' (e.g. createdAt.$lte, $in.0)
354
354
  if (path.split('.').some((seg) => /^\$[a-z]+$/i.test(seg))) return true;
355
355
 
356
356
  if (options.omitKeys?.some((k) => pathMatchesFieldKey(path, k))) return false;
357
- // Whitelist seulement si includeKeys est un tableau non vide
357
+ // Whitelist only if includeKeys is a non-empty array
358
358
  if (options.includeKeys && options.includeKeys.length > 0) {
359
359
  return options.includeKeys.some((k) => pathMatchesFieldKey(path, k));
360
360
  }
@@ -366,7 +366,7 @@ function formatToDate(data: any, options?: FormatToDateOptions, path = ''): any
366
366
  if (data instanceof Date) return data;
367
367
  if (data instanceof ObjectId) return data;
368
368
 
369
- // String racine (ex: item de tableau $in) — vérifier avant la branche objet
369
+ // Root string (e.g. $in array item) — check before the object branch
370
370
  if (typeof data === 'string' && isDate(data)) {
371
371
  return shouldFormatDateAtPath(path, options) ? stringToDate(data) : data;
372
372
  }
@@ -391,7 +391,7 @@ function formatToDate(data: any, options?: FormatToDateOptions, path = ''): any
391
391
  }
392
392
  } else if (typeof value === 'number' && isEpochNumber(value)) {
393
393
  if (shouldFormatDateAtPath(p, options)) {
394
- // 10 chiffres = secondes (×1000), 13 chiffres = millisecondes (déjà en ms)
394
+ // 10 digits = seconds (×1000), 13 digits = milliseconds (already in ms)
395
395
  const absVal = Math.abs(Math.trunc(value));
396
396
  const asMs = String(absVal).length === 10 ? value * 1000 : value;
397
397
  result[key] = new Date(asMs);
@@ -467,9 +467,9 @@ export type GenerateRandomOptions = {
467
467
  useNumbers?: boolean;
468
468
  includeSymbols?: string;
469
469
  excludeSymbols?: string;
470
- /** Préfixe (string fixe ou fonction qui reçoit la valeur générée). Ex: `(v) => 'BP-' + v` */
470
+ /** Prefix (fixed string or function that receives the generated value). Ex: `(v) => 'BP-' + v` */
471
471
  startWith?: string | ((value: string) => string);
472
- /** Suffixe (string fixe ou fonction qui reçoit la valeur générée). Ex: `(v) => v + '-X'` */
472
+ /** Suffix (fixed string or function that receives the generated value). Ex: `(v) => v + '-X'` */
473
473
  endWith?: string | ((value: string) => string);
474
474
  toLowerCase?: boolean;
475
475
  toUpperCase?: boolean;
@@ -523,8 +523,8 @@ async function generateRandom(options: GenerateRandomOptions, ctx: {
523
523
  result += charset[randomIndex];
524
524
  }
525
525
 
526
- // String → préfixe/suffixe classique. Fonction → elle reçoit la valeur
527
- // et retourne le résultat final (pas de re-concaténation pour éviter la duplication).
526
+ // String → classic prefix/suffix. Function → it receives the value
527
+ // and returns the final result (no re-concatenation to avoid duplication).
528
528
  if (typeof startWith === 'function') {
529
529
  result = startWith(result);
530
530
  } else {
@@ -703,14 +703,14 @@ function buildPipeline(p: FindOptions, options?: {
703
703
  status: 400
704
704
  });
705
705
  }
706
- // Faille 13: bound $regex (ReDoS) and $in/$nin (memory DoS)
706
+ // Flaw 13: bound $regex (ReDoS) and $in/$nin (memory DoS)
707
707
  boundMatchOperators(p.$match);
708
708
  pipeline.push({
709
709
  $match: p.$match
710
710
  })
711
711
  }
712
712
 
713
- // $sort — Faille 14: values must be 1 or -1
713
+ // $sort — Flaw 14: values must be 1 or -1
714
714
  if (p?.$sort) {
715
715
  for (const [key, dir] of Object.entries(p.$sort)) {
716
716
  if (dir !== 1 && dir !== -1) {
@@ -722,7 +722,7 @@ function buildPipeline(p: FindOptions, options?: {
722
722
  }
723
723
  }
724
724
 
725
- // $skip — Faille 14: must be a safe non-negative integer
725
+ // $skip — Flaw 14: must be a safe non-negative integer
726
726
  if (p?.$skip !== undefined && p?.$skip !== null) {
727
727
  if (!Number.isSafeInteger(p.$skip) || p.$skip < 0) {
728
728
  throw new AppError('Invalid $skip (expected a non-negative integer)', {
@@ -829,7 +829,7 @@ function buildPipeline(p: FindOptions, options?: {
829
829
 
830
830
  let as = include.as || include.localField
831
831
 
832
- // Faille 3: never trust client-supplied $lookup sub-pipeline — scan it
832
+ // Flaw 3: never trust client-supplied $lookup sub-pipeline — scan it
833
833
  if (include.pipeline && Array.isArray(include.pipeline)) {
834
834
  const check = isSafeAggregatePipeline(include.pipeline as Array<Record<string, unknown>>)
835
835
  if (!check.isSafe) {
@@ -906,7 +906,7 @@ function clone<T>(data: T): T {
906
906
  }
907
907
 
908
908
  /**
909
- * Faille 13: bounds dangerous $match operators to prevent ReDoS ($regex) and memory DoS ($in).
909
+ * Flaw 13: bounds dangerous $match operators to prevent ReDoS ($regex) and memory DoS ($in).
910
910
  * Walks nested objects/arrays ($and, $or, $nor, dotted fields).
911
911
  */
912
912
  const MAX_REGEX_LENGTH = 1000;
@@ -960,19 +960,19 @@ function boundMatchOperators(node: unknown): void {
960
960
  .replace(/^-|-$/g, '');
961
961
  }
962
962
 
963
- /** Vérifie qu'une chaîne est un slug valide (lettres minuscules, chiffres, tirets simples) */
963
+ /** Checks that a string is a valid slug (lowercase letters, digits, single hyphens) */
964
964
  function isSlug(value: string): boolean {
965
965
  return /^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(value);
966
966
  }
967
967
 
968
968
 
969
969
  /**
970
- * Parcourt récursivement `data` (objet ou tableau, profondeur quelconque)
971
- * et retourne les clés de `keys` qui apparaissent au moins une fois comme
972
- * nom de propriété propre (own key) d'un objet.
973
- * @param data - L'objet ou le tableau à inspecter.
974
- * @param keys - Les clés prohibées à rechercher.
975
- * @returns Un tableau des clés prohibées trouvées.
970
+ * Recursively walks `data` (object or array, any depth)
971
+ * and returns the keys from `keys` that appear at least once as an
972
+ * own property name of an object.
973
+ * @param data - The object or array to inspect.
974
+ * @param keys - The forbidden keys to look for.
975
+ * @returns An array of the forbidden keys found.
976
976
  */
977
977
  function hasUnauthorizedKeys(data: unknown, keys: string[]): string[] {
978
978
  const found = new Set<string>();
@@ -1041,7 +1041,7 @@ function isAggregationFieldPathRef(value: unknown): value is string {
1041
1041
  return typeof value === 'string' && value.startsWith('$') && !value.startsWith('$$');
1042
1042
  }
1043
1043
 
1044
- /** Carte des clés de sortie → ref source pour tout renommage illicite (`out !==` dernier segment du chemin). */
1044
+ /** Map of output keys → source ref for any illicit rename (`out !==` last path segment). */
1045
1045
  function getProjectFieldRenames(project: unknown): Record<string, string> {
1046
1046
  const renames: Record<string, string> = {};
1047
1047
  if (!project || typeof project !== 'object' || Array.isArray(project)) {
@@ -1063,12 +1063,12 @@ function getProjectFieldRenames(project: unknown): Record<string, string> {
1063
1063
  }
1064
1064
 
1065
1065
  /**
1066
- * Vérifie les entrées `$project` qui renomment un champ via une référence de chemin
1067
- * Mongo (valeur string `"$..."`), ex. `{ pw: "$password" }`.
1066
+ * Checks `$project` entries that rename a field through a Mongo path
1067
+ * reference (string value `"$..."`), e.g. `{ pw: "$password" }`.
1068
1068
  *
1069
- * On considère qu'il y a « renommage » lorsque la clé de sortie diffère du dernier
1070
- * segment du chemin source (`password` pour `$password`, `email` pour `user.email`).
1071
- * @throws AppError 400 `UNAUTHORIZED_PROJECT_FIELD_RENAME` si un renommage est détecté
1069
+ * A "rename" is considered to occur when the output key differs from the last
1070
+ * segment of the source path (`password` for `$password`, `email` for `user.email`).
1071
+ * @throws AppError 400 `UNAUTHORIZED_PROJECT_FIELD_RENAME` if a rename is detected
1072
1072
  */
1073
1073
  function protectFieldRenameOnProject(project: unknown): Record<string, unknown> {
1074
1074
  if (!project || typeof project !== 'object' || Array.isArray(project)) {
@@ -1087,7 +1087,7 @@ function protectFieldRenameOnProject(project: unknown): Record<string, unknown>
1087
1087
 
1088
1088
 
1089
1089
  /**
1090
- * Parcourt récursivement un pipeline d'agrégation MongoDB (stages `$facet`,
1090
+ * Recursively walks a MongoDB aggregation pipeline (stages `$facet`,
1091
1091
  * `$lookup.pipeline`, `$unionWith.pipeline`, `$graphLookup.pipeline`).
1092
1092
  */
1093
1093
  function walkAggregatePipeline(
@@ -1151,7 +1151,7 @@ function isSafeAggregatePipeline(pipeline: Array<Record<string, unknown>>, allow
1151
1151
  error: AppError | null;
1152
1152
  } {
1153
1153
 
1154
- // Unauthorized pipeline keys — Faille 12: include cross-collection exfiltration stages
1154
+ // Unauthorized pipeline keys — Flaw 12: include cross-collection exfiltration stages
1155
1155
  const UNAUTHORIZED_PIPELINE_KEYS = [
1156
1156
  '$out', '$merge', '$function', '$where', '$accumulator',
1157
1157
  '$unionWith', '$collStats', '$indexStats', '$planCacheStats',
@@ -1166,7 +1166,7 @@ function isSafeAggregatePipeline(pipeline: Array<Record<string, unknown>>, allow
1166
1166
  }
1167
1167
  }
1168
1168
 
1169
- // Faille 12: restrict $lookup / $graphLookup targets to declared tenant collections
1169
+ // Flaw 12: restrict $lookup / $graphLookup targets to declared tenant collections
1170
1170
  if (allowedCollections && allowedCollections.length > 0) {
1171
1171
  const badTargets: string[] = [];
1172
1172
  walkAggregatePipeline(pipeline, (stage) => {
@@ -1188,7 +1188,7 @@ function isSafeAggregatePipeline(pipeline: Array<Record<string, unknown>>, allow
1188
1188
  }
1189
1189
  }
1190
1190
 
1191
- // $project avec renommage : racine + imbriqué ($facet, $lookup.pipeline, …)
1191
+ // $project with rename: root + nested ($facet, $lookup.pipeline, …)
1192
1192
  let renameForbiddenKeys: string[] = [];
1193
1193
  let renameError: AppError | null = null;
1194
1194
  walkAggregatePipeline(pipeline, (stage) => {
@@ -1225,6 +1225,20 @@ function isSafeAggregatePipeline(pipeline: Array<Record<string, unknown>>, allow
1225
1225
  }
1226
1226
  }
1227
1227
 
1228
+ /**
1229
+ * Parse a human duration (`'500ms'`, `'30s'`, `'5m'`, `'2h'`, `'1d'`) or a number
1230
+ * of milliseconds. Returns `null` when the value cannot be parsed.
1231
+ */
1232
+ function parseDuration(value: string | number): number | null {
1233
+ if (typeof value === 'number') return Number.isFinite(value) ? Math.max(0, value) : null;
1234
+ const match = String(value).trim().match(/^(\d+(?:\.\d+)?)\s*(ms|s|m|h|d)?$/i);
1235
+ if (!match) return null;
1236
+ const amount = Number.parseFloat(match[1] ?? '0');
1237
+ const unit = (match[2] ?? 'ms').toLowerCase();
1238
+ const factor = unit === 's' ? 1_000 : unit === 'm' ? 60_000 : unit === 'h' ? 3_600_000 : unit === 'd' ? 86_400_000 : 1;
1239
+ return Math.round(amount * factor);
1240
+ }
1241
+
1228
1242
  export {
1229
1243
  buildPipeline,
1230
1244
  reorder,
@@ -1253,6 +1267,7 @@ export {
1253
1267
  walkAggregatePipeline,
1254
1268
  countAggregatePipelineStages,
1255
1269
  isSafeAggregatePipeline,
1270
+ parseDuration,
1256
1271
  }
1257
1272
 
1258
1273
  export type { PaginationResult, FormatToDateOptions }