hierarchical-approval 0.6.0 → 0.8.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 (49) hide show
  1. package/CHANGELOG.md +112 -0
  2. package/README.md +84 -4
  3. package/dist/{ApprovalEngine-D3rHIli1.d.ts → ApprovalEngine-DFY53zRK.d.ts} +45 -7
  4. package/dist/{ApprovalEngine-CIQFYhsX.d.cts → ApprovalEngine-DVMMtiSm.d.cts} +45 -7
  5. package/dist/{IAuditAdapter-B_DhuPsU.d.cts → IAuditAdapter-BODIlw4h.d.cts} +1 -1
  6. package/dist/{IAuditAdapter-B3vvU09m.d.ts → IAuditAdapter-CaM3A2Kt.d.ts} +1 -1
  7. package/dist/{IAuthorizationPolicy-CZESF3CJ.d.ts → IAuthorizationPolicy-DFD0ELtO.d.ts} +1 -1
  8. package/dist/{IAuthorizationPolicy-B6JzRNUk.d.cts → IAuthorizationPolicy-cT7LSHtl.d.cts} +1 -1
  9. package/dist/{INotificationAdapter-DVVmXU6a.d.cts → INotificationAdapter-Ci0gf6ac.d.cts} +1 -1
  10. package/dist/{INotificationAdapter-BdfVjYa8.d.ts → INotificationAdapter-Hn70VShV.d.ts} +1 -1
  11. package/dist/{IOperationMiddleware-KGAwT9f-.d.ts → IOperationMiddleware-BCcAqSzT.d.ts} +1 -1
  12. package/dist/{IOperationMiddleware-CXgXmGUF.d.cts → IOperationMiddleware-DOlOraCX.d.cts} +1 -1
  13. package/dist/{IStorageAdapter-D-oPxxun.d.ts → IStorageAdapter-D7jsZT6O.d.ts} +1 -1
  14. package/dist/{IStorageAdapter-BibHlgPw.d.cts → IStorageAdapter-egPQD6Ry.d.cts} +1 -1
  15. package/dist/adapters/MemoryAdapter.d.cts +2 -2
  16. package/dist/adapters/MemoryAdapter.d.ts +2 -2
  17. package/dist/adapters/PostgresAdapter.d.cts +2 -2
  18. package/dist/adapters/PostgresAdapter.d.ts +2 -2
  19. package/dist/index.cjs +109 -10
  20. package/dist/index.cjs.map +1 -1
  21. package/dist/index.d.cts +8 -8
  22. package/dist/index.d.ts +8 -8
  23. package/dist/index.js +108 -11
  24. package/dist/index.js.map +1 -1
  25. package/dist/{instance-D8D7b07N.d.cts → instance-CtkmEkYa.d.cts} +34 -2
  26. package/dist/{instance-D8D7b07N.d.ts → instance-CtkmEkYa.d.ts} +34 -2
  27. package/dist/nestjs.cjs +107 -10
  28. package/dist/nestjs.cjs.map +1 -1
  29. package/dist/nestjs.d.cts +7 -7
  30. package/dist/nestjs.d.ts +7 -7
  31. package/dist/nestjs.js +107 -10
  32. package/dist/nestjs.js.map +1 -1
  33. package/dist/plugins/audit.d.cts +2 -2
  34. package/dist/plugins/audit.d.ts +2 -2
  35. package/dist/plugins/notify.d.cts +2 -2
  36. package/dist/plugins/notify.d.ts +2 -2
  37. package/dist/plugins/resilience.d.cts +3 -3
  38. package/dist/plugins/resilience.d.ts +3 -3
  39. package/dist/plugins/tracing.d.cts +2 -2
  40. package/dist/plugins/tracing.d.ts +2 -2
  41. package/dist/plugins/webhook.d.cts +2 -2
  42. package/dist/plugins/webhook.d.ts +2 -2
  43. package/dist/testing.cjs +107 -10
  44. package/dist/testing.cjs.map +1 -1
  45. package/dist/testing.d.cts +7 -7
  46. package/dist/testing.d.ts +7 -7
  47. package/dist/testing.js +107 -10
  48. package/dist/testing.js.map +1 -1
  49. package/package.json +1 -1
package/CHANGELOG.md CHANGED
@@ -7,6 +7,118 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
8
  _Nothing yet._
9
9
 
