verifyhash 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (154) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +883 -0
  3. package/cli/abi/ContributionRegistry.json +881 -0
  4. package/cli/agent.js +2173 -0
  5. package/cli/anchor-artifact.js +853 -0
  6. package/cli/anchor.js +400 -0
  7. package/cli/claim.js +881 -0
  8. package/cli/core/agent-commit.js +448 -0
  9. package/cli/core/agent-session.js +598 -0
  10. package/cli/core/anchor-binding.js +663 -0
  11. package/cli/core/attestation.js +580 -0
  12. package/cli/core/evidence-plans.js +495 -0
  13. package/cli/core/fixtures/evidence-plans/baseline.json +19 -0
  14. package/cli/core/fulfill-intake.js +1082 -0
  15. package/cli/core/go-live-preflight.js +481 -0
  16. package/cli/core/license.js +534 -0
  17. package/cli/core/manifest.js +243 -0
  18. package/cli/core/packetseal.js +591 -0
  19. package/cli/core/registryArtifact.js +49 -0
  20. package/cli/core/revocation.js +539 -0
  21. package/cli/core/rfc3161.js +389 -0
  22. package/cli/core/timestamp.js +482 -0
  23. package/cli/core/trust-asof.js +479 -0
  24. package/cli/dataset.js +2950 -0
  25. package/cli/evidence.js +2227 -0
  26. package/cli/fulfill-webhook-http.js +438 -0
  27. package/cli/git.js +220 -0
  28. package/cli/hash.js +550 -0
  29. package/cli/identity.js +1072 -0
  30. package/cli/journal-cli.js +1110 -0
  31. package/cli/journal-log.js +454 -0
  32. package/cli/journal.js +334 -0
  33. package/cli/lineage.js +447 -0
  34. package/cli/list.js +287 -0
  35. package/cli/parcel.js +1509 -0
  36. package/cli/proof.js +578 -0
  37. package/cli/prove.js +300 -0
  38. package/cli/receipt.js +631 -0
  39. package/cli/registry.js +331 -0
  40. package/cli/reputation.js +344 -0
  41. package/cli/revocation.js +495 -0
  42. package/cli/serve-verify-http.js +298 -0
  43. package/cli/serve-verify.js +333 -0
  44. package/cli/show.js +339 -0
  45. package/cli/verify.js +383 -0
  46. package/cli/vh.js +3927 -0
  47. package/docs/ADOPT.md +183 -0
  48. package/docs/ADOPTION.json +11 -0
  49. package/docs/AGENTTRACE.md +247 -0
  50. package/docs/ANCHORING.md +167 -0
  51. package/docs/AUDIT.md +55 -0
  52. package/docs/CONFORMANCE.md +107 -0
  53. package/docs/DATALEDGER.md +638 -0
  54. package/docs/DECIDE.md +47 -0
  55. package/docs/DECISIONS-PENDING.md +27 -0
  56. package/docs/DEPLOY-PUBLIC-SITE.md +301 -0
  57. package/docs/ENGINE-LEDGER.json +12 -0
  58. package/docs/EVIDENCE.md +519 -0
  59. package/docs/GO-LIVE.md +66 -0
  60. package/docs/IDENTITY.md +123 -0
  61. package/docs/INDEPENDENT-VERIFICATION.md +377 -0
  62. package/docs/INTEGRITY-JOURNAL.md +337 -0
  63. package/docs/KEY-LIFECYCLE.md +179 -0
  64. package/docs/LICENSING.md +46 -0
  65. package/docs/LINEAGE.md +307 -0
  66. package/docs/LOOP-AUDIT-2026-07-03.json +580 -0
  67. package/docs/LOOP-HARDENING-PLAN.md +44 -0
  68. package/docs/MERKLE-LEAVES.md +113 -0
  69. package/docs/METRICS.jsonl +31 -0
  70. package/docs/MORNING.md +204 -0
  71. package/docs/PILOT.md +444 -0
  72. package/docs/PROOFPARCEL.md +227 -0
  73. package/docs/PROOFS.md +262 -0
  74. package/docs/RECEIPTS.md +341 -0
  75. package/docs/REPUTATION.md +158 -0
  76. package/docs/SDK.md +301 -0
  77. package/docs/STRATEGY-ARCHIVE.md +5055 -0
  78. package/docs/SUPERVISOR-RUNBOOK.md +52 -0
  79. package/docs/TRUST-BOUNDARIES.md +335 -0
  80. package/docs/TRUSTLEDGER.md +1976 -0
  81. package/docs/USAGE-BUDGET.json +121 -0
  82. package/docs/VERIFY-SERVICE.md +168 -0
  83. package/index.js +160 -0
  84. package/package.json +41 -0
  85. package/trustledger/build-standalone.js +796 -0
  86. package/trustledger/cli.js +3179 -0
  87. package/trustledger/close.js +391 -0
  88. package/trustledger/corpus.js +159 -0
  89. package/trustledger/dist/BUILD-PROVENANCE.json +99 -0
  90. package/trustledger/dist/trustledger-standalone.html +6197 -0
  91. package/trustledger/dist/trustledger-standalone.html.sha256 +1 -0
  92. package/trustledger/door-core.js +442 -0
  93. package/trustledger/fixtures/bank.csv +7 -0
  94. package/trustledger/fixtures/bank.malformed.csv +3 -0
  95. package/trustledger/fixtures/bank.noalias.csv +5 -0
  96. package/trustledger/fixtures/bank.ofx +34 -0
  97. package/trustledger/fixtures/bank.real.csv +5 -0
  98. package/trustledger/fixtures/corpus/_shared/prior-close.json +22 -0
  99. package/trustledger/fixtures/corpus/bank-book-mismatch--benign-twin/inputs.json +14 -0
  100. package/trustledger/fixtures/corpus/bank-book-mismatch--benign-twin/meta.json +7 -0
  101. package/trustledger/fixtures/corpus/bank-book-mismatch--out-of-trust/inputs.json +14 -0
  102. package/trustledger/fixtures/corpus/bank-book-mismatch--out-of-trust/meta.json +7 -0
  103. package/trustledger/fixtures/corpus/continuity-break--benign-twin/inputs.json +15 -0
  104. package/trustledger/fixtures/corpus/continuity-break--benign-twin/meta.json +7 -0
  105. package/trustledger/fixtures/corpus/continuity-break--out-of-trust/inputs.json +15 -0
  106. package/trustledger/fixtures/corpus/continuity-break--out-of-trust/meta.json +7 -0
  107. package/trustledger/fixtures/corpus/negative-tenant-ledger--benign-twin/inputs.json +13 -0
  108. package/trustledger/fixtures/corpus/negative-tenant-ledger--benign-twin/meta.json +7 -0
  109. package/trustledger/fixtures/corpus/negative-tenant-ledger--out-of-trust/inputs.json +13 -0
  110. package/trustledger/fixtures/corpus/negative-tenant-ledger--out-of-trust/meta.json +7 -0
  111. package/trustledger/fixtures/corpus/owner-overdraw--benign-twin/inputs.json +15 -0
  112. package/trustledger/fixtures/corpus/owner-overdraw--benign-twin/meta.json +7 -0
  113. package/trustledger/fixtures/corpus/owner-overdraw--out-of-trust/inputs.json +15 -0
  114. package/trustledger/fixtures/corpus/owner-overdraw--out-of-trust/meta.json +7 -0
  115. package/trustledger/fixtures/corpus/security-deposit-segregation--benign-twin/inputs.json +16 -0
  116. package/trustledger/fixtures/corpus/security-deposit-segregation--benign-twin/meta.json +7 -0
  117. package/trustledger/fixtures/corpus/security-deposit-segregation--out-of-trust/inputs.json +13 -0
  118. package/trustledger/fixtures/corpus/security-deposit-segregation--out-of-trust/meta.json +7 -0
  119. package/trustledger/fixtures/corpus/subledger-out-of-balance--benign-twin/inputs.json +13 -0
  120. package/trustledger/fixtures/corpus/subledger-out-of-balance--benign-twin/meta.json +7 -0
  121. package/trustledger/fixtures/corpus/subledger-out-of-balance--out-of-trust/inputs.json +13 -0
  122. package/trustledger/fixtures/corpus/subledger-out-of-balance--out-of-trust/meta.json +7 -0
  123. package/trustledger/fixtures/e2e/bank.aliased.csv +4 -0
  124. package/trustledger/fixtures/e2e/bank.csv +4 -0
  125. package/trustledger/fixtures/e2e/bank.nsf.csv +4 -0
  126. package/trustledger/fixtures/e2e/quickbooks.csv +6 -0
  127. package/trustledger/fixtures/e2e/quickbooks.nsf.csv +8 -0
  128. package/trustledger/fixtures/e2e/rentroll.csv +6 -0
  129. package/trustledger/fixtures/e2e/rentroll.nsf.csv +8 -0
  130. package/trustledger/fixtures/e2e/rentroll.short.csv +5 -0
  131. package/trustledger/fixtures/plans/baseline.json +25 -0
  132. package/trustledger/fixtures/plans/price-binding.example.json +27 -0
  133. package/trustledger/fixtures/policy/ambiguous-deposit-example.json +12 -0
  134. package/trustledger/fixtures/policy/baseline.json +19 -0
  135. package/trustledger/fixtures/policy/ca-example.json +12 -0
  136. package/trustledger/fixtures/policy/negative-tenant-ledger-example.json +12 -0
  137. package/trustledger/fixtures/policy/owner-overdraw-example.json +12 -0
  138. package/trustledger/fixtures/quickbooks.csv +7 -0
  139. package/trustledger/fixtures/quickbooks.real.csv +5 -0
  140. package/trustledger/fixtures/rentroll.csv +6 -0
  141. package/trustledger/fixtures/rentroll.real.csv +4 -0
  142. package/trustledger/ingest.js +1163 -0
  143. package/trustledger/lib/policy-bundled-loader.js +44 -0
  144. package/trustledger/lib/sha256-vendored.js +227 -0
  145. package/trustledger/license.js +563 -0
  146. package/trustledger/match.js +551 -0
  147. package/trustledger/plans.js +551 -0
  148. package/trustledger/policy.js +398 -0
  149. package/trustledger/public/index.html +512 -0
  150. package/trustledger/reconcile.js +1486 -0
  151. package/trustledger/report.js +887 -0
  152. package/trustledger/seal.js +854 -0
  153. package/trustledger/server.js +391 -0
  154. package/trustledger/valueproof.js +350 -0
