hierarchical-approval 0.5.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 +195 -0
- package/README.md +109 -4
- package/dist/{ApprovalEngine-mkh6D9n9.d.cts → ApprovalEngine-BET-UqQo.d.cts} +120 -18
- package/dist/{ApprovalEngine-jy4cYeES.d.ts → ApprovalEngine-BGfrrFxl.d.ts} +120 -18
- package/dist/ISchedulerAdapter-DKv_QjVN.d.cts +13 -0
- package/dist/ISchedulerAdapter-DKv_QjVN.d.ts +13 -0
- package/dist/{IStorageAdapter-ClBdEeTj.d.cts → IStorageAdapter-BibHlgPw.d.cts} +1 -1
- package/dist/{IStorageAdapter-8ajiGUeu.d.ts → IStorageAdapter-D-oPxxun.d.ts} +1 -1
- package/dist/{Logger-CIzvalFc.d.cts → Logger-BplhlU7l.d.cts} +1 -0
- package/dist/{Logger-CIzvalFc.d.ts → Logger-BplhlU7l.d.ts} +1 -0
- package/dist/adapters/MemoryAdapter.cjs +19 -7
- package/dist/adapters/MemoryAdapter.cjs.map +1 -1
- package/dist/adapters/MemoryAdapter.d.cts +2 -2
- package/dist/adapters/MemoryAdapter.d.ts +2 -2
- package/dist/adapters/MemoryAdapter.js +19 -7
- package/dist/adapters/MemoryAdapter.js.map +1 -1
- package/dist/adapters/PostgresAdapter.cjs +60 -31
- package/dist/adapters/PostgresAdapter.cjs.map +1 -1
- package/dist/adapters/PostgresAdapter.d.cts +2 -2
- package/dist/adapters/PostgresAdapter.d.ts +2 -2
- package/dist/adapters/PostgresAdapter.js +60 -31
- package/dist/adapters/PostgresAdapter.js.map +1 -1
- package/dist/index.cjs +745 -104
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +6 -5
- package/dist/index.d.ts +6 -5
- package/dist/index.js +745 -105
- package/dist/index.js.map +1 -1
- package/dist/nestjs.cjs +725 -97
- package/dist/nestjs.cjs.map +1 -1
- package/dist/nestjs.d.cts +4 -3
- package/dist/nestjs.d.ts +4 -3
- package/dist/nestjs.js +725 -97
- package/dist/nestjs.js.map +1 -1
- package/dist/plugins/audit.cjs +2 -0
- package/dist/plugins/audit.cjs.map +1 -1
- package/dist/plugins/audit.d.cts +1 -1
- package/dist/plugins/audit.d.ts +1 -1
- package/dist/plugins/audit.js +2 -0
- package/dist/plugins/audit.js.map +1 -1
- package/dist/plugins/metrics.cjs +2 -0
- package/dist/plugins/metrics.cjs.map +1 -1
- package/dist/plugins/metrics.d.cts +1 -1
- package/dist/plugins/metrics.d.ts +1 -1
- package/dist/plugins/metrics.js +2 -0
- package/dist/plugins/metrics.js.map +1 -1
- package/dist/plugins/notify.cjs +2 -0
- package/dist/plugins/notify.cjs.map +1 -1
- package/dist/plugins/notify.d.cts +1 -1
- package/dist/plugins/notify.d.ts +1 -1
- package/dist/plugins/notify.js +2 -0
- package/dist/plugins/notify.js.map +1 -1
- package/dist/plugins/resilience.cjs +2 -0
- package/dist/plugins/resilience.cjs.map +1 -1
- package/dist/plugins/resilience.d.cts +1 -1
- package/dist/plugins/resilience.d.ts +1 -1
- package/dist/plugins/resilience.js +2 -0
- package/dist/plugins/resilience.js.map +1 -1
- package/dist/plugins/scheduler.cjs +69 -0
- package/dist/plugins/scheduler.cjs.map +1 -0
- package/dist/plugins/scheduler.d.cts +43 -0
- package/dist/plugins/scheduler.d.ts +43 -0
- package/dist/plugins/scheduler.js +67 -0
- package/dist/plugins/scheduler.js.map +1 -0
- package/dist/plugins/webhook.cjs +226 -0
- package/dist/plugins/webhook.cjs.map +1 -0
- package/dist/plugins/webhook.d.cts +214 -0
- package/dist/plugins/webhook.d.ts +214 -0
- package/dist/plugins/webhook.js +221 -0
- package/dist/plugins/webhook.js.map +1 -0
- package/dist/testing.cjs +744 -104
- package/dist/testing.cjs.map +1 -1
- package/dist/testing.d.cts +4 -3
- package/dist/testing.d.ts +4 -3
- package/dist/testing.js +744 -104
- package/dist/testing.js.map +1 -1
- package/package.json +11 -1
package/CHANGELOG.md
CHANGED
|
@@ -3,6 +3,201 @@
|
|
|
3
3
|
All notable changes to `hierarchical-approval` are documented here. This project
|
|
4
4
|
adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
5
5
|
|
|
6
|
+
## [Unreleased]
|
|
7
|
+
|
|
8
|
+
_Nothing yet._
|
|
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
|
+
|
|
77
|
+
## [0.6.0] - 2026-08-21
|
|
78
|
+
|
|
79
|
+
### Fixed — event delivery, template reads, and the CI lint gate
|
|
80
|
+
|
|
81
|
+
- **A throwing `engine.on()` listener no longer breaks the operation that emitted
|
|
82
|
+
the event.** `EventBus` delivered events straight through `eventemitter3`, which
|
|
83
|
+
invokes listeners synchronously and does not swallow exceptions — and the engine
|
|
84
|
+
emits *after* persisting but *before* dispatching notifications and running
|
|
85
|
+
after-middleware. So one buggy subscriber would reject `approve()` with its own
|
|
86
|
+
error (while the instance stayed persisted as approved), skip notification
|
|
87
|
+
dispatch entirely, leave a tracing span opened and never ended, and suppress the
|
|
88
|
+
`approval:completed` emit on the following line. Listener failures are now
|
|
89
|
+
isolated per listener and reported via the engine's logger; a rejecting `async`
|
|
90
|
+
listener is caught too, instead of surfacing as a process-level
|
|
91
|
+
`unhandledRejection`. **Behaviour change:** a listener error no longer propagates
|
|
92
|
+
to the caller. Register an `onListenerError` sink by passing a `logger`, or use
|
|
93
|
+
`EventBus.setListenerErrorHandler` directly.
|
|
94
|
+
- **`approval:completed` is now delivered to notification adapters.** It was
|
|
95
|
+
emitted on the in-process bus only, so a webhook/email integrator never learned
|
|
96
|
+
a document had been fully approved and had to infer it from `approval:approved`
|
|
97
|
+
plus `isFinal`. Dispatched on both the normal-completion and `override` paths.
|
|
98
|
+
- **`PostgresAdapter.getTemplate`/`listTemplates` now return `createdAt` as a real
|
|
99
|
+
`Date`.** They returned the JSONB payload raw, so `createdAt` was an ISO string
|
|
100
|
+
despite `ApprovalTemplate` typing it as a `Date`. `TemplateRegistry.update()`
|
|
101
|
+
threaded that string into the next `saveTemplate()`, which called
|
|
102
|
+
`createdAt.toISOString()` on it — meaning **`engine.updateTemplate()` failed 100%
|
|
103
|
+
of the time against real PostgreSQL** while passing against `MemoryAdapter`,
|
|
104
|
+
which tolerates the string silently. No migration needed.
|
|
105
|
+
- **CI enforces lint again.** The workflow ran `npm run lint || true`, so lint could
|
|
106
|
+
never fail a build; an unused import had already accumulated on `main` as a
|
|
107
|
+
result. The `|| true` is gone.
|
|
108
|
+
|
|
109
|
+
### Added — cycle-time analytics
|
|
110
|
+
|
|
111
|
+
- **`getStatistics()` now reports time-to-decision.** The returned
|
|
112
|
+
`ApprovalStatistics` gains `cycleTime: CycleTimeStats` and
|
|
113
|
+
`cycleTimeByTemplate: Record<string, CycleTimeStats>`, each shaped as
|
|
114
|
+
`{ count, averageMs, p50Ms, p95Ms, minMs, maxMs }` (all durations in
|
|
115
|
+
milliseconds; new `CycleTimeStats` interface, exported from the package root).
|
|
116
|
+
- Counts instances in status `approved`, `rejected`, or `cancelled`.
|
|
117
|
+
**`expired` is deliberately excluded** — its terminal timestamp reflects a
|
|
118
|
+
scheduler deadline firing, not a decision, so including it would skew the
|
|
119
|
+
distribution rather than describe it.
|
|
120
|
+
- Elapsed time per instance is `updatedAt - createdAt`; no new fields were
|
|
121
|
+
added to `ApprovalInstance` or either storage adapter.
|
|
122
|
+
- Every field is `0` (never `NaN`) when `count` is `0`. `cycleTimeByTemplate`
|
|
123
|
+
mirrors `byTemplate`'s population rule: a template only appears once it has
|
|
124
|
+
at least one completed instance.
|
|
125
|
+
- Adapter-agnostic — computed from existing `getInstancesByFilter` pages, so
|
|
126
|
+
`MemoryAdapter` and `PostgresAdapter` both support it with no changes.
|
|
127
|
+
|
|
128
|
+
### Added — `plugins/webhook`
|
|
129
|
+
|
|
130
|
+
- **`hierarchical-approval/plugins/webhook`** — a sixth built-in plug-in
|
|
131
|
+
subpath: an HTTP `INotificationAdapter` with signing and retry, on its own
|
|
132
|
+
tree-shakeable import path with **zero new dependencies**.
|
|
133
|
+
- `WebhookNotificationAdapter` POSTs each event as JSON to a configured URL.
|
|
134
|
+
`notify()` never throws (per the `INotificationAdapter` contract) — it logs
|
|
135
|
+
and drops the notification once retries are exhausted.
|
|
136
|
+
- **Signing (opt-in via `secret`).** Each request carries an
|
|
137
|
+
`X-Approval-Signature: t=<unix-seconds>,v1=<hex-hmac>` header (Stripe-style),
|
|
138
|
+
the HMAC-SHA256 digest of the signing string `` `<unix-seconds>.<json-body>` ``.
|
|
139
|
+
Unsigned when `secret` is omitted. `DEFAULT_SIGNATURE_HEADER` names the
|
|
140
|
+
default header (`'X-Approval-Signature'`); override via `signatureHeader`.
|
|
141
|
+
- **Retry.** `5xx`, `408`, `429`, and network/timeout errors are retried with
|
|
142
|
+
exponential backoff and full jitter, up to `maxAttempts` (default `3`); a
|
|
143
|
+
`429` honors `Retry-After` (seconds or an HTTP date) in place of the
|
|
144
|
+
computed backoff. Any other `4xx` fails on the first attempt.
|
|
145
|
+
- **Durability.** The public `deliver()` method throws
|
|
146
|
+
`WebhookDeliveryError` (`status?`, `attempts`, `cause?`) on final failure
|
|
147
|
+
instead of swallowing it, so it can be bound as the `transport` of
|
|
148
|
+
`plugins/notify`'s `OutboxNotificationAdapter` for at-least-once delivery
|
|
149
|
+
across process restarts — no adapter shim needed.
|
|
150
|
+
- Ships a new `HttpClient` port (a plain `fetch`-shaped function type) and
|
|
151
|
+
`getDefaultHttpClient()`. **No new dependency** — the port is structurally
|
|
152
|
+
satisfied by the global `fetch` (Node.js 18+); pass a custom `httpClient`
|
|
153
|
+
to use a different implementation.
|
|
154
|
+
|
|
155
|
+
### Added — `plugins/scheduler`, and the `schedulerAdapter` option now works
|
|
156
|
+
|
|
157
|
+
- **`hierarchical-approval/plugins/scheduler`** — ships `InMemorySchedulerAdapter`,
|
|
158
|
+
the first reference implementation of the `ISchedulerAdapter` port, which until
|
|
159
|
+
now was exported from the package root with no implementation anywhere to copy.
|
|
160
|
+
- **`ApprovalEngineOptions.schedulerAdapter` was a no-op.** It was declared and
|
|
161
|
+
documented as "Replaces built-in setInterval polling", but `scheduleAt` and
|
|
162
|
+
`cancel` were never called — an injected BullMQ/Temporal/cron scheduler was
|
|
163
|
+
silently ignored while the built-in poller kept running. When supplied it now
|
|
164
|
+
drives the periodic scan via self-rescheduling one-shot calls. Default
|
|
165
|
+
behaviour with no option supplied is unchanged.
|
|
166
|
+
|
|
167
|
+
### Fixed — `PostgresAdapter`
|
|
168
|
+
|
|
169
|
+
Three defects affecting users on published `0.5.0`. If you use `PostgresAdapter`,
|
|
170
|
+
upgrading is recommended.
|
|
171
|
+
|
|
172
|
+
- **Cursor pagination silently corrupted every page after the first.**
|
|
173
|
+
`getInstancesByCursor`'s cursor decoder split the `updatedAt_iso:id` string on
|
|
174
|
+
the _first_ colon, but an ISO-8601 timestamp (e.g.
|
|
175
|
+
`2026-06-26T09:00:00.000Z`) itself contains colons — so every decoded cursor
|
|
176
|
+
had a truncated timestamp and a corrupted id, breaking every subsequent page
|
|
177
|
+
fetch via `getInstancesByCursor`. Also hardened cursor encoding to normalize
|
|
178
|
+
through `Date.prototype.toISOString()` regardless of the row value's shape.
|
|
179
|
+
No migration needed.
|
|
180
|
+
- **`templateId` was silently dropped on every write and always read back as
|
|
181
|
+
`''`.** `PostgresAdapter` had no `template_id` column, so
|
|
182
|
+
`ApprovalInstance.templateId` was permanently lost for any instance persisted
|
|
183
|
+
through this adapter. Fixed by adding a `template_id` column and including it
|
|
184
|
+
in both the insert and read paths.
|
|
185
|
+
**Action required on upgrade:** run the adapter's migration (its
|
|
186
|
+
`CREATE TABLE`/`ALTER TABLE ... ADD COLUMN IF NOT EXISTS` statements, executed
|
|
187
|
+
automatically the next time the adapter initializes) to add the new column —
|
|
188
|
+
existing rows backfill to `''` until rewritten.
|
|
189
|
+
- **The `schema` option was interpolated into SQL without the safe-identifier
|
|
190
|
+
validation `tablePrefix` already had**, an inconsistent injection surface at
|
|
191
|
+
construction time. `schema` is now validated against the same
|
|
192
|
+
`/^[a-z][a-z0-9_]*$/` pattern as `tablePrefix`, throwing
|
|
193
|
+
`ApprovalValidationError` on an invalid value. No migration needed; only
|
|
194
|
+
affects adapter construction with an attacker-controlled `schema` value.
|
|
195
|
+
|
|
196
|
+
### Fixed — `MemoryAdapter` and notifications
|
|
197
|
+
|
|
198
|
+
- **`TemplatedNotificationAdapter`** — now logs fatal events (notification render/send failures) at the `fatal` level instead of `error`.
|
|
199
|
+
- **`MemoryAdapter`** — `getInstancesByFilter` now honors `fromDate`/`toDate` (previously the JSON-cloned string dates made the comparison a no-op and both bounds silently matched nothing). `getTemplate`/`listTemplates` now return `createdAt` as a real `Date`, matching the `ApprovalTemplate` contract.
|
|
200
|
+
|
|
6
201
|
## [0.5.0] - 2026-07-23
|
|
7
202
|
|
|
8
203
|
### Added — NestJS integration
|
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
|
---
|
|
@@ -721,9 +760,27 @@ const stats = await engine.getStatistics({ documentType: 'purchase_order' });
|
|
|
721
760
|
// byStatus: { pending, approved, rejected, cancelled, expired },
|
|
722
761
|
// overdue: number, // pending past an escalation/expiry deadline
|
|
723
762
|
// approvalRate: number, // approved / (approved + rejected); 0 when none resolved
|
|
763
|
+
// byTemplate: Record<string, { total, approved, rejected, pending }>,
|
|
764
|
+
// cycleTime: CycleTimeStats,
|
|
765
|
+
// cycleTimeByTemplate: Record<string, CycleTimeStats>,
|
|
724
766
|
// }
|
|
725
767
|
```
|
|
726
768
|
|
|
769
|
+
`byTemplate` breaks the same counts down per template name (only populated for templates with at least one matching instance).
|
|
770
|
+
|
|
771
|
+
**Cycle-time analytics.** `cycleTime` (and its per-template mirror `cycleTimeByTemplate`) reports time-to-decision, in milliseconds, as `{ count, averageMs, p50Ms, p95Ms, minMs, maxMs }`:
|
|
772
|
+
|
|
773
|
+
```ts
|
|
774
|
+
console.log(`Median time-to-decision: ${(stats.cycleTime.p50Ms / 60_000).toFixed(1)} min`);
|
|
775
|
+
console.log(`p95 over ${stats.cycleTime.count} completed instances: ${stats.cycleTime.p95Ms}ms`);
|
|
776
|
+
|
|
777
|
+
for (const [templateName, timing] of Object.entries(stats.cycleTimeByTemplate)) {
|
|
778
|
+
console.log(templateName, timing.averageMs);
|
|
779
|
+
}
|
|
780
|
+
```
|
|
781
|
+
|
|
782
|
+
Only instances in status `approved`, `rejected`, or `cancelled` count as "completed" for this calculation — **`expired` is deliberately excluded**: its terminal timestamp reflects a scheduler deadline firing, not a decision being made, so including it would skew the distribution rather than describe it. Elapsed time per instance is `updatedAt - createdAt`. When `count` is `0` every other field is `0`, never `NaN`.
|
|
783
|
+
|
|
727
784
|
### Health check
|
|
728
785
|
|
|
729
786
|
```ts
|
|
@@ -1072,6 +1129,54 @@ const engine = new ApprovalEngine({
|
|
|
1072
1129
|
|
|
1073
1130
|
Every operation is wrapped in a span named `approval.<operation>` (e.g. `approval.approve`) carrying `approval.tenant_id`, `approval.actor_id` and `approval.instance_id` attributes. On success the span records the resulting `approval.result_status`/`approval.result_level` and status `OK`; on failure it calls `recordException`, tags `approval.error_code`, and sets status `ERROR` — then re-throws (tracing never swallows an error). There is **no hard dependency** on `@opentelemetry/api`: the middleware defaults to a no-op tracer, so you install and wire OpenTelemetry only if you want traces.
|
|
1074
1131
|
|
|
1132
|
+
### `plugins/webhook` — signed HTTP delivery with retries
|
|
1133
|
+
|
|
1134
|
+
```ts
|
|
1135
|
+
import { WebhookNotificationAdapter } from 'hierarchical-approval/plugins/webhook';
|
|
1136
|
+
|
|
1137
|
+
const webhook = new WebhookNotificationAdapter({
|
|
1138
|
+
url: 'https://example.com/hooks/approvals',
|
|
1139
|
+
secret: process.env.WEBHOOK_SECRET, // opt-in signing; omit to send unsigned requests
|
|
1140
|
+
});
|
|
1141
|
+
|
|
1142
|
+
const engine = new ApprovalEngine({ adapter, notificationAdapter: webhook });
|
|
1143
|
+
```
|
|
1144
|
+
|
|
1145
|
+
Every event is POSTed to `url` as JSON. When `secret` is configured, each request also carries an `X-Approval-Signature: t=<unix-seconds>,v1=<hex-hmac>` header (Stripe-style), where `v1` is the HMAC-SHA256 digest of the signing string `` `${timestamp}.${body}` ``. Folding the timestamp into the signed payload lets a receiver reject stale/replayed requests. A receiver verifies it like this:
|
|
1146
|
+
|
|
1147
|
+
```ts
|
|
1148
|
+
import { createHmac, timingSafeEqual } from 'node:crypto';
|
|
1149
|
+
|
|
1150
|
+
function verifyApprovalSignature(rawBody: string, header: string, secret: string): boolean {
|
|
1151
|
+
const [tPart, vPart] = header.split(',');
|
|
1152
|
+
const timestamp = tPart!.slice(2); // strip "t="
|
|
1153
|
+
const signature = vPart!.slice(3); // strip "v1="
|
|
1154
|
+
const expected = createHmac('sha256', secret).update(`${timestamp}.${rawBody}`).digest('hex');
|
|
1155
|
+
const sigBuf = Buffer.from(signature, 'hex');
|
|
1156
|
+
const expBuf = Buffer.from(expected, 'hex');
|
|
1157
|
+
return sigBuf.length === expBuf.length && timingSafeEqual(sigBuf, expBuf);
|
|
1158
|
+
}
|
|
1159
|
+
```
|
|
1160
|
+
|
|
1161
|
+
`5xx` responses, `408`, and `429`, plus network/timeout errors, are retried with exponential backoff and full jitter up to `maxAttempts` (default `3`); a `429` honors a `Retry-After` header (seconds or an HTTP date) in place of the computed backoff. Any other `4xx` (e.g. `400`, `401`, `404`) is treated as permanent and fails on the first attempt. `notify()` — the method the engine calls — never throws, per the `INotificationAdapter` contract: on exhausting `maxAttempts` it logs and drops the notification, which makes the adapter used alone _at-most-once_.
|
|
1162
|
+
|
|
1163
|
+
For at-least-once delivery that survives a process restart, compose the adapter's throwing `deliver()` method as the `transport` of `OutboxNotificationAdapter` instead of wiring `notify()` directly:
|
|
1164
|
+
|
|
1165
|
+
```ts
|
|
1166
|
+
import { OutboxNotificationAdapter } from 'hierarchical-approval/plugins/notify';
|
|
1167
|
+
import { WebhookNotificationAdapter } from 'hierarchical-approval/plugins/webhook';
|
|
1168
|
+
|
|
1169
|
+
const webhook = new WebhookNotificationAdapter({
|
|
1170
|
+
url: 'https://example.com/hooks/approvals',
|
|
1171
|
+
secret,
|
|
1172
|
+
});
|
|
1173
|
+
const durable = new OutboxNotificationAdapter({ transport: webhook.deliver.bind(webhook) });
|
|
1174
|
+
|
|
1175
|
+
const engine = new ApprovalEngine({ adapter, notificationAdapter: durable });
|
|
1176
|
+
```
|
|
1177
|
+
|
|
1178
|
+
`deliver` and `OutboxNotificationAdapter`'s `NotificationTransport` type are structurally compatible, so this composition needs no adapter shim. This adapter takes **no new dependency**: its `HttpClient` port is a plain `fetch`-shaped function, and the global `fetch` (Node.js 18+) satisfies it directly — pass a custom `httpClient` in the options only if you need different behavior (a proxy, request mocking, a different runtime's fetch).
|
|
1179
|
+
|
|
1075
1180
|
---
|
|
1076
1181
|
|
|
1077
1182
|
## NestJS integration
|
|
@@ -1,12 +1,13 @@
|
|
|
1
|
-
import { I as IStorageAdapter, P as PaginationOpts, a as PaginatedResult, b as InstanceFilter, C as CursorPaginationOpts, c as CursorPaginatedResult } from './IStorageAdapter-
|
|
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
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
3
|
import { I as INotificationAdapter, b as ApprovalEventName, a as ApprovalEventMap } from './INotificationAdapter-DVVmXU6a.cjs';
|
|
4
4
|
import { z } from 'zod';
|
|
5
|
-
import { L as Logger } from './Logger-
|
|
5
|
+
import { L as Logger } from './Logger-BplhlU7l.cjs';
|
|
6
6
|
import { C as Clock } from './Clock-3FnOczFJ.cjs';
|
|
7
7
|
import { I as IOperationMiddleware, a as ApprovalError } from './IOperationMiddleware-CXgXmGUF.cjs';
|
|
8
8
|
import { I as IAuditAdapter } from './IAuditAdapter-B_DhuPsU.cjs';
|
|
9
9
|
import { I as IMetricsAdapter } from './IMetricsAdapter-D9PUz4tM.cjs';
|
|
10
|
+
import { I as ISchedulerAdapter } from './ISchedulerAdapter-DKv_QjVN.cjs';
|
|
10
11
|
import { I as IAuthorizationPolicy } from './IAuthorizationPolicy-B6JzRNUk.cjs';
|
|
11
12
|
|
|
12
13
|
declare const SubmitOptionsSchema: z.ZodObject<{
|
|
@@ -107,7 +108,8 @@ interface WeekendCalendarOptions {
|
|
|
107
108
|
*/
|
|
108
109
|
declare function weekendCalendar(options?: WeekendCalendarOptions): BusinessCalendar;
|
|
109
110
|
|
|
110
|
-
type
|
|
111
|
+
type IdGeneratorPrefix = 'inst' | 'tpl';
|
|
112
|
+
type IdGeneratorFn = (prefix: IdGeneratorPrefix) => string;
|
|
111
113
|
declare const defaultIdGenerator: IdGeneratorFn;
|
|
112
114
|
|
|
113
115
|
interface OrgProvider {
|
|
@@ -128,18 +130,26 @@ type ApproverResolverFn = (config: Record<string, unknown>, ctx: {
|
|
|
128
130
|
}) => Promise<string[]> | string[];
|
|
129
131
|
|
|
130
132
|
type ConditionOperatorFn = (actual: unknown, expected: unknown) => boolean;
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
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;
|
|
143
153
|
|
|
144
154
|
interface ValidationResult {
|
|
145
155
|
valid: boolean;
|
|
@@ -192,6 +202,49 @@ interface ApprovalStatistics {
|
|
|
192
202
|
rejected: number;
|
|
193
203
|
pending: number;
|
|
194
204
|
}>;
|
|
205
|
+
/**
|
|
206
|
+
* Time-to-decision ("cycle time") analytics, in milliseconds, for
|
|
207
|
+
* **completed** instances matching the filter — status `'approved'`,
|
|
208
|
+
* `'rejected'`, or `'cancelled'`. `'expired'` instances are excluded: their
|
|
209
|
+
* terminal timestamp reflects a scheduler deadline firing, not a decision
|
|
210
|
+
* being made, so they would skew the distribution rather than describe it.
|
|
211
|
+
*
|
|
212
|
+
* Elapsed time per instance is `updatedAt - createdAt`: `createdAt` is the
|
|
213
|
+
* submission instant, and `updatedAt` is set at the moment the instance
|
|
214
|
+
* transitions to its terminal status (see {@link ApprovalEngine.approve},
|
|
215
|
+
* {@link ApprovalEngine.reject}, {@link ApprovalEngine.cancel}). See
|
|
216
|
+
* {@link CycleTimeStats} for the zeroed shape returned when there are no
|
|
217
|
+
* completed instances.
|
|
218
|
+
*/
|
|
219
|
+
cycleTime: CycleTimeStats;
|
|
220
|
+
/**
|
|
221
|
+
* The same {@link cycleTime} analytics broken down per template name.
|
|
222
|
+
* Mirrors the {@link byTemplate} population rule: a template only appears
|
|
223
|
+
* here when at least one of its instances is completed (`count > 0`).
|
|
224
|
+
*/
|
|
225
|
+
cycleTimeByTemplate: Record<string, CycleTimeStats>;
|
|
226
|
+
}
|
|
227
|
+
/**
|
|
228
|
+
* Time-to-decision ("cycle time") statistics for a set of completed approval
|
|
229
|
+
* instances. All duration fields are in **milliseconds**.
|
|
230
|
+
*
|
|
231
|
+
* When {@link count} is `0` (no completed instances matched), every other
|
|
232
|
+
* field is `0` — never `NaN` — mirroring {@link computeTimingStats}'s
|
|
233
|
+
* empty-input behavior, which this type's values are derived from.
|
|
234
|
+
*/
|
|
235
|
+
interface CycleTimeStats {
|
|
236
|
+
/** Number of completed instances included in this computation. */
|
|
237
|
+
count: number;
|
|
238
|
+
/** Arithmetic mean time-to-decision. `0` when {@link count} is `0`. */
|
|
239
|
+
averageMs: number;
|
|
240
|
+
/** 50th percentile (median) time-to-decision, via nearest-rank. `0` when {@link count} is `0`. */
|
|
241
|
+
p50Ms: number;
|
|
242
|
+
/** 95th percentile time-to-decision, via nearest-rank. `0` when {@link count} is `0`. */
|
|
243
|
+
p95Ms: number;
|
|
244
|
+
/** Smallest observed time-to-decision. `0` when {@link count} is `0`. */
|
|
245
|
+
minMs: number;
|
|
246
|
+
/** Largest observed time-to-decision. `0` when {@link count} is `0`. */
|
|
247
|
+
maxMs: number;
|
|
195
248
|
}
|
|
196
249
|
interface HealthResult {
|
|
197
250
|
status: 'healthy' | 'degraded' | 'unhealthy';
|
|
@@ -225,7 +278,7 @@ interface ApprovalEngineOptions {
|
|
|
225
278
|
*/
|
|
226
279
|
calendar?: BusinessCalendar;
|
|
227
280
|
/** Custom ID generator for instances and templates. Defaults to timestamp+random. */
|
|
228
|
-
generateId?:
|
|
281
|
+
generateId?: (prefix: IdGeneratorPrefix) => string;
|
|
229
282
|
/** Custom optimistic locking retry policy. */
|
|
230
283
|
retryPolicy?: RetryPolicy;
|
|
231
284
|
/** Custom idempotency key derivation function. Default: SHA-256 of tenant+documentType+documentId+templateName. */
|
|
@@ -236,7 +289,20 @@ interface ApprovalEngineOptions {
|
|
|
236
289
|
auditAdapter?: IAuditAdapter;
|
|
237
290
|
/** Metrics adapter for Prometheus / Datadog / OpenTelemetry. */
|
|
238
291
|
metricsAdapter?: IMetricsAdapter;
|
|
239
|
-
/**
|
|
292
|
+
/**
|
|
293
|
+
* Custom scheduler adapter (BullMQ, Temporal, cron) that drives the recurring
|
|
294
|
+
* escalation/expiry/SLA-breach/delegation-revert scan.
|
|
295
|
+
*
|
|
296
|
+
* When provided, the built-in `setInterval` poll (see
|
|
297
|
+
* {@link EscalationScheduler.start}) is never started. Instead,
|
|
298
|
+
* {@link ISchedulerAdapter.scheduleAt} schedules each scan, and its callback
|
|
299
|
+
* reschedules the next one itself once the scan completes — the adapter
|
|
300
|
+
* changes *how* the periodic scan is triggered, not *what* it scans; every
|
|
301
|
+
* tick still runs the exact same overdue-instance query the built-in poller
|
|
302
|
+
* runs. {@link ISchedulerAdapter.cancel} and {@link ISchedulerAdapter.shutdown}
|
|
303
|
+
* are invoked during {@link ApprovalEngine.shutdown}. Omitting this option
|
|
304
|
+
* preserves the built-in `setInterval` polling behavior unchanged.
|
|
305
|
+
*/
|
|
240
306
|
schedulerAdapter?: ISchedulerAdapter;
|
|
241
307
|
/** Authorization policy called before every mutating operation. */
|
|
242
308
|
authorizationPolicy?: IAuthorizationPolicy;
|
|
@@ -249,6 +315,7 @@ declare class ApprovalEngine {
|
|
|
249
315
|
private readonly registry;
|
|
250
316
|
private readonly resolver;
|
|
251
317
|
private readonly escalation;
|
|
318
|
+
private readonly escalationPollIntervalMs;
|
|
252
319
|
private readonly tenantId;
|
|
253
320
|
private readonly logger;
|
|
254
321
|
private readonly clock;
|
|
@@ -257,6 +324,10 @@ declare class ApprovalEngine {
|
|
|
257
324
|
private readonly maxBulkItems;
|
|
258
325
|
private readonly retryPolicy;
|
|
259
326
|
private readonly idempotencyKeyFn;
|
|
327
|
+
/** Handle for the currently-scheduled escalation tick when {@link ApprovalEngineOptions.schedulerAdapter} is set. */
|
|
328
|
+
private schedulerAdapterHandle;
|
|
329
|
+
/** Set by {@link shutdown}; stops the self-rescheduling loop from scheduling another tick. */
|
|
330
|
+
private schedulerStopped;
|
|
260
331
|
constructor(opts: ApprovalEngineOptions);
|
|
261
332
|
on<K extends ApprovalEventName>(event: K, listener: (payload: ApprovalEventMap[K]) => void): this;
|
|
262
333
|
off<K extends ApprovalEventName>(event: K, listener: (payload: ApprovalEventMap[K]) => void): this;
|
|
@@ -313,6 +384,37 @@ declare class ApprovalEngine {
|
|
|
313
384
|
*/
|
|
314
385
|
getStatistics(filter?: Omit<InstanceFilter, 'status'>): Promise<ApprovalStatistics>;
|
|
315
386
|
shutdown(): Promise<void>;
|
|
387
|
+
/**
|
|
388
|
+
* Schedules the next escalation scan via {@link ApprovalEngineOptions.schedulerAdapter}.
|
|
389
|
+
*
|
|
390
|
+
* Called once from the constructor to start the loop, then re-invoked by the
|
|
391
|
+
* scheduled callback itself after each scan completes — a self-rescheduling
|
|
392
|
+
* chain of one-shot {@link ISchedulerAdapter.scheduleAt} calls standing in for
|
|
393
|
+
* the `setInterval` that {@link EscalationScheduler.start} would otherwise
|
|
394
|
+
* use. Each invocation runs the exact same {@link EscalationScheduler.tick}
|
|
395
|
+
* scan the built-in poller runs; only the timer mechanism differs.
|
|
396
|
+
*
|
|
397
|
+
* A no-op once {@link shutdown} has set {@link schedulerStopped} — this is
|
|
398
|
+
* what stops the chain from rescheduling itself forever after teardown.
|
|
399
|
+
*/
|
|
400
|
+
private scheduleNextEscalationTick;
|
|
401
|
+
/**
|
|
402
|
+
* Compute {@link CycleTimeStats} for every "completed" instance (see
|
|
403
|
+
* {@link CYCLE_TIME_STATUSES}) matching `filter`. Adapter-agnostic: fetches
|
|
404
|
+
* full instances (not just counts) via {@link fetchAllByFilter} so the
|
|
405
|
+
* actual `createdAt`/`updatedAt` timestamps are available, then reuses the
|
|
406
|
+
* shared {@link computeTimingStats} quantile routine from the metrics
|
|
407
|
+
* plugin rather than a second implementation.
|
|
408
|
+
*/
|
|
409
|
+
private computeCycleTimeStats;
|
|
410
|
+
/**
|
|
411
|
+
* Page through every instance matching `filter` via the adapter's
|
|
412
|
+
* `getInstancesByFilter`, accumulating pages until the adapter reports no
|
|
413
|
+
* more results. Needed because adapters may impose a default page size
|
|
414
|
+
* (e.g. `PostgresAdapter` defaults to 50) when no explicit `limit` is given,
|
|
415
|
+
* so a single unbounded call cannot be relied on to return everything.
|
|
416
|
+
*/
|
|
417
|
+
private fetchAllByFilter;
|
|
316
418
|
private escalateInternal;
|
|
317
419
|
private expireInstance;
|
|
318
420
|
private markSlaBreached;
|
|
@@ -333,4 +435,4 @@ declare class ApprovalEngine {
|
|
|
333
435
|
private runExternalAudit;
|
|
334
436
|
}
|
|
335
437
|
|
|
336
|
-
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
|
|
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 };
|