10
+ ## [0.8.0] - 2026-09-04
11
+
12
+ ### Added — boolean condition expressions
13
+
14
+ - **A rule's `when` now accepts `all`, `any` and `not` groups, nestable to any
15
+ depth.** Conditions previously supported a single test or an array meaning
16
+ AND, so "escalate when the amount is large **or** the vendor is high-risk"
17
+ could not be written as one rule — it took two rules with duplicated
18
+ `addLevels`, and anything involving negation or a mix of AND and OR had no
19
+ expression at all.
20
+
21
+ ```ts
22
+ when: {
23
+ any: [
24
+ { all: [
25
+ { field: 'amount', operator: '>', value: 1000 },
26
+ { field: 'dept', operator: '==', value: 'engineering' },
27
+ ] },
28
+ { field: 'override', operator: '==', value: true },
29
+ ],
30
+ }
31
+ ```
32
+
33
+ A group sets exactly one combinator. `any` short-circuits on the first child
34
+ that holds. New `ConditionExpression` and `ConditionGroup` types are exported
35
+ from the package root.
36
+
37
+ **Fully backward compatible.** A bare condition still works, and an array is
38
+ shorthand for `all` — exactly what `when: [...]` already meant.
39
+
40
+ - **Condition trees are validated when the template is defined.** `validateTemplate()`
41
+ and `defineTemplate()` now reject an empty `all`/`any`, a group setting more
42
+ than one combinator, a non-array `all`/`any`, and a leaf missing its `field`
43
+ or `operator` — reporting the offending path, e.g.
44
+ `conditions[0].when.any[1].operator`. Previously a malformed condition was
45
+ only discovered at submit time, on a real document.
46
+
47
+ Operator *names* are deliberately not checked at definition time, because
48
+ custom operators can be registered after a template is defined; an unknown
49
+ operator still throws when the condition is evaluated.
50
+
51
+ - **`validateConditionExpression(expression, path)` is exported** for callers
52
+ that build condition trees dynamically and want to check one before handing it
53
+ to a template. It collects every problem rather than throwing on the first.
54
+
55
+ ## [0.7.0] - 2026-09-04
56
+
57
+ ### Fixed — two condition-evaluation bypasses
58
+
59
+ Both of these let a `ConditionRule` fire when it should not have, and because
60
+ conditions decide which levels an instance gets, a spurious match on a
61
+ `skipLevels` rule **removes approval levels from a live document**. Anyone using
62
+ `skipLevels` — or `addLevels` to *escalate* above a threshold — should upgrade.
63
+
64
+ - **Numeric operators no longer coerce non-numbers to zero.** `>`, `<`, `>=` and
65
+ `<=` compared with `Number(actual)`, and `Number()` maps `null`, `''`, `' '`,
66
+ `[]` and `false` all to `0`. So a fast-track rule like
67
+ `{ when: { field: 'amount', operator: '<', value: 5000 }, skipLevels: [2, 3] }`
68
+ matched a purchase order whose `amount` was `null` or blank, silently skipping
69
+ two approval levels on exactly the documents whose value was unknown. `false`
70
+ and `[]` did the same, and `true` compared as `1`.
71
+
72
+ A numeric comparison against a non-number is now treated as *undecidable* rather
73
+ than false-y: it reports **no match**, which is the outcome `undefined` has
74
+ always produced. Accepted operands are finite numbers, bigints, `Date`
75
+ (compared as epoch milliseconds), and numeric strings such as `'100'` or
76
+ `' 1e3 '` — ERP payloads routinely arrive as JSON strings, so string comparison
77
+ is retained. Rejected: `null`, `undefined`, booleans, arrays, objects, blank
78
+ strings, `NaN` and `Infinity`.
79
+
80
+ **Behaviour change.** A rule that was matching on blank or boolean data stops
81
+ matching. That is the fix, but it does change which levels such an instance
82
+ gets, so re-check any template whose conditions run against optional fields.
83
+ `==` and `!=` are untouched — they were already strict.
84
+
85
+ - **Dot-path field lookup now reads own properties only.** `getField` tested
86
+ `key in obj`, which walks the prototype chain, so a condition on `isFastTrack`
87
+ was satisfied by an inherited `Object.prototype.isFastTrack` that no document
88
+ ever declared — turning any prototype pollution elsewhere in the dependency
89
+ tree into an approval-level bypass. Resolution now uses
90
+ `Object.prototype.hasOwnProperty`, so a segment that is not an own property
91
+ resolves to `undefined` exactly as an absent field does. This also closes
92
+ `__proto__`, `constructor` and `prototype` as readable paths.
93
+
94
+ **Behaviour change.** Context data whose fields live on a prototype (a class
95
+ instance with getters, rather than a plain object) no longer resolves. Plain
96
+ objects, arrays, array indices and `Object.create(null)` objects are
97
+ unaffected, and context data does not survive JSONB round-tripping as a class
98
+ instance in any case.
99
+
100
+ ### Added
101
+
102
+ - **`toComparableNumber(value)` is exported from the package root.** The same
103
+ strict coercion the built-in numeric operators use, returning `number` or
104
+ `null`, so a custom operator registered with `registerConditionOperator()` can
105
+ inherit the identical semantics instead of re-introducing `Number()`. The
106
+ README's `between` recipe now uses it — the previous version of that snippet
107
+ demonstrated the zero-coercion bug.
108
+
109
+ ### Tests
110
+
111
+ - Restored the **17 `ConditionEvaluator` unit tests that commit `8d648d8`
112
+ deleted**, having replaced the suite body with a `// ... existing tests ...`
113
+ placeholder and a single test. Coverage of the evaluator had silently dropped
114
+ to 74% of statements and 56% of functions.
115
+ - Added 36 tests across the two fixes (per-type non-comparable operand matrix for
116
+ each numeric operator, the `skipLevels` bypass scenario, prototype-pollution
117
+ guards, `toComparableNumber` directly) plus an executable copy of the README's
118
+ custom-operator recipes, so a documented snippet cannot rot or stop compiling.
119
+ All 24 of the pre-existing-bug assertions were confirmed to fail against the
120
+ unfixed source.
121
+
10
122
  ## [0.6.0] - 2026-08-21
11
123
 
12
124
  ### Fixed — event delivery, template reads, and the CI lint gate
