import { createHash } from "node:crypto"; /** * Canonical JSON — recursive deep key-sort (objects at every depth, * arrays mapped element-wise), so a JSONB round-trip through Postgres * (which does NOT preserve original key order — it reorders by key * length then alphabetically) never changes the string a hash was * computed over. Shared by audit_log (repositories/audit.ts) and the * e-signature module's signature_audit_events (repositories/ * signatures.ts) — both hash-chained tables, same algorithm. * * The legacy OrçaFácil this was ported from carries a v1/v2 version * split here: v1 only sorted top-level keys, which broke against * Postgres's jsonb key reordering for anything nested (e.g. inside a * `metadata` object) — a real bug discovered after already-hashed * production events existed. EDEN has no pre-existing hash-chain rows * from before this fix, so there is no v1 to port — every event this * system ever writes uses this (the legacy's "v2") formula from day * one. `hash_algorithm_version` is still recorded per-event (see the * signature_audit_events schema) purely so a future algorithm change * has the same version-aware escape hatch, not because v1 exists here. */ export function canonicalJson(value: unknown): string { if (value === undefined) return "null"; if (value === null || typeof value !== "object") return JSON.stringify(value); if (Array.isArray(value)) return `[${value.map(canonicalJson).join(",")}]`; const obj = value as Record; const keys = Object.keys(obj).sort(); return `{${keys.map((k) => `${JSON.stringify(k)}:${canonicalJson(obj[k])}`).join(",")}}`; } /** `event_hash = SHA256(previousEventHash + canonicalJson(fields))` — the one formula every hash-chained table in this codebase uses. */ export function computeEventHash(previousEventHash: string, fields: Record): string { return createHash("sha256") .update(previousEventHash + canonicalJson(fields)) .digest("hex"); }