This is a demo/state-machine library, not a production payment system. Every claim below is verifiable against the source in
src/.
dataquest-task-lifecycle models the rules of a two-sided human-data
marketplace task lifecycle reproduced from the DataQuest portfolio case
study: state transitions from DRAFT to PAID, an append-only audit
history, and payout webhook signing. It is an off-chain reproduction —
it holds no real money, runs no on-chain code, and moves no payment.
Its only cryptography is in node:crypto: SHA-256 / HMAC-SHA256 for the
audit hash chain (src/taskLifecycle.ts) and HMAC-SHA256 for payout
webhook signatures (src/settlementWebhook.ts).
- The RBAC actor is a caller assertion, NOT identity.
TaskLifecycleOptions.rolePolicymaps events to allowed actor names anddispatch(..., { actor })checks the supplied string against that allowlist (src/taskLifecycle.ts; README "Event-level RBAC") — but the caller asserts the actor string and nothing verifies who the caller is. Anyone who can calldispatchcan claim to be"senior-moderator". The policy guarantees only that dispatches violating the declared allowlist do not happen and that the audit trail records the actor string it was given. Real identity authentication — sessions, signatures, verified credentials — remains entirely the caller's responsibility. auditSecretmanagement is the caller's responsibility. The optional keyed audit chain (see below) is only as strong as the secret behind it. The library never generates, stores, or transmits the secret, and the secret is never written into a snapshot (src/taskLifecycle.ts:toJSON()excludes it; a restored task must be handed the secret again viafromJSON(snapshot, { auditSecret })). ABuffersecret is defensively copied at construction so later caller mutation cannot change it. Anyone who obtains the secret can rewrite the log and re-MAC it.- Payout webhook secret distribution is the caller's responsibility.
src/settlementWebhook.tsdocuments this in its header comment and the README repeats it: whoever holds the secret can forgesha256=<hex>signatures. This library does not generate, store, or distribute secrets — store the secret like any other API credential. What the library does offer is rotation support on the verify side:verifyPayoutWebhookaccepts a{ secrets: [...] }candidate set, so during the caller's own rotation window signatures made with either the old or the new secret verify (any-match wins, all-mismatch fails closed). Signing always uses the single current secret given tobuildPayoutWebhook; the rotation schedule itself stays entirely with the caller. - Receiver-side trust.
buildPayoutWebhookderives every payload field from the audit-backedPAYOUT_COMPLETEhistory entry (nothing is invented), but the receiver must verify the signature over the raw body bytes and deduplicate oneventId; an unverified receiver is trusting the network, not the library. That deduplication step is included asPayoutEventDedupe(src/settlementWebhook.ts): a receiver-side TTL + LRU store run after a successful verify. It is single-process and in-memory only — a multi-process receiver must deduplicate over shared storage instead. - SLA deadlines do not expire tasks by themselves. A deadline set
with
setSlaDeadlineis advisory only (README "SLA deadlines"): the library provides no scheduler or timer. Expiry stays an explicitEXPIREdispatch by the caller (for example via theexpiredTasks/actOnStaleTaskswatchdog helpers), so it lands in the audit history. payoutRef/payoutAmountare advisory and unevidenced.dispatch(..., { payoutRef, payoutAmount })records what the caller claims an external payment system did; the library cannot observe that payment, so aPAIDtask whosePAYOUT_COMPLETEentry carries neither field still builds a webhook (the payload simply omits them) — a missing reference is unevidenced, not a build failure. The opt-inrequirePayoutRef: trueconstructor flag tightens this for callers who want it: aPAYOUT_COMPLETEdispatch without apayoutRefthen throws.quotedAmounthas the same advisory status on the quote side.
- Audit hash chain: tamper evidence by default, a MAC when keyed.
Every history entry carries
prevHash/hashover a canonical serialization (genesisprevHashis"GENESIS").verifyHistoryChain(history)(src/taskLifecycle.ts) recomputes the chain and returnsfalseon tampering, deletion, or reordering. The default chain is unkeyed SHA-256 — tamper evidence, not a MAC: it catches edits made without recomputing the hashes, not a full-log rewrite by someone who recomputes them. With anauditSecret, every link becomes HMAC-SHA256 over the same canonical input, and verification is fail-closed across modes: a keyed chain does not verify without the secret or with the wrong one, and an unkeyed chain does not verify when a secret is supplied. An empty or non-string/non-Buffersecret is a configuration error (assertAuditSecretinsrc/taskLifecycle.ts). The MAC comparison itself runs in constant time (timingSafeEqualafter a length guard), so an online verifier exposes no per-byte timing oracle. - Strict snapshot and history validation.
TaskLifecycle.fromJSONrunsparseSnapshot(src/taskLifecycle.ts) on untrusted input: the schema-version gate (v, when present, must be exactlySNAPSHOT_VERSION= 1; any other value is rejected before structural checks), unknown fields are rejected fail-closed at both the snapshot and history-entry level (never silently dropped),seqruns from 1 with no gaps, the from/to chain is continuous, and the hash chain is re-verified on rehydration. - Payout webhook verification is constant-time and fail-closed.
verifyPayoutWebhook(src/settlementWebhook.ts) compares withtimingSafeEqual; malformed signatures returnfalserather than throwing, so hostile input cannot turn verification into an unhandled exception. Verify over the raw body bytes — the object overload re-stringifies with a fixed key order for in-process convenience, but raw bytes are the transport-safe path. An empty secret (positional, or any entry ofsecrets) is a caller configuration error and throws; passing both the positional secret andsecretsalso throws. Signatures do not expire on their own: without the opt-inmaxAgeMs, a legitimately-signed payload from a year ago still verifies. PassVerifyPayoutWebhookOptions.maxAgeMsto fail-closed reject payloads older than the window, and the companionmaxFutureSkewMsto also reject payloads whoseatlies further in the future than the tolerated clock skew; both boundaries are inclusive, the signature is checked first, and an unparseableatreturnsfalse. Together the two form a two-sided freshness window. Neither bound replaces deduplication oneventId. - Event-level RBAC allowlist (when a policy is set). A task
constructed with
rolePolicyrejects a dispatch of a listed event whoseactoris missing or not in the allowlist withactor not authorized for …, before anything is appended — no history entry and no idempotency key is consumed. Events the policy does not list are unrestricted, and a task with no policy skips the check entirely. Invalid policies throw at construction. This enforces the declared allowlist only; it does not authenticate the actor (see the caller-trust section above). - Optimistic-concurrency guard (when
expectedSeqis passed).dispatchrejects a caller whoseexpectedSeqno longer equals the current history length withdispatch conflict: …, before any other check and with no side effects (no append, no idempotency key consumed, no listener notified) — two writers racing off the same snapshot cannot both advance the task in-process. It is not a distributed lock: separate processes hold separate histories and still need store-level compare-and-swap. - In-process history integrity.
task.historyreturns a frozen, detached copy on every call (array and entries), so consumers cannot rewrite the audit log at runtime;dispatch()remains the only append path.
Amounts are plain JavaScript numbers, not big-decimal arithmetic.
payoutAmount and quotedAmount are validated at dispatch (finite
number ≥ 0; anything else throws) and stored verbatim on the audit
entry with no rounding at write time (src/taskLifecycle.ts); only
aggregation helpers such as totalPaidOut round a summed total to
cents (Math.round(x * 100) / 100). This has not been audited.
Do not use for precision-critical accounting.
- Real payment execution or any on-chain settlement
- Identity authentication (this repo's
rolePolicyis only a caller-supplied actor allowlist — see above) - Secret management, storage, or distribution for webhook secrets or
the
auditSecret(rotation-window verification accepts multiple candidate secrets, but the library never generates, stores, or schedules the rotation itself) - A deadline scheduler (
EXPIREis dispatched by the caller; SLA deadlines are advisory only) - Durable storage, a shared/distributed idempotency store (consumed
dispatch keys persist only inside each task's own
toJSON()snapshot, andPayoutEventDedupeis single-process in-memory), or concurrency control beyond the single-processexpectedSeqoptimistic guard ondispatch - A durable notification fan-out:
subscribe()listeners are in-memory and fire-and-forget, and webhook delivery is in-process with retries but no durable queue — if the process dies mid-delivery or mid-fan-out, the caller must reconcile and re-deliver, letting receivers deduplicate oneventId - Big-decimal / precision-critical money arithmetic (see above)
- Reputation or quality scoring (out of scope for the case study)
If you find a security problem in this demo, please open an issue on this repository describing it. Do not include secrets, private keys, or any credentials in the report.
Reference and demo use only.