@herald/cbor-det — HERALD v0.2 CBOR-deterministic codec
A focused, dependency-free TypeScript implementation of the HERALD
SPEC §3.5 (../../../../SPEC.md) canonicalisation profile, sitting on
top of RFC 8949 §4.2.1
core deterministic encoding.
This is the bytes layer on which T-003 (envelope build/sign/verify) and
everything downstream depends. It produces byte-identical canonical
bytes to worker-A's Python cbor2.dumps(value, canonical=True) output.
Install / build
cd manthan/state/findings/T-002
npm install # installs typescript + vitest only (zero runtime deps)
npm run build # tsc → dist/
npm test # vitest run
npm run typecheck # tsc --noEmitNode ≥ 18 (required for Float32Array, BigInt, TextEncoder /TextDecoder with fatal: true).
Usage
import { encode, decode, ProfileError } from "@herald/cbor-det";
const bytes = encode({
v: "herald/0.2",
ts: 1746432000,
nonce: new Uint8Array(16).fill(0xaa),
payload: { hello: "world" },
});
const value = decode(bytes);
// value === { v: "herald/0.2", ts: 1746432000, nonce: Uint8Array, ... }
// Strict mode: any non-canonical byte sequence throws.
try {
decode(badBytes);
} catch (e) {
if (e instanceof ProfileError) {
console.error(e.code, e.message); // e.g. "E_MAP_ORDER ..."
}
}What gets encoded as what
| JS input | CBOR major type / encoding |
|---|---|
null | Simple(22) |
true / false | Simple(21) / Simple(20) |
number (integer) | mt 0/1, shortest argument |
number (float, integer-valued) | mt 0/1 (integer-prefer rule) |
number (float, fractional) | mt 7, shortest of float16/32/64 |
bigint in [-(2^64), 2^64-1] | mt 0/1 |
string | mt 3, UTF-8 bytes |
Uint8Array | mt 2 |
T[] | mt 4 |
| plain object (string keys) | mt 5, keys sorted by encoded-key bytes |
anything else (Date, Map, Set, class instances, Symbol, function, undefined, ArrayBuffer) | ProfileError E_TYPE |
What strict decode rejects
| Error code | What triggers it |
|---|---|
E_INDEFINITE | indefinite-length items (mt 2/3/4/5 with info=31), break byte |
E_NON_MINIMAL_LEN | length/integer encoded longer than necessary |
E_MAP_KEY | map key not a text string |
E_MAP_ORDER | map keys not in canonical sorted order |
E_DUP_KEY | two map entries with identical encoded keys |
E_NAN / E_INF / E_NEG_ZERO | forbidden float values |
E_FLOAT_IS_INT | float value is an integer (must use integer encoding) |
E_FLOAT_WIDE | float wider than necessary (must use shortest IEEE-754 width) |
E_TAG | any tag (no Appendix A in v0.2 — see INTERPRETATION.md) |
E_UNDEF | Simple(23) "undefined" |
E_SIMPLE | other reserved/unsupported simple values |
E_RESERVED_INFO | additional-information value 28/29/30 |
E_UTF8 | text string contains invalid UTF-8 |
E_TRAILING | input has bytes after a complete top-level value |
E_EOF | input truncated mid-value |
E_INT_RANGE | integer outside [-(2^64), 2^64-1] (would need bignum tag) |
E_TOO_BIG | collection length too large to handle safely |
Tests
npm testCurrently:
| Test file | Cases | What it covers |
|---|---|---|
round-trip.test.ts | 9 | decode(encode(x)) ≡ x for ints, bigints, strings, byte strings, null, booleans, arrays, envelope-shaped objects, floats |
strict-mode.test.ts | 25 | hand-crafted non-canonical inputs are rejected with the correct error code |
edge-cases.test.ts | 24 | encoder rejects NaN/Inf/-0/undefined/Date/Map/Set/Symbol/function/ArrayBuffer/class instance; integer-prefer; float widths; bigint range |
vectors-interop.test.ts | 14 | byte-identical to test-vectors/v0.2/vectors.json: 7 decode→encode round-trips + 7 build-from-inputs reconstructions (replay-genesis skipped from PART 2 because it carries no inputs of its own) |
72 tests pass.
Layout
src/
├── encode.ts (~280 LoC) canonical encoder
├── decode.ts (~370 LoC) strict decoder
├── profile.ts (~110 LoC) SPEC §3.5 constants and ProfileError
└── index.ts public exports
test/
├── round-trip.test.ts
├── strict-mode.test.ts
├── edge-cases.test.ts
└── vectors-interop.test.tsCite
If T-003 (or any later library) needs to encode envelope bodies, import
from this package. The contract is:
encode(x)is a pure function. Same input → same bytes, always.decode(b)either returns a value or throwsProfileError— never
returns silently re-canonicalised data.