package/README.md CHANGED
@@ -305,6 +305,74 @@ conditions: [
305
305
 
306
306
  **Built-in operators:** `>`, `<`, `>=`, `<=`, `==`, `!=`, `in`, `not_in`
307
307
 
308
+ #### Combining conditions
309
+
310
+ A `when` can be a single condition, an array (every element must hold), or a
311
+ boolean group — `all`, `any`, `not` — which nest to any depth:
312
+
313
+ ```ts
314
+ conditions: [
315
+ {
316
+ // (amount > 1000 AND dept is engineering) OR an explicit override flag
317
+ when: {
318
+ any: [
319
+ {
320
+ all: [
321
+ { field: 'amount', operator: '>', value: 1000 },
322
+ { field: 'dept', operator: '==', value: 'engineering' },
323
+ ],
324
+ },
325
+ { field: 'override', operator: '==', value: true },
326
+ ],
327
+ },
328
+ addLevels: [{ level: 3, name: 'CFO', approvers: [{ type: 'user', userId: 'cfo' }], mode: 'any' }],
329
+ },
330
+ {
331
+ // Everything outside the US skips the domestic finance review
332
+ when: { not: { field: 'region', operator: '==', value: 'US' } },
333
+ skipLevels: [2],
334
+ },
335
+ ]
336
+ ```
337
+
338
+ A group sets exactly one of `all` / `any` / `not`. `any` short-circuits on the
339
+ first child that holds. An array is shorthand for `all`, so every template
340
+ written before groups existed keeps working unchanged.
341
+
342
+ Groups are checked when the template is defined, not when a document is
343
+ submitted — `validateTemplate()` and `defineTemplate()` reject an empty `all`,
344
+ a group setting two combinators, or a leaf missing its `field`/`operator`, and
345
+ report the offending path (`conditions[0].when.any[1].operator`). Operator
346
+ *names* are deliberately not checked there, since custom operators may be
347
+ registered after the template is defined.
348
+
349
+ #### How values are compared
350
+
351
+ `==` and `!=` compare **strictly** — `'100'` does not equal `100`.
352
+
353
+ The numeric operators (`>`, `<`, `>=`, `<=`) match only when **both** sides are
354
+ unambiguously numeric: finite numbers, bigints, `Date` (compared as epoch
355
+ milliseconds), and numeric strings such as `'100'` or `' 1e3 '` — because ERP
356
+ payloads routinely arrive as JSON strings. Anything else — `null`, `undefined`,
357
+ `true`/`false`, arrays, objects, blank strings, `NaN`, `Infinity` — is treated as
358
+ *not comparable*, and the condition reports **no match**.
359
+
360
+ This matters for safety. A rule that skips levels on small amounts must not fire
361
+ on a document whose amount was never populated:
362
+
363
+ ```ts
364
+ { when: { field: 'amount', operator: '<', value: 5000 }, skipLevels: [2, 3] }
365
+
366
+ // amount: 4999 -> matches, levels 2 and 3 are skipped
367
+ // amount: null -> NO match, every level is kept
368
+ // amount: '' -> NO match, every level is kept
369
+ ```
370
+
371
+ Field paths resolve **own properties only**, so an inherited or polluted
372
+ `Object.prototype` member can never satisfy a condition. A path segment that is
373
+ not an own property — including `__proto__`, `constructor`, and `prototype` —
374
+ resolves to `undefined`, exactly as a genuinely absent field does.
375
+
308
376
  **Register custom operators** at engine level:
309
377
 
310
378
  ```ts
@@ -312,11 +380,23 @@ engine.registerConditionOperator(
312
380
  'contains',
313
381
  (actual, expected) => typeof actual === 'string' && actual.includes(String(expected)),
314
382
  );
383
+ ```
315
384
 
316
- engine.registerConditionOperator(
317
- 'between',
318
- (actual, [min, max]: number[]) => Number(actual) >= min && Number(actual) <= max,
319
- );
385
+ Custom numeric operators should reuse the same strictness via the exported
386
+ `toComparableNumber` helper, which returns `null` for anything that is not
387
+ unambiguously a number:
388
+
389
+ ```ts
390
+ import { toComparableNumber } from 'hierarchical-approval';
391
+
392
+ engine.registerConditionOperator('between', (actual, expected) => {
393
+ const value = toComparableNumber(actual);
394
+ if (value === null || !Array.isArray(expected) || expected.length !== 2) return false;
395
+ const min = toComparableNumber(expected[0]);
396
+ const max = toComparableNumber(expected[1]);
397
+ if (min === null || max === null) return false;
398
+ return value >= min && value <= max;
399
+ });
320
400
  ```
321
401
 
322
402
  ---
@@ -1,14 +1,14 @@
1
- import { I as IStorageAdapter, P as PaginationOpts, a as PaginatedResult, b as InstanceFilter, C as CursorPaginationOpts, c as CursorPaginatedResult } from './IStorageAdapter-D-oPxxun.js';
2
- import { m as ResolverFn, g as ApprovalTemplateConfig, A as ApprovalTemplate, j as AuditContext, a as ApprovalInstance, e as ApprovalMode, b as AuditEntry } from './instance-D8D7b07N.js';
3
- import { I as INotificationAdapter, b as ApprovalEventName, a as ApprovalEventMap } from './INotificationAdapter-BdfVjYa8.js';
1
+ import { I as IStorageAdapter, P as PaginationOpts, a as PaginatedResult, b as InstanceFilter, C as CursorPaginationOpts, c as CursorPaginatedResult } from './IStorageAdapter-D7jsZT6O.js';
2
+ import { k as ConditionExpression, o as ResolverFn, g as ApprovalTemplateConfig, A as ApprovalTemplate, j as AuditContext, a as ApprovalInstance, e as ApprovalMode, b as AuditEntry } from './instance-CtkmEkYa.js';
3
+ import { I as INotificationAdapter, b as ApprovalEventName, a as ApprovalEventMap } from './INotificationAdapter-Hn70VShV.js';
4
4
  import { z } from 'zod';
5
5
  import { L as Logger } from './Logger-BplhlU7l.js';
6
6
  import { C as Clock } from './Clock-3FnOczFJ.js';
7
- import { I as IOperationMiddleware, a as ApprovalError } from './IOperationMiddleware-KGAwT9f-.js';
8
- import { I as IAuditAdapter } from './IAuditAdapter-B3vvU09m.js';
7
+ import { I as IOperationMiddleware, a as ApprovalError } from './IOperationMiddleware-BCcAqSzT.js';
8
+ import { I as IAuditAdapter } from './IAuditAdapter-CaM3A2Kt.js';
9
9
  import { I as IMetricsAdapter } from './IMetricsAdapter-D9PUz4tM.js';
10
10
  import { I as ISchedulerAdapter } from './ISchedulerAdapter-DKv_QjVN.js';
11
- import { I as IAuthorizationPolicy } from './IAuthorizationPolicy-CZESF3CJ.js';
11
+ import { I as IAuthorizationPolicy } from './IAuthorizationPolicy-DFD0ELtO.js';
12
12
 
13
13
  declare const SubmitOptionsSchema: z.ZodObject<{
14
14
  templateName: z.ZodString;
@@ -130,6 +130,44 @@ type ApproverResolverFn = (config: Record<string, unknown>, ctx: {
130
130
  }) => Promise<string[]> | string[];
131
131
 
132
132
  type ConditionOperatorFn = (actual: unknown, expected: unknown) => boolean;
133
+ /**
134
+ * Coerce a value to a number *only* when it unambiguously represents one.
135
+ *
136
+ * Plain `Number()` maps `null`, `''`, `' '`, `[]` and `false` all to `0`, which
137
+ * in an approval engine is an approval-bypass hazard: a rule such as
138
+ * `{ amount: '<' 5000 } -> skipLevels: [2, 3]` would fire on a document whose
139
+ * `amount` is missing or blank, silently skipping two approval levels. Numeric
140
+ * comparison against a value that is not a number is not "false-y", it is
141
+ * *undecidable*, so this returns `null` and the comparison reports no match —
142
+ * the same outcome `undefined` has always produced.
143
+ *
144
+ * Accepted: finite numbers, bigints, `Date` (compared as epoch ms), and numeric
145
+ * strings such as `'100'` or `' 1e3 '` (ERP payloads routinely arrive as JSON
146
+ * strings). Rejected: `null`, `undefined`, booleans, arrays, objects, blank
147
+ * strings, and the non-finite `NaN` / `Infinity`.
148
+ *
149
+ * @param value - The raw value taken from the condition or the context data.
150
+ * @returns The numeric value, or `null` when the value is not comparable.
151
+ */
152
+ declare function toComparableNumber(value: unknown): number | null;
153
+ /**
154
+ * Statically check a condition expression tree, collecting every problem rather
155
+ * than throwing on the first.
156
+ *
157
+ * Runs at template-definition time so a malformed group is caught while the
158
+ * author is looking at it, instead of at submit time on a real document. The
159
+ * operator check is deliberately deferred: custom operators can be registered
160
+ * after a template is defined, so an unknown name is only an error once the
161
+ * condition is actually evaluated.
162
+ *
163
+ * @param expression - The `when` expression to check.
164
+ * @param path - Field path prefix used in reported errors.
165
+ * @returns One entry per problem found; empty when the tree is well formed.
166
+ */
167
+ declare function validateConditionExpression(expression: ConditionExpression, path: string): Array<{
168
+ field: string;
169
+ message: string;
170
+ }>;
133
171
 
134
172
  interface ValidationResult {
135
173
  valid: boolean;
@@ -415,4 +453,4 @@ declare class ApprovalEngine {
415
453
  private runExternalAudit;
416
454
  }
417
455
 
418
- export { type AddCommentOptions as A, type BulkResult as B, type CanApproveResult as C, type DelegateOptions as D, type EscalateOptions as E, type HealthResult as H, type IdGeneratorFn as I, type OrgProvider as O, type PreviewChainLevel as P, type ReassignOptions as R, type SubmitOptions as S, type ValidationResult as V, type WeekendCalendarOptions as W, ApprovalEngine as a, type ApprovalEngineOptions as b, type ApprovalStatistics as c, type ApproveOptions as d, type ApproverResolverFn as e, type BusinessCalendar as f, type CancelOptions as g, type ConditionOperatorFn as h, type CycleTimeStats as i, type IdempotencyKeyFn as j, type OverrideOptions as k, type PreviewResult as l, type RejectOptions as m, type ResubmitOptions as n, type RetryPolicy as o, defaultIdGenerator as p, weekendCalendar as w };
456
+ export { type AddCommentOptions as A, type BulkResult as B, type CanApproveResult as C, type DelegateOptions as D, type EscalateOptions as E, type HealthResult as H, type IdGeneratorFn as I, type OrgProvider as O, type PreviewChainLevel as P, type ReassignOptions as R, type SubmitOptions as S, type ValidationResult as V, type WeekendCalendarOptions as W, ApprovalEngine as a, type ApprovalEngineOptions as b, type ApprovalStatistics as c, type ApproveOptions as d, type ApproverResolverFn as e, type BusinessCalendar as f, type CancelOptions as g, type ConditionOperatorFn as h, type CycleTimeStats as i, type IdempotencyKeyFn as j, type OverrideOptions as k, type PreviewResult as l, type RejectOptions as m, type ResubmitOptions as n, type RetryPolicy as o, defaultIdGenerator as p, toComparableNumber as t, validateConditionExpression as v, weekendCalendar as w };
@@ -1,14 +1,14 @@
1
- import { I as IStorageAdapter, P as PaginationOpts, a as PaginatedResult, b as InstanceFilter, C as CursorPaginationOpts, c as CursorPaginatedResult } from './IStorageAdapter-BibHlgPw.cjs';
2
- import { m as ResolverFn, g as ApprovalTemplateConfig, A as ApprovalTemplate, j as AuditContext, a as ApprovalInstance, e as ApprovalMode, b as AuditEntry } from './instance-D8D7b07N.cjs';
3
- import { I as INotificationAdapter, b as ApprovalEventName, a as ApprovalEventMap } from './INotificationAdapter-DVVmXU6a.cjs';
1
+ import { I as IStorageAdapter, P as PaginationOpts, a as PaginatedResult, b as InstanceFilter, C as CursorPaginationOpts, c as CursorPaginatedResult } from './IStorageAdapter-egPQD6Ry.cjs';
2
+ import { k as ConditionExpression, o as ResolverFn, g as ApprovalTemplateConfig, A as ApprovalTemplate, j as AuditContext, a as ApprovalInstance, e as ApprovalMode, b as AuditEntry } from './instance-CtkmEkYa.cjs';
3
+ import { I as INotificationAdapter, b as ApprovalEventName, a as ApprovalEventMap } from './INotificationAdapter-Ci0gf6ac.cjs';
4
4
  import { z } from 'zod';
5
5
  import { L as Logger } from './Logger-BplhlU7l.cjs';
6
6
  import { C as Clock } from './Clock-3FnOczFJ.cjs';
7
- import { I as IOperationMiddleware, a as ApprovalError } from './IOperationMiddleware-CXgXmGUF.cjs';
8
- import { I as IAuditAdapter } from './IAuditAdapter-B_DhuPsU.cjs';
7
+ import { I as IOperationMiddleware, a as ApprovalError } from './IOperationMiddleware-DOlOraCX.cjs';
8
+ import { I as IAuditAdapter } from './IAuditAdapter-BODIlw4h.cjs';
9
9
  import { I as IMetricsAdapter } from './IMetricsAdapter-D9PUz4tM.cjs';
10
10
  import { I as ISchedulerAdapter } from './ISchedulerAdapter-DKv_QjVN.cjs';
11
- import { I as IAuthorizationPolicy } from './IAuthorizationPolicy-B6JzRNUk.cjs';
11
+ import { I as IAuthorizationPolicy } from './IAuthorizationPolicy-cT7LSHtl.cjs';
12
12
 
13
13
  declare const SubmitOptionsSchema: z.ZodObject<{
14
14
  templateName: z.ZodString;
@@ -130,6 +130,44 @@ type ApproverResolverFn = (config: Record<string, unknown>, ctx: {
130
130
  }) => Promise<string[]> | string[];
131
131
 
132
132
  type ConditionOperatorFn = (actual: unknown, expected: unknown) => boolean;
133
+ /**
134
+ * Coerce a value to a number *only* when it unambiguously represents one.
135
+ *
136
+ * Plain `Number()` maps `null`, `''`, `' '`, `[]` and `false` all to `0`, which
137
+ * in an approval engine is an approval-bypass hazard: a rule such as
138
+ * `{ amount: '<' 5000 } -> skipLevels: [2, 3]` would fire on a document whose
139
+ * `amount` is missing or blank, silently skipping two approval levels. Numeric
140
+ * comparison against a value that is not a number is not "false-y", it is
141
+ * *undecidable*, so this returns `null` and the comparison reports no match —
142
+ * the same outcome `undefined` has always produced.
143
+ *
144
+ * Accepted: finite numbers, bigints, `Date` (compared as epoch ms), and numeric
145
+ * strings such as `'100'` or `' 1e3 '` (ERP payloads routinely arrive as JSON
146
+ * strings). Rejected: `null`, `undefined`, booleans, arrays, objects, blank
147
+ * strings, and the non-finite `NaN` / `Infinity`.
148
+ *
149
+ * @param value - The raw value taken from the condition or the context data.
150
+ * @returns The numeric value, or `null` when the value is not comparable.
151
+ */
152
+ declare function toComparableNumber(value: unknown): number | null;
153
+ /**
154
+ * Statically check a condition expression tree, collecting every problem rather
155
+ * than throwing on the first.
156
+ *
157
+ * Runs at template-definition time so a malformed group is caught while the
158
+ * author is looking at it, instead of at submit time on a real document. The
159
+ * operator check is deliberately deferred: custom operators can be registered
160
+ * after a template is defined, so an unknown name is only an error once the
161
+ * condition is actually evaluated.
162
+ *
163
+ * @param expression - The `when` expression to check.
164
+ * @param path - Field path prefix used in reported errors.
165
+ * @returns One entry per problem found; empty when the tree is well formed.
166
+ */
167
+ declare function validateConditionExpression(expression: ConditionExpression, path: string): Array<{
168
+ field: string;
169
+ message: string;
170
+ }>;
133
171
 
134
172
  interface ValidationResult {
135
173
  valid: boolean;
@@ -415,4 +453,4 @@ declare class ApprovalEngine {
415
453
  private runExternalAudit;
416
454
  }
417
455
 
418
- export { type AddCommentOptions as A, type BulkResult as B, type CanApproveResult as C, type DelegateOptions as D, type EscalateOptions as E, type HealthResult as H, type IdGeneratorFn as I, type OrgProvider as O, type PreviewChainLevel as P, type ReassignOptions as R, type SubmitOptions as S, type ValidationResult as V, type WeekendCalendarOptions as W, ApprovalEngine as a, type ApprovalEngineOptions as b, type ApprovalStatistics as c, type ApproveOptions as d, type ApproverResolverFn as e, type BusinessCalendar as f, type CancelOptions as g, type ConditionOperatorFn as h, type CycleTimeStats as i, type IdempotencyKeyFn as j, type OverrideOptions as k, type PreviewResult as l, type RejectOptions as m, type ResubmitOptions as n, type RetryPolicy as o, defaultIdGenerator as p, weekendCalendar as w };
456
+ export { type AddCommentOptions as A, type BulkResult as B, type CanApproveResult as C, type DelegateOptions as D, type EscalateOptions as E, type HealthResult as H, type IdGeneratorFn as I, type OrgProvider as O, type PreviewChainLevel as P, type ReassignOptions as R, type SubmitOptions as S, type ValidationResult as V, type WeekendCalendarOptions as W, ApprovalEngine as a, type ApprovalEngineOptions as b, type ApprovalStatistics as c, type ApproveOptions as d, type ApproverResolverFn as e, type BusinessCalendar as f, type CancelOptions as g, type ConditionOperatorFn as h, type CycleTimeStats as i, type IdempotencyKeyFn as j, type OverrideOptions as k, type PreviewResult as l, type RejectOptions as m, type ResubmitOptions as n, type RetryPolicy as o, defaultIdGenerator as p, toComparableNumber as t, validateConditionExpression as v, weekendCalendar as w };
@@ -1,4 +1,4 @@
1
- import { b as AuditEntry, a as ApprovalInstance } from './instance-D8D7b07N.cjs';
1
+ import { b as AuditEntry, a as ApprovalInstance } from './instance-CtkmEkYa.cjs';
2
2
 
3
3
  interface IAuditAdapter {
4
4
  /**
@@ -1,4 +1,4 @@
1
- import { b as AuditEntry, a as ApprovalInstance } from './instance-D8D7b07N.js';
1
+ import { b as AuditEntry, a as ApprovalInstance } from './instance-CtkmEkYa.js';
2
2
 
3
3
  interface IAuditAdapter {
4
4
  /**
@@ -1,4 +1,4 @@
1
- import { a as ApprovalInstance, d as ApprovalLevelInstance } from './instance-D8D7b07N.js';
1
+ import { a as ApprovalInstance, d as ApprovalLevelInstance } from './instance-CtkmEkYa.js';
2
2
 
3
3
  interface AuthorizationContext {
4
4
  operation: 'submit' | 'approve' | 'reject' | 'delegate' | 'reassign' | 'cancel' | 'escalate' | 'override' | 'resubmit' | 'addComment';
@@ -1,4 +1,4 @@
1
- import { a as ApprovalInstance, d as ApprovalLevelInstance } from './instance-D8D7b07N.cjs';
1
+ import { a as ApprovalInstance, d as ApprovalLevelInstance } from './instance-CtkmEkYa.cjs';
2
2
 
3
3
  interface AuthorizationContext {
4
4
  operation: 'submit' | 'approve' | 'reject' | 'delegate' | 'reassign' | 'cancel' | 'escalate' | 'override' | 'resubmit' | 'addComment';
@@ -1,4 +1,4 @@
1
- import { a as ApprovalInstance, b as AuditEntry } from './instance-D8D7b07N.cjs';
1
+ import { a as ApprovalInstance, b as AuditEntry } from './instance-CtkmEkYa.cjs';
2
2
 
3
3
  interface ApprovalEvent {
4
4
  instanceId: string;
@@ -1,4 +1,4 @@
1
- import { a as ApprovalInstance, b as AuditEntry } from './instance-D8D7b07N.js';
1
+ import { a as ApprovalInstance, b as AuditEntry } from './instance-CtkmEkYa.js';
2
2
 
3
3
  interface ApprovalEvent {
4
4
  instanceId: string;
@@ -1,4 +1,4 @@
1
- import { a as ApprovalInstance } from './instance-D8D7b07N.js';
1
+ import { a as ApprovalInstance } from './instance-CtkmEkYa.js';
2
2
 
3
3
  declare class ApprovalError extends Error {
4
4
  readonly code: string;
@@ -1,4 +1,4 @@
1
- import { a as ApprovalInstance } from './instance-D8D7b07N.cjs';
1
+ import { a as ApprovalInstance } from './instance-CtkmEkYa.cjs';
2
2
 
3
3
  declare class ApprovalError extends Error {
4
4
  readonly code: string;
@@ -1,4 +1,4 @@
1
- import { A as ApprovalTemplate, a as ApprovalInstance, f as ApprovalStatus, b as AuditEntry } from './instance-D8D7b07N.js';
1
+ import { A as ApprovalTemplate, a as ApprovalInstance, f as ApprovalStatus, b as AuditEntry } from './instance-CtkmEkYa.js';
2
2
 
3
3
  interface PaginationOpts {
4
4
  limit: number;
@@ -1,4 +1,4 @@
1
- import { A as ApprovalTemplate, a as ApprovalInstance, f as ApprovalStatus, b as AuditEntry } from './instance-D8D7b07N.cjs';
1
+ import { A as ApprovalTemplate, a as ApprovalInstance, f as ApprovalStatus, b as AuditEntry } from './instance-CtkmEkYa.cjs';
2
2
 
3
3
  interface PaginationOpts {
4
4
  limit: number;
@@ -1,5 +1,5 @@
1
- import { I as IStorageAdapter, P as PaginationOpts, a as PaginatedResult, b as InstanceFilter, C as CursorPaginationOpts, c as CursorPaginatedResult } from '../IStorageAdapter-BibHlgPw.cjs';
2
- import { A as ApprovalTemplate, a as ApprovalInstance, b as AuditEntry } from '../instance-D8D7b07N.cjs';
1
+ import { I as IStorageAdapter, P as PaginationOpts, a as PaginatedResult, b as InstanceFilter, C as CursorPaginationOpts, c as CursorPaginatedResult } from '../IStorageAdapter-egPQD6Ry.cjs';
2
+ import { A as ApprovalTemplate, a as ApprovalInstance, b as AuditEntry } from '../instance-CtkmEkYa.cjs';
3
3
 
4
4
  declare class MemoryAdapter implements IStorageAdapter {
5
5
  private templates;
@@ -1,5 +1,5 @@
1
- import { I as IStorageAdapter, P as PaginationOpts, a as PaginatedResult, b as InstanceFilter, C as CursorPaginationOpts, c as CursorPaginatedResult } from '../IStorageAdapter-D-oPxxun.js';
2
- import { A as ApprovalTemplate, a as ApprovalInstance, b as AuditEntry } from '../instance-D8D7b07N.js';
1
+ import { I as IStorageAdapter, P as PaginationOpts, a as PaginatedResult, b as InstanceFilter, C as CursorPaginationOpts, c as CursorPaginatedResult } from '../IStorageAdapter-D7jsZT6O.js';
2
+ import { A as ApprovalTemplate, a as ApprovalInstance, b as AuditEntry } from '../instance-CtkmEkYa.js';
3
3
 
4
4
  declare class MemoryAdapter implements IStorageAdapter {
5
5
  private templates;
@@ -1,7 +1,7 @@
1
1
  import * as tls from 'tls';
2
2
  import * as pg from 'pg';
3
- import { I as IStorageAdapter, P as PaginationOpts, a as PaginatedResult, b as InstanceFilter, C as CursorPaginationOpts, c as CursorPaginatedResult } from '../IStorageAdapter-BibHlgPw.cjs';
4
- import { A as ApprovalTemplate, a as ApprovalInstance, b as AuditEntry } from '../instance-D8D7b07N.cjs';
3
+ import { I as IStorageAdapter, P as PaginationOpts, a as PaginatedResult, b as InstanceFilter, C as CursorPaginationOpts, c as CursorPaginatedResult } from '../IStorageAdapter-egPQD6Ry.cjs';
4
+ import { A as ApprovalTemplate, a as ApprovalInstance, b as AuditEntry } from '../instance-CtkmEkYa.cjs';
5
5
 
6
6
  interface PostgresAdapterOptions {
7
7
  connectionString?: string;
@@ -1,7 +1,7 @@
1
1
  import * as tls from 'tls';
2
2
  import * as pg from 'pg';
3
- import { I as IStorageAdapter, P as PaginationOpts, a as PaginatedResult, b as InstanceFilter, C as CursorPaginationOpts, c as CursorPaginatedResult } from '../IStorageAdapter-D-oPxxun.js';
4
- import { A as ApprovalTemplate, a as ApprovalInstance, b as AuditEntry } from '../instance-D8D7b07N.js';
3
+ import { I as IStorageAdapter, P as PaginationOpts, a as PaginatedResult, b as InstanceFilter, C as CursorPaginationOpts, c as CursorPaginatedResult } from '../IStorageAdapter-D7jsZT6O.js';
4
+ import { A as ApprovalTemplate, a as ApprovalInstance, b as AuditEntry } from '../instance-CtkmEkYa.js';
5
5
 
6
6
  interface PostgresAdapterOptions {
7
7
  connectionString?: string;
package/dist/index.cjs CHANGED
@@ -439,11 +439,33 @@ var EscalationScheduler = class {
439
439
  };
440
440
 
441
441
  // src/engine/ConditionEvaluator.ts
442
+ function toComparableNumber(value) {
443
+ if (typeof value === "number") return Number.isFinite(value) ? value : null;
444
+ if (typeof value === "bigint") return Number(value);
445
+ if (value instanceof Date) {
446
+ const time = value.getTime();
447
+ return Number.isFinite(time) ? time : null;
448
+ }
449
+ if (typeof value === "string") {
450
+ if (value.trim() === "") return null;
451
+ const parsed = Number(value);
452
+ return Number.isFinite(parsed) ? parsed : null;
453
+ }
454
+ return null;
455
+ }
456
+ function numeric(compare) {
457
+ return (actual, expected) => {
458
+ const left = toComparableNumber(actual);
459
+ const right = toComparableNumber(expected);
460
+ if (left === null || right === null) return false;
461
+ return compare(left, right);
462
+ };
463
+ }
442
464
  var operatorRegistry = /* @__PURE__ */ new Map([
443
- [">", (a, e) => Number(a) > Number(e)],
444
- ["<", (a, e) => Number(a) < Number(e)],
445
- [">=", (a, e) => Number(a) >= Number(e)],
446
- ["<=", (a, e) => Number(a) <= Number(e)],
465
+ [">", numeric((a, e) => a > e)],
466
+ ["<", numeric((a, e) => a < e)],
467
+ [">=", numeric((a, e) => a >= e)],
468
+ ["<=", numeric((a, e) => a <= e)],
447
469
  ["==", (a, e) => a === e],
448
470
  ["!=", (a, e) => a !== e],
449
471
  ["in", (a, e) => Array.isArray(e) && e.includes(a)],
@@ -460,7 +482,7 @@ function registerConditionOperator(name, fn) {
460
482
  }
461
483
  function getField(data, path) {
462
484
  return path.split(".").reduce((obj, key) => {
463
- if (obj !== null && typeof obj === "object" && key in obj) {
485
+ if (obj !== null && typeof obj === "object" && Object.prototype.hasOwnProperty.call(obj, key)) {
464
486
  return obj[key];
465
487
  }
466
488
  return void 0;
@@ -476,16 +498,88 @@ function evaluateCondition(condition, data) {
476
498
  const actual = getField(data, condition.field);
477
499
  return fn(actual, condition.value);
478
500
  }
479
- function evaluateRule(rule, data) {
480
- if (Array.isArray(rule)) {
481
- return rule.every((c) => evaluateCondition(c, data));
501
+ function asGroup(expression) {
502
+ if (expression === null || typeof expression !== "object" || Array.isArray(expression)) {
503
+ return null;
504
+ }
505
+ const candidate = expression;
506
+ const keys = ["all", "any", "not"].filter((k) => candidate[k] !== void 0);
507
+ if (keys.length === 0) return null;
508
+ if (keys.length > 1) {
509
+ throw new ApprovalValidationError(
510
+ `Condition group must set exactly one of "all", "any" or "not" (got ${keys.join(", ")}).`
511
+ );
512
+ }
513
+ const key = keys[0];
514
+ if (key !== "not" && !Array.isArray(candidate[key])) {
515
+ throw new ApprovalValidationError(`Condition group "${key}" must be an array of expressions.`);
516
+ }
517
+ if (key !== "not" && candidate[key].length === 0) {
518
+ throw new ApprovalValidationError(`Condition group "${key}" must not be empty.`);
519
+ }
520
+ return expression;
521
+ }
522
+ function evaluateExpression(expression, data) {
523
+ if (Array.isArray(expression)) {
524
+ return expression.every((child) => evaluateExpression(child, data));
525
+ }
526
+ const group = asGroup(expression);
527
+ if (group === null) {
528
+ return evaluateCondition(expression, data);
482
529
  }
483
- return evaluateCondition(rule, data);
530
+ if (group.all !== void 0) {
531
+ return group.all.every((child) => evaluateExpression(child, data));
532
+ }
533
+ if (group.any !== void 0) {
534
+ return group.any.some((child) => evaluateExpression(child, data));
535
+ }
536
+ return !evaluateExpression(group.not, data);
537
+ }
538
+ function validateConditionExpression(expression, path) {
539
+ const errors = [];
540
+ const walk = (node, at) => {
541
+ if (Array.isArray(node)) {
542
+ if (node.length === 0) {
543
+ errors.push({ field: at, message: "Condition list must not be empty." });
544
+ }
545
+ node.forEach((child, i) => walk(child, `${at}[${i}]`));
546
+ return;
547
+ }
548
+ let group;
549
+ try {
550
+ group = asGroup(node);
551
+ } catch (err) {
552
+ errors.push({ field: at, message: err.message });
553
+ return;
554
+ }
555
+ if (group === null) {
556
+ const leaf = node;
557
+ if (typeof leaf?.field !== "string" || leaf.field.length === 0) {
558
+ errors.push({
559
+ field: `${at}.field`,
560
+ message: "Condition requires a non-empty field path."
561
+ });
562
+ }
563
+ if (typeof leaf?.operator !== "string" || leaf.operator.length === 0) {
564
+ errors.push({ field: `${at}.operator`, message: "Condition requires an operator." });
565
+ }
566
+ return;
567
+ }
568
+ if (group.all !== void 0) {
569
+ group.all.forEach((child, i) => walk(child, `${at}.all[${i}]`));
570
+ } else if (group.any !== void 0) {
571
+ group.any.forEach((child, i) => walk(child, `${at}.any[${i}]`));
572
+ } else {
573
+ walk(group.not, `${at}.not`);
574
+ }
575
+ };
576
+ walk(expression, path);
577
+ return errors;
484
578
  }
485
579
  function evaluateConditions(conditions, data) {
486
580
  const mutations = { addLevels: [], skipLevels: /* @__PURE__ */ new Set() };
487
581
  for (const rule of conditions) {
488
- if (evaluateRule(rule.when, data)) {
582
+ if (evaluateExpression(rule.when, data)) {
489
583
  if (rule.addLevels) mutations.addLevels.push(...rule.addLevels);
490
584
  if (rule.skipLevels) rule.skipLevels.forEach((l) => mutations.skipLevels.add(l));
491
585
  }
@@ -783,6 +877,9 @@ var ApprovalEngine = class {
783
877
  }
784
878
  if (config.conditions) {
785
879
  config.conditions.forEach((rule, ruleIdx) => {
880
+ errors.push(
881
+ ...validateConditionExpression(rule.when, `conditions[${ruleIdx}].when`)
882
+ );
786
883
  if (rule.addLevels) {
787
884
  rule.addLevels.forEach((al, alIdx) => {
788
885
  const conflictsWithStatic = config.levels.some((l) => l.level === al.level);
@@ -2596,6 +2693,8 @@ exports.MemoryAdapter = MemoryAdapter;
2596
2693
  exports.defaultIdGenerator = defaultIdGenerator;
2597
2694
  exports.noopLogger = noopLogger;
2598
2695
  exports.systemClock = systemClock;
2696
+ exports.toComparableNumber = toComparableNumber;
2697
+ exports.validateConditionExpression = validateConditionExpression;
2599
2698
  exports.weekendCalendar = weekendCalendar;
2600
2699
  //# sourceMappingURL=index.cjs.map
2601
2700
  //# sourceMappingURL=index.cjs.map