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 +67 -0
- package/README.md +43 -4
- package/dist/{ApprovalEngine-CIQFYhsX.d.cts → ApprovalEngine-BET-UqQo.d.cts} +21 -1
- package/dist/{ApprovalEngine-D3rHIli1.d.ts → ApprovalEngine-BGfrrFxl.d.ts} +21 -1
- package/dist/index.cjs +28 -5
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.js +28 -6
- package/dist/index.js.map +1 -1
- package/dist/nestjs.cjs +27 -5
- package/dist/nestjs.cjs.map +1 -1
- package/dist/nestjs.d.cts +1 -1
- package/dist/nestjs.d.ts +1 -1
- package/dist/nestjs.js +27 -5
- package/dist/nestjs.js.map +1 -1
- package/dist/testing.cjs +27 -5
- package/dist/testing.cjs.map +1 -1
- package/dist/testing.d.cts +1 -1
- package/dist/testing.d.ts +1 -1
- package/dist/testing.js +27 -5
- package/dist/testing.js.map +1 -1
- package/package.json +1 -1
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
|
-
|
|
317
|
-
|
|
318
|
-
|
|
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) =>
|
|
444
|
-
["<", (a, e) =>
|
|
445
|
-
[">=", (a, e) =>
|
|
446
|
-
["<=", (a, 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
|
|
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
|