@@ -0,0 +1 @@
1
+ 1afa0e08c9624505ad967ab88c91553c6af00b0e5e0f2b790ddf315741ed1742 trustledger-standalone.html
@@ -0,0 +1,442 @@
1
+ "use strict";
2
+
3
+ // TrustLedger — door-core.js
4
+ //
5
+ // T-65.2: the PURE payload→result core of the web door, factored out of server.js
6
+ // so that BOTH surfaces call the SAME functions and can never drift:
7
+ // * trustledger/server.js (the HTTP door) routes /api/reconcile and /api/inspect
8
+ // straight into reconcilePayload / inspectPayload below, exactly as before —
9
+ // behavior, error names, statuses, and bytes UNCHANGED by the factoring;
10
+ // * trustledger/build-standalone.js inlines THIS FILE VERBATIM into the offline
11
+ // single-file app (trustledger/dist/trustledger-standalone.html), where the
12
+ // page's transport seams call the same two functions directly in-page.
13
+ //
14
+ // EVERYTHING here is PURE and I/O-free: no fs, no http, no clock (the caller
15
+ // injects `reportDate`), no writes. The ONLY module dependencies are the engine
16
+ // modules (ingest / report / policy / close) and the license verifier — all
17
+ // required at top level with plain relative specifiers so the standalone bundler
18
+ // can rewrite them against its own module registry (the license module is swapped
19
+ // there for a FAIL-CLOSED offline shim; the gate below is inlined verbatim and is
20
+ // therefore REUSED, never re-implemented and never weakened).
21
+ //
22
+ // The prose below is moved verbatim from server.js (T-27.1 / T-28.1 / T-28.2 /
23
+ // T-29.3) — the contracts are unchanged; only the file they live in moved.
24
+
25
+ const ingest = require("./ingest");
26
+ const report = require("./report");
27
+ const policy = require("./policy");
28
+ const close = require("./close");
29
+ const license = require("./license");
30
+
31
+ // ---------------------------------------------------------------------------
32
+ // A named, HTTP-status-bearing error for the request layer. Carries a stable
33
+ // machine `error` code (snake_case) plus the human `message`, so the JSON body
34
+ // is { error, message } — a named error, never a stack trace.
35
+ // ---------------------------------------------------------------------------
36
+
37
+ class HttpError extends Error {
38
+ constructor(status, code, message) {
39
+ super(message);
40
+ this.name = "HttpError";
41
+ this.status = status;
42
+ this.code = code;
43
+ }
44
+ }
45
+
46
+ // Map an engine error to a stable snake_case machine code. The engine's own
47
+ // named errors (IngestError / ReportError / PolicyError / CloseError) carry the
48
+ // precise, already-located human message; we only need a coarse machine label.
49
+ function engineErrorCode(err) {
50
+ switch (err && err.name) {
51
+ case "IngestError":
52
+ return "ingest_error";
53
+ case "ReportError":
54
+ return "report_error";
55
+ case "PolicyError":
56
+ return "policy_error";
57
+ case "CloseError":
58
+ return "close_error";
59
+ default:
60
+ return "reconcile_error";
61
+ }
62
+ }
63
+
64
+ // ---------------------------------------------------------------------------
65
+ // T-29.3: the WEB door's LICENSE GATE — the SAME gate the CLI `gateReconcile`
66
+ // applies, threaded through HTTP. A request that asks for a PAID surface
67
+ // (`state`/`policy` => multi-state policy packs, or `seal` => the reconciliation
68
+ // seal) WITHOUT a valid, vendor-pinned license is REFUSED with a NAMED 4xx; the
69
+ // FREE inspect + baseline reconcile routes stay open and behave byte-for-byte as
70
+ // before. The server holds NO key and verifies OFFLINE: `verifyLicense` needs only
71
+ // the pinned vendor ADDRESS + the supplied container (no network, no signing key).
72
+ //
73
+ // The flag->entitlement mapping is the SAME contract the CLI uses (so the two gates
74
+ // can never drift): `state`/`policy` need `multi_state_policy`; `seal` needs `seal`.
75
+ // A refusal carries the PRECISE reason verifyLicense returns (wrong_issuer /
76
+ // expired / not_yet_valid / bad_signature / malformed), so a wrong/expired license
77
+ // NEVER silently downgrades to a free run — it is reported, not ignored.
78
+ // ---------------------------------------------------------------------------
79
+
80
+ // Which entitlement each paid WEB surface requires. Mirrors the CLI's
81
+ // PAID_FEATURE_ENTITLEMENTS exactly (state/policy => multi_state_policy; seal =>
82
+ // seal), keyed off the request payload's own field names.
83
+ const WEB_PAID_FEATURE_ENTITLEMENTS = Object.freeze([
84
+ {
85
+ requested: (p) =>
86
+ (p.state != null && String(p.state).trim() !== "") ||
87
+ (p.policy != null && String(p.policy).trim() !== ""),
88
+ entitlement: "multi_state_policy",
89
+ label: "multi-state policy packs (state)",
90
+ },
91
+ {
92
+ requested: (p) => p.seal === true,
93
+ entitlement: "seal",
94
+ label: "the tamper-evident reconciliation seal (seal)",
95
+ },
96
+ ]);
97
+
98
+ // Apply the license gate to an already-parsed request payload. Returns silently
99
+ // when the request is permitted (free tier, or a valid license covering every
100
+ // requested paid feature). Throws a NAMED HttpError otherwise:
101
+ // * license_required (402) — a paid surface was requested with NO license at all
102
+ // * license_invalid (403) — a license WAS supplied but is invalid for this run
103
+ // (malformed / bad_signature / wrong_issuer / expired / not_yet_valid, or a
104
+ // valid license that does not carry the required entitlement)
105
+ // `now` is the injected report date so the verdict is deterministic. The license
106
+ // container + vendorAddress come from the request body; the server holds no key.
107
+ function gatePayload(payload, now) {
108
+ const needed = WEB_PAID_FEATURE_ENTITLEMENTS.filter((f) => f.requested(payload));
109
+ if (needed.length === 0) {
110
+ // FREE TIER: no paid surface requested — proceed unchanged. A stray
111
+ // license/vendorAddress with no paid feature costs nothing and is ignored.
112
+ return;
113
+ }
114
+
115
+ const featureList = needed.map((f) => f.label).join(" and ");
116
+ const hasLicense = payload.license != null;
117
+ const hasVendor = payload.vendorAddress != null;
118
+
119
+ // Both must travel together: a license is worthless without the vendor address
120
+ // to PIN it to, and a vendor with no license is nothing to verify.
121
+ if (!hasLicense && !hasVendor) {
122
+ throw new HttpError(
123
+ 402,
124
+ "license_required",
125
+ `${featureList} ${needed.length > 1 ? "are" : "is"} a paid feature and requires a license; ` +
126
+ "supply { license, vendorAddress } in the request body. " +
127
+ "The free tier — baseline-policy reconcile + file inspect — needs no license."
128
+ );
129
+ }
130
+ if (!hasLicense || !hasVendor) {
131
+ throw new HttpError(
132
+ 402,
133
+ "license_required",
134
+ 'both "license" and "vendorAddress" must be supplied together (a license is verified by ' +
135
+ "pinning it to the vendor address)"
136
+ );
137
+ }
138
+
139
+ // Parse + validate the supplied container (a JSON string OR an already-parsed
140
+ // object). A malformed container is a license_invalid 403 — never half-trusted.
141
+ let container;
142
+ try {
143
+ container = license.readLicense(payload.license);
144
+ } catch (e) {
145
+ throw new HttpError(
146
+ 403,
147
+ "license_invalid",
148
+ `the supplied license is not a valid signed license container: ${e.message}`
149
+ );
150
+ }
151
+
152
+ // Verify OFFLINE against the pinned vendor, dated at the run's reportDate. A
153
+ // malformed vendorAddress is a request error (the address could not be parsed).
154
+ let verdict;
155
+ try {
156
+ verdict = license.verifyLicense(container, { now, vendorAddress: payload.vendorAddress });
157
+ } catch (e) {
158
+ // verifyLicense throws only for a garbage vendorAddress / now — a request bug.
159
+ throw new HttpError(400, "bad_request", e.message);
160
+ }
161
+
162
+ if (!verdict.valid) {
163
+ // Report the PRECISE reason — never silently downgrade to a free run.
164
+ throw new HttpError(
165
+ 403,
166
+ "license_invalid",
167
+ `${featureList} requires a valid license, but the supplied license is invalid ` +
168
+ `(reason: ${verdict.reason}); the free baseline reconcile remains available without state/policy/seal.`
169
+ );
170
+ }
171
+
172
+ // Valid + in-window + correct issuer. Require EACH requested feature's
173
+ // entitlement to actually be granted — a license never grants what it was not sold.
174
+ for (const f of needed) {
175
+ if (!license.hasEntitlement(verdict, f.entitlement)) {
176
+ throw new HttpError(
177
+ 403,
178
+ "license_invalid",
179
+ `the supplied license is valid but does not include the "${f.entitlement}" entitlement ` +
180
+ `needed for ${f.label}; this license grants only [${verdict.entitlements.join(", ")}]. ` +
181
+ "The free baseline reconcile remains available."
182
+ );
183
+ }
184
+ }
185
+ }
186
+
187
+ // ---------------------------------------------------------------------------
188
+ // The pure core: take the already-parsed request payload (strings + optional
189
+ // state/priorClose) and produce the response shape. NO I/O. Throws HttpError on
190
+ // any bad input (a malformed file, an unknown state, a bad prior-close) so the
191
+ // caller maps it straight to an HTTP status + named JSON body. `reportDate` is
192
+ // injected (the caller supplies today) so this function stays deterministic.
193
+ // ---------------------------------------------------------------------------
194
+
195
+ // Read the OPTIONAL per-file `maps` object (T-28.2). Returns a frozen
196
+ // { bank, ledger, rentroll } where each entry is either a plain object (the
197
+ // `columnMap` to thread into that file's strict parser) or `undefined` (so the
198
+ // parser is called with `columnMap: undefined` — the byte-identical no-map path).
199
+ // STRICT on shape: `maps` (when present) must be a plain object, and each named
200
+ // per-file entry (when present) must be a plain object — anything else is a named
201
+ // 400, never a coercion. Unknown keys inside `maps` are ignored (only the three
202
+ // file keys are honoured), and the deep validity of each map (unknown logical
203
+ // field, or a header absent from the file) is left to the strict parser, which
204
+ // raises the SAME located IngestError it always would.
205
+ function readOptionalMaps(raw) {
206
+ const empty = Object.freeze({ bank: undefined, ledger: undefined, rentroll: undefined });
207
+ if (raw == null) return empty;
208
+ if (typeof raw !== "object" || Array.isArray(raw)) {
209
+ throw new HttpError(
210
+ 400,
211
+ "invalid_maps",
212
+ '"maps" must be an object of { bank?, ledger?, rentroll? } column maps'
213
+ );
214
+ }
215
+ const out = {};
216
+ for (const key of ["bank", "ledger", "rentroll"]) {
217
+ const m = raw[key];
218
+ if (m == null) {
219
+ out[key] = undefined;
220
+ continue;
221
+ }
222
+ if (typeof m !== "object" || Array.isArray(m)) {
223
+ throw new HttpError(
224
+ 400,
225
+ "invalid_maps",
226
+ `"maps.${key}" must be an object of { <logicalField>: <headerName> }`
227
+ );
228
+ }
229
+ out[key] = m;
230
+ }
231
+ return Object.freeze(out);
232
+ }
233
+
234
+ function reconcilePayload(payload, reportDate) {
235
+ if (payload == null || typeof payload !== "object" || Array.isArray(payload)) {
236
+ throw new HttpError(400, "bad_request", "request body must be a JSON object");
237
+ }
238
+
239
+ // The three file CONTENTS are REQUIRED and must be strings. A missing or
240
+ // non-string field is a named 400 — never a coercion of, say, a number to "".
241
+ for (const key of ["bank", "ledger", "rentroll"]) {
242
+ if (typeof payload[key] !== "string") {
243
+ throw new HttpError(
244
+ 400,
245
+ "missing_file",
246
+ `"${key}" is required and must be the file contents as a text string`
247
+ );
248
+ }
249
+ }
250
+
251
+ // LICENSE GATE (T-29.3). Apply the SAME paid-surface gate the CLI uses BEFORE any
252
+ // paid feature is resolved, so a gated request (state/policy/seal) without a valid
253
+ // license is refused with a NAMED license_required/license_invalid — never folded
254
+ // into a downstream policy_error and never silently downgraded to a free run.
255
+ gatePayload(payload, reportDate);
256
+
257
+ // Optional per-state policy. An unknown code is a named 400 (PolicyError's
258
+ // message names the available codes), not a silent fall-through to baseline.
259
+ let activePolicy = null;
260
+ if (payload.state != null && String(payload.state).trim() !== "") {
261
+ try {
262
+ activePolicy = policy.resolveState(String(payload.state));
263
+ } catch (e) {
264
+ throw new HttpError(400, "policy_error", e.message);
265
+ }
266
+ }
267
+
268
+ // Optional roll-forward from a prior period's close artifact (its JSON TEXT).
269
+ // Seeds this run's opening balances; a malformed close is a named 400.
270
+ let priorClose = null;
271
+ let opening = { bank: 0, book: 0 };
272
+ if (payload.priorClose != null && String(payload.priorClose).trim() !== "") {
273
+ try {
274
+ priorClose = close.readClose(String(payload.priorClose));
275
+ } catch (e) {
276
+ throw new HttpError(400, "close_error", e.message);
277
+ }
278
+ opening = { bank: priorClose.ending.bank, book: priorClose.ending.book };
279
+ }
280
+
281
+ // Optional per-file column maps (T-28.2). A mapping the broker fixed in the
282
+ // inspect flow is threaded back here so the REAL run honours it — the same
283
+ // `{ <logicalField>: <headerName> }` shape the strict parsers' `columnMap`
284
+ // already accepts, keyed by the SAME three file keys. When `maps` is absent (or
285
+ // a per-file map is absent) behaviour is BYTE-FOR-BYTE the no-map path: each
286
+ // parser is called with `columnMap: undefined`, exactly as before. A bad shape
287
+ // (not a plain object, or a per-file entry that is not a plain object) is a
288
+ // named 400 — never a silent coercion.
289
+ const maps = readOptionalMaps(payload.maps);
290
+
291
+ // Ingest the three files (STRICT — the first malformed row raises a located
292
+ // IngestError, which we surface as a named 400 rather than dropping the row).
293
+ let bank;
294
+ let book;
295
+ let rentroll;
296
+ try {
297
+ bank = ingest.parseBankStatement(payload.bank, { columnMap: maps.bank });
298
+ book = ingest.parseQuickBooksCSV(payload.ledger, { columnMap: maps.ledger });
299
+ rentroll = ingest.parseRentRollCSV(payload.rentroll, { columnMap: maps.rentroll });
300
+ } catch (e) {
301
+ throw new HttpError(400, engineErrorCode(e), e.message);
302
+ }
303
+
304
+ // Build the deterministic packet model (match + reconcile + report inside),
305
+ // then render the SAME HTML + CSV artifacts the CLI emits — but keep them in
306
+ // memory and return them in the JSON response. Nothing is written to disk.
307
+ let model;
308
+ let reportHtml;
309
+ let reportCsv;
310
+ try {
311
+ model = report.buildPacket({
312
+ bank,
313
+ book,
314
+ rentroll,
315
+ reportDate,
316
+ opening,
317
+ policy: activePolicy,
318
+ priorClose,
319
+ });
320
+ reportHtml = report.renderHTML(model);
321
+ reportCsv = report.renderExceptionsCSV(model);
322
+ } catch (e) {
323
+ throw new HttpError(400, engineErrorCode(e), e.message);
324
+ }
325
+
326
+ return {
327
+ tiesOut: model.tiesOut,
328
+ pass: model.pass,
329
+ balances: model.balances,
330
+ exceptions: model.exceptions,
331
+ summary: report.summaryLine(model),
332
+ reportHtml,
333
+ reportCsv,
334
+ };
335
+ }
336
+
337
+ // ---------------------------------------------------------------------------
338
+ // T-28.1: the read-only DIAGNOSTIC core. Maps a broker-facing `source` spelling
339
+ // (the SAME file keys /api/reconcile uses, plus the `quickbooks` synonym) to the
340
+ // engine's SOURCE.*, then calls ingest.diagnoseSource VERBATIM and returns its
341
+ // report shape. UNLIKE reconcilePayload this does NOT fail closed: a well-formed
342
+ // file with unmatched columns returns 200 with `requiredMissing` populated — that
343
+ // is a self-service finding the UI renders, not a server error. It throws an
344
+ // HttpError 400 ONLY for a request that is itself malformed: an unknown source, a
345
+ // missing/non-string `text`, or a malformed `columnMap` (the SAME named IngestError
346
+ // the strict parser/indexHeader gives, raised EARLY via validateColumnMapForSource
347
+ // so a bad map is a 400 rather than being folded into the diagnose error list).
348
+ // PURE / I-O-free, exactly like reconcilePayload — the server never writes to disk.
349
+ // ---------------------------------------------------------------------------
350
+
351
+ // Broker-facing `source` spelling -> engine SOURCE.*. Accepts the SAME keys
352
+ // /api/reconcile uses for its three files (bank / ledger / rentroll) plus the
353
+ // natural `quickbooks` / `rent_roll` synonyms, so the door names sources the way
354
+ // the CLI's `--as` does without forcing the browser to know the engine's enum.
355
+ const INSPECT_SOURCE = Object.freeze({
356
+ bank: ingest.SOURCE.BANK,
357
+ ledger: ingest.SOURCE.QUICKBOOKS,
358
+ quickbooks: ingest.SOURCE.QUICKBOOKS,
359
+ rentroll: ingest.SOURCE.RENT_ROLL,
360
+ rent_roll: ingest.SOURCE.RENT_ROLL,
361
+ });
362
+
363
+ function inspectPayload(payload) {
364
+ if (payload == null || typeof payload !== "object" || Array.isArray(payload)) {
365
+ throw new HttpError(400, "bad_request", "request body must be a JSON object");
366
+ }
367
+
368
+ // The `source` selects which of the three logical types to diagnose. An unknown
369
+ // (or missing) spelling is a named 400 that NAMES the accepted spellings, so the
370
+ // caller can self-correct without reading source.
371
+ const source = INSPECT_SOURCE[String(payload.source)];
372
+ if (!source) {
373
+ throw new HttpError(
374
+ 400,
375
+ "unknown_source",
376
+ `"source" must be one of: ${Object.keys(INSPECT_SOURCE).join(", ")}`
377
+ );
378
+ }
379
+
380
+ // The file CONTENTS are REQUIRED and must be a string — a missing or non-string
381
+ // `text` is a named 400, never a coercion (diagnoseSource treats a null text as
382
+ // a file-level "no input" finding, but over the door an absent body field is a
383
+ // client mistake, so we reject it up front with a clear message).
384
+ if (typeof payload.text !== "string") {
385
+ throw new HttpError(
386
+ 400,
387
+ "missing_text",
388
+ `"text" is required and must be the file contents as a text string`
389
+ );
390
+ }
391
+
392
+ // Optional column-map override. Validate it EARLY against this file's real
393
+ // header so a malformed map (unknown logical key, or a header absent from the
394
+ // file) is a named 400 with the SAME IngestError message the strict parser gives
395
+ // — rather than being folded into the diagnose report's error list. A bad shape
396
+ // (not a plain object) is likewise a named 400.
397
+ let columnMap = null;
398
+ if (payload.columnMap != null) {
399
+ if (typeof payload.columnMap !== "object" || Array.isArray(payload.columnMap)) {
400
+ throw new HttpError(
401
+ 400,
402
+ "invalid_column_map",
403
+ '"columnMap" must be an object of { <logicalField>: <headerName> }'
404
+ );
405
+ }
406
+ columnMap = payload.columnMap;
407
+ try {
408
+ ingest.validateColumnMapForSource(source, payload.text, columnMap);
409
+ } catch (e) {
410
+ throw new HttpError(400, engineErrorCode(e), e.message);
411
+ }
412
+ }
413
+
414
+ // Call the EXISTING diagnostic VERBATIM (no re-implementation of parsing) and
415
+ // return EXACTLY the diagnose report shape the CLI `vh trust inspect` consumes.
416
+ // diagnoseSource is pure and only throws on an unknown source (already guarded);
417
+ // every file/row problem is reported in the structure, not thrown.
418
+ const rep = ingest.diagnoseSource(source, payload.text, { columnMap });
419
+
420
+ return {
421
+ source: rep.source,
422
+ format: rep.format,
423
+ header: rep.header,
424
+ mapped: rep.mapped,
425
+ requiredMissing: rep.requiredMissing,
426
+ rowCount: rep.rowCount,
427
+ okCount: rep.okCount,
428
+ sample: rep.sample,
429
+ errors: rep.errors,
430
+ };
431
+ }
432
+
433
+ module.exports = {
434
+ HttpError,
435
+ engineErrorCode,
436
+ WEB_PAID_FEATURE_ENTITLEMENTS,
437
+ gatePayload,
438
+ readOptionalMaps,
439
+ reconcilePayload,
440
+ INSPECT_SOURCE,
441
+ inspectPayload,
442
+ };
@@ -0,0 +1,7 @@
1
+ Date,Description,Debit,Credit,Type
2
+ 2026-05-01,"Deposit - rent received",,1500.00,Deposit
3
+ 2026-05-03,"Check #1042 owner draw - Smith",750.00,,Check
4
+ 2026-05-05,"NSF returned item - Jones rent reversal",1200.00,,NSF
5
+ 2026-05-07,"Partial deposit - Doe rent (1 of 2)",,650.00,Deposit
6
+ 2026-05-07,"Partial deposit - Doe rent (2 of 2)",,650.00,Deposit
7
+ 2026-05-09,"Monthly service charge",12.50,,Fee
@@ -0,0 +1,3 @@
1
+ Date,Description,Debit,Credit,Type
2
+ 2026-05-01,"Good deposit",,1500.00,Deposit
3
+ 2026-05-02,"Bad amount - too many decimals",,10.005,Deposit
@@ -0,0 +1,5 @@
1
+ When,Narrative,MoneyOut,MoneyIn,Kategorie
2
+ 2026-05-01,"Deposit - rent received",,1500.00,Deposit
3
+ 2026-05-03,"Check #1042 owner draw - Smith",750.00,,Check
4
+ 2026-05-05,"NSF returned item - Jones rent reversal",1200.00,,NSF
5
+ 2026-05-09,"Monthly service charge",12.50,,Fee
@@ -0,0 +1,34 @@
1
+ OFXHEADER:100
2
+ DATA:OFXSGML
3
+ VERSION:102
4
+
5
+ <OFX>
6
+ <BANKMSGSRSV1>
7
+ <STMTTRNRS>
8
+ <STMTRS>
9
+ <BANKTRANLIST>
10
+ <STMTTRN>
11
+ <TRNTYPE>CREDIT
12
+ <DTPOSTED>20260501120000
13
+ <TRNAMT>1500.00
14
+ <NAME>Deposit - rent received
15
+ <MEMO>Deposit - rent received
16
+ </STMTTRN>
17
+ <STMTTRN>
18
+ <TRNTYPE>CHECK
19
+ <DTPOSTED>20260503
20
+ <TRNAMT>-750.00
21
+ <NAME>Owner draw
22
+ <MEMO>Check #1042 owner draw - Smith
23
+ </STMTTRN>
24
+ <STMTTRN>
25
+ <TRNTYPE>DEBIT
26
+ <DTPOSTED>20260505
27
+ <TRNAMT>-1200.00
28
+ <MEMO>NSF returned item - Jones rent reversal
29
+ </STMTTRN>
30
+ </BANKTRANLIST>
31
+ </STMTRS>
32
+ </STMTTRNRS>
33
+ </BANKMSGSRSV1>
34
+ </OFX>
@@ -0,0 +1,5 @@
1
+ Posting Date,Description,Withdrawal Amt,Deposit Amt,Running Balance,Check Number
2
+ "Jan 5, 2024","Deposit - rent received",,1500.00,1500.00,
3
+ "Jan 7, 2024","Check owner draw - Smith",750.00,,750.00,1042
4
+ "Jan 9, 2024","NSF returned item - Jones rent reversal",1200.00,,-450.00,
5
+ "Jan 11, 2024","Monthly service charge",12.50,,-462.50,
@@ -0,0 +1,22 @@
1
+ {
2
+ "schemaVersion": "trustledger.period-close/v1",
3
+ "period": "2026-04",
4
+ "reportDate": "2026-04-30",
5
+ "opening": {
6
+ "bank": 0,
7
+ "book": 0
8
+ },
9
+ "ending": {
10
+ "bank": 250000,
11
+ "book": 250000
12
+ },
13
+ "subledger": 250000,
14
+ "tiesOut": true,
15
+ "pass": true,
16
+ "inputs": {
17
+ "bankRecords": 1,
18
+ "bookRecords": 1,
19
+ "rentrollRecords": 1
20
+ },
21
+ "inputsDigest": "05f5b527084ac007b60f8117f7bf188ebcdc60fde8e20f6c934c1f33b6e8f166"
22
+ }
@@ -0,0 +1,14 @@
1
+ {
2
+ "reportDate": "2026-05-31",
3
+ "period": "2026-05",
4
+ "opening": { "bank": 50000, "book": 50000 },
5
+ "bank": [
6
+ { "date": "2026-05-02", "amount": 150000, "memo": "deposit smith", "kind": "deposit", "party": "", "source": "bank" }
7
+ ],
8
+ "book": [
9
+ { "date": "2026-05-01", "amount": 150000, "memo": "rent smith", "kind": "deposit", "party": "Smith (4A)", "source": "quickbooks" }
10
+ ],
11
+ "rentroll": [
12
+ { "date": "2026-05-01", "amount": 200000, "memo": "rent", "kind": "rent", "party": "Smith (4A)", "source": "rentroll" }
13
+ ]
14
+ }
@@ -0,0 +1,7 @@
1
+ {
2
+ "id": "bank-book-mismatch--benign-twin",
3
+ "control": "bank_book_mismatch",
4
+ "expectedVerdict": "PASS",
5
+ "expectedFinding": "bank_book_mismatch",
6
+ "principle": "The benign near-twin of the bank/book control: the SAME matched activity and the SAME $2,000 of book + sub-ledger, but now the bank opened at the SAME $500 as the book. The adjusted bank balance equals the book exactly, the three balances tie out, the engine raises NO bank_book_mismatch finding, and the packet PASSes. The only difference from the out-of-trust twin is the bank opening: $500 vs $0."
7
+ }
@@ -0,0 +1,14 @@
1
+ {
2
+ "reportDate": "2026-05-31",
3
+ "period": "2026-05",
4
+ "opening": { "bank": 0, "book": 50000 },
5
+ "bank": [
6
+ { "date": "2026-05-02", "amount": 150000, "memo": "deposit smith", "kind": "deposit", "party": "", "source": "bank" }
7
+ ],
8
+ "book": [
9
+ { "date": "2026-05-01", "amount": 150000, "memo": "rent smith", "kind": "deposit", "party": "Smith (4A)", "source": "quickbooks" }
10
+ ],
11
+ "rentroll": [
12
+ { "date": "2026-05-01", "amount": 200000, "memo": "rent", "kind": "rent", "party": "Smith (4A)", "source": "rentroll" }
13
+ ]
14
+ }
@@ -0,0 +1,7 @@
1
+ {
2
+ "id": "bank-book-mismatch--out-of-trust",
3
+ "control": "bank_book_mismatch",
4
+ "expectedVerdict": "FAIL",
5
+ "expectedFinding": "bank_book_mismatch",
6
+ "principle": "After outstanding/in-transit items, the adjusted bank balance must equal the book balance. Here the activity lines match (no timing items), but the bank opened $500 SHORT of the book: the books say the trust account should hold $2,000 while the bank holds only $1,500. The bank is short of cash relative to the records — a genuine shortage, the textbook out-of-trust case (money that should be in the account is not there). The engine raises an error-severity bank_book_mismatch finding for the -$500 gap and the packet FAILs."
7
+ }
@@ -0,0 +1,15 @@
1
+ {
2
+ "reportDate": "2026-05-31",
3
+ "period": "2026-05",
4
+ "opening": { "bank": 250000, "book": 250000 },
5
+ "priorClosePath": "_shared/prior-close.json",
6
+ "bank": [
7
+ { "date": "2026-05-02", "amount": 150000, "memo": "deposit smith", "kind": "deposit", "party": "", "source": "bank" }
8
+ ],
9
+ "book": [
10
+ { "date": "2026-05-01", "amount": 150000, "memo": "rent smith", "kind": "deposit", "party": "Smith (4A)", "source": "quickbooks" }
11
+ ],
12
+ "rentroll": [
13
+ { "date": "2026-05-01", "amount": 400000, "memo": "rent carried", "kind": "rent", "party": "Smith (4A)", "source": "rentroll" }
14
+ ]
15
+ }
@@ -0,0 +1,7 @@
1
+ {
2
+ "id": "continuity-break--benign-twin",
3
+ "control": "continuity_break",
4
+ "expectedVerdict": "PASS",
5
+ "expectedFinding": "continuity_break",
6
+ "principle": "The benign near-twin of the continuity control: the SAME prior close (_shared/prior-close.json, ending $2,500 / $2,500), but this period opens EXACTLY where the prior period closed — $2,500 bank / $2,500 book. The roll-forward is clean, the chain of custody is unbroken, the three balances tie out ($4,000 across all legs), the engine raises NO continuity_break finding, and the packet PASSes. Two fields differ from the out-of-trust twin, and ONLY the first is the variable under test: (1) the opening balances go from $0 to $2,500 — this is the continuity fix; (2) the carried sub-ledger (rent-roll) line goes from $1,500 to $4,000 (150000 to 400000 cents). The second change is NOT free — it is forced by trust math: once $2,500 of beneficiary money is carried forward into the opening, the book balance becomes $4,000, so the beneficiary sub-ledger must also carry $4,000 or the three legs would no longer tie and the engine would (correctly) FAIL on subledger_out_of_balance instead. Carrying the sub-ledger forward keeps every other control satisfied, so the continuity control is the only thing this pair varies."
7
+ }
@@ -0,0 +1,15 @@
1
+ {
2
+ "reportDate": "2026-05-31",
3
+ "period": "2026-05",
4
+ "opening": { "bank": 0, "book": 0 },
5
+ "priorClosePath": "_shared/prior-close.json",
6
+ "bank": [
7
+ { "date": "2026-05-02", "amount": 150000, "memo": "deposit smith", "kind": "deposit", "party": "", "source": "bank" }
8
+ ],
9
+ "book": [
10
+ { "date": "2026-05-01", "amount": 150000, "memo": "rent smith", "kind": "deposit", "party": "Smith (4A)", "source": "quickbooks" }
11
+ ],
12
+ "rentroll": [
13
+ { "date": "2026-05-01", "amount": 150000, "memo": "rent", "kind": "rent", "party": "Smith (4A)", "source": "rentroll" }
14
+ ]
15
+ }
@@ -0,0 +1,7 @@
1
+ {
2
+ "id": "continuity-break--out-of-trust",
3
+ "control": "continuity_break",
4
+ "expectedVerdict": "FAIL",
5
+ "expectedFinding": "continuity_break",
6
+ "principle": "The trust chain requires that each period's OPENING balances equal the prior period's signed ENDING balances, to the penny. The prior close (_shared/prior-close.json) ended at $2,500 bank / $2,500 book, but this period opens at $0 / $0. A non-zero roll-forward gap means a period was skipped, edited, or re-keyed and the chain of custody over the trust money is broken. The engine raises an error-severity continuity_break finding for the -$2,500 gap and the packet FAILs."
7
+ }
@@ -0,0 +1,13 @@
1
+ {
2
+ "reportDate": "2026-05-31",
3
+ "period": "2026-05",
4
+ "bank": [],
5
+ "book": [
6
+ { "date": "2026-05-01", "amount": 50000, "memo": "rent smith", "kind": "deposit", "party": "Smith (4A)", "source": "quickbooks" },
7
+ { "date": "2026-05-01", "amount": 50000, "memo": "rent jones", "kind": "deposit", "party": "Jones (4B)", "source": "quickbooks" }
8
+ ],
9
+ "rentroll": [
10
+ { "date": "2026-05-01", "amount": 50000, "memo": "rent smith", "kind": "rent", "party": "Smith (4A)", "source": "rentroll" },
11
+ { "date": "2026-05-01", "amount": 50000, "memo": "rent jones", "kind": "rent", "party": "Jones (4B)", "source": "rentroll" }
12
+ ]
13
+ }
@@ -0,0 +1,7 @@
1
+ {
2
+ "id": "negative-tenant-ledger--benign-twin",
3
+ "control": "negative_tenant_ledger",
4
+ "expectedVerdict": "PASS",
5
+ "expectedFinding": "negative_tenant_ledger",
6
+ "principle": "The benign near-twin of the negative-ledger control. It shares the SAME bank (empty), the SAME book ($500 deposit each for Smith (4A) and Jones (4B), book balance $1,000), and the SAME two beneficiaries as the out-of-trust twin. The ONLY difference is the two rent-roll sub-ledger lines: instead of crediting $1,500 to Smith and -$500 to Jones (Jones robbed to cover Smith), each tenant is simply credited their own $500. Every individual ledger is now non-negative (Smith +$500, Jones +$500), the pooled sub-ledger sum ($1,000) still ties to the $1,000 book exactly as before, no beneficiary is short, the engine raises NO negative_tenant_ledger finding, and the packet PASSes. NOTE: the separate control-account-exclusion rule (a 'Owner ...'-prefixed account is allowed to go negative without being flagged) is exercised by the owner-overdraw control's own fixtures, not here, so this pair varies only the one beneficiary's sign."
7
+ }