@tgldisputepro/snapshot-canonicalize
v1.0.2
Published
Deterministic JSON canonicalization + SHA-256 hashing for bureau snapshots. Shared between TGL Dispute Pro and TP Print & Ship.
Downloads
276
Readme
@tgldisputepro/snapshot-canonicalize
Deterministic JSON canonicalization + SHA-256 hashing for bureau snapshots. Shared between TGL Dispute Pro and TP Print & Ship to guarantee identical hash output across systems.
Purpose
When a dispute letter is generated, it is bound to the bureau address it will be mailed to via a SHA-256 hash of a canonicalized JSON snapshot of that address. Both the sending platform (TGL Dispute Pro) and the printing platform (TP Print & Ship) must compute the exact same hash for the exact same snapshot — otherwise verification fails and letters cannot be released for mailing.
This package is the single source of truth for that canonicalization + hashing logic. Both sides install it from npm and call the same function; identical hashes are guaranteed by construction.
Install
npm install @tgldisputepro/snapshot-canonicalizeRequires Node.js ≥ 18.
Quick start
import { canonicalizeAndHash } from "@tgldisputepro/snapshot-canonicalize";
const result = canonicalizeAndHash({
bureau: "equifax",
name: "Equifax Information Services LLC",
address_line_1: "P.O. Box 740256",
address_line_2: null,
city: "Atlanta",
state: "GA",
zip: "30374-0256",
snapshotted_at: "2026-06-02T22:00:00.000Z",
});
result.hash;
// → "96a3e9fd0b5971fe4d5c122502010e6253d14040d538668e78876820e19741bc"
result.canonicalJson;
// → '{"address_line_1":"P.O. Box 740256","bureau":"equifax",...}'The result includes both the SHA-256 hash AND the exact canonical JSON string that was fed into SHA-256. When two systems disagree on the hash, compare canonicalJson strings first — divergence almost always shows up in normalization, not hashing.
API
canonicalizeAndHash(input: unknown): CanonicalizationResult
The main entry point. Validates, canonicalizes, and hashes in one call.
Returns { canonicalJson: string; hash: string }.
Throws SnapshotValidationError on invalid input.
canonicalize(input: unknown): string
Validates and canonicalizes only — returns the canonical JSON string without hashing.
sha256Hex(s: string): string
Hashes an arbitrary UTF-8 string with SHA-256, returns 64 lowercase hex chars. Exposed for power users (e.g. verifying a hash against an existing canonical string).
validate(input: unknown): asserts input is BureauSnapshot
Validates the snapshot shape and field constraints. Throws SnapshotValidationError on failure; returns void on success.
SnapshotValidationError
Thrown when validation fails. Includes:
field: string— name of the failing field (snake_case, matches input shape)reason: string— human-readable failure reasonmessage: string— full formatted message
Types
type Bureau = "equifax" | "experian" | "transunion";
interface BureauSnapshot {
bureau: Bureau;
name: string;
address_line_1: string;
address_line_2: string | null; // ONLY field allowed to be null
city: string;
state: string; // US 2-letter code (validated against 50 + DC)
zip: string; // 5 digits or 5-4 digits
snapshotted_at: string; // ISO-8601 UTC with ms precision
}
interface CanonicalizationResult {
canonicalJson: string;
hash: string; // 64 lowercase hex chars
}Canonicalization spec (v1.0.0 — locked)
The canonicalization algorithm is 9 steps, applied in order. Any change to this algorithm is a major version bump.
Required input shape
| Field | Type | Nullable | Constraint |
|---|---|---|---|
| bureau | string | No | One of: equifax, experian, transunion (lowercase exact match) |
| name | string | No | Non-empty after trim |
| address_line_1 | string | No | Non-empty after trim |
| address_line_2 | string \| null | Yes | Either null OR non-empty after trim (empty string is rejected — use null) |
| city | string | No | Non-empty after trim |
| state | string | No | 2-letter US code (50 states + DC), case-insensitive on input |
| zip | string | No | After stripping non-digit-or-dash: ^\d{5}(-\d{4})?$ |
| snapshotted_at | string | No | ISO-8601 UTC with millisecond precision: YYYY-MM-DDTHH:MM:SS.sssZ |
The 9 canonicalization steps
- Trim leading and trailing whitespace on every string field
- Collapse internal whitespace runs (spaces, tabs, newlines, NBSP, thin space, etc.) to a single ASCII space
- Uppercase the 2-letter state code
- Strip any character that is not a digit or dash from the zip
- Omit
address_line_2from the canonical output when it isnull. Any other field beingnull→ throwSnapshotValidationErrorBEFORE canonicalization - Sort keys alphabetically (encoded as the fixed canonical order in source)
- JSON.stringify with no extra whitespace, no trailing newline, and literal UTF-8 byte encoding — non-ASCII characters MUST appear as raw bytes, NOT as
\uXXXXescape sequences - SHA-256 the resulting UTF-8 byte string
- Return the digest as lowercase hex (exactly 64 characters)
Strict binding
snapshotted_at is INCLUDED in the hash (locked per Decision #65). Two snapshots that differ only in snapshotted_at produce different hashes. This is intentional — it prevents silent address drift.
Validation timing
Validation runs BEFORE any normalization. A corrupted snapshot (e.g. city: null) MUST NOT produce a "valid-looking" hash for incomplete data — it MUST throw.
Test vectors
The package ships canonical test vectors at the /vectors subpath:
import { TEST_VECTORS } from "@tgldisputepro/snapshot-canonicalize/vectors";Each vector includes:
id— short identifier (e.g.baseline-equifax)description— what the vector locks downinput— the rawBureauSnapshot(or invalid input)canonical_json— expected canonical JSON string (pre-hash) [OK vectors only]expected_hash— expected SHA-256 hex (post-hash) [OK vectors only]expect_throw: true— for invalid vectors that MUST throwexpect_error_field— for invalid vectors, the expectedSnapshotValidationError.field
Both TGL and TP MUST run these vectors as a CI gate. Any divergence indicates a normalization drift and blocks release.
Vector matrix (8 cases)
| ID | Locks |
|---|---|
| baseline-equifax | Canonical form for Equifax + key order + no-spaces JSON |
| baseline-experian | Canonical form for Experian |
| baseline-transunion | Canonical form for TransUnion |
| whitespace-collapsed | Steps 1+2 produce identical hash to baseline-equifax |
| state-lowercase | Step 3 produces identical hash to baseline-equifax |
| zip-space-vs-dash | ZIP with internal space is REJECTED (only digit+dash chars preserved) |
| address-line-2-present | address_line_2 included when non-null |
| unicode-em-dash-literal | Step 7 — Unicode chars are LITERAL bytes, not \uXXXX |
| timestamp-1ms-different-hash | Strict binding — 1ms diff → different hash |
| invalid-state-name | Full state name ("California") throws on state field |
| null-city-throws | Required null field throws (only address_line_2 may be null) |
Versioning
Strict SemVer.
- MAJOR (
1.x.x → 2.0.0) — any change to the canonicalization algorithm (would change existing hashes), OR tightening validation (rejecting inputs1.xwould have accepted) - MINOR (
1.0.x → 1.1.0) — backward-compatible additions (new optional input fields with defaults, new exports) - PATCH (
1.0.0 → 1.0.1) — bug fixes that DO NOT change hash output
Deprecation policy
When 2.0.0 ships, the 1.x line will continue to receive critical security patches for at least 12 months. This guarantees both TGL and TP have a migration window without breakage.
Security
This package:
- Performs no I/O (no network, no filesystem, no environment access)
- Has zero runtime dependencies (uses only Node's built-in
crypto) - Publishes with npm provenance attestations (verifiable build origin from GitHub Actions)
To verify the provenance of an installed copy:
npm view @tgldisputepro/snapshot-canonicalize --json | jq .distContributing
This is a spec repository, not a feature repository. PRs are welcome from authorized contributors (TGL team, TP team). The bar for accepting changes is intentionally high because every change ripples to two production systems.
Please open an issue first to discuss any proposed change. For changes that would alter hash output, expect to ship a new major version.
License
MIT — see LICENSE.
Authors
- TGL Global — TGL Dispute Pro
- TP Print & Ship — npm co-maintainer for continuity insurance
