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. The same opt-in exists for the amount:requirePayoutAmount: truemakes aPAYOUT_COMPLETEdispatch without apayoutAmountthrow (a present amount is still validated as finite, non-negative, and at most 2 decimal places; 0 is legal).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.Dedupe snapshots get the same treatment as an opt-in layer:
PayoutEventDedupe.exportSignedSnapshot(secret)wraps the unsigned{ v: 1, entries }snapshot in an HMAC-SHA256 envelope{ v: 1, snapshot, mac }(same shape and canonical form as the sibling escrowSettlementEventDedupesigned snapshot and the rfc9421ReplayCachesigned snapshot), andrestoreSignedSnapshot(envelope, secret)verifies the MAC in constant time before any snapshot parsing. A rewritten persisted file — a deleted consumedeventId, an injected forged one, a shiftedseenAtMs— then fails closed at startup instead of silently reopening the double-payout window or discarding a legitimate payout as an apparent duplicate. The unsigned snapshot stays accepted as trusted configuration, and the two forms never accept each other, so a signed restore cannot silently downgrade. The MAC is integrity protection only, not encryption: the eventIds inside are wire-visible values, and the secret is never written into the envelope — a restored dedupe must be handed the secret again, like the audit secret above, and key storage and distribution remain the caller's responsibility. The envelope can also carry an optional expiry (expiresAt/maxAgeMson export, covered by the MAC); restore rejects an expired envelope (now > Date.parse(expiresAt)) after the MAC check and before any snapshot parsing, so a stolen envelope stops working at its expiry instead of resurrecting state indefinitely. An envelope withoutexpiresAtnever expires.Task snapshots get the same treatment as an opt-in layer:
TaskLifecycle.exportSignedSnapshot(secret)wraps the wholeTaskSnapshotin an HMAC-SHA256 envelope{ v: 1, snapshot, mac }(same shape and canonical form as the sibling escrowEscrowsigned snapshot and this repo'sPayoutEventDedupesigned snapshot), andrestoreSignedSnapshot(envelope, secret, opts)verifies the MAC in constant time before any snapshot parsing. The audit hash chain covers history entries only, so without the envelope a rewritten persisted file could push an SLA deadline back, loosen a budget or a policy, or clear consumed idempotency keys without breaking any history link; with it, any such rewrite fails closed at restore with no task produced. The envelope secret need not equal the task'sauditSecret—opts.auditSecretis still handed through tofromJSON, so a keyed chain re-verifies under its own key — and the unsigned snapshot stays accepted as trusted configuration, the two forms never accepting each other. The envelope can also carry an optional expiry (expiresAt/maxAgeMson export, covered by the MAC); restore rejects an expired envelope (now > Date.parse(expiresAt)) after the MAC check and before any snapshot parsing, so a stolen envelope stops working at its expiry instead of resurrecting revoked state indefinitely. An envelope withoutexpiresAtnever expires.Outbox snapshots get the same treatment:
PayoutWebhookOutbox.exportSignedSnapshot(secret)wraps the unsigned{ v: 1, entries }snapshot in an HMAC-SHA256 envelope{ v: 1, snapshot, mac }(same shape and canonical form as the dedupe envelope), andrestoreSignedSnapshot(envelope, secret, opts)(aliasfromSignedSnapshot) verifies the MAC in constant time before any snapshot parsing. A rewritten persisted file — a loweredattemptscount that resurrects a dead letter, an inflated one that pushes a still-retryable pair into the dead letter, a forgedlastError/lastAtor(endpoint, eventId)key — then fails closed at startup instead of silently rewriting the failure ledger. The unsigned snapshot stays accepted as trusted configuration, and the two forms never accept each other, so a signed restore cannot silently downgrade. The MAC is integrity protection only, not encryption: the endpoints and eventIds inside are wire-visible values, and the secret is never written into the envelope — a restored outbox must be handed the secret again, like the audit key above, and key storage and distribution remain the caller's responsibility. The envelope can also carry an optional expiry (expiresAt/maxAgeMson export, covered by the MAC); restore rejects an expired envelope (now > Date.parse(expiresAt)) after the MAC check and before any snapshot parsing, so a stolen envelope stops working at its expiry instead of resurrecting dead letters indefinitely. An envelope withoutexpiresAtnever expires. -
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. -
Payout webhook delivery rejects plaintext by default.
deliverPayoutWebhook(and each fan-out endpoint indeliverPayoutWebhookToMany) throws a caller configuration error before any request when the URL ishttp:unless the caller explicitly setsallowInsecureHttp: true(call-level, or per endpoint for fan-out). The payload carries a settlement amount and theX-Hub-Signature-256HMAC header, so plaintext delivery would let the signature be captured and replayed in full. Honest limit: this only rejects plaintext — it performs no certificate validation or TLS pinning, so anhttps:URL to an untrusted endpoint is still accepted, and the opt-in must only be used for loopback/test receivers or endpoints secured some other way. -
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.