hierarchical-approval 0.6.0 → 0.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.
package/CHANGELOG.md CHANGED
@@ -7,6 +7,73 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
8
  _Nothing yet._
9
9
 
10
+ ## [0.7.0] - 2026-09-04
11
+
12
+ ### Fixed — two condition-evaluation bypasses
13
+
14
+ Both of these let a `ConditionRule` fire when it should not have, and because
15
+ conditions decide which levels an instance gets, a spurious match on a
16
+ `skipLevels` rule **removes approval levels from a live document**. Anyone using
17
+ `skipLevels` — or `addLevels` to *escalate* above a threshold — should upgrade.
18
+
19
+ - **Numeric operators no longer coerce non-numbers to zero.** `>`, `<`, `>=` and
20
+ `<=` compared with `Number(actual)`, and `Number()` maps `null`, `''`, `' '`,
21
+ `[]` and `false` all to `0`. So a fast-track rule like
22
+ `{ when: { field: 'amount', operator: '<', value: 5000 }, skipLevels: [2, 3] }`
23
+ matched a purchase order whose `amount` was `null` or blank, silently skipping
24
+ two approval levels on exactly the documents whose value was unknown. `false`
25
+ and `[]` did the same, and `true` compared as `1`.
26
+
27
+ A numeric comparison against a non-number is now treated as *undecidable* rather
28
+ than false-y: it reports **no match**, which is the outcome `undefined` has
29
+ always produced. Accepted operands are finite numbers, bigints, `Date`
30
+ (compared as epoch milliseconds), and numeric strings such as `'100'` or
31
+ `' 1e3 '` — ERP payloads routinely arrive as JSON strings, so string comparison
32
+ is retained. Rejected: `null`, `undefined`, booleans, arrays, objects, blank
33
+ strings, `NaN` and `Infinity`.
34
+
35
+ **Behaviour change.** A rule that was matching on blank or boolean data stops
36
+ matching. That is the fix, but it does change which levels such an instance
37
+ gets, so re-check any template whose conditions run against optional fields.
38
+ `==` and `!=` are untouched — they were already strict.
39
+
40
+ - **Dot-path field lookup now reads own properties only.** `getField` tested
41
+ `key in obj`, which walks the prototype chain, so a condition on `isFastTrack`
42
+ was satisfied by an inherited `Object.prototype.isFastTrack` that no document
43
+ ever declared — turning any prototype pollution elsewhere in the dependency
44
+ tree into an approval-level bypass. Resolution now uses
45
+ `Object.prototype.hasOwnProperty`, so a segment that is not an own property
46
+ resolves to `undefined` exactly as an absent field does. This also closes
47
+ `__proto__`, `constructor` and `prototype` as readable paths.
48
+
49
+ **Behaviour change.** Context data whose fields live on a prototype (a class
50
+ instance with getters, rather than a plain object) no longer resolves. Plain
51
+ objects, arrays, array indices and `Object.create(null)` objects are
52
+ unaffected, and context data does not survive JSONB round-tripping as a class
53
+ instance in any case.
54
+
55
+ ### Added
56
+
57
+ - **`toComparableNumber(value)` is exported from the package root.** The same
58
+ strict coercion the built-in numeric operators use, returning `number` or
59
+ `null`, so a custom operator registered with `registerConditionOperator()` can
60
+ inherit the identical semantics instead of re-introducing `Number()`. The
61
+ README's `between` recipe now uses it — the previous version of that snippet
62
+ demonstrated the zero-coercion bug.
63
+
64
+ ### Tests
65
+
66
+ - Restored the **17 `ConditionEvaluator` unit tests that commit `8d648d8`
67
+ deleted**, having replaced the suite body with a `// ... existing tests ...`
68
+ placeholder and a single test. Coverage of the evaluator had silently dropped
69
+ to 74% of statements and 56% of functions.
70
+ - Added 36 tests across the two fixes (per-type non-comparable operand matrix for
71
+ each numeric operator, the `skipLevels` bypass scenario, prototype-pollution
72
+ guards, `toComparableNumber` directly) plus an executable copy of the README's
73
+ custom-operator recipes, so a documented snippet cannot rot or stop compiling.
74
+ All 24 of the pre-existing-bug assertions were confirmed to fail against the
75
+ unfixed source.
76
+
10
77
  ## [0.6.0] - 2026-08-21
