Browser — @hesohq/verify-wasm
@hesohq/verify-wasm is the HESO Rust core compiled to WASM as a verify-only package. Load it once, then check receipts, chains, transparency proofs, approval tokens, and policy previews in the browser or in Node. It never signs and never holds a key. Because it is the same core, its verdicts match the Python and Node SDKs byte for byte.
A receipt proves you authorized an action under your policy. It does not prove the action succeeded downstream. This package can tell you a receipt is Valid at L0 or L1; it cannot mint one. For the signing surface, see @hesohq/core.
Common tasks
The three calls most code reaches for, in order of how often you need them:
- Verify a receipt — verifyActionReceipt() takes receipt bytes and returns a verdict plus the re-derived trust level.
- Read the status — branch on the verdict string (Valid, HashMismatch, WrongAlgorithm, Malformed, Unsupported, …). A failed check returns a verdict, it does not throw.
- Preview a policy — parsePolicy() and ruleToSentence() render and floor-check a heso.toml in the browser before deploy.
Everything else — chains and proofs, approval tokens, and the hashing primitives — is listed below. All of it requires init() first.
Install
The package is ESM and runs in the browser and in Node. There is no CommonJS build, and no runtime dependency beyond the bundled .wasm binary.
npm install @hesohq/verify-wasmThe .wasm file must be reachable at runtime, because init() fetches it. If your bundler does not emit and resolve the asset for you, pass an explicit URL or an already-fetched module to init() (see below). For App Router specifics — where to put the file and how to load it once on the client — follow the Next.js guide.
Initialization
Everything starts with the default export, init. Await it once, cache the returned promise, and from then on the named exports run synchronously.
init()function
The wasm-bindgen entry point. Fetches and loads the .wasm module, then resolves. You must await this once before calling any other export. With no argument it fetches the default asset URL; pass a URL, Request, Response, or already-compiled module to override that. A synchronous initSync(module) variant exists for when you already hold the compiled module bytes.
export default function init(module_or_path?: InitInput): Promise<InitOutput>Parameters
- module_or_pathInitInput
- Optional. A URL,
Request,Response,BufferSource, or compiledWebAssembly.Moduleto instantiate from. Omit it to fetch the default bundled asset.
ReturnsPromise<InitOutput>
A promise resolving to InitOutput once the module is ready. Call it once and cache the promise so concurrent callers share one instantiation.
Example
import init, { verifyActionReceipt } from "@hesohq/verify-wasm"
// Call init() once and cache the promise. After it resolves,
// every named export runs synchronously.
let ready: Promise<unknown> | null = null
export function loadVerifier() {
ready ??= init()
return ready
}
await loadVerifier()
const verdict = verifyActionReceipt(receiptBytes)Verify a receipt
The core call. Hand it the receipt bytes and it runs the full verify order — algorithm, version, hash, operator signature, approver signature, redaction markers, trust level — and stops at the first failing gate. The trust level is re-derived from which signatures actually pass, so a receipt that claims more than its signatures support fails with a mismatch.
verifyActionReceipt()function
Verify a single Action Receipt end to end and return an ActionVerdict. Never panics: malformed input comes back as a Malformed verdict, not an exception.
function verifyActionReceipt(receiptBytes: Uint8Array): ActionVerdictParameters
- receiptBytesUint8Arrayrequired
- The serialized ActionReceipt JSON as bytes.
ReturnsActionVerdict
An ActionVerdict with two fields. verdict is "Valid" on success, or a variant name describing the first failing gate (see statuses). trust_level is the re-derived level ("L0" or "L1"), meaningful only when the verdict is "Valid".
Example
import init, { verifyActionReceipt } from "@hesohq/verify-wasm"
await init()
const verdict = verifyActionReceipt(receiptBytes) // receiptBytes: Uint8Array
if (verdict.verdict === "Valid") {
console.log("trust", verdict.trust_level) // "L0" | "L1"
} else {
console.warn("rejected:", verdict.verdict) // e.g. "HashMismatch"
}Read the status
A failed verification does not throw. It returns an ActionVerdict whose verdict is the variant name of the gate that failed. Branch on the string.
| verdict | Meaning |
|---|---|
Valid | Every gate passed. Read trust_level for L0 or L1. |
Invalid | A signature did not verify (full form is InvalidSignature:…). |
HashMismatch | The action_hash does not match the recomputed BLAKE3 — content was edited. |
WrongAlgorithm | The alg field is not heso-action/v2+ed25519. |
Malformed | The bytes are not a well-formed ActionReceipt. |
Unsupported | The receipt uses a feature this build cannot check. |
Some verdicts append a colon and detail — for example "WrongAlgorithm:…", "InvalidSignature:…", or "TrustLevelMismatch:embedded=L1,derived=L0". Match the prefix with startsWith when you need the category. For the full list, see Verdicts.
// verdict.verdict is the variant name of the first failing gate,
// or "Valid" when every gate passes.
switch (true) {
case verdict.verdict === "Valid":
// operator (and approver, at L1) signatures check out
break
case verdict.verdict === "HashMismatch":
// action_hash does not match the recomputed BLAKE3 — content was edited
break
case verdict.verdict.startsWith("WrongAlgorithm"):
// alg is not "heso-action/v2+ed25519"
break
case verdict.verdict.startsWith("Malformed"):
// bytes are not a well-formed ActionReceipt
break
default:
// InvalidSignature, TrustLevelMismatch, Unsupported, …
break
}This build compiles without the RFC-3161 time-anchor stack. A time-anchored receipt that Node verifies against its TSA stack is handled differently here, so for those receipts the verdict is not byte-identical to Node. Plain receipts verify identically everywhere.
Chains and proofs
Beyond a single receipt, the package verifies whole audit chains and the transparency-log proofs. Chain checks return a ChainResult that points at the exact seq where a chain broke. Proof checks return a boolean.
verifyChain()function
Verify a sequence of receipts as a BLAKE3-linked hash chain — each entry must link to the one before it.
function verifyChain(receiptsBytes: Uint8Array): ChainResultParameters
- receiptsBytesUint8Arrayrequired
- Bytes of a JSON array of receipts, in order.
ReturnsChainResult
A ChainResult. On success, ok is true and length is the entry count. On failure, ok is false and seq, error, and detail locate the break.
Example
import init, { verifyChain } from "@hesohq/verify-wasm"
await init()
const result = verifyChain(receiptsBytes)
if (!result.ok) {
console.error("chain broke at seq", result.seq, "—", result.error)
}verifySessionChain()function
Verify one session's receipt chain with lifecycle role and transition checks. Same ChainResult shape as verifyChain().
function verifySessionChain(receiptsBytes: Uint8Array): ChainResultParameters
- receiptsBytesUint8Arrayrequired
- Bytes of the receipts for one session, in order.
ReturnsChainResult
A ChainResult for the session.
verifySessionChainWithRotation()function
Verify a session chain with key-rotation, as-of-position, against a pinned genesis producer key and an optional decision key.
function verifySessionChainWithRotation(receiptsBytes: Uint8Array, producerKey: string, decisionKey?: string): ChainResultParameters
- receiptsBytesUint8Arrayrequired
- Bytes of the receipts for the session, in order.
- producerKeystringrequired
- Base64 genesis producer (operator) public key — the TOFU pin.
- decisionKeystring
- Optional base64 genesis decision (approver) public key.
ReturnsChainResult
A ChainResult for the session under the supplied keys.
verifyInclusion()function
Check an RFC-6962 Merkle inclusion proof: that a leaf sits at a given index in a tree of a given size under a given root.
function verifyInclusion(leafValueHex: string, index: number, size: number, rootHex: string, proofHashesJson: string): booleanParameters
- leafValueHexstringrequired
- The 64-hex action_hash; its raw bytes are the leaf value.
- indexnumberrequired
- The leaf's index in the tree.
- sizenumberrequired
- The tree size the proof was issued against.
- rootHexstringrequired
- The expected SHA-256 Merkle root, hex-encoded.
- proofHashesJsonstringrequired
- The sibling hashes as a JSON array (or space-separated string) of 64-hex SHA-256 hashes.
Returnsboolean
True if the inclusion proof holds, false otherwise.
verifyConsistency()function
Check an RFC-6962 consistency proof: that a newer tree is an append-only extension of an older one.
function verifyConsistency(oldSize: number, oldRootHex: string, newSize: number, newRootHex: string, proofHashesJson: string): booleanParameters
- oldSizenumberrequired
- The earlier tree size.
- oldRootHexstringrequired
- The earlier Merkle root, hex-encoded.
- newSizenumberrequired
- The later tree size.
- newRootHexstringrequired
- The later Merkle root, hex-encoded.
- proofHashesJsonstringrequired
- The proof hashes as a JSON array of hex strings.
Returnsboolean
True if the new tree is consistent with the old one, false otherwise.
Preview a policy
These exports make the console’s policy editor live. They parse, describe, and floor-check a heso.toml entirely in the browser, so an author sees the decision a rule would produce — and catches a floor bypass — before anything deploys. This is the same Rust engine the server runs, so the in-browser verdict matches what the cloud enforces.
parsePolicy()function
Parse and validate a policy file, running the load-time floor check. Returns nothing on success; throws on a malformed file or a floor bypass.
function parsePolicy(tomlSrc: string): voidParameters
- tomlSrcstringrequired
- The heso.toml policy source.
Throws
A [PARSE] error for a malformed policy, or a [FLOOR_BYPASS] error naming the offending rule id and verb if a rule tries to allow a dangerous lane without approval.
ruleToSentence()function
Render one rule as its canonical English sentence — the same string a receipt carries as rule_display.
function ruleToSentence(ruleJson: string): stringParameters
- ruleJsonstringrequired
- A single policy rule, as a JSON string.
Returnsstring
The natural-language sentence for the rule, identical to the receipt’s rule_display.
Example
import init, { parsePolicy, policyRulesFromToml, ruleToSentence } from "@hesohq/verify-wasm"
await init()
parsePolicy(tomlSrc) // throws "[PARSE]…" or "[FLOOR_BYPASS]…"
// Render a pulled policy via the real parser, then the canonical sentence:
const rules = JSON.parse(policyRulesFromToml(tomlSrc)) // PolicyRule[]
const sentence = ruleToSentence(JSON.stringify(rules[0])) // the receipt rule_displaypolicyRulesFromToml()function
Parse a policy file into its structured rules via the real engine parser — used to render the rule list in the editor. Empty TOML yields [].
function policyRulesFromToml(tomlSrc: string): stringParameters
- tomlSrcstringrequired
- The heso.toml policy source.
Returnsstring
A JSON array of PolicyRule objects, as a string. Throws the same [PARSE] / [FLOOR_BYPASS] errors as parsePolicy.
validateNoFloorBypass()function
Floor pre-check over a candidate ruleset without serializing to TOML — the live check the builder runs as the user edits. Returns nothing on success; throws if any rule bypasses a floor.
function validateNoFloorBypass(rulesJson: string): voidParameters
- rulesJsonstringrequired
- A JSON array of policy rules.
Throws
A [FLOOR_BYPASS] error naming the rule id and verb that tries to allow a dangerous lane without approval.
Both parsePolicy and validateNoFloorBypass enforce the same pinned floors the engine checks at load time, in the browser — so a policy that would be rejected on the server is rejected before it ever leaves the editor. For the full simulate-then-deploy loop, see Test & deploy.
Approval tokens
At L1, a human approver signs an action with their own device-held key. verifyApprovalToken checks that Ed25519 token against the action it authorizes, blocks replays with a set of nonces it has already seen, and confirms the token is in scope and signed by a registered key.
verifyApprovalToken()function
Verify an approval token and return its ApprovalTokenClaims if it passes. Throws a [CODE]-prefixed error on failure.
function verifyApprovalToken(
token: Uint8Array,
actionCanonical: Uint8Array,
nowUnixSecs: bigint,
seenNonces: Uint8Array[],
requiredScope: string,
requiredDecision: string,
registeredKeysB64: string[],
): ApprovalTokenClaimsParameters
- tokenUint8Arrayrequired
- The approval token bytes.
- actionCanonicalUint8Arrayrequired
- The canonical bytes of the action the token authorizes — produce these with actionCanonicalBytes().
- nowUnixSecsbigintrequired
- The current time in Unix seconds, used to reject expired tokens.
- seenNoncesUint8Array[]required
- 32-byte nonces already accepted, so a replayed token is rejected.
- requiredScopestringrequired
- The scope the token must carry to authorize this action.
- requiredDecisionstringrequired
- The verdict the token must carry —
"approved"or"rejected". Non-defaulted: a token whose signed decision differs is rejected. - registeredKeysB64string[]required
- The base64 approver public keys you accept signatures from.
ReturnsApprovalTokenClaims
An ApprovalTokenClaims with nonce, expiry_unix_secs, decision, scope, and approver_public_key.
Example
import init, { verifyApprovalToken } from "@hesohq/verify-wasm"
await init()
const claims = verifyApprovalToken(
token, // Uint8Array
actionCanonical, // Uint8Array — from actionCanonicalBytes()
BigInt(Math.floor(Date.now() / 1000)),
seenNonces, // Uint8Array[] — replay guard
"payment", // required scope
"approved", // required decision — the verdict the token must carry
registeredKeysB64 // string[] — approver pubkeys you trust
)
console.log(claims.decision, claims.scope, claims.approver_public_key)For the client-approval path — where your customer co-signs from their own browser — verifyDelegation verifies the operator delegation envelope plus the human co-signature. l1CosignPayload and quorumCosignPayload return the exact bytes a hosted approver signs to lift a suspended L0 action to L1: the first for a single approver, the second for one approver in a k-of-n quorum. The cloud relays that detached signature (it holds no key) and the operator SDK re-mints and locally verifies the L1 before keeping it. See Human approval.
verifyDelegation()function
Verify a delegation envelope and the human co-sign presented against it — the client-approval path.
function verifyDelegation(
wire: Uint8Array,
registeredOperatorKey: Uint8Array,
actionHash: Uint8Array,
approvalToken: Uint8Array,
requiredScope: string,
requiredDecision: string,
nowUnixSecs: bigint,
): VerifiedDelegationParameters
- wireUint8Arrayrequired
- The raw delegation envelope bytes.
- registeredOperatorKeyUint8Arrayrequired
- The org-registered operator public key (raw 32 bytes).
- actionHashUint8Arrayrequired
- The raw 32-byte BLAKE3 action digest being authorized.
- approvalTokenUint8Arrayrequired
- The human co-sign bearer token presented by the authorized key.
- requiredScopestringrequired
- The scope the delegated authority must cover.
- requiredDecisionstringrequired
- The verdict the co-sign must carry —
"approved"or"rejected"(non-defaulted). - nowUnixSecsbigintrequired
- The current time in Unix seconds.
ReturnsVerifiedDelegation
A VerifiedDelegation with the authorized key, subject, scope, and validity window. Throws a [CODE]-prefixed error on failure.
l1CosignPayload()function
Return the exact bytes a hosted approver must sign to co-sign a suspended (L0) action into a single-approver L1 receipt: the approval signing domain prefix plus the promoted L1 body's canonical bytes.
function l1CosignPayload(suspendedContentJson: string, approverRecordJson: string): Uint8ArrayParameters
- suspendedContentJsonstringrequired
- The suspended L0 ActionContent JSON (may already carry a relayed time_anchor).
- approverRecordJsonstringrequired
- The human's ApproverRecord (decision, identity, reason, decided_at, sla_minutes) as JSON.
ReturnsUint8Array
The co-sign payload bytes the remote signer returns a detached Ed25519 signature over.
Throws
A [CODE]-prefixed error per BuildL1Error — for example [AlreadyDecided] when the body is already decided or is not a clean L0.
quorumCosignPayload()function
Return the exact bytes ONE approver must sign under the k-of-n quorum lane. Each approver vouches only their own record — a quorum receipt still derives L1, not a higher level.
function quorumCosignPayload(
suspendedContentJson: string,
threshold: number,
rosterJson: string,
approverRecordJson: string,
): Uint8ArrayParameters
- suspendedContentJsonstringrequired
- The suspended L0 ActionContent JSON.
- thresholdnumberrequired
- The k-of-n approval threshold (>= 1).
- rosterJsonstringrequired
- JSON array of base64 admissible approver public keys.
- approverRecordJsonstringrequired
- THIS approver's ApproverRecord as JSON.
ReturnsUint8Array
The per-record co-sign payload bytes the remote signer returns a detached Ed25519 signature over.
Throws
A [CODE]-prefixed error per BuildQuorumError — [AnchorOnAsyncL1Path], [AlreadyDecided], [ThresholdZero], or [EmptyRoster].
Hashing and canonicalization
The same building blocks the verifier uses internally are exposed directly, so you can reproduce a receipt’s action_hash, derive canonical bytes for an approval check, or recompute a chain link. Canonicalization is RFC-8785 (JCS) with action_hash stripped before hashing. The content hash is BLAKE3.
contentHash()function
Compute the BLAKE3 content hash of an action's content JSON — the value that lands in action_hash.
function contentHash(contentJson: string): stringParameters
- contentJsonstringrequired
- The ActionContent as a JSON string.
Returnsstring
The BLAKE3 hash as a lowercase 64-hex string.
Example
import init, { contentHash, actionCanonicalBytes, chainHashHex } from "@hesohq/verify-wasm"
await init()
const bytes = actionCanonicalBytes(contentJson) // RFC-8785 JCS, action_hash stripped
const hash = contentHash(contentJson) // BLAKE3, 64-hex
const link = chainHashHex(prevChainHex, hash) // BLAKE3(prev ++ action)anchoredContentHash()function
Compute the pre-anchor content hash — the hash an optional RFC-3161 time anchor binds, computed with the time_anchor field excluded so a timestamp can be added later without changing it.
function anchoredContentHash(contentJson: string): stringParameters
- contentJsonstringrequired
- The ActionContent as a JSON string.
Returnsstring
The pre-anchor hash as a hex string. Trusted time is optional and off by default; when an anchor is present, its genTime bounds when the assembled body existed, not when a human decided.
actionCanonicalBytes()function
Produce the canonical (RFC-8785 JCS) bytes for an action's content — the bytes that get hashed and the input to approval-token checks.
function actionCanonicalBytes(contentJson: string): Uint8ArrayParameters
- contentJsonstringrequired
- The ActionContent as a JSON string.
ReturnsUint8Array
The canonical bytes, with action_hash stripped before serialization.
chainHashHex()function
Compute one audit-chain link: a domain-separated BLAKE3 digest of the previous chain hash and the current action hash.
function chainHashHex(prevHex: string, actionHex: string): stringParameters
- prevHexstringrequired
- The previous entry’s chain hash, hex-encoded (or
"genesis"for seq 0). - actionHexstringrequired
- The current entry's action hash, hex-encoded.
Returnsstring
The chain hash for this entry, hex-encoded.
shortHash()function
Render a 64-hex BLAKE3 hash in short display form ({prefix}:{first8}).
function shortHash(hex: string, prefix?: string): stringParameters
- hexstringrequired
- The full 64-hex hash.
- prefixstring
- Optional prefix to prepend to the shortened form.
Returnsstring
A short display string for the hash.
Result classes
Four small classes are returned by the functions above. Each is a plain data carrier — read its fields, do not construct it yourself.
| Class | Fields | Returned by |
|---|---|---|
ActionVerdict | verdict: string, trust_level: string | verifyActionReceipt() |
ApprovalTokenClaims | nonce: Uint8Array, expiry_unix_secs: bigint, decision: string, scope: string, approver_public_key: string | verifyApprovalToken() |
VerifiedDelegation | authorized_key: Uint8Array, sub: string, scope: string, expiry_unix_secs: bigint, not_before_unix_secs: bigint | verifyDelegation() |
ChainResult | ok: boolean, length?: number, error?: string, seq?: number, detail?: string | verifyChain() and the session-chain checks |
The verdicts, hashes, and policy decisions this package returns are byte-identical to the Node core and the Python SDK, because all three call the same Rust engine. The one exception is time-anchored receipts, noted above.