11
78
 
12
79
  ### Fixed — event delivery, template reads, and the CI lint gate
package/README.md CHANGED
@@ -305,6 +305,33 @@ conditions: [
305
305
 
306
306
  **Built-in operators:** `>`, `<`, `>=`, `<=`, `==`, `!=`, `in`, `not_in`
307
307
 
308
+ #### How values are compared
309
+
310
+ `==` and `!=` compare **strictly** — `'100'` does not equal `100`.
311
+
312
+ The numeric operators (`>`, `<`, `>=`, `<=`) match only when **both** sides are
313
+ unambiguously numeric: finite numbers, bigints, `Date` (compared as epoch
314
+ milliseconds), and numeric strings such as `'100'` or `' 1e3 '` — because ERP
315
+ payloads routinely arrive as JSON strings. Anything else — `null`, `undefined`,
316
+ `true`/`false`, arrays, objects, blank strings, `NaN`, `Infinity` — is treated as
317
+ *not comparable*, and the condition reports **no match**.
318
+
319
+ This matters for safety. A rule that skips levels on small amounts must not fire
320
+ on a document whose amount was never populated:
321
+
322
+ ```ts
323
+ { when: { field: 'amount', operator: '<', value: 5000 }, skipLevels: [2, 3] }
324
+
325
+ // amount: 4999 -> matches, levels 2 and 3 are skipped
326
+ // amount: null -> NO match, every level is kept
327
+ // amount: '' -> NO match, every level is kept
328
+ ```
329
+
330
+ Field paths resolve **own properties only**, so an inherited or polluted
331
+ `Object.prototype` member can never satisfy a condition. A path segment that is
332
+ not an own property — including `__proto__`, `constructor`, and `prototype` —
333
+ resolves to `undefined`, exactly as a genuinely absent field does.
334
+
308
335
  **Register custom operators** at engine level:
309
336
 
310
337
  ```ts
@@ -312,11 +339,23 @@ engine.registerConditionOperator(
312
339
  'contains',
313
340
  (actual, expected) => typeof actual === 'string' && actual.includes(String(expected)),
314
341
  );
342
+ ```
315
343
 
316
- engine.registerConditionOperator(
317
- 'between',
318
- (actual, [min, max]: number[]) => Number(actual) >= min && Number(actual) <= max,
319
- );
344
+ Custom numeric operators should reuse the same strictness via the exported
345
+ `toComparableNumber` helper, which returns `null` for anything that is not
346
+ unambiguously a number:
347
+
348
+ ```ts
349
+ import { toComparableNumber } from 'hierarchical-approval';
350
+
351
+ engine.registerConditionOperator('between', (actual, expected) => {
352
+ const value = toComparableNumber(actual);
353
+ if (value === null || !Array.isArray(expected) || expected.length !== 2) return false;
354
+ const min = toComparableNumber(expected[0]);
355
+ const max = toComparableNumber(expected[1]);
356
+ if (min === null || max === null) return false;
357
+ return value >= min && value <= max;
358
+ });
320
359
  ```
321
360
 
322
361
  ---
@@ -130,6 +130,26 @@ 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;
133
153
 
134
154
  interface ValidationResult {
135
155
  valid: boolean;
@@ -415,4 +435,4 @@ declare class ApprovalEngine {
415
435
  private runExternalAudit;
416
436
  }
417
437
 
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 };
438
+ 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, weekendCalendar as w };
@@ -130,6 +130,26 @@ 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;
133
153
 
134
154
  interface ValidationResult {
135
155
  valid: boolean;
@@ -415,4 +435,4 @@ declare class ApprovalEngine {
415
435
  private runExternalAudit;
416
436
  }
417
437
 
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 };
438
+ 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, weekendCalendar as w };
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;
@@ -2596,6 +2618,7 @@ exports.MemoryAdapter = MemoryAdapter;
2596
2618
  exports.defaultIdGenerator = defaultIdGenerator;
2597
2619
  exports.noopLogger = noopLogger;
2598
2620
  exports.systemClock = systemClock;
2621
+ exports.toComparableNumber = toComparableNumber;
2599
2622
  exports.weekendCalendar = weekendCalendar;
2600
2623
  //# sourceMappingURL=index.cjs.map
2601
2624
  //# sourceMappingURL=index.cjs.map