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.
- package/LICENSE +201 -0
- package/README.md +883 -0
- package/cli/abi/ContributionRegistry.json +881 -0
- package/cli/agent.js +2173 -0
- package/cli/anchor-artifact.js +853 -0
- package/cli/anchor.js +400 -0
- package/cli/claim.js +881 -0
- package/cli/core/agent-commit.js +448 -0
- package/cli/core/agent-session.js +598 -0
- package/cli/core/anchor-binding.js +663 -0
- package/cli/core/attestation.js +580 -0
- package/cli/core/evidence-plans.js +495 -0
- package/cli/core/fixtures/evidence-plans/baseline.json +19 -0
- package/cli/core/fulfill-intake.js +1082 -0
- package/cli/core/go-live-preflight.js +481 -0
- package/cli/core/license.js +534 -0
- package/cli/core/manifest.js +243 -0
- package/cli/core/packetseal.js +591 -0
- package/cli/core/registryArtifact.js +49 -0
- package/cli/core/revocation.js +539 -0
- package/cli/core/rfc3161.js +389 -0
- package/cli/core/timestamp.js +482 -0
- package/cli/core/trust-asof.js +479 -0
- package/cli/dataset.js +2950 -0
- package/cli/evidence.js +2227 -0
- package/cli/fulfill-webhook-http.js +438 -0
- package/cli/git.js +220 -0
- package/cli/hash.js +550 -0
- package/cli/identity.js +1072 -0
- package/cli/journal-cli.js +1110 -0
- package/cli/journal-log.js +454 -0
- package/cli/journal.js +334 -0
- package/cli/lineage.js +447 -0
- package/cli/list.js +287 -0
- package/cli/parcel.js +1509 -0
- package/cli/proof.js +578 -0
- package/cli/prove.js +300 -0
- package/cli/receipt.js +631 -0
- package/cli/registry.js +331 -0
- package/cli/reputation.js +344 -0
- package/cli/revocation.js +495 -0
- package/cli/serve-verify-http.js +298 -0
- package/cli/serve-verify.js +333 -0
- package/cli/show.js +339 -0
- package/cli/verify.js +383 -0
- package/cli/vh.js +3927 -0
- package/docs/ADOPT.md +183 -0
- package/docs/ADOPTION.json +11 -0
- package/docs/AGENTTRACE.md +247 -0
- package/docs/ANCHORING.md +167 -0
- package/docs/AUDIT.md +55 -0
- package/docs/CONFORMANCE.md +107 -0
- package/docs/DATALEDGER.md +638 -0
- package/docs/DECIDE.md +47 -0
- package/docs/DECISIONS-PENDING.md +27 -0
- package/docs/DEPLOY-PUBLIC-SITE.md +301 -0
- package/docs/ENGINE-LEDGER.json +12 -0
- package/docs/EVIDENCE.md +519 -0
- package/docs/GO-LIVE.md +66 -0
- package/docs/IDENTITY.md +123 -0
- package/docs/INDEPENDENT-VERIFICATION.md +377 -0
- package/docs/INTEGRITY-JOURNAL.md +337 -0
- package/docs/KEY-LIFECYCLE.md +179 -0
- package/docs/LICENSING.md +46 -0
- package/docs/LINEAGE.md +307 -0
- package/docs/LOOP-AUDIT-2026-07-03.json +580 -0
- package/docs/LOOP-HARDENING-PLAN.md +44 -0
- package/docs/MERKLE-LEAVES.md +113 -0
- package/docs/METRICS.jsonl +31 -0
- package/docs/MORNING.md +204 -0
- package/docs/PILOT.md +444 -0
- package/docs/PROOFPARCEL.md +227 -0
- package/docs/PROOFS.md +262 -0
- package/docs/RECEIPTS.md +341 -0
- package/docs/REPUTATION.md +158 -0
- package/docs/SDK.md +301 -0
- package/docs/STRATEGY-ARCHIVE.md +5055 -0
- package/docs/SUPERVISOR-RUNBOOK.md +52 -0
- package/docs/TRUST-BOUNDARIES.md +335 -0
- package/docs/TRUSTLEDGER.md +1976 -0
- package/docs/USAGE-BUDGET.json +121 -0
- package/docs/VERIFY-SERVICE.md +168 -0
- package/index.js +160 -0
- package/package.json +41 -0
- package/trustledger/build-standalone.js +796 -0
- package/trustledger/cli.js +3179 -0
- package/trustledger/close.js +391 -0
- package/trustledger/corpus.js +159 -0
- package/trustledger/dist/BUILD-PROVENANCE.json +99 -0
- package/trustledger/dist/trustledger-standalone.html +6197 -0
- package/trustledger/dist/trustledger-standalone.html.sha256 +1 -0
- package/trustledger/door-core.js +442 -0
- package/trustledger/fixtures/bank.csv +7 -0
- package/trustledger/fixtures/bank.malformed.csv +3 -0
- package/trustledger/fixtures/bank.noalias.csv +5 -0
- package/trustledger/fixtures/bank.ofx +34 -0
- package/trustledger/fixtures/bank.real.csv +5 -0
- package/trustledger/fixtures/corpus/_shared/prior-close.json +22 -0
- package/trustledger/fixtures/corpus/bank-book-mismatch--benign-twin/inputs.json +14 -0
- package/trustledger/fixtures/corpus/bank-book-mismatch--benign-twin/meta.json +7 -0
- package/trustledger/fixtures/corpus/bank-book-mismatch--out-of-trust/inputs.json +14 -0
- package/trustledger/fixtures/corpus/bank-book-mismatch--out-of-trust/meta.json +7 -0
- package/trustledger/fixtures/corpus/continuity-break--benign-twin/inputs.json +15 -0
- package/trustledger/fixtures/corpus/continuity-break--benign-twin/meta.json +7 -0
- package/trustledger/fixtures/corpus/continuity-break--out-of-trust/inputs.json +15 -0
- package/trustledger/fixtures/corpus/continuity-break--out-of-trust/meta.json +7 -0
- package/trustledger/fixtures/corpus/negative-tenant-ledger--benign-twin/inputs.json +13 -0
- package/trustledger/fixtures/corpus/negative-tenant-ledger--benign-twin/meta.json +7 -0
- package/trustledger/fixtures/corpus/negative-tenant-ledger--out-of-trust/inputs.json +13 -0
- package/trustledger/fixtures/corpus/negative-tenant-ledger--out-of-trust/meta.json +7 -0
- package/trustledger/fixtures/corpus/owner-overdraw--benign-twin/inputs.json +15 -0
- package/trustledger/fixtures/corpus/owner-overdraw--benign-twin/meta.json +7 -0
- package/trustledger/fixtures/corpus/owner-overdraw--out-of-trust/inputs.json +15 -0
- package/trustledger/fixtures/corpus/owner-overdraw--out-of-trust/meta.json +7 -0
- package/trustledger/fixtures/corpus/security-deposit-segregation--benign-twin/inputs.json +16 -0
- package/trustledger/fixtures/corpus/security-deposit-segregation--benign-twin/meta.json +7 -0
- package/trustledger/fixtures/corpus/security-deposit-segregation--out-of-trust/inputs.json +13 -0
- package/trustledger/fixtures/corpus/security-deposit-segregation--out-of-trust/meta.json +7 -0
- package/trustledger/fixtures/corpus/subledger-out-of-balance--benign-twin/inputs.json +13 -0
- package/trustledger/fixtures/corpus/subledger-out-of-balance--benign-twin/meta.json +7 -0
- package/trustledger/fixtures/corpus/subledger-out-of-balance--out-of-trust/inputs.json +13 -0
- package/trustledger/fixtures/corpus/subledger-out-of-balance--out-of-trust/meta.json +7 -0
- package/trustledger/fixtures/e2e/bank.aliased.csv +4 -0
- package/trustledger/fixtures/e2e/bank.csv +4 -0
- package/trustledger/fixtures/e2e/bank.nsf.csv +4 -0
- package/trustledger/fixtures/e2e/quickbooks.csv +6 -0
- package/trustledger/fixtures/e2e/quickbooks.nsf.csv +8 -0
- package/trustledger/fixtures/e2e/rentroll.csv +6 -0
- package/trustledger/fixtures/e2e/rentroll.nsf.csv +8 -0
- package/trustledger/fixtures/e2e/rentroll.short.csv +5 -0
- package/trustledger/fixtures/plans/baseline.json +25 -0
- package/trustledger/fixtures/plans/price-binding.example.json +27 -0
- package/trustledger/fixtures/policy/ambiguous-deposit-example.json +12 -0
- package/trustledger/fixtures/policy/baseline.json +19 -0
- package/trustledger/fixtures/policy/ca-example.json +12 -0
- package/trustledger/fixtures/policy/negative-tenant-ledger-example.json +12 -0
- package/trustledger/fixtures/policy/owner-overdraw-example.json +12 -0
- package/trustledger/fixtures/quickbooks.csv +7 -0
- package/trustledger/fixtures/quickbooks.real.csv +5 -0
- package/trustledger/fixtures/rentroll.csv +6 -0
- package/trustledger/fixtures/rentroll.real.csv +4 -0
- package/trustledger/ingest.js +1163 -0
- package/trustledger/lib/policy-bundled-loader.js +44 -0
- package/trustledger/lib/sha256-vendored.js +227 -0
- package/trustledger/license.js +563 -0
- package/trustledger/match.js +551 -0
- package/trustledger/plans.js +551 -0
- package/trustledger/policy.js +398 -0
- package/trustledger/public/index.html +512 -0
- package/trustledger/reconcile.js +1486 -0
- package/trustledger/report.js +887 -0
- package/trustledger/seal.js +854 -0
- package/trustledger/server.js +391 -0
- package/trustledger/valueproof.js +350 -0
|
@@ -0,0 +1,3179 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
// TrustLedger — cli.js
|
|
4
|
+
//
|
|
5
|
+
// T-22.4: `vh trust reconcile <bank> <ledger> <rentroll> [--out <dir>]`.
|
|
6
|
+
//
|
|
7
|
+
// T-27.3: `vh trust serve [--port <n>] [--host <h>]` launches the LOCAL web
|
|
8
|
+
// front-door (trustledger/server.js) over this same engine so a non-technical
|
|
9
|
+
// broker can drop the three files in a browser. Files are processed in-memory;
|
|
10
|
+
// nothing is persisted server-side. Exposing it (nginx/Cloudflare on the broker's
|
|
11
|
+
// own domain) is a HUMAN deploy step — it is never auto-deployed.
|
|
12
|
+
//
|
|
13
|
+
// T-26.2: `vh trust reconcile ... --out <dir> --seal [<file>]` additionally emits a
|
|
14
|
+
// TAMPER-EVIDENT reconciliation seal AFTER the packet (binding the 3 source inputs +
|
|
15
|
+
// every emitted packet file, and the emitted close when --emit-close is used). The CLI
|
|
16
|
+
// does all the file READING and hands seal.js already-loaded { relPath, bytes } entries
|
|
17
|
+
// (seal.js stays pure). `--seal` REQUIRES `--out` (no emitted packet, nothing to seal).
|
|
18
|
+
// `vh trust verify-seal <sealfile> [--dir <d>] [--json]` independently RE-DERIVES each
|
|
19
|
+
// sealed file from disk OFFLINE (no key, no network) and prints ACCEPTED (0) only when
|
|
20
|
+
// EVERY file matches, else REJECTED (3) with the per-file CHANGED/MISSING/UNEXPECTED list.
|
|
21
|
+
//
|
|
22
|
+
// The one command a broker runs: hand it the three files they already have every
|
|
23
|
+
// month and it runs the WHOLE pipeline end to end —
|
|
24
|
+
//
|
|
25
|
+
// ingest (parse the bank statement, the QuickBooks ledger, the rent roll)
|
|
26
|
+
// -> match (pair bank<->book lines)
|
|
27
|
+
// -> reconcile (the three-balance check + classified exceptions)
|
|
28
|
+
// -> report (a DATED, deterministic, audit-ready HTML + CSV packet)
|
|
29
|
+
//
|
|
30
|
+
// and prints a single PASS/FAIL line with a CI-gateable exit code.
|
|
31
|
+
//
|
|
32
|
+
// PER-STATE POLICY (T-23.2): pass `--state <code>` to score under a bundled
|
|
33
|
+
// per-state trust-rule policy, or `--policy <file>` for an explicit one. The
|
|
34
|
+
// policy overrides exception severities (e.g. a state that makes an NSF reversal
|
|
35
|
+
// a hard ERROR) BEFORE the PASS/FAIL verdict and exit code are computed, so the
|
|
36
|
+
// gate reflects the REVIEWED severities. With neither flag the built-in baseline
|
|
37
|
+
// is used (byte-for-byte unchanged). Supplying both, or an unknown `--state`, is
|
|
38
|
+
// a usage error (exit 2). The packet names which policy governed the run and
|
|
39
|
+
// surfaces each override's citation; the policy itself is still a DRAFT a CPA/
|
|
40
|
+
// counsel must review (it is NOT legal advice).
|
|
41
|
+
//
|
|
42
|
+
// FILESYSTEM HYGIENE: side-effect files (the packet) are written ONLY to the
|
|
43
|
+
// caller-chosen --out directory — never silently to cwd. Without --out the
|
|
44
|
+
// command prints the summary + the report to stdout and writes NOTHING, so it is
|
|
45
|
+
// safe to run anywhere (and trivially CI-pipeable). The exit code is a stable,
|
|
46
|
+
// documented contract: 0 = PASS (ties out, no error-severity finding),
|
|
47
|
+
// 3 = FAIL (does not tie out, or an out-of-trust finding), 2 = usage error,
|
|
48
|
+
// 1 = an input/IO error (e.g. an unreadable or malformed file).
|
|
49
|
+
|
|
50
|
+
const fs = require("fs");
|
|
51
|
+
const path = require("path");
|
|
52
|
+
|
|
53
|
+
const ingest = require("./ingest");
|
|
54
|
+
const report = require("./report");
|
|
55
|
+
const corpus = require("./corpus");
|
|
56
|
+
const valueproof = require("./valueproof");
|
|
57
|
+
const policy = require("./policy");
|
|
58
|
+
const close = require("./close");
|
|
59
|
+
const seal = require("./seal");
|
|
60
|
+
const server = require("./server");
|
|
61
|
+
const license = require("./license");
|
|
62
|
+
const plans = require("./plans");
|
|
63
|
+
|
|
64
|
+
// The three reconcile sources, keyed by the broker-facing label used on the
|
|
65
|
+
// command line (`--map <source>:<logical>=<header>` and the --map-file top-level
|
|
66
|
+
// keys). Shared with inspect's --as so the two commands name sources identically.
|
|
67
|
+
const MAP_SOURCES = Object.freeze(["bank", "ledger", "rentroll"]);
|
|
68
|
+
|
|
69
|
+
// Parse ONE `--map` value into { source?, logical, header }. Accepts either
|
|
70
|
+
// "<logical>=<header>" (inspect: the source is implied by --as), or
|
|
71
|
+
// "<source>:<logical>=<header>" (reconcile: which of the three files).
|
|
72
|
+
// Malformed syntax (no "=", an empty logical/header, or an unknown source
|
|
73
|
+
// prefix) is a USAGE error (exit 2) — a bad flag value, surfaced clearly.
|
|
74
|
+
function parseMapArg(raw, { requireSource } = {}) {
|
|
75
|
+
const s = String(raw == null ? "" : raw);
|
|
76
|
+
const eq = s.indexOf("=");
|
|
77
|
+
if (eq === -1) {
|
|
78
|
+
const e = new Error(
|
|
79
|
+
`--map must be <logical>=<header>${
|
|
80
|
+
requireSource ? " (prefixed <source>:<logical>=<header>)" : ""
|
|
81
|
+
} (got "${raw}")`
|
|
82
|
+
);
|
|
83
|
+
e.usage = true;
|
|
84
|
+
throw e;
|
|
85
|
+
}
|
|
86
|
+
let lhs = s.slice(0, eq).trim();
|
|
87
|
+
const header = s.slice(eq + 1); // header kept verbatim (may contain spaces)
|
|
88
|
+
let source;
|
|
89
|
+
const colon = lhs.indexOf(":");
|
|
90
|
+
if (colon !== -1) {
|
|
91
|
+
source = lhs.slice(0, colon).trim().toLowerCase();
|
|
92
|
+
lhs = lhs.slice(colon + 1).trim();
|
|
93
|
+
if (!MAP_SOURCES.includes(source)) {
|
|
94
|
+
const e = new Error(
|
|
95
|
+
`--map source must be one of ${MAP_SOURCES.join("|")} (got "${source}")`
|
|
96
|
+
);
|
|
97
|
+
e.usage = true;
|
|
98
|
+
throw e;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
if (requireSource && source === undefined) {
|
|
102
|
+
const e = new Error(
|
|
103
|
+
`--map for reconcile must be <source>:<logical>=<header> ` +
|
|
104
|
+
`(source one of ${MAP_SOURCES.join("|")}) (got "${raw}")`
|
|
105
|
+
);
|
|
106
|
+
e.usage = true;
|
|
107
|
+
throw e;
|
|
108
|
+
}
|
|
109
|
+
const logical = lhs;
|
|
110
|
+
if (logical === "" || String(header).trim() === "") {
|
|
111
|
+
const e = new Error(
|
|
112
|
+
`--map must be <logical>=<header> with both sides non-empty (got "${raw}")`
|
|
113
|
+
);
|
|
114
|
+
e.usage = true;
|
|
115
|
+
throw e;
|
|
116
|
+
}
|
|
117
|
+
return { source, logical, header };
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// Read + parse a `--map-file <json>`: a `{ bank|ledger|rentroll: { <logical>:
|
|
121
|
+
// <header> } }` per-source mapping. An unreadable file or malformed JSON, a
|
|
122
|
+
// non-object body, an unknown top-level source key, or a non-string mapping
|
|
123
|
+
// value is a USAGE error (exit 2). Returns { bank?, ledger?, rentroll? } where
|
|
124
|
+
// each value is a plain `{ <logical>: <header> }` columnMap.
|
|
125
|
+
function readMapFile(file) {
|
|
126
|
+
let text;
|
|
127
|
+
try {
|
|
128
|
+
text = fs.readFileSync(path.resolve(file), "utf8");
|
|
129
|
+
} catch (e) {
|
|
130
|
+
const err = new Error(`cannot read --map-file ${file}: ${e.message}`);
|
|
131
|
+
err.usage = true;
|
|
132
|
+
throw err;
|
|
133
|
+
}
|
|
134
|
+
let obj;
|
|
135
|
+
try {
|
|
136
|
+
obj = JSON.parse(text);
|
|
137
|
+
} catch (e) {
|
|
138
|
+
const err = new Error(`invalid JSON in --map-file ${file}: ${e.message}`);
|
|
139
|
+
err.usage = true;
|
|
140
|
+
throw err;
|
|
141
|
+
}
|
|
142
|
+
if (obj == null || typeof obj !== "object" || Array.isArray(obj)) {
|
|
143
|
+
const err = new Error(
|
|
144
|
+
`--map-file ${file} must be a JSON object of { bank|ledger|rentroll: {logical: header} }`
|
|
145
|
+
);
|
|
146
|
+
err.usage = true;
|
|
147
|
+
throw err;
|
|
148
|
+
}
|
|
149
|
+
const out = {};
|
|
150
|
+
for (const [src, map] of Object.entries(obj)) {
|
|
151
|
+
if (!MAP_SOURCES.includes(src)) {
|
|
152
|
+
const err = new Error(
|
|
153
|
+
`--map-file ${file}: unknown source key "${src}" (expected one of ${MAP_SOURCES.join(
|
|
154
|
+
", "
|
|
155
|
+
)})`
|
|
156
|
+
);
|
|
157
|
+
err.usage = true;
|
|
158
|
+
throw err;
|
|
159
|
+
}
|
|
160
|
+
if (map == null || typeof map !== "object" || Array.isArray(map)) {
|
|
161
|
+
const err = new Error(
|
|
162
|
+
`--map-file ${file}: "${src}" must map to an object of {logical: header}`
|
|
163
|
+
);
|
|
164
|
+
err.usage = true;
|
|
165
|
+
throw err;
|
|
166
|
+
}
|
|
167
|
+
const cm = {};
|
|
168
|
+
for (const [logical, header] of Object.entries(map)) {
|
|
169
|
+
if (typeof header !== "string" || header.trim() === "") {
|
|
170
|
+
const err = new Error(
|
|
171
|
+
`--map-file ${file}: ${src}.${logical} must be a non-empty header string`
|
|
172
|
+
);
|
|
173
|
+
err.usage = true;
|
|
174
|
+
throw err;
|
|
175
|
+
}
|
|
176
|
+
cm[logical] = header;
|
|
177
|
+
}
|
|
178
|
+
out[src] = cm;
|
|
179
|
+
}
|
|
180
|
+
return out;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// Merge per-source maps: --map-file provides the base, individual --map entries
|
|
184
|
+
// OVERRIDE it (last-write-wins for a repeated logical). Returns { <source>:
|
|
185
|
+
// columnMap } using only sources that have at least one mapping. PURE.
|
|
186
|
+
function buildSourceMaps(mapFileMaps, mapArgs) {
|
|
187
|
+
const out = {};
|
|
188
|
+
for (const src of MAP_SOURCES) {
|
|
189
|
+
if (mapFileMaps && mapFileMaps[src]) out[src] = { ...mapFileMaps[src] };
|
|
190
|
+
}
|
|
191
|
+
for (const { source, logical, header } of mapArgs) {
|
|
192
|
+
if (!out[source]) out[source] = {};
|
|
193
|
+
out[source][logical] = header;
|
|
194
|
+
}
|
|
195
|
+
return out;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// Exit codes — shared, documented contract (mirrors the dataset/parcel gates:
|
|
199
|
+
// 0 PASS, 3 data/gate FAIL, 2 usage, 1 IO/input error).
|
|
200
|
+
//
|
|
201
|
+
// DATA_GAP (4) is value-proof-ONLY (T-45.2): it distinguishes the "fix-my-data
|
|
202
|
+
// and re-run" value-proof outcome from a genuine out-of-trust FAIL (3) so a CI
|
|
203
|
+
// pipeline can gate on the make-or-break difference. The reconcile gate itself
|
|
204
|
+
// still uses only 0/3 — this code never changes a reconcile verdict or exit.
|
|
205
|
+
const EXIT = Object.freeze({ PASS: 0, IO: 1, USAGE: 2, FAIL: 3, DATA_GAP: 4 });
|
|
206
|
+
|
|
207
|
+
// ---------------------------------------------------------------------------
|
|
208
|
+
// Argument parsing
|
|
209
|
+
// ---------------------------------------------------------------------------
|
|
210
|
+
|
|
211
|
+
// Parse `reconcile` argv into options. Three positional files (bank, ledger,
|
|
212
|
+
// rentroll) in order, plus flags. Unknown flags and missing positionals are
|
|
213
|
+
// reported by the caller as usage errors.
|
|
214
|
+
function parseReconcileArgs(argv) {
|
|
215
|
+
const opts = {
|
|
216
|
+
bank: undefined,
|
|
217
|
+
ledger: undefined,
|
|
218
|
+
rentroll: undefined,
|
|
219
|
+
out: undefined,
|
|
220
|
+
json: false,
|
|
221
|
+
date: undefined, // override the report date (default: today); MUST be YYYY-MM-DD
|
|
222
|
+
period: undefined, // optional human label for the statement period
|
|
223
|
+
openingBank: 0,
|
|
224
|
+
openingBook: 0,
|
|
225
|
+
toleranceCents: 0,
|
|
226
|
+
bankFormat: undefined, // force "csv" | "ofx" for the bank file
|
|
227
|
+
policyFile: undefined, // explicit per-state policy file (--policy <file>)
|
|
228
|
+
state: undefined, // bundled per-state policy by its state code (--state <code>)
|
|
229
|
+
priorClose: undefined, // prior period's close.json to roll forward FROM (--prior-close <file>)
|
|
230
|
+
emitClose: undefined, // path to write THIS run's close.json TO (--emit-close <file>)
|
|
231
|
+
seal: false, // --seal [<file>] given at all (T-26.2): emit a reconciliation seal
|
|
232
|
+
sealFile: undefined, // caller-named seal path; undefined => default name under --out
|
|
233
|
+
license: undefined, // path to a signed *.vhlicense.json that UNLOCKS the paid surfaces (T-29.2)
|
|
234
|
+
vendor: undefined, // 0x-address the license issuer is pinned to (T-29.2)
|
|
235
|
+
mapArgs: [], // repeatable --map <source>:<logical>=<header> (T-25.3)
|
|
236
|
+
mapFile: undefined, // --map-file <json> per-source column maps (T-25.3)
|
|
237
|
+
_positionals: [],
|
|
238
|
+
};
|
|
239
|
+
for (let i = 0; i < argv.length; i++) {
|
|
240
|
+
const a = argv[i];
|
|
241
|
+
switch (a) {
|
|
242
|
+
case "--out":
|
|
243
|
+
opts.out = argv[++i];
|
|
244
|
+
break;
|
|
245
|
+
case "--json":
|
|
246
|
+
opts.json = true;
|
|
247
|
+
break;
|
|
248
|
+
case "--date":
|
|
249
|
+
opts.date = argv[++i];
|
|
250
|
+
break;
|
|
251
|
+
case "--period":
|
|
252
|
+
opts.period = argv[++i];
|
|
253
|
+
break;
|
|
254
|
+
case "--opening-bank":
|
|
255
|
+
opts.openingBank = parseCentsArg(argv[++i], "--opening-bank");
|
|
256
|
+
opts.openingBankSet = true;
|
|
257
|
+
break;
|
|
258
|
+
case "--opening-book":
|
|
259
|
+
opts.openingBook = parseCentsArg(argv[++i], "--opening-book");
|
|
260
|
+
opts.openingBookSet = true;
|
|
261
|
+
break;
|
|
262
|
+
case "--tolerance-cents":
|
|
263
|
+
opts.toleranceCents = parseIntArg(argv[++i], "--tolerance-cents");
|
|
264
|
+
break;
|
|
265
|
+
case "--bank-format":
|
|
266
|
+
opts.bankFormat = argv[++i];
|
|
267
|
+
break;
|
|
268
|
+
case "--policy":
|
|
269
|
+
opts.policyFile = argv[++i];
|
|
270
|
+
break;
|
|
271
|
+
case "--state":
|
|
272
|
+
opts.state = argv[++i];
|
|
273
|
+
break;
|
|
274
|
+
case "--license":
|
|
275
|
+
opts.license = argv[++i];
|
|
276
|
+
break;
|
|
277
|
+
case "--vendor":
|
|
278
|
+
opts.vendor = argv[++i];
|
|
279
|
+
break;
|
|
280
|
+
case "--prior-close":
|
|
281
|
+
opts.priorClose = argv[++i];
|
|
282
|
+
break;
|
|
283
|
+
case "--emit-close":
|
|
284
|
+
opts.emitClose = argv[++i];
|
|
285
|
+
break;
|
|
286
|
+
case "--seal": {
|
|
287
|
+
// --seal takes an OPTIONAL <file>. Peek the next token: consume it as the
|
|
288
|
+
// caller-named seal path ONLY when it exists and is not another flag. The
|
|
289
|
+
// three positional files conventionally precede the flags, so this does not
|
|
290
|
+
// swallow them; without a value the seal lands at a default name under --out.
|
|
291
|
+
opts.seal = true;
|
|
292
|
+
const next = argv[i + 1];
|
|
293
|
+
if (next !== undefined && !String(next).startsWith("--")) {
|
|
294
|
+
opts.sealFile = next;
|
|
295
|
+
i++;
|
|
296
|
+
}
|
|
297
|
+
break;
|
|
298
|
+
}
|
|
299
|
+
case "--map":
|
|
300
|
+
// reconcile: source is REQUIRED (three files), so <source>:<logical>=<header>.
|
|
301
|
+
opts.mapArgs.push(parseMapArg(argv[++i], { requireSource: true }));
|
|
302
|
+
break;
|
|
303
|
+
case "--map-file":
|
|
304
|
+
opts.mapFile = argv[++i];
|
|
305
|
+
break;
|
|
306
|
+
default:
|
|
307
|
+
if (a && a.startsWith("--")) {
|
|
308
|
+
const e = new Error(`unknown option: ${a}`);
|
|
309
|
+
e.usage = true;
|
|
310
|
+
throw e;
|
|
311
|
+
}
|
|
312
|
+
opts._positionals.push(a);
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
[opts.bank, opts.ledger, opts.rentroll] = opts._positionals;
|
|
316
|
+
return opts;
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
function parseCentsArg(raw, flag) {
|
|
320
|
+
// Reuse ingest's exact dollar->cents parser so --opening-bank "1,234.56" works
|
|
321
|
+
// identically to a file amount (no float drift).
|
|
322
|
+
try {
|
|
323
|
+
return ingest.parseCents(raw, flag);
|
|
324
|
+
} catch (e) {
|
|
325
|
+
const err = new Error(`${flag}: ${e.message}`);
|
|
326
|
+
err.usage = true;
|
|
327
|
+
throw err;
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
function parseIntArg(raw, flag) {
|
|
332
|
+
if (!/^\d+$/.test(String(raw || ""))) {
|
|
333
|
+
const err = new Error(`${flag} must be a non-negative integer (cents)`);
|
|
334
|
+
err.usage = true;
|
|
335
|
+
throw err;
|
|
336
|
+
}
|
|
337
|
+
return Number(raw);
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
// ---------------------------------------------------------------------------
|
|
341
|
+
// The license GATE for the paid reconcile surfaces (T-29.2).
|
|
342
|
+
//
|
|
343
|
+
// Maps each requested PAID flag to the entitlement it needs, then — only when at
|
|
344
|
+
// least one paid surface is requested — REQUIRES a valid, vendor-pinned license
|
|
345
|
+
// carrying every needed entitlement. Pure of I/O except a single offline read of
|
|
346
|
+
// the caller-chosen license file; holds NO signing key (verify is key-free), no
|
|
347
|
+
// network. Returns { code } — EXIT.PASS to proceed, EXIT.USAGE to refuse — and
|
|
348
|
+
// writes the precise, ACTIONABLE reason to writeErr. Never throws on an ordinary
|
|
349
|
+
// refusal (a malformed flag is the only caller error, surfaced as a usage line).
|
|
350
|
+
//
|
|
351
|
+
// The reason is reported EXACTLY as `verifyLicense` returns it (wrong_issuer /
|
|
352
|
+
// expired / not_yet_valid / bad_signature / malformed) so a refusal is never
|
|
353
|
+
// ambiguous, and a wrong/expired license NEVER silently downgrades to a free run.
|
|
354
|
+
// ---------------------------------------------------------------------------
|
|
355
|
+
|
|
356
|
+
// Which entitlement each paid reconcile surface requires. The ONLY place the
|
|
357
|
+
// flag->entitlement mapping lives, so the gate and the help can never drift.
|
|
358
|
+
const PAID_FEATURE_ENTITLEMENTS = Object.freeze([
|
|
359
|
+
{
|
|
360
|
+
requested: (o) => o.state != null || o.policyFile != null,
|
|
361
|
+
entitlement: "multi_state_policy",
|
|
362
|
+
label: "multi-state policy packs (--state/--policy)",
|
|
363
|
+
},
|
|
364
|
+
{
|
|
365
|
+
requested: (o) => o.seal === true,
|
|
366
|
+
entitlement: "seal",
|
|
367
|
+
label: "the tamper-evident reconciliation seal (--seal)",
|
|
368
|
+
},
|
|
369
|
+
]);
|
|
370
|
+
|
|
371
|
+
function gateReconcile(opts, reportDate, writeErr) {
|
|
372
|
+
// Which paid features were requested in THIS run?
|
|
373
|
+
const needed = PAID_FEATURE_ENTITLEMENTS.filter((f) => f.requested(opts));
|
|
374
|
+
if (needed.length === 0) {
|
|
375
|
+
// FREE TIER. No paid surface requested: proceed UNCHANGED. (A stray --license/
|
|
376
|
+
// --vendor with no paid feature is simply ignored — it costs nothing and keeps
|
|
377
|
+
// the free path byte-for-byte identical.)
|
|
378
|
+
return { code: EXIT.PASS };
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
const featureList = needed.map((f) => f.label).join(" and ");
|
|
382
|
+
const hasLicense = opts.license != null;
|
|
383
|
+
const hasVendor = opts.vendor != null;
|
|
384
|
+
|
|
385
|
+
// Both license sources must be present together — a license file is worthless
|
|
386
|
+
// without the vendor key to PIN it to, and a vendor with no file is nothing to
|
|
387
|
+
// verify. Either alone is a usage error (parser parity with --key-env/--key-file).
|
|
388
|
+
if (!hasLicense && !hasVendor) {
|
|
389
|
+
writeErr(
|
|
390
|
+
`error: ${featureList} ${needed.length > 1 ? "are" : "is"} a PAID feature and ` +
|
|
391
|
+
"requires a license; pass --license <file> --vendor <0xaddr> " +
|
|
392
|
+
"(mint one with `vh trust license issue`, verify it with `vh trust license verify`). " +
|
|
393
|
+
"The FREE tier — baseline-policy reconcile + `vh trust inspect` — needs no license.\n"
|
|
394
|
+
);
|
|
395
|
+
return { code: EXIT.USAGE };
|
|
396
|
+
}
|
|
397
|
+
if (!hasLicense || !hasVendor) {
|
|
398
|
+
writeErr(
|
|
399
|
+
"error: --license and --vendor must be supplied together (a license file is " +
|
|
400
|
+
"verified by pinning it to the vendor key); pass BOTH --license <file> --vendor <0xaddr>\n"
|
|
401
|
+
);
|
|
402
|
+
return { code: EXIT.USAGE };
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
// Read the license file OFFLINE (the only I/O). An unreadable/garbled file is a
|
|
406
|
+
// usage error with a key-free message (there is no key in a license anyway).
|
|
407
|
+
let container;
|
|
408
|
+
try {
|
|
409
|
+
const text = fs.readFileSync(path.resolve(opts.license), "utf8");
|
|
410
|
+
container = license.readLicense(text);
|
|
411
|
+
} catch (e) {
|
|
412
|
+
writeErr(`error: cannot read --license file ${opts.license}: ${e.message}\n`);
|
|
413
|
+
return { code: EXIT.USAGE };
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
// Verify OFFLINE against the pinned vendor, dated at the run's reportDate. A
|
|
417
|
+
// malformed --vendor is a caller error thrown by verifyLicense — surface it as a
|
|
418
|
+
// usage line, never as a crash, and never echoing anything sensitive.
|
|
419
|
+
let verdict;
|
|
420
|
+
try {
|
|
421
|
+
verdict = license.verifyLicense(container, { now: reportDate, vendorAddress: opts.vendor });
|
|
422
|
+
} catch (e) {
|
|
423
|
+
writeErr(`error: ${e.message}\n`);
|
|
424
|
+
return { code: EXIT.USAGE };
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
if (!verdict.valid) {
|
|
428
|
+
// Report the precise reason verifyLicense returned — never silently downgrade.
|
|
429
|
+
writeErr(
|
|
430
|
+
`error: ${featureList} requires a VALID license, but the supplied license is ` +
|
|
431
|
+
`INVALID (reason: ${verdict.reason}). It does NOT unlock the paid surface; ` +
|
|
432
|
+
"the FREE baseline reconcile remains available without --state/--policy/--seal.\n"
|
|
433
|
+
);
|
|
434
|
+
return { code: EXIT.USAGE };
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
// Valid + in-window + correct issuer. Now require EACH requested feature's
|
|
438
|
+
// entitlement to actually be granted. A valid license that does not carry the
|
|
439
|
+
// entitlement still REFUSES (it never grants a feature it was not sold).
|
|
440
|
+
for (const f of needed) {
|
|
441
|
+
if (!license.hasEntitlement(verdict, f.entitlement)) {
|
|
442
|
+
writeErr(
|
|
443
|
+
`error: the supplied license is valid but does NOT include the "${f.entitlement}" ` +
|
|
444
|
+
`entitlement needed for ${f.label}; this license grants only ` +
|
|
445
|
+
`[${verdict.entitlements.join(", ")}]. The FREE baseline reconcile remains available.\n`
|
|
446
|
+
);
|
|
447
|
+
return { code: EXIT.USAGE };
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
return { code: EXIT.PASS };
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
// ---------------------------------------------------------------------------
|
|
455
|
+
// The pipeline runner (pure of argv; takes resolved options + an injectable
|
|
456
|
+
// today() so the CLI passes a real date while tests pass a fixed one).
|
|
457
|
+
// ---------------------------------------------------------------------------
|
|
458
|
+
|
|
459
|
+
// runReconcile reads the three files, runs the pipeline, optionally writes the
|
|
460
|
+
// packet, and returns { code, model, summary, written, render }.
|
|
461
|
+
// opts: { bank, ledger, rentroll, out, json, date, period,
|
|
462
|
+
// openingBank, openingBook, toleranceCents, bankFormat }
|
|
463
|
+
// io: { write, writeErr, today } injectable; defaults to process + a real
|
|
464
|
+
// "YYYY-MM-DD" today only when no explicit --date was given.
|
|
465
|
+
function runReconcile(opts, io = {}) {
|
|
466
|
+
const write = io.write || ((s) => process.stdout.write(s));
|
|
467
|
+
const writeErr = io.writeErr || ((s) => process.stderr.write(s));
|
|
468
|
+
|
|
469
|
+
if (!opts.bank || !opts.ledger || !opts.rentroll) {
|
|
470
|
+
writeErr(
|
|
471
|
+
"error: `vh trust reconcile` requires three files: <bank> <ledger> <rentroll>\n"
|
|
472
|
+
);
|
|
473
|
+
return { code: EXIT.USAGE };
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
// `--seal` seals the EMITTED packet, so there must be a packet on disk to seal.
|
|
477
|
+
// Without --out the command writes NOTHING (it streams to stdout), so --seal has
|
|
478
|
+
// nothing to bind — that is a usage error, surfaced with the fix, not silently
|
|
479
|
+
// ignored (parser parity with --out's other dependents).
|
|
480
|
+
if (opts.seal && !opts.out) {
|
|
481
|
+
writeErr(
|
|
482
|
+
"error: --seal requires --out (there is no emitted packet to seal without a " +
|
|
483
|
+
"--out <dir>); pass --out <dir> [--seal [<file>]]\n"
|
|
484
|
+
);
|
|
485
|
+
return { code: EXIT.USAGE };
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
// Report date: explicit --date wins (keeps output reproducible); else today.
|
|
489
|
+
// The function never calls `new Date()` itself when a date is provided, so a
|
|
490
|
+
// test can pin it; the CLI supplies today via io.today.
|
|
491
|
+
let reportDate = opts.date;
|
|
492
|
+
if (reportDate == null) {
|
|
493
|
+
reportDate = (io.today || todayISO)();
|
|
494
|
+
}
|
|
495
|
+
if (!/^\d{4}-\d{2}-\d{2}$/.test(String(reportDate || ""))) {
|
|
496
|
+
writeErr(`error: --date must be "YYYY-MM-DD" (got "${reportDate}")\n`);
|
|
497
|
+
return { code: EXIT.USAGE };
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
// -- LICENSE GATE (T-29.2). Decide BEFORE any data work. -------------------
|
|
501
|
+
// The FREE tier — baseline-policy reconcile (no --state/--policy), no --seal —
|
|
502
|
+
// needs NO license and behaves byte-for-byte as before so a broker can evaluate
|
|
503
|
+
// the product before buying. The moment a PAID surface is requested
|
|
504
|
+
// (multi-state policy via --state/--policy, or the tamper-evident --seal), a
|
|
505
|
+
// VALID, in-window, vendor-pinned license carrying the matching entitlement is
|
|
506
|
+
// REQUIRED. A missing/expired/wrong-issuer/under-entitled license REFUSES with
|
|
507
|
+
// the precise reason (exit 2, a clear gate) and never silently downgrades to a
|
|
508
|
+
// free result. `now` is the resolved reportDate (the SAME injectable clock the
|
|
509
|
+
// packet is dated under) so verification is offline + deterministic; this path
|
|
510
|
+
// is read-only, holds NO key, and touches NO network.
|
|
511
|
+
const gate = gateReconcile(opts, reportDate, writeErr);
|
|
512
|
+
if (gate.code !== EXIT.PASS) return { code: gate.code };
|
|
513
|
+
|
|
514
|
+
// -- Resolve the per-state trust-rule policy (if any). ---------------------
|
|
515
|
+
// `--policy <file>` reads an explicit file; `--state <code>` resolves a bundled
|
|
516
|
+
// fixture by its state code. Supplying BOTH is ambiguous, and an unknown
|
|
517
|
+
// `--state` is unactionable — both are USAGE errors (exit 2), as is a malformed
|
|
518
|
+
// or unreadable policy file (a bad flag value, not a data-file IO error). With
|
|
519
|
+
// neither flag the run uses the built-in baseline severities (policy = null),
|
|
520
|
+
// which is byte-for-byte today's behaviour.
|
|
521
|
+
let activePolicy = null;
|
|
522
|
+
if (opts.policyFile != null && opts.state != null) {
|
|
523
|
+
writeErr(
|
|
524
|
+
"error: --policy and --state are mutually exclusive (choose an explicit " +
|
|
525
|
+
"policy file OR a bundled state code, not both)\n"
|
|
526
|
+
);
|
|
527
|
+
return { code: EXIT.USAGE };
|
|
528
|
+
}
|
|
529
|
+
if (opts.state != null) {
|
|
530
|
+
try {
|
|
531
|
+
activePolicy = policy.resolveState(opts.state);
|
|
532
|
+
} catch (e) {
|
|
533
|
+
writeErr(`error: ${e.message}\n`);
|
|
534
|
+
return { code: EXIT.USAGE };
|
|
535
|
+
}
|
|
536
|
+
} else if (opts.policyFile != null) {
|
|
537
|
+
let policyText;
|
|
538
|
+
try {
|
|
539
|
+
policyText = fs.readFileSync(path.resolve(opts.policyFile), "utf8");
|
|
540
|
+
} catch (e) {
|
|
541
|
+
writeErr(`error: cannot read --policy file ${opts.policyFile}: ${e.message}\n`);
|
|
542
|
+
return { code: EXIT.USAGE };
|
|
543
|
+
}
|
|
544
|
+
try {
|
|
545
|
+
activePolicy = policy.readPolicy(policyText);
|
|
546
|
+
} catch (e) {
|
|
547
|
+
writeErr(`error: invalid --policy file ${opts.policyFile}: ${e.message}\n`);
|
|
548
|
+
return { code: EXIT.USAGE };
|
|
549
|
+
}
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
// -- Resolve the prior period's close (--prior-close), if any. -------------
|
|
553
|
+
// Mirrors how --policy is handled: a malformed/unreadable close is a USAGE
|
|
554
|
+
// error (exit 2) — a BAD FLAG VALUE, not a data-file IO error.
|
|
555
|
+
//
|
|
556
|
+
// SEED-then-OVERRIDE. When present, the prior close's `ending` SEEDS this run's
|
|
557
|
+
// opening balances. An explicit --opening-bank/--opening-book then acts as an
|
|
558
|
+
// explicit OVERRIDE of that seed. BUILDER'S CHOICE (documented): a disagreeing
|
|
559
|
+
// override is HONORED but NOTED — we let the broker open where they say (e.g. a
|
|
560
|
+
// documented mid-period adjustment), AND we surface the disagreement on stderr,
|
|
561
|
+
// AND — crucially — the continuity check then compares the OPENING actually used
|
|
562
|
+
// against the prior ending, so a disagreeing override that breaks the chain
|
|
563
|
+
// SHOWS UP as a CONTINUITY_BREAK in the packet (flipping the verdict) rather than
|
|
564
|
+
// being silently swallowed. This is strictly safer than honoring it invisibly:
|
|
565
|
+
// the gap is recorded in the signed packet, not hidden behind a one-line warning.
|
|
566
|
+
let priorClose = null;
|
|
567
|
+
let openingNotes = [];
|
|
568
|
+
if (opts.priorClose != null) {
|
|
569
|
+
let closeText;
|
|
570
|
+
try {
|
|
571
|
+
closeText = fs.readFileSync(path.resolve(opts.priorClose), "utf8");
|
|
572
|
+
} catch (e) {
|
|
573
|
+
writeErr(
|
|
574
|
+
`error: cannot read --prior-close file ${opts.priorClose}: ${e.message}\n`
|
|
575
|
+
);
|
|
576
|
+
return { code: EXIT.USAGE };
|
|
577
|
+
}
|
|
578
|
+
try {
|
|
579
|
+
priorClose = close.readClose(closeText);
|
|
580
|
+
} catch (e) {
|
|
581
|
+
writeErr(
|
|
582
|
+
`error: invalid --prior-close file ${opts.priorClose}: ${e.message}\n`
|
|
583
|
+
);
|
|
584
|
+
return { code: EXIT.USAGE };
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
// Seed each leg from the prior ending UNLESS the broker explicitly overrode it.
|
|
588
|
+
if (!opts.openingBankSet) {
|
|
589
|
+
opts.openingBank = priorClose.ending.bank;
|
|
590
|
+
} else if (opts.openingBank !== priorClose.ending.bank) {
|
|
591
|
+
openingNotes.push(
|
|
592
|
+
`note: --opening-bank ${opts.openingBank} overrides the prior close's ` +
|
|
593
|
+
`ending bank balance ${priorClose.ending.bank}; the roll-forward ` +
|
|
594
|
+
"continuity check below will flag the resulting gap"
|
|
595
|
+
);
|
|
596
|
+
}
|
|
597
|
+
if (!opts.openingBookSet) {
|
|
598
|
+
opts.openingBook = priorClose.ending.book;
|
|
599
|
+
} else if (opts.openingBook !== priorClose.ending.book) {
|
|
600
|
+
openingNotes.push(
|
|
601
|
+
`note: --opening-book ${opts.openingBook} overrides the prior close's ` +
|
|
602
|
+
`ending book balance ${priorClose.ending.book}; the roll-forward ` +
|
|
603
|
+
"continuity check below will flag the resulting gap"
|
|
604
|
+
);
|
|
605
|
+
}
|
|
606
|
+
}
|
|
607
|
+
for (const n of openingNotes) writeErr(`${n}\n`);
|
|
608
|
+
|
|
609
|
+
// -- Read the three files (IO errors are exit 1, not a crash). -------------
|
|
610
|
+
let bankText;
|
|
611
|
+
let ledgerText;
|
|
612
|
+
let rentText;
|
|
613
|
+
try {
|
|
614
|
+
bankText = fs.readFileSync(path.resolve(opts.bank), "utf8");
|
|
615
|
+
} catch (e) {
|
|
616
|
+
writeErr(`error: cannot read bank file ${opts.bank}: ${e.message}\n`);
|
|
617
|
+
return { code: EXIT.IO };
|
|
618
|
+
}
|
|
619
|
+
try {
|
|
620
|
+
ledgerText = fs.readFileSync(path.resolve(opts.ledger), "utf8");
|
|
621
|
+
} catch (e) {
|
|
622
|
+
writeErr(`error: cannot read ledger file ${opts.ledger}: ${e.message}\n`);
|
|
623
|
+
return { code: EXIT.IO };
|
|
624
|
+
}
|
|
625
|
+
try {
|
|
626
|
+
rentText = fs.readFileSync(path.resolve(opts.rentroll), "utf8");
|
|
627
|
+
} catch (e) {
|
|
628
|
+
writeErr(`error: cannot read rent-roll file ${opts.rentroll}: ${e.message}\n`);
|
|
629
|
+
return { code: EXIT.IO };
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
// -- Resolve the per-source column maps (--map-file + --map). --------------
|
|
633
|
+
// A malformed --map-file (unreadable, bad JSON, unknown source key) is a USAGE
|
|
634
|
+
// error (a bad flag value, exit 2) — same class as a bad --policy file. The
|
|
635
|
+
// individual --map flags were already syntax-validated in the arg parser.
|
|
636
|
+
let sourceMaps;
|
|
637
|
+
try {
|
|
638
|
+
const mapFileMaps = opts.mapFile != null ? readMapFile(opts.mapFile) : null;
|
|
639
|
+
sourceMaps = buildSourceMaps(mapFileMaps, opts.mapArgs || []);
|
|
640
|
+
} catch (e) {
|
|
641
|
+
writeErr(`error: ${e.message}\n`);
|
|
642
|
+
return { code: EXIT.USAGE };
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
// -- Validate the resolved column maps up front (USAGE, not IO). -----------
|
|
646
|
+
// A STRUCTURALLY-INVALID column map (an unknown logical key, OR a mapped-to
|
|
647
|
+
// header absent from the file) is a BAD FLAG VALUE — the same error class as a
|
|
648
|
+
// malformed --map-file (readMapFile, exit 2) and as inspect's preview. Without
|
|
649
|
+
// this pre-flight the IDENTICAL mistake routed through an inline --map would
|
|
650
|
+
// fall through to the strict-ingest try/catch below and exit 1 (IO), splitting
|
|
651
|
+
// one broker mistake across exit 1/2/3 by flag form. We validate here, BEFORE
|
|
652
|
+
// any row parsing, reusing the SAME parseCSV + schema + validateColumnMap the
|
|
653
|
+
// strict parser uses, so a bad map exits 2 regardless of which flag carried it.
|
|
654
|
+
// The message (already naming the available headers/fields) is unchanged.
|
|
655
|
+
try {
|
|
656
|
+
ingest.validateColumnMapForSource(ingest.SOURCE.BANK, bankText, sourceMaps.bank, {
|
|
657
|
+
format: opts.bankFormat,
|
|
658
|
+
});
|
|
659
|
+
ingest.validateColumnMapForSource(
|
|
660
|
+
ingest.SOURCE.QUICKBOOKS,
|
|
661
|
+
ledgerText,
|
|
662
|
+
sourceMaps.ledger
|
|
663
|
+
);
|
|
664
|
+
ingest.validateColumnMapForSource(
|
|
665
|
+
ingest.SOURCE.RENT_ROLL,
|
|
666
|
+
rentText,
|
|
667
|
+
sourceMaps.rentroll
|
|
668
|
+
);
|
|
669
|
+
} catch (e) {
|
|
670
|
+
writeErr(`error: ${e.message}\n`);
|
|
671
|
+
return { code: EXIT.USAGE };
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
// -- Ingest (a malformed row is a clear, located error -> exit 1). ---------
|
|
675
|
+
// The resolved column maps thread into BOTH the bank/QB/rent parsers, so a
|
|
676
|
+
// file whose headers no alias matches still loads under an explicit map. A
|
|
677
|
+
// structurally-invalid map was already rejected (USAGE) by the pre-flight
|
|
678
|
+
// above; any IngestError reaching this catch is a genuine data/row problem.
|
|
679
|
+
let bank;
|
|
680
|
+
let book;
|
|
681
|
+
let rentroll;
|
|
682
|
+
try {
|
|
683
|
+
bank = ingest.parseBankStatement(bankText, {
|
|
684
|
+
format: opts.bankFormat,
|
|
685
|
+
columnMap: sourceMaps.bank,
|
|
686
|
+
});
|
|
687
|
+
book = ingest.parseQuickBooksCSV(ledgerText, { columnMap: sourceMaps.ledger });
|
|
688
|
+
rentroll = ingest.parseRentRollCSV(rentText, {
|
|
689
|
+
columnMap: sourceMaps.rentroll,
|
|
690
|
+
});
|
|
691
|
+
} catch (e) {
|
|
692
|
+
writeErr(`error: ${e.message}\n`);
|
|
693
|
+
return { code: EXIT.IO };
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
// -- Build the packet model (match + reconcile inside). --------------------
|
|
697
|
+
let model;
|
|
698
|
+
try {
|
|
699
|
+
model = report.buildPacket({
|
|
700
|
+
bank,
|
|
701
|
+
book,
|
|
702
|
+
rentroll,
|
|
703
|
+
reportDate,
|
|
704
|
+
period: opts.period,
|
|
705
|
+
opening: { bank: opts.openingBank || 0, book: opts.openingBook || 0 },
|
|
706
|
+
toleranceCents: opts.toleranceCents || 0,
|
|
707
|
+
policy: activePolicy,
|
|
708
|
+
priorClose,
|
|
709
|
+
emitClosePath: opts.emitClose != null ? path.resolve(opts.emitClose) : null,
|
|
710
|
+
});
|
|
711
|
+
} catch (e) {
|
|
712
|
+
writeErr(`error: ${e.message}\n`);
|
|
713
|
+
return { code: EXIT.IO };
|
|
714
|
+
}
|
|
715
|
+
|
|
716
|
+
const summary = report.summaryLine(model);
|
|
717
|
+
// T-43.2: the ROOT-CAUSE triage headline, printed as a SECOND human line AFTER
|
|
718
|
+
// the verdict summary (which stays byte-for-byte the existing first line). It
|
|
719
|
+
// names the make-or-break distinction at first contact — a genuine OUT-OF-TRUST
|
|
720
|
+
// finding vs. a data-shape gap to fix and re-run vs. nothing to fix. It is
|
|
721
|
+
// PURELY additive: it never changes the PASS/FAIL verdict or the exit code.
|
|
722
|
+
const triageLine = report.triageHeadline(model);
|
|
723
|
+
const render = report.renderPacket(model);
|
|
724
|
+
const code = model.pass ? EXIT.PASS : EXIT.FAIL;
|
|
725
|
+
|
|
726
|
+
// -- Emit THIS run's period close (--emit-close), if requested. ------------
|
|
727
|
+
// Built PURELY from the packet model (close.buildClose) and written ONLY to the
|
|
728
|
+
// caller-named path — never silently to cwd, exactly like the packet. The close
|
|
729
|
+
// round-trips through close.readClose so the next month's --prior-close consumes
|
|
730
|
+
// it. This run's verdict/exit code is unaffected by emitting it.
|
|
731
|
+
let closeWritten = null;
|
|
732
|
+
if (opts.emitClose != null) {
|
|
733
|
+
const closePath = path.resolve(opts.emitClose);
|
|
734
|
+
let closeArtifact;
|
|
735
|
+
try {
|
|
736
|
+
closeArtifact = close.buildClose(model);
|
|
737
|
+
} catch (e) {
|
|
738
|
+
writeErr(`error: cannot build --emit-close artifact: ${e.message}\n`);
|
|
739
|
+
return { code: EXIT.IO };
|
|
740
|
+
}
|
|
741
|
+
try {
|
|
742
|
+
const parent = path.dirname(closePath);
|
|
743
|
+
fs.mkdirSync(parent, { recursive: true });
|
|
744
|
+
fs.writeFileSync(closePath, JSON.stringify(closeArtifact, null, 2) + "\n");
|
|
745
|
+
} catch (e) {
|
|
746
|
+
writeErr(
|
|
747
|
+
`error: cannot write --emit-close file ${opts.emitClose}: ${e.message}\n`
|
|
748
|
+
);
|
|
749
|
+
return { code: EXIT.IO };
|
|
750
|
+
}
|
|
751
|
+
closeWritten = closePath;
|
|
752
|
+
}
|
|
753
|
+
|
|
754
|
+
// -- Output. ---------------------------------------------------------------
|
|
755
|
+
let written = [];
|
|
756
|
+
let sealWritten = null;
|
|
757
|
+
if (opts.out) {
|
|
758
|
+
// Write the packet ONLY into the caller-chosen directory. Create it if
|
|
759
|
+
// missing (recursively), but never write outside it and never to cwd.
|
|
760
|
+
const outDir = path.resolve(opts.out);
|
|
761
|
+
try {
|
|
762
|
+
fs.mkdirSync(outDir, { recursive: true });
|
|
763
|
+
} catch (e) {
|
|
764
|
+
writeErr(`error: cannot create --out directory ${opts.out}: ${e.message}\n`);
|
|
765
|
+
return { code: EXIT.IO };
|
|
766
|
+
}
|
|
767
|
+
try {
|
|
768
|
+
for (const name of Object.keys(render).sort()) {
|
|
769
|
+
const p = path.join(outDir, name);
|
|
770
|
+
fs.writeFileSync(p, render[name]);
|
|
771
|
+
written.push(p);
|
|
772
|
+
}
|
|
773
|
+
} catch (e) {
|
|
774
|
+
writeErr(`error: cannot write packet into ${opts.out}: ${e.message}\n`);
|
|
775
|
+
return { code: EXIT.IO };
|
|
776
|
+
}
|
|
777
|
+
|
|
778
|
+
// -- Emit the reconciliation seal (--seal), AFTER every packet file (and the
|
|
779
|
+
// emitted close, if any) is on disk. The CLI does ALL the file READING here
|
|
780
|
+
// and hands seal.js already-loaded { relPath, bytes } entries — seal.js stays
|
|
781
|
+
// PURE. The seal binds the 3 SOURCE inputs (by their logical role) + every
|
|
782
|
+
// emitted packet file (+ the emitted close, if --emit-close) into ONE
|
|
783
|
+
// content-addressed root.
|
|
784
|
+
//
|
|
785
|
+
// PORTABLE relPaths (REWORK Finding 1). The deliverable is "the packet a broker
|
|
786
|
+
// hands a state examiner months later." For that handoff to verify, the sealed
|
|
787
|
+
// relPaths must NOT depend on the producing machine's on-disk layout:
|
|
788
|
+
// * OUTPUTS live in the out dir, so they are sealed by their BASENAME
|
|
789
|
+
// (path.relative(sealDir, abs) is already the basename when the seal sits in
|
|
790
|
+
// the same dir — the common case; a caller-named seal elsewhere still resolves).
|
|
791
|
+
// * INPUTS are the 3 ORIGINAL sources, which may live ANYWHERE (a month folder,
|
|
792
|
+
// a sibling tree, an absolute path). Sealing them by a seal-dir-relative path
|
|
793
|
+
// would (a) escape the packet dir as `../bank.csv` so shipping ONLY the out/
|
|
794
|
+
// folder reports them MISSING, and (b) leak the producing machine's absolute
|
|
795
|
+
// home path when the sources live outside the out tree — and make the root
|
|
796
|
+
// depend on that layout, breaking "same inputs => same root." So inputs are
|
|
797
|
+
// sealed by their BASENAME: the broker ships each source NEXT TO the seal and
|
|
798
|
+
// `vh trust verify-seal` finds it with no machine-specific offset. (Inputs may
|
|
799
|
+
// be located elsewhere at verify time via `verify-seal --inputs <dir>`.)
|
|
800
|
+
if (opts.seal) {
|
|
801
|
+
const sealPath = opts.sealFile
|
|
802
|
+
? path.resolve(opts.sealFile)
|
|
803
|
+
: path.join(outDir, `reconciliation-${reportDate}-seal.json`);
|
|
804
|
+
const sealDir = path.dirname(sealPath);
|
|
805
|
+
|
|
806
|
+
// The seal can never seal ITSELF (it does not exist yet, and its bytes depend
|
|
807
|
+
// on the very set being hashed). A caller-named seal path landing inside the
|
|
808
|
+
// packet dir is fine — it is simply not one of the sealed entries.
|
|
809
|
+
const relTo = (abs) => path.relative(sealDir, abs);
|
|
810
|
+
|
|
811
|
+
// Inputs: the 3 ORIGINAL sources, read from their original location, tagged
|
|
812
|
+
// with the seal's logical roles (bank / book / rentroll). ledger -> "book".
|
|
813
|
+
// Sealed by BASENAME so the binding travels with the packet (see header note).
|
|
814
|
+
const inputSpecs = [
|
|
815
|
+
{ role: "bank", abs: path.resolve(opts.bank) },
|
|
816
|
+
{ role: "book", abs: path.resolve(opts.ledger) },
|
|
817
|
+
{ role: "rentroll", abs: path.resolve(opts.rentroll) },
|
|
818
|
+
];
|
|
819
|
+
// Outputs: every emitted packet file, PLUS the emitted close if --emit-close
|
|
820
|
+
// was used (so the seal binds the WHOLE emitted artifact set).
|
|
821
|
+
const outputAbs = [...written];
|
|
822
|
+
if (closeWritten) outputAbs.push(closeWritten);
|
|
823
|
+
|
|
824
|
+
// Guard the basename-flattening: if two inputs (or an input and an output) would
|
|
825
|
+
// collide on the same name once flattened, the partition becomes ambiguous and
|
|
826
|
+
// seal.buildSeal would (correctly) reject a duplicate relPath. Surface it here as
|
|
827
|
+
// an actionable IO error naming the colliding name rather than a generic build error.
|
|
828
|
+
const inputRel = inputSpecs.map((s) => ({ ...s, relPath: path.basename(s.abs) }));
|
|
829
|
+
const outputRel = outputAbs.map((abs) => ({ abs, relPath: relTo(abs) }));
|
|
830
|
+
const seenName = new Map();
|
|
831
|
+
for (const r of [...inputRel, ...outputRel]) {
|
|
832
|
+
if (seenName.has(r.relPath)) {
|
|
833
|
+
writeErr(
|
|
834
|
+
`error: cannot build seal: two sealed files flatten to the same name ` +
|
|
835
|
+
`${JSON.stringify(r.relPath)} (rename a source so the bank/book/rentroll ` +
|
|
836
|
+
`inputs and the packet files each have a distinct filename)\n`
|
|
837
|
+
);
|
|
838
|
+
return { code: EXIT.IO };
|
|
839
|
+
}
|
|
840
|
+
seenName.set(r.relPath, true);
|
|
841
|
+
}
|
|
842
|
+
|
|
843
|
+
let files;
|
|
844
|
+
try {
|
|
845
|
+
files = {
|
|
846
|
+
inputs: inputRel.map((s) => ({
|
|
847
|
+
role: s.role,
|
|
848
|
+
relPath: s.relPath,
|
|
849
|
+
bytes: fs.readFileSync(s.abs),
|
|
850
|
+
})),
|
|
851
|
+
outputs: outputRel.map((o) => ({
|
|
852
|
+
relPath: o.relPath,
|
|
853
|
+
bytes: fs.readFileSync(o.abs),
|
|
854
|
+
})),
|
|
855
|
+
};
|
|
856
|
+
} catch (e) {
|
|
857
|
+
writeErr(`error: cannot read a file to seal: ${e.message}\n`);
|
|
858
|
+
return { code: EXIT.IO };
|
|
859
|
+
}
|
|
860
|
+
|
|
861
|
+
let sealObj;
|
|
862
|
+
try {
|
|
863
|
+
sealObj = seal.buildSeal({
|
|
864
|
+
files,
|
|
865
|
+
verdict: {
|
|
866
|
+
pass: model.pass,
|
|
867
|
+
reportDate: model.reportDate,
|
|
868
|
+
period: model.period == null ? null : model.period,
|
|
869
|
+
},
|
|
870
|
+
});
|
|
871
|
+
} catch (e) {
|
|
872
|
+
writeErr(`error: cannot build seal: ${e.message}\n`);
|
|
873
|
+
return { code: EXIT.IO };
|
|
874
|
+
}
|
|
875
|
+
|
|
876
|
+
try {
|
|
877
|
+
fs.mkdirSync(sealDir, { recursive: true });
|
|
878
|
+
fs.writeFileSync(sealPath, seal.serializeSeal(sealObj));
|
|
879
|
+
} catch (e) {
|
|
880
|
+
writeErr(`error: cannot write seal file ${sealPath}: ${e.message}\n`);
|
|
881
|
+
return { code: EXIT.IO };
|
|
882
|
+
}
|
|
883
|
+
sealWritten = sealPath;
|
|
884
|
+
}
|
|
885
|
+
|
|
886
|
+
if (opts.json) {
|
|
887
|
+
write(
|
|
888
|
+
JSON.stringify(
|
|
889
|
+
{ ...model, summary, written, outDir, closeWritten, sealWritten },
|
|
890
|
+
null,
|
|
891
|
+
2
|
|
892
|
+
) + "\n"
|
|
893
|
+
);
|
|
894
|
+
} else {
|
|
895
|
+
write(`${summary}\n`);
|
|
896
|
+
write(`${triageLine}\n`);
|
|
897
|
+
for (const p of written) write(`wrote ${p}\n`);
|
|
898
|
+
if (closeWritten) write(`wrote close ${closeWritten}\n`);
|
|
899
|
+
if (sealWritten) write(`wrote seal ${sealWritten}\n`);
|
|
900
|
+
}
|
|
901
|
+
} else {
|
|
902
|
+
// No --out: print the summary + the HTML report to stdout, write NOTHING
|
|
903
|
+
// (except the explicitly caller-named --emit-close file, already written).
|
|
904
|
+
if (opts.json) {
|
|
905
|
+
write(JSON.stringify({ ...model, summary, closeWritten }, null, 2) + "\n");
|
|
906
|
+
} else {
|
|
907
|
+
write(`${summary}\n`);
|
|
908
|
+
write(`${triageLine}\n`);
|
|
909
|
+
if (closeWritten) write(`wrote close ${closeWritten}\n`);
|
|
910
|
+
const htmlName = report.packetFilenames(reportDate).html;
|
|
911
|
+
write("\n");
|
|
912
|
+
write(render[htmlName]);
|
|
913
|
+
}
|
|
914
|
+
}
|
|
915
|
+
|
|
916
|
+
return { code, model, summary, written, render, closeWritten, sealWritten };
|
|
917
|
+
}
|
|
918
|
+
|
|
919
|
+
// Real "today" as a UTC YYYY-MM-DD. The ONLY impure call in this module, isolated
|
|
920
|
+
// here and injectable so the pipeline itself stays deterministic.
|
|
921
|
+
function todayISO() {
|
|
922
|
+
const d = new Date();
|
|
923
|
+
const pad = (n) => String(n).padStart(2, "0");
|
|
924
|
+
return `${d.getUTCFullYear()}-${pad(d.getUTCMonth() + 1)}-${pad(d.getUTCDate())}`;
|
|
925
|
+
}
|
|
926
|
+
|
|
927
|
+
// ---------------------------------------------------------------------------
|
|
928
|
+
// `vh trust inspect` (T-25.2) — read-only file validator / preview
|
|
929
|
+
// ---------------------------------------------------------------------------
|
|
930
|
+
//
|
|
931
|
+
// The onboarding companion to `reconcile`. `reconcile` fails CLOSED (the first
|
|
932
|
+
// malformed row aborts the whole file) because a trust reconciliation must never
|
|
933
|
+
// silently partial-parse. That is correct for the gate, but it is a DEAD END
|
|
934
|
+
// when a broker first feeds the tool a real export: they get one error and no
|
|
935
|
+
// path forward. `inspect` turns that dead end into a self-service fix.
|
|
936
|
+
//
|
|
937
|
+
// It runs `diagnoseSource` over ONE file and prints, for that file: the detected
|
|
938
|
+
// header; the logical->header column map (or "(not found)"); the OK/total parse
|
|
939
|
+
// count; a small SAMPLE of normalized records; and EVERY failing row (number +
|
|
940
|
+
// reason). When a required column is missing OR any row failed it prints an
|
|
941
|
+
// ACTIONABLE hint and exits 3 (the data-gate FAIL code); a fully-clean file
|
|
942
|
+
// exits 0. It is STRICTLY read-only: it writes NOTHING anywhere — no packet, no
|
|
943
|
+
// receipt, not even with a path flag. It does NOT reconcile or attest; it only
|
|
944
|
+
// checks that the file PARSES into the normalized model.
|
|
945
|
+
|
|
946
|
+
// Map the broker-facing `--as` value to the ingest SOURCE. The three logical
|
|
947
|
+
// kinds a reconcile consumes: a bank statement, a QuickBooks ledger, a rent roll.
|
|
948
|
+
const INSPECT_AS = Object.freeze({
|
|
949
|
+
bank: ingest.SOURCE.BANK,
|
|
950
|
+
ledger: ingest.SOURCE.QUICKBOOKS,
|
|
951
|
+
rentroll: ingest.SOURCE.RENT_ROLL,
|
|
952
|
+
});
|
|
953
|
+
|
|
954
|
+
// Parse `inspect` argv: one positional <file>, plus flags. Unknown flags and a
|
|
955
|
+
// missing/duplicate positional are USAGE errors (parser parity with reconcile —
|
|
956
|
+
// a typo never silently returns a wrong view). `--as` is REQUIRED and validated.
|
|
957
|
+
function parseInspectArgs(argv) {
|
|
958
|
+
const opts = {
|
|
959
|
+
file: undefined,
|
|
960
|
+
as: undefined,
|
|
961
|
+
bankFormat: undefined,
|
|
962
|
+
json: false,
|
|
963
|
+
sample: undefined, // sample size (default applied by the runner)
|
|
964
|
+
mapArgs: [], // repeatable --map <logical>=<header> (source = --as) (T-25.3)
|
|
965
|
+
mapFile: undefined, // --map-file <json> per-source column maps (T-25.3)
|
|
966
|
+
_positionals: [],
|
|
967
|
+
};
|
|
968
|
+
for (let i = 0; i < argv.length; i++) {
|
|
969
|
+
const a = argv[i];
|
|
970
|
+
switch (a) {
|
|
971
|
+
case "--as":
|
|
972
|
+
opts.as = argv[++i];
|
|
973
|
+
if (opts.as === undefined) {
|
|
974
|
+
const e = new Error("--as requires a value");
|
|
975
|
+
e.usage = true;
|
|
976
|
+
throw e;
|
|
977
|
+
}
|
|
978
|
+
break;
|
|
979
|
+
case "--bank-format":
|
|
980
|
+
opts.bankFormat = argv[++i];
|
|
981
|
+
if (opts.bankFormat === undefined) {
|
|
982
|
+
const e = new Error("--bank-format requires a value");
|
|
983
|
+
e.usage = true;
|
|
984
|
+
throw e;
|
|
985
|
+
}
|
|
986
|
+
break;
|
|
987
|
+
case "--json":
|
|
988
|
+
opts.json = true;
|
|
989
|
+
break;
|
|
990
|
+
case "--sample":
|
|
991
|
+
opts.sample = parseIntArg(argv[++i], "--sample");
|
|
992
|
+
break;
|
|
993
|
+
case "--map":
|
|
994
|
+
// inspect: a single file, so the source is implied by --as; an optional
|
|
995
|
+
// <source>: prefix is still accepted (and must agree with --as later).
|
|
996
|
+
opts.mapArgs.push(parseMapArg(argv[++i], { requireSource: false }));
|
|
997
|
+
break;
|
|
998
|
+
case "--map-file":
|
|
999
|
+
opts.mapFile = argv[++i];
|
|
1000
|
+
break;
|
|
1001
|
+
default:
|
|
1002
|
+
if (a && a.startsWith("--")) {
|
|
1003
|
+
const e = new Error(`unknown option: ${a}`);
|
|
1004
|
+
e.usage = true;
|
|
1005
|
+
throw e;
|
|
1006
|
+
}
|
|
1007
|
+
opts._positionals.push(a);
|
|
1008
|
+
}
|
|
1009
|
+
}
|
|
1010
|
+
if (opts._positionals.length > 1) {
|
|
1011
|
+
const e = new Error(
|
|
1012
|
+
`unexpected extra argument: ${opts._positionals[1]} ` +
|
|
1013
|
+
"(inspect takes exactly one <file>)"
|
|
1014
|
+
);
|
|
1015
|
+
e.usage = true;
|
|
1016
|
+
throw e;
|
|
1017
|
+
}
|
|
1018
|
+
opts.file = opts._positionals[0];
|
|
1019
|
+
return opts;
|
|
1020
|
+
}
|
|
1021
|
+
|
|
1022
|
+
// Pretty-print signed integer cents as a signed dollar string (e.g. -75000 ->
|
|
1023
|
+
// "-750.00"). Pure; used only for the human SAMPLE table.
|
|
1024
|
+
function fmtCents(cents) {
|
|
1025
|
+
const n = Number(cents) || 0;
|
|
1026
|
+
const sign = n < 0 ? "-" : "";
|
|
1027
|
+
const abs = Math.abs(n);
|
|
1028
|
+
return `${sign}${Math.floor(abs / 100)}.${String(abs % 100).padStart(2, "0")}`;
|
|
1029
|
+
}
|
|
1030
|
+
|
|
1031
|
+
// The standing TrustLedger caveat the output LEADS with, and the inspect-specific
|
|
1032
|
+
// scope note. Centralized so the human and (commented) JSON paths agree.
|
|
1033
|
+
const INSPECT_CAVEAT =
|
|
1034
|
+
"TrustLedger AIDS reconciliation; the broker remains the responsible custodian.";
|
|
1035
|
+
const INSPECT_SCOPE =
|
|
1036
|
+
"`inspect` only checks that this file PARSES into the normalized model — it does " +
|
|
1037
|
+
"NOT reconcile or attest anything. To reconcile, run `vh trust reconcile`.";
|
|
1038
|
+
|
|
1039
|
+
// Render the diagnostic report as the human inspect view. Pure: takes the
|
|
1040
|
+
// report + resolved opts, returns a string. Leads with the caveat + scope, then
|
|
1041
|
+
// the header, the logical->header map, the OK/total count, the sample, every
|
|
1042
|
+
// failing row, and (when anything is wrong) the actionable hint.
|
|
1043
|
+
function renderInspect(report, opts) {
|
|
1044
|
+
const L = [];
|
|
1045
|
+
L.push(`# vh trust inspect — ${opts.as} (${opts.file})`);
|
|
1046
|
+
L.push(INSPECT_CAVEAT);
|
|
1047
|
+
L.push(INSPECT_SCOPE);
|
|
1048
|
+
L.push("");
|
|
1049
|
+
|
|
1050
|
+
// Detected format (CSV vs OFX/QFX) — honest about which path ran, so an OFX
|
|
1051
|
+
// bank export is recognized rather than mis-read as a one-column CSV.
|
|
1052
|
+
if (report.format) {
|
|
1053
|
+
L.push(`detected format: ${report.format}`);
|
|
1054
|
+
}
|
|
1055
|
+
|
|
1056
|
+
// Detected header columns (CSV header row, or the OFX tags we read).
|
|
1057
|
+
L.push(
|
|
1058
|
+
`${report.format === "ofx" ? "OFX tags" : "header columns"} ` +
|
|
1059
|
+
`(${report.header.length}): ` +
|
|
1060
|
+
(report.header.length ? report.header.join(", ") : "(none)")
|
|
1061
|
+
);
|
|
1062
|
+
L.push("");
|
|
1063
|
+
|
|
1064
|
+
// Logical field -> header it mapped to (or "(not found)").
|
|
1065
|
+
L.push("logical field -> header column:");
|
|
1066
|
+
for (const logical of Object.keys(report.mapped)) {
|
|
1067
|
+
const mapped = report.mapped[logical];
|
|
1068
|
+
const req = report.requiredMissing.includes(logical) ? " [REQUIRED]" : "";
|
|
1069
|
+
L.push(` ${logical}: ${mapped == null ? "(not found)" : mapped}${req}`);
|
|
1070
|
+
}
|
|
1071
|
+
L.push("");
|
|
1072
|
+
|
|
1073
|
+
// Parse count.
|
|
1074
|
+
L.push(`parsed: ${report.okCount} OK of ${report.rowCount} data row(s)`);
|
|
1075
|
+
|
|
1076
|
+
// Sample of normalized records (date / signed-cents / kind / party / memo).
|
|
1077
|
+
L.push("");
|
|
1078
|
+
if (report.sample.length) {
|
|
1079
|
+
L.push(`sample (first ${report.sample.length} normalized record(s)):`);
|
|
1080
|
+
for (const r of report.sample) {
|
|
1081
|
+
L.push(
|
|
1082
|
+
` ${r.date} ${fmtCents(r.amount).padStart(12)} ${r.kind} ` +
|
|
1083
|
+
`${r.party || "(no party)"} | ${r.memo || ""}`.trimEnd()
|
|
1084
|
+
);
|
|
1085
|
+
}
|
|
1086
|
+
} else {
|
|
1087
|
+
L.push("sample: (no rows parsed)");
|
|
1088
|
+
}
|
|
1089
|
+
|
|
1090
|
+
// Every failing row with its number + reason.
|
|
1091
|
+
L.push("");
|
|
1092
|
+
if (report.errors.length) {
|
|
1093
|
+
L.push(`failures (${report.errors.length}):`);
|
|
1094
|
+
for (const e of report.errors) {
|
|
1095
|
+
const where = e.row == null ? "file" : `row ${e.row}`;
|
|
1096
|
+
L.push(` ${where}: ${e.message}`);
|
|
1097
|
+
}
|
|
1098
|
+
} else {
|
|
1099
|
+
L.push("failures: none");
|
|
1100
|
+
}
|
|
1101
|
+
|
|
1102
|
+
// Actionable hint when a required column is missing OR any row failed.
|
|
1103
|
+
const hint = inspectHint(report);
|
|
1104
|
+
if (hint.length) {
|
|
1105
|
+
L.push("");
|
|
1106
|
+
L.push("how to fix:");
|
|
1107
|
+
for (const h of hint) L.push(` - ${h}`);
|
|
1108
|
+
}
|
|
1109
|
+
|
|
1110
|
+
L.push("");
|
|
1111
|
+
return L.join("\n");
|
|
1112
|
+
}
|
|
1113
|
+
|
|
1114
|
+
// Build the actionable hint lines: for each missing required column, name the
|
|
1115
|
+
// accepted aliases the broker can rename/add a column to. A row-level failure
|
|
1116
|
+
// (with all required columns present) gets a generic "fix the cells" line.
|
|
1117
|
+
// Returns [] when the file is fully clean.
|
|
1118
|
+
//
|
|
1119
|
+
// T-25.3: the no-edit column-mapping override now EXISTS, so each missing-column
|
|
1120
|
+
// hint also names the WORKING `--map <logical>=<header>` escape hatch — a broker
|
|
1121
|
+
// whose header no alias matches can map it WITHOUT editing the export. The hint
|
|
1122
|
+
// only ever advertises what the tool can actually do today, and following it
|
|
1123
|
+
// (rename/add a column OR pass --map) succeeds — never a dead end.
|
|
1124
|
+
function inspectHint(report) {
|
|
1125
|
+
const out = [];
|
|
1126
|
+
for (const logical of report.requiredMissing) {
|
|
1127
|
+
const aliases = ingest.aliasesFor(report.source, logical);
|
|
1128
|
+
out.push(
|
|
1129
|
+
`the "${logical}" column was not found — rename your column to (or add) ` +
|
|
1130
|
+
`one named one of [${aliases.join(", ")}], OR map your existing header ` +
|
|
1131
|
+
`with --map ${logical}=<your header>`
|
|
1132
|
+
);
|
|
1133
|
+
}
|
|
1134
|
+
// The amount group (signed amount OR a split pair) is reported as a file-level
|
|
1135
|
+
// error rather than a missing single column; surface its own add-a-column hint.
|
|
1136
|
+
for (const e of report.errors) {
|
|
1137
|
+
if (e.row == null && /needs an "amount" column|debit\/credit|payment\/charge/.test(e.message)) {
|
|
1138
|
+
out.push(
|
|
1139
|
+
`${e.message} — rename/add one of those columns, OR map your existing ` +
|
|
1140
|
+
`header(s) with --map <logical>=<your header>`
|
|
1141
|
+
);
|
|
1142
|
+
}
|
|
1143
|
+
}
|
|
1144
|
+
// Row-level failures with the header otherwise intact: a per-row data problem.
|
|
1145
|
+
const rowFails = report.errors.filter((e) => e.row != null);
|
|
1146
|
+
if (rowFails.length) {
|
|
1147
|
+
out.push(
|
|
1148
|
+
`${rowFails.length} row(s) above failed to parse — fix the listed cells, ` +
|
|
1149
|
+
"then re-run `vh trust inspect` until 0 failures before `vh trust reconcile`"
|
|
1150
|
+
);
|
|
1151
|
+
}
|
|
1152
|
+
return out;
|
|
1153
|
+
}
|
|
1154
|
+
|
|
1155
|
+
// runInspect: read the one file, run diagnoseSource, render, and return
|
|
1156
|
+
// { code, report, render }. Read-only — writes NOTHING. Exit contract:
|
|
1157
|
+
// 0 = clean (every required column present AND every row parsed),
|
|
1158
|
+
// 3 = data-gate FAIL (a required/amount column missing OR any row failed),
|
|
1159
|
+
// 2 = usage error (bad --as), 1 = IO error (unreadable file) — consistent
|
|
1160
|
+
// with `reconcile`.
|
|
1161
|
+
function runInspect(opts, io = {}) {
|
|
1162
|
+
const write = io.write || ((s) => process.stdout.write(s));
|
|
1163
|
+
const writeErr = io.writeErr || ((s) => process.stderr.write(s));
|
|
1164
|
+
|
|
1165
|
+
if (!opts.file) {
|
|
1166
|
+
writeErr("error: `vh trust inspect` requires a <file>\n");
|
|
1167
|
+
return { code: EXIT.USAGE };
|
|
1168
|
+
}
|
|
1169
|
+
if (opts.as == null) {
|
|
1170
|
+
writeErr(
|
|
1171
|
+
"error: `vh trust inspect` requires --as <bank|ledger|rentroll>\n"
|
|
1172
|
+
);
|
|
1173
|
+
return { code: EXIT.USAGE };
|
|
1174
|
+
}
|
|
1175
|
+
const source = INSPECT_AS[opts.as];
|
|
1176
|
+
if (!source) {
|
|
1177
|
+
writeErr(
|
|
1178
|
+
`error: --as must be one of bank|ledger|rentroll (got "${opts.as}")\n`
|
|
1179
|
+
);
|
|
1180
|
+
return { code: EXIT.USAGE };
|
|
1181
|
+
}
|
|
1182
|
+
if (
|
|
1183
|
+
opts.bankFormat != null &&
|
|
1184
|
+
opts.bankFormat !== "csv" &&
|
|
1185
|
+
opts.bankFormat !== "ofx"
|
|
1186
|
+
) {
|
|
1187
|
+
writeErr(
|
|
1188
|
+
`error: --bank-format must be "csv" or "ofx" (got "${opts.bankFormat}")\n`
|
|
1189
|
+
);
|
|
1190
|
+
return { code: EXIT.USAGE };
|
|
1191
|
+
}
|
|
1192
|
+
|
|
1193
|
+
// Resolve the column map for THIS file (the source is `--as`). --map-file may
|
|
1194
|
+
// carry per-source maps; only the entry for this --as applies. A bare --map
|
|
1195
|
+
// (no <source>: prefix) targets this --as; a prefixed --map MUST agree with it.
|
|
1196
|
+
// Malformed --map-file is a USAGE error, mirroring reconcile.
|
|
1197
|
+
let columnMap;
|
|
1198
|
+
try {
|
|
1199
|
+
const mapFileMaps = opts.mapFile != null ? readMapFile(opts.mapFile) : null;
|
|
1200
|
+
// Re-scope each --map onto this --as: a bare --map targets it; a prefixed
|
|
1201
|
+
// --map MUST agree with it (a plain loop, not a throwing filter callback).
|
|
1202
|
+
const scoped = [];
|
|
1203
|
+
for (const m of opts.mapArgs || []) {
|
|
1204
|
+
if (m.source !== undefined && m.source !== opts.as) {
|
|
1205
|
+
const e = new Error(
|
|
1206
|
+
`--map source "${m.source}" does not match --as ${opts.as}`
|
|
1207
|
+
);
|
|
1208
|
+
e.usage = true;
|
|
1209
|
+
throw e;
|
|
1210
|
+
}
|
|
1211
|
+
scoped.push({ ...m, source: opts.as });
|
|
1212
|
+
}
|
|
1213
|
+
const merged = buildSourceMaps(mapFileMaps, scoped);
|
|
1214
|
+
columnMap = merged[opts.as];
|
|
1215
|
+
} catch (e) {
|
|
1216
|
+
writeErr(`error: ${e.message}\n`);
|
|
1217
|
+
return { code: EXIT.USAGE };
|
|
1218
|
+
}
|
|
1219
|
+
|
|
1220
|
+
// Read the file (an unreadable file is exit 1, not a crash) — read-only.
|
|
1221
|
+
let text;
|
|
1222
|
+
try {
|
|
1223
|
+
text = fs.readFileSync(path.resolve(opts.file), "utf8");
|
|
1224
|
+
} catch (e) {
|
|
1225
|
+
writeErr(`error: cannot read file ${opts.file}: ${e.message}\n`);
|
|
1226
|
+
return { code: EXIT.IO };
|
|
1227
|
+
}
|
|
1228
|
+
|
|
1229
|
+
// Run the diagnostic core. It is PURE and side-effect-free.
|
|
1230
|
+
let report;
|
|
1231
|
+
try {
|
|
1232
|
+
report = ingest.diagnoseSource(source, text, {
|
|
1233
|
+
sampleSize: opts.sample == null ? 5 : opts.sample,
|
|
1234
|
+
// Honour --bank-format (csv|ofx) for --as bank; undefined => auto-detect.
|
|
1235
|
+
// Only meaningful for the bank source, ignored by diagnoseSource otherwise.
|
|
1236
|
+
format: opts.bankFormat,
|
|
1237
|
+
// The SAME map the reconcile run would use, so inspect previews identically.
|
|
1238
|
+
columnMap,
|
|
1239
|
+
});
|
|
1240
|
+
} catch (e) {
|
|
1241
|
+
// diagnoseSource only throws on an unknown source (already guarded above) or
|
|
1242
|
+
// a genuine (non-ingest) bug; treat as an input error rather than crashing.
|
|
1243
|
+
writeErr(`error: ${e.message}\n`);
|
|
1244
|
+
return { code: EXIT.IO };
|
|
1245
|
+
}
|
|
1246
|
+
|
|
1247
|
+
// Verdict: clean iff every required column is present AND every row parsed.
|
|
1248
|
+
const clean = report.requiredMissing.length === 0 && report.errors.length === 0;
|
|
1249
|
+
const code = clean ? EXIT.PASS : EXIT.FAIL;
|
|
1250
|
+
|
|
1251
|
+
if (opts.json) {
|
|
1252
|
+
write(
|
|
1253
|
+
JSON.stringify(
|
|
1254
|
+
{
|
|
1255
|
+
...report,
|
|
1256
|
+
file: opts.file,
|
|
1257
|
+
as: opts.as,
|
|
1258
|
+
clean,
|
|
1259
|
+
code,
|
|
1260
|
+
hint: inspectHint(report),
|
|
1261
|
+
caveat: INSPECT_CAVEAT,
|
|
1262
|
+
scope: INSPECT_SCOPE,
|
|
1263
|
+
},
|
|
1264
|
+
null,
|
|
1265
|
+
2
|
|
1266
|
+
) + "\n"
|
|
1267
|
+
);
|
|
1268
|
+
} else {
|
|
1269
|
+
write(renderInspect(report, opts));
|
|
1270
|
+
}
|
|
1271
|
+
|
|
1272
|
+
return { code, report, render: undefined };
|
|
1273
|
+
}
|
|
1274
|
+
|
|
1275
|
+
function cmdInspect(argv, io = {}) {
|
|
1276
|
+
const writeErr = io.writeErr || ((s) => process.stderr.write(s));
|
|
1277
|
+
let opts;
|
|
1278
|
+
try {
|
|
1279
|
+
opts = parseInspectArgs(argv);
|
|
1280
|
+
} catch (e) {
|
|
1281
|
+
writeErr(`error: ${e.message}\n`);
|
|
1282
|
+
return EXIT.USAGE;
|
|
1283
|
+
}
|
|
1284
|
+
return runInspect(opts, io).code;
|
|
1285
|
+
}
|
|
1286
|
+
|
|
1287
|
+
// ---------------------------------------------------------------------------
|
|
1288
|
+
// `vh trust corpus [--json]` (T-44.2) — run the committed out-of-trust corpus
|
|
1289
|
+
// ---------------------------------------------------------------------------
|
|
1290
|
+
//
|
|
1291
|
+
// The one-command, read-only artifact a CPA or broker RUNS to confirm the gate
|
|
1292
|
+
// is correct WITHOUT reading test/. It loads the committed corpus, drives every
|
|
1293
|
+
// scenario through the REAL reconcile + buildPacket verdict path, and prints a
|
|
1294
|
+
// deterministic per-scenario table:
|
|
1295
|
+
//
|
|
1296
|
+
// id | control | principle (one sentence) | expected | actual | OK/MISMATCH
|
|
1297
|
+
//
|
|
1298
|
+
// plus a one-line summary. The exit code is the CI gate: 0 only when EVERY
|
|
1299
|
+
// scenario's live verdict matches its recorded verdict; 3 on ANY mismatch (a
|
|
1300
|
+
// corpus-drift / engine-regression signal — an out-of-trust case that did NOT
|
|
1301
|
+
// FAIL, or a benign case that did NOT PASS). It writes NOTHING. --json carries
|
|
1302
|
+
// the structured rows + summary so a pipeline can gate on the data, not the
|
|
1303
|
+
// rendered text. The exit contract reuses the family's: 0 ok, 3 gate FAIL, 2
|
|
1304
|
+
// usage (an unknown flag), 1 IO (a corpus that cannot be loaded).
|
|
1305
|
+
|
|
1306
|
+
const CORPUS_CAVEAT =
|
|
1307
|
+
"This is a CORRECTNESS-CORPUS check, not an audit. It proves the reconciliation " +
|
|
1308
|
+
"gate FAILs the canonical out-of-trust frauds and PASSes their benign twins, on " +
|
|
1309
|
+
"committed fixtures, through the SAME engine path the real reconcile exit uses.";
|
|
1310
|
+
|
|
1311
|
+
function parseCorpusArgs(argv) {
|
|
1312
|
+
const opts = { json: false };
|
|
1313
|
+
for (let i = 0; i < argv.length; i++) {
|
|
1314
|
+
const a = argv[i];
|
|
1315
|
+
if (a === "--json") {
|
|
1316
|
+
opts.json = true;
|
|
1317
|
+
} else {
|
|
1318
|
+
const e = new Error(
|
|
1319
|
+
`unknown flag for \`vh trust corpus\`: ${a} (expected: [--json])`
|
|
1320
|
+
);
|
|
1321
|
+
e.usage = true;
|
|
1322
|
+
throw e;
|
|
1323
|
+
}
|
|
1324
|
+
}
|
|
1325
|
+
return opts;
|
|
1326
|
+
}
|
|
1327
|
+
|
|
1328
|
+
// Pad a cell to a fixed width (left-justified) for a stable monospace table.
|
|
1329
|
+
function padCell(s, w) {
|
|
1330
|
+
const str = String(s == null ? "" : s);
|
|
1331
|
+
return str.length >= w ? str : str + " ".repeat(w - str.length);
|
|
1332
|
+
}
|
|
1333
|
+
|
|
1334
|
+
// Render the human table. Columns are fixed-width so the table is deterministic
|
|
1335
|
+
// and scannable; the principle is the one-sentence trust-law annotation and is
|
|
1336
|
+
// printed in full UNDER each row (not truncated) so the CPA/broker reads the
|
|
1337
|
+
// actual rule, while the aligned columns stay readable.
|
|
1338
|
+
function renderCorpus(result) {
|
|
1339
|
+
const lines = [];
|
|
1340
|
+
lines.push("vh trust corpus — out-of-trust correctness corpus");
|
|
1341
|
+
lines.push(CORPUS_CAVEAT);
|
|
1342
|
+
lines.push("");
|
|
1343
|
+
|
|
1344
|
+
const W_ID = 38;
|
|
1345
|
+
const W_CTRL = 28;
|
|
1346
|
+
const W_EXP = 8;
|
|
1347
|
+
const W_ACT = 8;
|
|
1348
|
+
const header =
|
|
1349
|
+
padCell("SCENARIO", W_ID) +
|
|
1350
|
+
" " +
|
|
1351
|
+
padCell("CONTROL", W_CTRL) +
|
|
1352
|
+
" " +
|
|
1353
|
+
padCell("EXPECT", W_EXP) +
|
|
1354
|
+
" " +
|
|
1355
|
+
padCell("ACTUAL", W_ACT) +
|
|
1356
|
+
" RESULT";
|
|
1357
|
+
lines.push(header);
|
|
1358
|
+
lines.push("-".repeat(header.length));
|
|
1359
|
+
|
|
1360
|
+
for (const r of result.rows) {
|
|
1361
|
+
lines.push(
|
|
1362
|
+
padCell(r.id, W_ID) +
|
|
1363
|
+
" " +
|
|
1364
|
+
padCell(r.control, W_CTRL) +
|
|
1365
|
+
" " +
|
|
1366
|
+
padCell(r.expected, W_EXP) +
|
|
1367
|
+
" " +
|
|
1368
|
+
padCell(r.actual, W_ACT) +
|
|
1369
|
+
" " +
|
|
1370
|
+
(r.match ? "OK" : "MISMATCH")
|
|
1371
|
+
);
|
|
1372
|
+
// The trust-law principle (one sentence), indented under its row.
|
|
1373
|
+
lines.push(" principle: " + r.principle);
|
|
1374
|
+
}
|
|
1375
|
+
|
|
1376
|
+
lines.push("");
|
|
1377
|
+
lines.push(corpusSummaryLine(result));
|
|
1378
|
+
return lines.join("\n") + "\n";
|
|
1379
|
+
}
|
|
1380
|
+
|
|
1381
|
+
// The one-line, machine-greppable summary. Leads with the verdict word
|
|
1382
|
+
// (CORPUS OK / CORPUS DRIFT) so a human and a `grep` agree on the gate state.
|
|
1383
|
+
function corpusSummaryLine(result) {
|
|
1384
|
+
if (result.ok) {
|
|
1385
|
+
return `CORPUS OK: ${result.matched}/${result.total} scenarios match their recorded verdict.`;
|
|
1386
|
+
}
|
|
1387
|
+
return (
|
|
1388
|
+
`CORPUS DRIFT: ${result.mismatched}/${result.total} scenario(s) did NOT match ` +
|
|
1389
|
+
`the recorded verdict (a regression in the gate or the corpus) — ` +
|
|
1390
|
+
`${result.matched}/${result.total} matched.`
|
|
1391
|
+
);
|
|
1392
|
+
}
|
|
1393
|
+
|
|
1394
|
+
function runCorpusCmd(opts, io = {}) {
|
|
1395
|
+
const write = io.write || ((s) => process.stdout.write(s));
|
|
1396
|
+
const writeErr = io.writeErr || ((s) => process.stderr.write(s));
|
|
1397
|
+
|
|
1398
|
+
// Load + run the whole corpus through the REAL engine path. A corpus that
|
|
1399
|
+
// cannot be loaded (missing/malformed fixtures) is an IO error, never a crash.
|
|
1400
|
+
// `opts.corpusDir` overrides the committed root (used by tests to drive a
|
|
1401
|
+
// deliberately-mislabeled copy and prove the gate is not a rubber stamp); the
|
|
1402
|
+
// shipped command always runs the committed corpus.
|
|
1403
|
+
let result;
|
|
1404
|
+
try {
|
|
1405
|
+
result =
|
|
1406
|
+
opts.corpusDir != null
|
|
1407
|
+
? corpus.runCorpus(opts.corpusDir)
|
|
1408
|
+
: corpus.runCorpus();
|
|
1409
|
+
} catch (e) {
|
|
1410
|
+
writeErr(`error: cannot run trust corpus: ${e.message}\n`);
|
|
1411
|
+
return { code: EXIT.IO };
|
|
1412
|
+
}
|
|
1413
|
+
|
|
1414
|
+
// Exit code IS the gate: 0 only when every scenario matched; 3 on any drift.
|
|
1415
|
+
const code = result.ok ? EXIT.PASS : EXIT.FAIL;
|
|
1416
|
+
|
|
1417
|
+
if (opts.json) {
|
|
1418
|
+
write(
|
|
1419
|
+
JSON.stringify(
|
|
1420
|
+
{
|
|
1421
|
+
rows: result.rows,
|
|
1422
|
+
total: result.total,
|
|
1423
|
+
matched: result.matched,
|
|
1424
|
+
mismatched: result.mismatched,
|
|
1425
|
+
ok: result.ok,
|
|
1426
|
+
summary: corpusSummaryLine(result),
|
|
1427
|
+
caveat: CORPUS_CAVEAT,
|
|
1428
|
+
code,
|
|
1429
|
+
},
|
|
1430
|
+
null,
|
|
1431
|
+
2
|
|
1432
|
+
) + "\n"
|
|
1433
|
+
);
|
|
1434
|
+
} else {
|
|
1435
|
+
write(renderCorpus(result));
|
|
1436
|
+
}
|
|
1437
|
+
|
|
1438
|
+
return { code, result };
|
|
1439
|
+
}
|
|
1440
|
+
|
|
1441
|
+
function cmdCorpus(argv, io = {}) {
|
|
1442
|
+
const writeErr = io.writeErr || ((s) => process.stderr.write(s));
|
|
1443
|
+
let opts;
|
|
1444
|
+
try {
|
|
1445
|
+
opts = parseCorpusArgs(argv);
|
|
1446
|
+
} catch (e) {
|
|
1447
|
+
writeErr(`error: ${e.message}\n`);
|
|
1448
|
+
return EXIT.USAGE;
|
|
1449
|
+
}
|
|
1450
|
+
return runCorpusCmd(opts, io).code;
|
|
1451
|
+
}
|
|
1452
|
+
|
|
1453
|
+
// ---------------------------------------------------------------------------
|
|
1454
|
+
// `vh trust value-proof <bank> <ledger> <rentroll>` (T-45.2) — the read-only
|
|
1455
|
+
// "what your manual close let through" proof, as a CI-gateable command.
|
|
1456
|
+
// ---------------------------------------------------------------------------
|
|
1457
|
+
//
|
|
1458
|
+
// The go-to-market seam. A pilot broker's highest-signal input is a period they
|
|
1459
|
+
// ALREADY closed by hand and signed off as CLEAN. This command runs that SAME
|
|
1460
|
+
// already-closed period's three files through the EXACT reconcile/buildPacket
|
|
1461
|
+
// verdict path the gate uses, then diffs the gate's findings against the manual
|
|
1462
|
+
// close via the pure `valueproof.valueProof` lens, and prints — deterministically:
|
|
1463
|
+
//
|
|
1464
|
+
// * a one-word OUTCOME (clean_confirmed | out_of_trust_missed | data_gap_only),
|
|
1465
|
+
// * the one-sentence HEADLINE the broker reads to decide, and
|
|
1466
|
+
// * the per-class dollar table (the SAME numbers triage rolled up).
|
|
1467
|
+
//
|
|
1468
|
+
// Every number is read VERBATIM off the packet's own triage rollup — this command
|
|
1469
|
+
// adds NO new severity, finding, verdict, count, or money figure; it is a
|
|
1470
|
+
// presentation lens over the gate's existing output. A test drives the SAME inputs
|
|
1471
|
+
// through BOTH `reconcile` and `value-proof` and asserts the numbers are identical.
|
|
1472
|
+
//
|
|
1473
|
+
// EXIT CODE IS THE GATE (a CI pipeline can branch on it):
|
|
1474
|
+
// 0 = clean_confirmed (the gate AGREES the account is not out of trust),
|
|
1475
|
+
// 3 = out_of_trust_missed (a genuine shortage the manual close let through — FAIL),
|
|
1476
|
+
// 4 = data_gap_only (no out-of-trust finding, but the data could not fully
|
|
1477
|
+
// reconcile — fix-and-rerun, distinct from a real FAIL),
|
|
1478
|
+
// 2 = usage error (a bad flag), 1 = IO/input error (an unreadable/malformed file).
|
|
1479
|
+
//
|
|
1480
|
+
// FILESYSTEM HYGIENE: like `inspect` / `corpus`, it is STRICTLY read-only — it
|
|
1481
|
+
// writes NOTHING anywhere (no packet, no seal, not even with a path flag), so it
|
|
1482
|
+
// is safe to run on a partner's real files in any directory or CI step.
|
|
1483
|
+
//
|
|
1484
|
+
// THE MANUAL-CLOSE BASELINE: by default the broker is asserting the period was
|
|
1485
|
+
// CLEAN (the pilot case). `--asserted-flagged` flips the baseline to "the manual
|
|
1486
|
+
// close DID flag this period" (so `agrees` reflects a true positive). `--asserted-net
|
|
1487
|
+
// <cents>` echoes the manual close's signed-off net figure as an ANNOTATION ONLY —
|
|
1488
|
+
// it never changes the outcome, a verdict, a severity, a count, or any dollar number.
|
|
1489
|
+
|
|
1490
|
+
const VALUE_PROOF_CAVEAT =
|
|
1491
|
+
"This is a value-proof over the SAME reconcile gate, not a second opinion or an audit. " +
|
|
1492
|
+
"Every number is read verbatim off the period's reconciliation; it adds no new finding, " +
|
|
1493
|
+
"severity, or verdict. The broker remains the responsible custodian and a CPA review governs.";
|
|
1494
|
+
|
|
1495
|
+
// value-proof outcome -> exit code. The outcome strings are valueproof's closed
|
|
1496
|
+
// contract (VALUE_OUTCOME); a NULL-prototype map so a forged/unknown outcome can
|
|
1497
|
+
// never inherit a bogus code — it falls through to the explicit guard below.
|
|
1498
|
+
const VALUE_PROOF_EXIT = Object.freeze(
|
|
1499
|
+
Object.assign(Object.create(null), {
|
|
1500
|
+
[valueproof.VALUE_OUTCOME.CLEAN_CONFIRMED]: EXIT.PASS,
|
|
1501
|
+
[valueproof.VALUE_OUTCOME.OUT_OF_TRUST]: EXIT.FAIL,
|
|
1502
|
+
[valueproof.VALUE_OUTCOME.DATA_GAP]: EXIT.DATA_GAP,
|
|
1503
|
+
})
|
|
1504
|
+
);
|
|
1505
|
+
|
|
1506
|
+
// Parse `value-proof` argv. Three positional files (bank, ledger, rentroll) in
|
|
1507
|
+
// order, plus the SAME packet-shaping flags reconcile accepts (so the period is
|
|
1508
|
+
// reconciled IDENTICALLY) plus the manual-close baseline flags. Unknown flags and
|
|
1509
|
+
// missing positionals are USAGE errors, matching the rest of the family.
|
|
1510
|
+
//
|
|
1511
|
+
// VERDICT-EQUIVALENCE (T-45.2 rework). value-proof MUST run the broker's period
|
|
1512
|
+
// through the SAME verdict-shaping inputs the production reconcile gate threads,
|
|
1513
|
+
// or it is NOT verdict-equivalent to the gate the paying broker actually runs:
|
|
1514
|
+
// * --policy/--state — the licensed per-state policy that can ESCALATE an
|
|
1515
|
+
// exception's severity and FLIP the SAME files PASS->FAIL,
|
|
1516
|
+
// * --prior-close — the prior period's close that seeds opening balances
|
|
1517
|
+
// and can surface a CONTINUITY_BREAK,
|
|
1518
|
+
// * --license/--vendor — the license that unlocks the paid policy surface (the
|
|
1519
|
+
// SAME gate reconcile applies before honoring --policy),
|
|
1520
|
+
// * --map-file/--map — the per-source column maps so a broker whose real files
|
|
1521
|
+
// use non-default headers reconciles instead of IO-erroring.
|
|
1522
|
+
// Without these, value-proof could confidently print "clean confirmed" / exit 0 on
|
|
1523
|
+
// a closed period the broker's OWN licensed gate FAILs — the worst failure mode for
|
|
1524
|
+
// a counterparty-facing proof. They are threaded into buildPacket/ingest IDENTICALLY
|
|
1525
|
+
// to reconcile, so value-proof is the same verdict path, not a narrower one.
|
|
1526
|
+
function parseValueProofArgs(argv) {
|
|
1527
|
+
const opts = {
|
|
1528
|
+
bank: undefined,
|
|
1529
|
+
ledger: undefined,
|
|
1530
|
+
rentroll: undefined,
|
|
1531
|
+
json: false,
|
|
1532
|
+
date: undefined, // override the report date (default: today); MUST be YYYY-MM-DD
|
|
1533
|
+
period: undefined, // optional human label for the closed period
|
|
1534
|
+
openingBank: 0,
|
|
1535
|
+
openingBook: 0,
|
|
1536
|
+
toleranceCents: 0,
|
|
1537
|
+
bankFormat: undefined, // force "csv" | "ofx" for the bank file
|
|
1538
|
+
policyFile: undefined, // explicit per-state policy file (--policy <file>)
|
|
1539
|
+
state: undefined, // bundled per-state policy by its state code (--state <code>)
|
|
1540
|
+
priorClose: undefined, // prior period's close.json to roll forward FROM (--prior-close <file>)
|
|
1541
|
+
license: undefined, // path to a signed *.vhlicense.json unlocking the paid surface
|
|
1542
|
+
vendor: undefined, // 0x-address the license issuer is pinned to
|
|
1543
|
+
mapArgs: [], // repeatable --map <source>:<logical>=<header>
|
|
1544
|
+
mapFile: undefined, // --map-file <json> per-source column maps
|
|
1545
|
+
assertedClean: true, // the manual-close baseline; --asserted-flagged flips it
|
|
1546
|
+
assertedNetCents: undefined, // OPTIONAL echoed annotation (integer cents)
|
|
1547
|
+
_positionals: [],
|
|
1548
|
+
};
|
|
1549
|
+
for (let i = 0; i < argv.length; i++) {
|
|
1550
|
+
const a = argv[i];
|
|
1551
|
+
switch (a) {
|
|
1552
|
+
case "--json":
|
|
1553
|
+
opts.json = true;
|
|
1554
|
+
break;
|
|
1555
|
+
case "--date":
|
|
1556
|
+
opts.date = argv[++i];
|
|
1557
|
+
break;
|
|
1558
|
+
case "--period":
|
|
1559
|
+
opts.period = argv[++i];
|
|
1560
|
+
break;
|
|
1561
|
+
case "--opening-bank":
|
|
1562
|
+
opts.openingBank = parseCentsArg(argv[++i], "--opening-bank");
|
|
1563
|
+
opts.openingBankSet = true;
|
|
1564
|
+
break;
|
|
1565
|
+
case "--opening-book":
|
|
1566
|
+
opts.openingBook = parseCentsArg(argv[++i], "--opening-book");
|
|
1567
|
+
opts.openingBookSet = true;
|
|
1568
|
+
break;
|
|
1569
|
+
case "--tolerance-cents":
|
|
1570
|
+
opts.toleranceCents = parseIntArg(argv[++i], "--tolerance-cents");
|
|
1571
|
+
break;
|
|
1572
|
+
case "--bank-format":
|
|
1573
|
+
opts.bankFormat = argv[++i];
|
|
1574
|
+
break;
|
|
1575
|
+
case "--policy":
|
|
1576
|
+
opts.policyFile = argv[++i];
|
|
1577
|
+
break;
|
|
1578
|
+
case "--state":
|
|
1579
|
+
opts.state = argv[++i];
|
|
1580
|
+
break;
|
|
1581
|
+
case "--prior-close":
|
|
1582
|
+
opts.priorClose = argv[++i];
|
|
1583
|
+
break;
|
|
1584
|
+
case "--license":
|
|
1585
|
+
opts.license = argv[++i];
|
|
1586
|
+
break;
|
|
1587
|
+
case "--vendor":
|
|
1588
|
+
opts.vendor = argv[++i];
|
|
1589
|
+
break;
|
|
1590
|
+
case "--map":
|
|
1591
|
+
// value-proof has three files (like reconcile), so the source is REQUIRED.
|
|
1592
|
+
opts.mapArgs.push(parseMapArg(argv[++i], { requireSource: true }));
|
|
1593
|
+
break;
|
|
1594
|
+
case "--map-file":
|
|
1595
|
+
opts.mapFile = argv[++i];
|
|
1596
|
+
break;
|
|
1597
|
+
case "--asserted-clean":
|
|
1598
|
+
// Explicit form of the default baseline (the period was closed clean).
|
|
1599
|
+
opts.assertedClean = true;
|
|
1600
|
+
break;
|
|
1601
|
+
case "--asserted-flagged":
|
|
1602
|
+
// The manual close DID flag this period (so `agrees` is a true positive).
|
|
1603
|
+
opts.assertedClean = false;
|
|
1604
|
+
break;
|
|
1605
|
+
case "--asserted-net":
|
|
1606
|
+
// A DOLLAR figure (parsed to integer cents, exactly like --opening-bank),
|
|
1607
|
+
// echoed as an annotation only — it NEVER changes the outcome or any number.
|
|
1608
|
+
opts.assertedNetCents = parseCentsArg(argv[++i], "--asserted-net");
|
|
1609
|
+
break;
|
|
1610
|
+
default:
|
|
1611
|
+
if (a && a.startsWith("--")) {
|
|
1612
|
+
const e = new Error(`unknown option: ${a}`);
|
|
1613
|
+
e.usage = true;
|
|
1614
|
+
throw e;
|
|
1615
|
+
}
|
|
1616
|
+
opts._positionals.push(a);
|
|
1617
|
+
}
|
|
1618
|
+
}
|
|
1619
|
+
if (opts._positionals.length > 3) {
|
|
1620
|
+
const e = new Error(
|
|
1621
|
+
`unexpected extra argument: ${opts._positionals[3]} ` +
|
|
1622
|
+
"(value-proof takes exactly three files: <bank> <ledger> <rentroll>)"
|
|
1623
|
+
);
|
|
1624
|
+
e.usage = true;
|
|
1625
|
+
throw e;
|
|
1626
|
+
}
|
|
1627
|
+
[opts.bank, opts.ledger, opts.rentroll] = opts._positionals;
|
|
1628
|
+
return opts;
|
|
1629
|
+
}
|
|
1630
|
+
|
|
1631
|
+
// Render the human value-proof report. PURE: takes the valueProof result + ctx,
|
|
1632
|
+
// returns a string. Leads with the caveat, then the outcome word + the production
|
|
1633
|
+
// gate's PASS/FAIL verdict, the headline, the per-class dollar table (the SAME
|
|
1634
|
+
// numbers triage rolled up), the totals + the manual-close annotation. Mirrors the
|
|
1635
|
+
// inspect/corpus output shape. `ctx.gateVerdict` is the gate's actual verdict under
|
|
1636
|
+
// the governing policy/prior-close; `ctx.escalated`/`ctx.tolerated` disclose when the
|
|
1637
|
+
// policy/prior-close flipped the verdict relative to the type-based class outcome.
|
|
1638
|
+
function renderValueProof(vp, ctx) {
|
|
1639
|
+
const L = [];
|
|
1640
|
+
L.push(`# vh trust value-proof — ${ctx.period == null ? "(period unlabeled)" : ctx.period}`);
|
|
1641
|
+
L.push(VALUE_PROOF_CAVEAT);
|
|
1642
|
+
L.push("");
|
|
1643
|
+
L.push(`outcome: ${vp.outcome}`);
|
|
1644
|
+
if (ctx.gateVerdict != null) {
|
|
1645
|
+
L.push(`gate verdict: ${ctx.gateVerdict} (the production reconcile gate's verdict for these inputs)`);
|
|
1646
|
+
}
|
|
1647
|
+
L.push(`headline: ${vp.headline}`);
|
|
1648
|
+
// Disclose a policy/prior-close-driven divergence between the type-based class
|
|
1649
|
+
// outcome and the gate's actual verdict, so the human is never shown a "clean"
|
|
1650
|
+
// class outcome on a period the licensed gate FAILs (or vice-versa) without a word.
|
|
1651
|
+
if (ctx.escalated) {
|
|
1652
|
+
L.push(
|
|
1653
|
+
"note: the governing policy/prior-close ESCALATED a finding the type-based class " +
|
|
1654
|
+
"outcome treats as benign — the production gate FAILs this period, so this is NOT " +
|
|
1655
|
+
"a clean confirmation. Re-run `vh trust reconcile` with the SAME policy for the full packet."
|
|
1656
|
+
);
|
|
1657
|
+
} else if (ctx.tolerated) {
|
|
1658
|
+
L.push(
|
|
1659
|
+
"note: the governing policy DOWN-graded an out-of-trust-class finding (named in the table " +
|
|
1660
|
+
"below); the production gate PASSes this period under that policy, so this is not failed as " +
|
|
1661
|
+
"out-of-trust here. A CPA should confirm the policy's grading is correct."
|
|
1662
|
+
);
|
|
1663
|
+
}
|
|
1664
|
+
L.push("");
|
|
1665
|
+
|
|
1666
|
+
// Per-class dollar table (already rank-sorted most-urgent-first by triage).
|
|
1667
|
+
const W_CLASS = 18;
|
|
1668
|
+
const W_COUNT = 7;
|
|
1669
|
+
const header =
|
|
1670
|
+
padCell("CLASS", W_CLASS) + " " + padCell("COUNT", W_COUNT) + " IMPACT";
|
|
1671
|
+
L.push("findings the manual close did not flag, by root cause:");
|
|
1672
|
+
L.push(header);
|
|
1673
|
+
L.push("-".repeat(header.length));
|
|
1674
|
+
if (vp.missedFindings.byClass.length === 0) {
|
|
1675
|
+
L.push(" (none — the gate found nothing the manual close missed)");
|
|
1676
|
+
} else {
|
|
1677
|
+
for (const c of vp.missedFindings.byClass) {
|
|
1678
|
+
L.push(
|
|
1679
|
+
padCell(c.label, W_CLASS) +
|
|
1680
|
+
" " +
|
|
1681
|
+
padCell(String(c.count), W_COUNT) +
|
|
1682
|
+
" " +
|
|
1683
|
+
fmtDollars(c.absImpact)
|
|
1684
|
+
);
|
|
1685
|
+
}
|
|
1686
|
+
}
|
|
1687
|
+
L.push(
|
|
1688
|
+
"-".repeat(header.length) +
|
|
1689
|
+
"\n" +
|
|
1690
|
+
padCell("TOTAL", W_CLASS) +
|
|
1691
|
+
" " +
|
|
1692
|
+
padCell(String(vp.missedFindings.count), W_COUNT) +
|
|
1693
|
+
" " +
|
|
1694
|
+
fmtDollars(vp.missedFindings.absImpact)
|
|
1695
|
+
);
|
|
1696
|
+
L.push("");
|
|
1697
|
+
|
|
1698
|
+
// The manual-close baseline + the agreement flag + the optional net annotation.
|
|
1699
|
+
L.push(
|
|
1700
|
+
`manual close asserted: ${vp.manualCloseClean ? "CLEAN" : "FLAGGED"}` +
|
|
1701
|
+
` (gate ${vp.agrees ? "AGREES" : "DISAGREES"})`
|
|
1702
|
+
);
|
|
1703
|
+
if (vp.assertedNetCents != null) {
|
|
1704
|
+
L.push(`manual close net (annotation only): ${fmtDollars(vp.assertedNetCents)}`);
|
|
1705
|
+
}
|
|
1706
|
+
L.push("");
|
|
1707
|
+
return L.join("\n");
|
|
1708
|
+
}
|
|
1709
|
+
|
|
1710
|
+
// Format integer cents as a signed dollar string with thousands grouping
|
|
1711
|
+
// ("$1,234.56" / "-$1,234.56"), for the human table. Mirrors valueproof's own
|
|
1712
|
+
// fmtCents grouping locally so the CLI takes no new dependency on it. PURE.
|
|
1713
|
+
function fmtDollars(cents) {
|
|
1714
|
+
const n = Number(cents) || 0;
|
|
1715
|
+
const neg = n < 0;
|
|
1716
|
+
const abs = Math.abs(n);
|
|
1717
|
+
const dollars = Math.floor(abs / 100);
|
|
1718
|
+
const rem = abs % 100;
|
|
1719
|
+
const grouped = String(dollars).replace(/\B(?=(\d{3})+(?!\d))/g, ",");
|
|
1720
|
+
const body = `$${grouped}.${String(rem).padStart(2, "0")}`;
|
|
1721
|
+
return neg ? `-${body}` : body;
|
|
1722
|
+
}
|
|
1723
|
+
|
|
1724
|
+
// runValueProof reads the three files, runs the SAME ingest + buildPacket verdict
|
|
1725
|
+
// path reconcile uses (license gate, --policy/--state, --prior-close, --map-file/--map
|
|
1726
|
+
// threaded IDENTICALLY), applies the pure valueProof lens, prints the outcome +
|
|
1727
|
+
// headline + per-class dollar table, and returns { code, model, vp }. Read-only —
|
|
1728
|
+
// writes NOTHING. The exit code maps the outcome through VALUE_PROOF_EXIT and is then
|
|
1729
|
+
// RECONCILED with the gate's actual PASS/FAIL so value-proof never claims "clean
|
|
1730
|
+
// confirmed" (exit 0) on a period the broker's OWN licensed gate FAILs.
|
|
1731
|
+
function runValueProof(opts, io = {}) {
|
|
1732
|
+
const write = io.write || ((s) => process.stdout.write(s));
|
|
1733
|
+
const writeErr = io.writeErr || ((s) => process.stderr.write(s));
|
|
1734
|
+
|
|
1735
|
+
if (!opts.bank || !opts.ledger || !opts.rentroll) {
|
|
1736
|
+
writeErr(
|
|
1737
|
+
"error: `vh trust value-proof` requires three files: <bank> <ledger> <rentroll>\n"
|
|
1738
|
+
);
|
|
1739
|
+
return { code: EXIT.USAGE };
|
|
1740
|
+
}
|
|
1741
|
+
|
|
1742
|
+
// Report date: explicit --date wins (keeps output reproducible); else today.
|
|
1743
|
+
let reportDate = opts.date;
|
|
1744
|
+
if (reportDate == null) {
|
|
1745
|
+
reportDate = (io.today || todayISO)();
|
|
1746
|
+
}
|
|
1747
|
+
if (!/^\d{4}-\d{2}-\d{2}$/.test(String(reportDate || ""))) {
|
|
1748
|
+
writeErr(`error: --date must be "YYYY-MM-DD" (got "${reportDate}")\n`);
|
|
1749
|
+
return { code: EXIT.USAGE };
|
|
1750
|
+
}
|
|
1751
|
+
|
|
1752
|
+
if (
|
|
1753
|
+
opts.bankFormat != null &&
|
|
1754
|
+
opts.bankFormat !== "csv" &&
|
|
1755
|
+
opts.bankFormat !== "ofx"
|
|
1756
|
+
) {
|
|
1757
|
+
writeErr(
|
|
1758
|
+
`error: --bank-format must be "csv" or "ofx" (got "${opts.bankFormat}")\n`
|
|
1759
|
+
);
|
|
1760
|
+
return { code: EXIT.USAGE };
|
|
1761
|
+
}
|
|
1762
|
+
|
|
1763
|
+
// -- LICENSE GATE. IDENTICAL to reconcile (gateReconcile) — the per-state policy
|
|
1764
|
+
// (--state/--policy) is the SAME license-gated PAID surface here. value-proof
|
|
1765
|
+
// must REFUSE a policy run without a valid, vendor-pinned license exactly as
|
|
1766
|
+
// reconcile does; otherwise it would silently run an UNLICENSED policy path that
|
|
1767
|
+
// the production gate would never grant. The free baseline (no --state/--policy)
|
|
1768
|
+
// needs no license and is byte-for-byte unchanged.
|
|
1769
|
+
const gate = gateReconcile(opts, reportDate, writeErr);
|
|
1770
|
+
if (gate.code !== EXIT.PASS) return { code: gate.code };
|
|
1771
|
+
|
|
1772
|
+
// -- Resolve the per-state policy (--policy/--state), IDENTICALLY to reconcile.
|
|
1773
|
+
// Mutually-exclusive flags, an unknown --state, and a malformed/unreadable
|
|
1774
|
+
// policy file are USAGE errors (a bad flag value), matching reconcile exactly.
|
|
1775
|
+
// A policy can ESCALATE a finding's severity and FLIP the gate PASS->FAIL on the
|
|
1776
|
+
// SAME files — which is precisely why value-proof must thread it.
|
|
1777
|
+
let activePolicy = null;
|
|
1778
|
+
if (opts.policyFile != null && opts.state != null) {
|
|
1779
|
+
writeErr(
|
|
1780
|
+
"error: --policy and --state are mutually exclusive (choose an explicit " +
|
|
1781
|
+
"policy file OR a bundled state code, not both)\n"
|
|
1782
|
+
);
|
|
1783
|
+
return { code: EXIT.USAGE };
|
|
1784
|
+
}
|
|
1785
|
+
if (opts.state != null) {
|
|
1786
|
+
try {
|
|
1787
|
+
activePolicy = policy.resolveState(opts.state);
|
|
1788
|
+
} catch (e) {
|
|
1789
|
+
writeErr(`error: ${e.message}\n`);
|
|
1790
|
+
return { code: EXIT.USAGE };
|
|
1791
|
+
}
|
|
1792
|
+
} else if (opts.policyFile != null) {
|
|
1793
|
+
let policyText;
|
|
1794
|
+
try {
|
|
1795
|
+
policyText = fs.readFileSync(path.resolve(opts.policyFile), "utf8");
|
|
1796
|
+
} catch (e) {
|
|
1797
|
+
writeErr(`error: cannot read --policy file ${opts.policyFile}: ${e.message}\n`);
|
|
1798
|
+
return { code: EXIT.USAGE };
|
|
1799
|
+
}
|
|
1800
|
+
try {
|
|
1801
|
+
activePolicy = policy.readPolicy(policyText);
|
|
1802
|
+
} catch (e) {
|
|
1803
|
+
writeErr(`error: invalid --policy file ${opts.policyFile}: ${e.message}\n`);
|
|
1804
|
+
return { code: EXIT.USAGE };
|
|
1805
|
+
}
|
|
1806
|
+
}
|
|
1807
|
+
|
|
1808
|
+
// -- Resolve the prior period's close (--prior-close), IDENTICALLY to reconcile.
|
|
1809
|
+
// SEED-then-OVERRIDE: the prior close's ending seeds this run's opening unless
|
|
1810
|
+
// an explicit --opening-bank/--opening-book overrode it (a disagreeing override
|
|
1811
|
+
// is honored but surfaces as a CONTINUITY_BREAK in the packet, flipping the
|
|
1812
|
+
// verdict — never silently swallowed). A malformed/unreadable close is a USAGE
|
|
1813
|
+
// error (a bad flag value), exactly as in reconcile.
|
|
1814
|
+
let priorClose = null;
|
|
1815
|
+
const openingNotes = [];
|
|
1816
|
+
if (opts.priorClose != null) {
|
|
1817
|
+
let closeText;
|
|
1818
|
+
try {
|
|
1819
|
+
closeText = fs.readFileSync(path.resolve(opts.priorClose), "utf8");
|
|
1820
|
+
} catch (e) {
|
|
1821
|
+
writeErr(
|
|
1822
|
+
`error: cannot read --prior-close file ${opts.priorClose}: ${e.message}\n`
|
|
1823
|
+
);
|
|
1824
|
+
return { code: EXIT.USAGE };
|
|
1825
|
+
}
|
|
1826
|
+
try {
|
|
1827
|
+
priorClose = close.readClose(closeText);
|
|
1828
|
+
} catch (e) {
|
|
1829
|
+
writeErr(
|
|
1830
|
+
`error: invalid --prior-close file ${opts.priorClose}: ${e.message}\n`
|
|
1831
|
+
);
|
|
1832
|
+
return { code: EXIT.USAGE };
|
|
1833
|
+
}
|
|
1834
|
+
if (!opts.openingBankSet) {
|
|
1835
|
+
opts.openingBank = priorClose.ending.bank;
|
|
1836
|
+
} else if (opts.openingBank !== priorClose.ending.bank) {
|
|
1837
|
+
openingNotes.push(
|
|
1838
|
+
`note: --opening-bank ${opts.openingBank} overrides the prior close's ` +
|
|
1839
|
+
`ending bank balance ${priorClose.ending.bank}; the roll-forward ` +
|
|
1840
|
+
"continuity check below will flag the resulting gap"
|
|
1841
|
+
);
|
|
1842
|
+
}
|
|
1843
|
+
if (!opts.openingBookSet) {
|
|
1844
|
+
opts.openingBook = priorClose.ending.book;
|
|
1845
|
+
} else if (opts.openingBook !== priorClose.ending.book) {
|
|
1846
|
+
openingNotes.push(
|
|
1847
|
+
`note: --opening-book ${opts.openingBook} overrides the prior close's ` +
|
|
1848
|
+
`ending book balance ${priorClose.ending.book}; the roll-forward ` +
|
|
1849
|
+
"continuity check below will flag the resulting gap"
|
|
1850
|
+
);
|
|
1851
|
+
}
|
|
1852
|
+
}
|
|
1853
|
+
for (const n of openingNotes) writeErr(`${n}\n`);
|
|
1854
|
+
|
|
1855
|
+
// -- Read the three files (IO errors are exit 1, not a crash). -------------
|
|
1856
|
+
// Same error classes + messages as reconcile, so the SAME bad file fails the
|
|
1857
|
+
// SAME way regardless of which command read it.
|
|
1858
|
+
let bankText;
|
|
1859
|
+
let ledgerText;
|
|
1860
|
+
let rentText;
|
|
1861
|
+
try {
|
|
1862
|
+
bankText = fs.readFileSync(path.resolve(opts.bank), "utf8");
|
|
1863
|
+
} catch (e) {
|
|
1864
|
+
writeErr(`error: cannot read bank file ${opts.bank}: ${e.message}\n`);
|
|
1865
|
+
return { code: EXIT.IO };
|
|
1866
|
+
}
|
|
1867
|
+
try {
|
|
1868
|
+
ledgerText = fs.readFileSync(path.resolve(opts.ledger), "utf8");
|
|
1869
|
+
} catch (e) {
|
|
1870
|
+
writeErr(`error: cannot read ledger file ${opts.ledger}: ${e.message}\n`);
|
|
1871
|
+
return { code: EXIT.IO };
|
|
1872
|
+
}
|
|
1873
|
+
try {
|
|
1874
|
+
rentText = fs.readFileSync(path.resolve(opts.rentroll), "utf8");
|
|
1875
|
+
} catch (e) {
|
|
1876
|
+
writeErr(`error: cannot read rent-roll file ${opts.rentroll}: ${e.message}\n`);
|
|
1877
|
+
return { code: EXIT.IO };
|
|
1878
|
+
}
|
|
1879
|
+
|
|
1880
|
+
// -- Resolve the per-source column maps (--map-file + --map), IDENTICALLY to
|
|
1881
|
+
// reconcile, and validate them up front (a structurally-invalid map is a USAGE
|
|
1882
|
+
// error, the SAME class as a malformed --map-file). Without this a broker whose
|
|
1883
|
+
// real files use non-default headers would IO-error here where reconcile loads.
|
|
1884
|
+
let sourceMaps;
|
|
1885
|
+
try {
|
|
1886
|
+
const mapFileMaps = opts.mapFile != null ? readMapFile(opts.mapFile) : null;
|
|
1887
|
+
sourceMaps = buildSourceMaps(mapFileMaps, opts.mapArgs || []);
|
|
1888
|
+
} catch (e) {
|
|
1889
|
+
writeErr(`error: ${e.message}\n`);
|
|
1890
|
+
return { code: EXIT.USAGE };
|
|
1891
|
+
}
|
|
1892
|
+
try {
|
|
1893
|
+
ingest.validateColumnMapForSource(ingest.SOURCE.BANK, bankText, sourceMaps.bank, {
|
|
1894
|
+
format: opts.bankFormat,
|
|
1895
|
+
});
|
|
1896
|
+
ingest.validateColumnMapForSource(
|
|
1897
|
+
ingest.SOURCE.QUICKBOOKS,
|
|
1898
|
+
ledgerText,
|
|
1899
|
+
sourceMaps.ledger
|
|
1900
|
+
);
|
|
1901
|
+
ingest.validateColumnMapForSource(
|
|
1902
|
+
ingest.SOURCE.RENT_ROLL,
|
|
1903
|
+
rentText,
|
|
1904
|
+
sourceMaps.rentroll
|
|
1905
|
+
);
|
|
1906
|
+
} catch (e) {
|
|
1907
|
+
writeErr(`error: ${e.message}\n`);
|
|
1908
|
+
return { code: EXIT.USAGE };
|
|
1909
|
+
}
|
|
1910
|
+
|
|
1911
|
+
// -- Ingest (a malformed row is a clear, located error -> exit 1). The resolved
|
|
1912
|
+
// column maps thread into the parsers IDENTICALLY to reconcile.
|
|
1913
|
+
let bank;
|
|
1914
|
+
let book;
|
|
1915
|
+
let rentroll;
|
|
1916
|
+
try {
|
|
1917
|
+
bank = ingest.parseBankStatement(bankText, {
|
|
1918
|
+
format: opts.bankFormat,
|
|
1919
|
+
columnMap: sourceMaps.bank,
|
|
1920
|
+
});
|
|
1921
|
+
book = ingest.parseQuickBooksCSV(ledgerText, { columnMap: sourceMaps.ledger });
|
|
1922
|
+
rentroll = ingest.parseRentRollCSV(rentText, {
|
|
1923
|
+
columnMap: sourceMaps.rentroll,
|
|
1924
|
+
});
|
|
1925
|
+
} catch (e) {
|
|
1926
|
+
writeErr(`error: ${e.message}\n`);
|
|
1927
|
+
return { code: EXIT.IO };
|
|
1928
|
+
}
|
|
1929
|
+
|
|
1930
|
+
// -- Build the SAME packet model reconcile builds (match + reconcile + triage
|
|
1931
|
+
// inside), threading policy + priorClose IDENTICALLY to reconcile (cli.js:699).
|
|
1932
|
+
// value-proof reuses the EXACT verdict path so its numbers — AND its PASS/FAIL
|
|
1933
|
+
// verdict — EQUAL the reconcile verdict for the same inputs. value-proof writes
|
|
1934
|
+
// NOTHING, so it never threads emitClosePath (the close-emit is reconcile-only).
|
|
1935
|
+
let model;
|
|
1936
|
+
try {
|
|
1937
|
+
model = report.buildPacket({
|
|
1938
|
+
bank,
|
|
1939
|
+
book,
|
|
1940
|
+
rentroll,
|
|
1941
|
+
reportDate,
|
|
1942
|
+
period: opts.period,
|
|
1943
|
+
opening: { bank: opts.openingBank || 0, book: opts.openingBook || 0 },
|
|
1944
|
+
toleranceCents: opts.toleranceCents || 0,
|
|
1945
|
+
policy: activePolicy,
|
|
1946
|
+
priorClose,
|
|
1947
|
+
});
|
|
1948
|
+
} catch (e) {
|
|
1949
|
+
writeErr(`error: ${e.message}\n`);
|
|
1950
|
+
return { code: EXIT.IO };
|
|
1951
|
+
}
|
|
1952
|
+
|
|
1953
|
+
// -- Apply the pure value-proof lens over the gate's already-computed triage.
|
|
1954
|
+
// A ValueProofError here is an internal/forged-model bug, not a broker
|
|
1955
|
+
// mistake; surface it as an IO/input error rather than crashing.
|
|
1956
|
+
let vp;
|
|
1957
|
+
try {
|
|
1958
|
+
vp = valueproof.valueProof(model, {
|
|
1959
|
+
assertedClean: opts.assertedClean,
|
|
1960
|
+
assertedNetCents:
|
|
1961
|
+
opts.assertedNetCents != null ? opts.assertedNetCents : undefined,
|
|
1962
|
+
period: opts.period != null ? opts.period : reportDate,
|
|
1963
|
+
});
|
|
1964
|
+
} catch (e) {
|
|
1965
|
+
writeErr(`error: cannot build value-proof: ${e.message}\n`);
|
|
1966
|
+
return { code: EXIT.IO };
|
|
1967
|
+
}
|
|
1968
|
+
|
|
1969
|
+
// -- Map the outcome to the gateable exit code, then RECONCILE it with the gate's
|
|
1970
|
+
// actual PASS/FAIL verdict. -------------------------------------------------
|
|
1971
|
+
//
|
|
1972
|
+
// The valueProof OUTCOME keys off the root-cause CLASS (type-based, policy-
|
|
1973
|
+
// INDEPENDENT), so the per-class dollar rollup is faithful no matter the policy.
|
|
1974
|
+
// But a per-state policy (or a prior-close CONTINUITY_BREAK) can ESCALATE a finding
|
|
1975
|
+
// the class lens treats as benign (e.g. an `ambiguous_deposit` graded WARNING->ERROR,
|
|
1976
|
+
// or a `needs_review` note made a hard finding) and FLIP the production gate
|
|
1977
|
+
// PASS->FAIL on the SAME files — WITHOUT changing topClass. If we exited purely on
|
|
1978
|
+
// the class outcome, value-proof could print "clean confirmed" / exit 0 on a period
|
|
1979
|
+
// the broker's OWN licensed gate FAILs: the exact inversion this command must never
|
|
1980
|
+
// make. So when the gate FAILs (model.pass === false) we NEVER report clean/exit 0:
|
|
1981
|
+
// a class-clean outcome under a failing gate is surfaced as a policy-escalated FAIL.
|
|
1982
|
+
// (model.pass FAILs => exit is 3 or 4, always non-zero; PASSes => 0 or 4, never 3.)
|
|
1983
|
+
const baseCode = VALUE_PROOF_EXIT[vp.outcome];
|
|
1984
|
+
if (baseCode === undefined) {
|
|
1985
|
+
writeErr(`error: value-proof produced an unknown outcome "${vp.outcome}"\n`);
|
|
1986
|
+
return { code: EXIT.IO };
|
|
1987
|
+
}
|
|
1988
|
+
const gatePass = model.pass === true;
|
|
1989
|
+
// The gate verdict ANCHORS the exit code, refined by the class diagnosis:
|
|
1990
|
+
// * gate FAILs + class clean -> policy/prior-close escalated a hard finding
|
|
1991
|
+
// the type lens calls benign: FAIL (3), never 0.
|
|
1992
|
+
// * gate PASSes + class out-of-trust -> the policy DOWN-graded an out-of-trust
|
|
1993
|
+
// finding so the licensed gate tolerates it:
|
|
1994
|
+
// the gate PASSes, so we do not FAIL it (->
|
|
1995
|
+
// data_gap/4 when there is also a data gap,
|
|
1996
|
+
// else clean/0). The faithful per-class table
|
|
1997
|
+
// still names the finding; the verdict matches
|
|
1998
|
+
// the gate the broker actually runs.
|
|
1999
|
+
// Otherwise the class outcome already agrees with the gate and rides through.
|
|
2000
|
+
let code = baseCode;
|
|
2001
|
+
let escalated = false; // gate FAILs but the type-based outcome was clean
|
|
2002
|
+
let tolerated = false; // gate PASSes but the type-based outcome was out-of-trust
|
|
2003
|
+
if (!gatePass && code === EXIT.PASS) {
|
|
2004
|
+
code = EXIT.FAIL;
|
|
2005
|
+
escalated = true;
|
|
2006
|
+
} else if (gatePass && code === EXIT.FAIL) {
|
|
2007
|
+
code = vp.dataGap ? EXIT.DATA_GAP : EXIT.PASS;
|
|
2008
|
+
tolerated = true;
|
|
2009
|
+
}
|
|
2010
|
+
|
|
2011
|
+
// The verdict surfaced to the human + JSON is the GATE's verdict, so the
|
|
2012
|
+
// clean/out-of-trust framing can never disagree with the exit code.
|
|
2013
|
+
const gateVerdict = gatePass ? "PASS" : "FAIL";
|
|
2014
|
+
|
|
2015
|
+
if (opts.json) {
|
|
2016
|
+
write(
|
|
2017
|
+
JSON.stringify(
|
|
2018
|
+
{
|
|
2019
|
+
...vp,
|
|
2020
|
+
reportDate,
|
|
2021
|
+
// The production gate's actual verdict for these inputs under the governing
|
|
2022
|
+
// policy/prior-close — what the paying broker's licensed gate would return.
|
|
2023
|
+
gateVerdict,
|
|
2024
|
+
// True iff the policy/prior-close FLIPPED the verdict relative to the
|
|
2025
|
+
// type-based class outcome (escalated a benign-class finding to a FAIL, or
|
|
2026
|
+
// tolerated an out-of-trust-class finding the gate PASSes). Disclosed so a
|
|
2027
|
+
// consumer can see the class outcome and the gate verdict diverged and why.
|
|
2028
|
+
policyEscalated: escalated,
|
|
2029
|
+
policyTolerated: tolerated,
|
|
2030
|
+
code,
|
|
2031
|
+
caveat: VALUE_PROOF_CAVEAT,
|
|
2032
|
+
},
|
|
2033
|
+
null,
|
|
2034
|
+
2
|
|
2035
|
+
) + "\n"
|
|
2036
|
+
);
|
|
2037
|
+
} else {
|
|
2038
|
+
write(
|
|
2039
|
+
renderValueProof(vp, {
|
|
2040
|
+
period: opts.period,
|
|
2041
|
+
gateVerdict,
|
|
2042
|
+
escalated,
|
|
2043
|
+
tolerated,
|
|
2044
|
+
})
|
|
2045
|
+
);
|
|
2046
|
+
}
|
|
2047
|
+
|
|
2048
|
+
return { code, model, vp };
|
|
2049
|
+
}
|
|
2050
|
+
|
|
2051
|
+
function cmdValueProof(argv, io = {}) {
|
|
2052
|
+
const writeErr = io.writeErr || ((s) => process.stderr.write(s));
|
|
2053
|
+
let opts;
|
|
2054
|
+
try {
|
|
2055
|
+
opts = parseValueProofArgs(argv);
|
|
2056
|
+
} catch (e) {
|
|
2057
|
+
writeErr(`error: ${e.message}\n`);
|
|
2058
|
+
return EXIT.USAGE;
|
|
2059
|
+
}
|
|
2060
|
+
return runValueProof(opts, io).code;
|
|
2061
|
+
}
|
|
2062
|
+
|
|
2063
|
+
// ---------------------------------------------------------------------------
|
|
2064
|
+
// `vh trust verify-seal <sealfile>` (T-26.2) — read-only, OFFLINE seal verify
|
|
2065
|
+
// ---------------------------------------------------------------------------
|
|
2066
|
+
//
|
|
2067
|
+
// The independent companion to `reconcile --seal`. Given ONLY the seal file (+
|
|
2068
|
+
// the files it names), re-derive each listed file's content hash and the manifest
|
|
2069
|
+
// root from the bytes on disk and compare against the seal's stored expectation.
|
|
2070
|
+
// It needs NO key, NO network, NO contract — purely the seal core's `verifySeal`.
|
|
2071
|
+
//
|
|
2072
|
+
// Files are resolved RELATIVE TO the seal file's directory by default (the seal
|
|
2073
|
+
// stores relPaths relative to where it was written), or relative to --dir. Prints
|
|
2074
|
+
// ACCEPTED only when EVERY sealed file MATCHes (no CHANGED/MISSING/UNEXPECTED, no
|
|
2075
|
+
// role swap, AND the root re-derives); otherwise REJECTED with the precise per-file
|
|
2076
|
+
// list and a non-zero exit. Exit contract mirrors the rest of the family:
|
|
2077
|
+
// 0 ACCEPTED, 3 REJECTED, 2 usage (bad flag), 1 IO (unreadable/missing seal).
|
|
2078
|
+
//
|
|
2079
|
+
// The output LEADS with the standing custodian/trust caveat + the seal posture
|
|
2080
|
+
// (tamper-evidence, NOT a trusted timestamp; the CPA review still governs).
|
|
2081
|
+
|
|
2082
|
+
// The caveat the verify-seal output LEADS with — the custodian responsibility +
|
|
2083
|
+
// the honest seal posture. Stated here so the human + JSON paths agree.
|
|
2084
|
+
const VERIFY_SEAL_CAVEAT =
|
|
2085
|
+
"The broker remains the responsible trust-account custodian. A seal is TAMPER-EVIDENT, " +
|
|
2086
|
+
"NOT a trusted timestamp (a matching seal proves the bytes are byte-for-byte what was " +
|
|
2087
|
+
"sealed, NOT when the sealing happened) and NOT a legal opinion (the CPA review still " +
|
|
2088
|
+
"governs). verify-seal RE-DERIVES the root from the files on disk — it never trusts the " +
|
|
2089
|
+
"seal's own stored hashes.";
|
|
2090
|
+
|
|
2091
|
+
// Parse `verify-seal` argv: one positional <sealfile>, plus --dir / --json. Unknown
|
|
2092
|
+
// flags and a missing/duplicate positional are USAGE errors (parser parity with
|
|
2093
|
+
// reconcile/inspect — a typo never silently changes what is verified).
|
|
2094
|
+
function parseVerifySealArgs(argv) {
|
|
2095
|
+
const opts = { sealfile: undefined, dir: undefined, inputsDir: undefined, json: false, _positionals: [] };
|
|
2096
|
+
for (let i = 0; i < argv.length; i++) {
|
|
2097
|
+
const a = argv[i];
|
|
2098
|
+
switch (a) {
|
|
2099
|
+
case "--dir":
|
|
2100
|
+
opts.dir = argv[++i];
|
|
2101
|
+
if (opts.dir === undefined) {
|
|
2102
|
+
const e = new Error("--dir requires a value");
|
|
2103
|
+
e.usage = true;
|
|
2104
|
+
throw e;
|
|
2105
|
+
}
|
|
2106
|
+
break;
|
|
2107
|
+
case "--inputs":
|
|
2108
|
+
// Locate the SOURCE inputs (bank/book/rentroll) in a dir distinct from the
|
|
2109
|
+
// packet outputs. Default: the same base dir as the outputs (the portable
|
|
2110
|
+
// handoff ships the sources NEXT TO the seal). Useful when the examiner keeps
|
|
2111
|
+
// the originals in a separate folder from the emitted packet.
|
|
2112
|
+
opts.inputsDir = argv[++i];
|
|
2113
|
+
if (opts.inputsDir === undefined) {
|
|
2114
|
+
const e = new Error("--inputs requires a value");
|
|
2115
|
+
e.usage = true;
|
|
2116
|
+
throw e;
|
|
2117
|
+
}
|
|
2118
|
+
break;
|
|
2119
|
+
case "--json":
|
|
2120
|
+
opts.json = true;
|
|
2121
|
+
break;
|
|
2122
|
+
default:
|
|
2123
|
+
if (a && a.startsWith("--")) {
|
|
2124
|
+
const e = new Error(`unknown option: ${a}`);
|
|
2125
|
+
e.usage = true;
|
|
2126
|
+
throw e;
|
|
2127
|
+
}
|
|
2128
|
+
opts._positionals.push(a);
|
|
2129
|
+
}
|
|
2130
|
+
}
|
|
2131
|
+
if (opts._positionals.length > 1) {
|
|
2132
|
+
const e = new Error(
|
|
2133
|
+
`unexpected extra argument: ${opts._positionals[1]} ` +
|
|
2134
|
+
"(verify-seal takes exactly one <sealfile>)"
|
|
2135
|
+
);
|
|
2136
|
+
e.usage = true;
|
|
2137
|
+
throw e;
|
|
2138
|
+
}
|
|
2139
|
+
opts.sealfile = opts._positionals[0];
|
|
2140
|
+
return opts;
|
|
2141
|
+
}
|
|
2142
|
+
|
|
2143
|
+
// Render the human verify-seal report. PURE: takes the verifySeal result + context,
|
|
2144
|
+
// returns a string. Leads with the caveat, then the verdict + the precise per-file
|
|
2145
|
+
// CHANGED/MISSING/UNEXPECTED/role lists.
|
|
2146
|
+
function renderVerifySeal(result, ctx) {
|
|
2147
|
+
const L = [];
|
|
2148
|
+
L.push(`# vh trust verify-seal — ${ctx.sealfile}`);
|
|
2149
|
+
L.push(VERIFY_SEAL_CAVEAT);
|
|
2150
|
+
L.push("");
|
|
2151
|
+
L.push(`sealed root: ${result.sealedRoot}`);
|
|
2152
|
+
L.push(`recomputed root: ${result.recomputedRoot}`);
|
|
2153
|
+
L.push(`root matches: ${result.rootMatches ? "yes" : "NO"}`);
|
|
2154
|
+
L.push(
|
|
2155
|
+
`sealed verdict: ${ctx.verdict.pass ? "PASS" : "FAIL"} ` +
|
|
2156
|
+
`(reportDate ${ctx.verdict.reportDate}` +
|
|
2157
|
+
`${ctx.verdict.period ? `, period ${ctx.verdict.period}` : ""})`
|
|
2158
|
+
);
|
|
2159
|
+
L.push(
|
|
2160
|
+
`files: ${result.counts.matched} matched, ${result.counts.changed} changed, ` +
|
|
2161
|
+
`${result.counts.missing} missing, ${result.counts.unexpected} unexpected, ` +
|
|
2162
|
+
`${result.counts.roleMismatched} role-mismatched`
|
|
2163
|
+
);
|
|
2164
|
+
L.push("");
|
|
2165
|
+
if (result.accepted) {
|
|
2166
|
+
L.push("ACCEPTED — every sealed file re-derives byte-for-byte and the root matches.");
|
|
2167
|
+
} else {
|
|
2168
|
+
L.push("REJECTED — the files on disk do NOT match the seal:");
|
|
2169
|
+
for (const c of result.changed) {
|
|
2170
|
+
L.push(
|
|
2171
|
+
` CHANGED ${c.relPath}${c.role ? ` (${c.role})` : ""}: ` +
|
|
2172
|
+
`sealed ${c.expectedContentHash} != on-disk ${c.actualContentHash}`
|
|
2173
|
+
);
|
|
2174
|
+
}
|
|
2175
|
+
for (const m of result.missing) {
|
|
2176
|
+
L.push(` MISSING ${m.relPath}${m.role ? ` (${m.role})` : ""}: sealed but not found on disk`);
|
|
2177
|
+
}
|
|
2178
|
+
for (const u of result.unexpected) {
|
|
2179
|
+
L.push(` UNEXPECTED ${u.relPath}: on disk but not named in the seal`);
|
|
2180
|
+
}
|
|
2181
|
+
for (const r of result.roleMismatches) {
|
|
2182
|
+
L.push(
|
|
2183
|
+
` ROLE ${r.relPath}: sealed as ${r.sealedRole} but supplied as ${r.suppliedRole}`
|
|
2184
|
+
);
|
|
2185
|
+
}
|
|
2186
|
+
if (!result.rootMatches && result.changed.length === 0 && result.missing.length === 0 &&
|
|
2187
|
+
result.unexpected.length === 0 && result.roleMismatches.length === 0) {
|
|
2188
|
+
L.push(" ROOT the recomputed root does not equal the sealed root");
|
|
2189
|
+
}
|
|
2190
|
+
}
|
|
2191
|
+
L.push("");
|
|
2192
|
+
return L.join("\n");
|
|
2193
|
+
}
|
|
2194
|
+
|
|
2195
|
+
// runVerifySeal: load the seal, resolve + read every listed file, recompute via
|
|
2196
|
+
// verifySeal, print the verdict, return { code, result }. Read-only — writes NOTHING.
|
|
2197
|
+
function runVerifySeal(opts, io = {}) {
|
|
2198
|
+
const write = io.write || ((s) => process.stdout.write(s));
|
|
2199
|
+
const writeErr = io.writeErr || ((s) => process.stderr.write(s));
|
|
2200
|
+
|
|
2201
|
+
if (!opts.sealfile) {
|
|
2202
|
+
writeErr("error: `vh trust verify-seal` requires a <sealfile>\n");
|
|
2203
|
+
return { code: EXIT.USAGE };
|
|
2204
|
+
}
|
|
2205
|
+
|
|
2206
|
+
// Load + STRICT-validate the seal BEFORE any sealed file is read — a malformed or
|
|
2207
|
+
// missing seal hard-errors loudly (exit 1), never half-accepted nor treated as
|
|
2208
|
+
// "everything changed".
|
|
2209
|
+
const sealPath = path.resolve(opts.sealfile);
|
|
2210
|
+
let sealText;
|
|
2211
|
+
try {
|
|
2212
|
+
sealText = fs.readFileSync(sealPath, "utf8");
|
|
2213
|
+
} catch (e) {
|
|
2214
|
+
writeErr(`error: cannot read seal file ${opts.sealfile}: ${e.message}\n`);
|
|
2215
|
+
return { code: EXIT.IO };
|
|
2216
|
+
}
|
|
2217
|
+
let sealObj;
|
|
2218
|
+
try {
|
|
2219
|
+
sealObj = seal.readSeal(sealText);
|
|
2220
|
+
} catch (e) {
|
|
2221
|
+
writeErr(`error: invalid seal file ${opts.sealfile}: ${e.message}\n`);
|
|
2222
|
+
return { code: EXIT.IO };
|
|
2223
|
+
}
|
|
2224
|
+
|
|
2225
|
+
// Resolve OUTPUT files relative to --dir (if given) else the seal file's own
|
|
2226
|
+
// directory — the seal stored output relPaths relative to where it was written.
|
|
2227
|
+
// INPUT files (the bank/book/rentroll sources, sealed by basename) resolve relative
|
|
2228
|
+
// to --inputs (if given) else the SAME base dir as the outputs — the portable
|
|
2229
|
+
// handoff ships the sources next to the seal, so the default just works.
|
|
2230
|
+
const baseDir = opts.dir != null ? path.resolve(opts.dir) : path.dirname(sealPath);
|
|
2231
|
+
const inputsDir = opts.inputsDir != null ? path.resolve(opts.inputsDir) : baseDir;
|
|
2232
|
+
|
|
2233
|
+
// Read every sealed entry's bytes from disk. A file the seal NAMES but that is
|
|
2234
|
+
// absent must NOT abort — it is a MISSING finding the verify localizes. So we
|
|
2235
|
+
// skip unreadable sealed files here (omitting them from the supplied set makes
|
|
2236
|
+
// verifySeal report them MISSING); a present file's broken read surfaces the same
|
|
2237
|
+
// way. Only the SEAL itself being unreadable is the IO hard-error above. verifySeal
|
|
2238
|
+
// tolerates a PARTIAL supplied set, so even an all-absent set routes through it and
|
|
2239
|
+
// is localized honestly (present files are recomputed; only genuinely-absent ones
|
|
2240
|
+
// are MISSING) — no synthesized "everything missing" shortcut that would mislabel a
|
|
2241
|
+
// co-located packet file as MISSING (REWORK Finding 2).
|
|
2242
|
+
const files = { inputs: [], outputs: [] };
|
|
2243
|
+
for (const e of sealObj.inputs) {
|
|
2244
|
+
const abs = path.resolve(inputsDir, e.relPath);
|
|
2245
|
+
let bytes;
|
|
2246
|
+
try {
|
|
2247
|
+
bytes = fs.readFileSync(abs);
|
|
2248
|
+
} catch (_) {
|
|
2249
|
+
continue; // absent -> verifySeal reports MISSING
|
|
2250
|
+
}
|
|
2251
|
+
files.inputs.push({ role: e.role, relPath: e.relPath, bytes });
|
|
2252
|
+
}
|
|
2253
|
+
for (const e of sealObj.outputs) {
|
|
2254
|
+
const abs = path.resolve(baseDir, e.relPath);
|
|
2255
|
+
let bytes;
|
|
2256
|
+
try {
|
|
2257
|
+
bytes = fs.readFileSync(abs);
|
|
2258
|
+
} catch (_) {
|
|
2259
|
+
continue; // absent -> verifySeal reports MISSING
|
|
2260
|
+
}
|
|
2261
|
+
files.outputs.push({ relPath: e.relPath, bytes });
|
|
2262
|
+
}
|
|
2263
|
+
|
|
2264
|
+
let result;
|
|
2265
|
+
try {
|
|
2266
|
+
result = seal.verifySeal(sealObj, files);
|
|
2267
|
+
} catch (e) {
|
|
2268
|
+
writeErr(`error: ${e.message}\n`);
|
|
2269
|
+
return { code: EXIT.IO };
|
|
2270
|
+
}
|
|
2271
|
+
|
|
2272
|
+
const code = result.accepted ? EXIT.PASS : EXIT.FAIL;
|
|
2273
|
+
|
|
2274
|
+
if (opts.json) {
|
|
2275
|
+
write(
|
|
2276
|
+
JSON.stringify(
|
|
2277
|
+
{
|
|
2278
|
+
...result,
|
|
2279
|
+
sealfile: opts.sealfile,
|
|
2280
|
+
dir: baseDir,
|
|
2281
|
+
inputsDir,
|
|
2282
|
+
verdictSealed: sealObj.verdict,
|
|
2283
|
+
caveat: VERIFY_SEAL_CAVEAT,
|
|
2284
|
+
},
|
|
2285
|
+
null,
|
|
2286
|
+
2
|
|
2287
|
+
) + "\n"
|
|
2288
|
+
);
|
|
2289
|
+
} else {
|
|
2290
|
+
write(renderVerifySeal(result, { sealfile: opts.sealfile, verdict: sealObj.verdict }));
|
|
2291
|
+
}
|
|
2292
|
+
|
|
2293
|
+
return { code, result };
|
|
2294
|
+
}
|
|
2295
|
+
|
|
2296
|
+
function cmdVerifySeal(argv, io = {}) {
|
|
2297
|
+
const writeErr = io.writeErr || ((s) => process.stderr.write(s));
|
|
2298
|
+
let opts;
|
|
2299
|
+
try {
|
|
2300
|
+
opts = parseVerifySealArgs(argv);
|
|
2301
|
+
} catch (e) {
|
|
2302
|
+
writeErr(`error: ${e.message}\n`);
|
|
2303
|
+
return EXIT.USAGE;
|
|
2304
|
+
}
|
|
2305
|
+
return runVerifySeal(opts, io).code;
|
|
2306
|
+
}
|
|
2307
|
+
|
|
2308
|
+
// ---------------------------------------------------------------------------
|
|
2309
|
+
// `vh trust serve [--port <n>] [--host <h>] [--out <dir>]` (T-27.3)
|
|
2310
|
+
// ---------------------------------------------------------------------------
|
|
2311
|
+
//
|
|
2312
|
+
// Launch the local web front-door over the engine — the broker-facing door so a
|
|
2313
|
+
// non-technical custodian can open a browser, drop their three monthly files, and
|
|
2314
|
+
// watch the balances tie out WITHOUT a terminal. It REUSES `server.js` VERBATIM:
|
|
2315
|
+
// this is only the CLI plumbing that parses the port/host, binds the http.Server,
|
|
2316
|
+
// and prints the URL. The pipeline itself is unchanged.
|
|
2317
|
+
//
|
|
2318
|
+
// FILE PRIVACY POSTURE (stated in-band + in docs): the server processes the three
|
|
2319
|
+
// uploaded files PURELY in memory and persists NOTHING server-side. There is no
|
|
2320
|
+
// `--out` for serve — a long-lived public server must never silently accumulate a
|
|
2321
|
+
// broker's trust-account files on its disk. (The CLI `vh trust reconcile --out` is
|
|
2322
|
+
// the path that WRITES a packet, and only to a caller-chosen dir.)
|
|
2323
|
+
//
|
|
2324
|
+
// HUMAN DEPLOY STEP (never auto-deployed): this binds to LOCALHOST by default and
|
|
2325
|
+
// is meant to be run locally or behind the broker's OWN nginx/Cloudflare on their
|
|
2326
|
+
// OWN domain with TLS. The loop NEVER deploys it to a public network.
|
|
2327
|
+
//
|
|
2328
|
+
// Exit contract: this command does not "complete" — it LISTENS until killed. The
|
|
2329
|
+
// runner returns { code, server, url } so a test can start it, hit it, and close
|
|
2330
|
+
// it; `code` is only meaningful for the early-exit USAGE error (a bad --port).
|
|
2331
|
+
|
|
2332
|
+
const SERVE_DEFAULT_PORT = 4173;
|
|
2333
|
+
const SERVE_DEFAULT_HOST = "127.0.0.1";
|
|
2334
|
+
|
|
2335
|
+
// Parse `serve` argv into options. Flags only (no positionals). An unknown flag or
|
|
2336
|
+
// a positional is a USAGE error, matching the rest of the family.
|
|
2337
|
+
function parseServeArgs(argv) {
|
|
2338
|
+
const opts = { port: undefined, host: undefined, _positionals: [] };
|
|
2339
|
+
for (let i = 0; i < argv.length; i++) {
|
|
2340
|
+
const a = argv[i];
|
|
2341
|
+
switch (a) {
|
|
2342
|
+
case "--port":
|
|
2343
|
+
opts.port = parsePortArg(argv[++i]);
|
|
2344
|
+
break;
|
|
2345
|
+
case "--host":
|
|
2346
|
+
opts.host = argv[++i];
|
|
2347
|
+
if (opts.host === undefined) {
|
|
2348
|
+
const e = new Error("--host requires a value");
|
|
2349
|
+
e.usage = true;
|
|
2350
|
+
throw e;
|
|
2351
|
+
}
|
|
2352
|
+
break;
|
|
2353
|
+
default:
|
|
2354
|
+
if (a && a.startsWith("--")) {
|
|
2355
|
+
const e = new Error(`unknown option: ${a}`);
|
|
2356
|
+
e.usage = true;
|
|
2357
|
+
throw e;
|
|
2358
|
+
}
|
|
2359
|
+
opts._positionals.push(a);
|
|
2360
|
+
}
|
|
2361
|
+
}
|
|
2362
|
+
if (opts._positionals.length > 0) {
|
|
2363
|
+
const e = new Error(
|
|
2364
|
+
`unexpected argument: ${opts._positionals[0]} (serve takes no positionals)`
|
|
2365
|
+
);
|
|
2366
|
+
e.usage = true;
|
|
2367
|
+
throw e;
|
|
2368
|
+
}
|
|
2369
|
+
return opts;
|
|
2370
|
+
}
|
|
2371
|
+
|
|
2372
|
+
// A --port must be an integer in the valid TCP range (1..65535) OR 0 (bind an
|
|
2373
|
+
// EPHEMERAL port — useful for tests and for "pick any free port"). A bad value is
|
|
2374
|
+
// a USAGE error (exit 2), never silently coerced.
|
|
2375
|
+
function parsePortArg(raw) {
|
|
2376
|
+
const s = String(raw == null ? "" : raw);
|
|
2377
|
+
if (!/^\d+$/.test(s)) {
|
|
2378
|
+
const e = new Error(`--port must be a non-negative integer (got "${raw}")`);
|
|
2379
|
+
e.usage = true;
|
|
2380
|
+
throw e;
|
|
2381
|
+
}
|
|
2382
|
+
const n = Number(s);
|
|
2383
|
+
if (n > 65535) {
|
|
2384
|
+
const e = new Error(`--port must be in 0..65535 (got "${raw}")`);
|
|
2385
|
+
e.usage = true;
|
|
2386
|
+
throw e;
|
|
2387
|
+
}
|
|
2388
|
+
return n;
|
|
2389
|
+
}
|
|
2390
|
+
|
|
2391
|
+
// runServe binds the server and prints the URL. It does NOT block; it returns
|
|
2392
|
+
// { code, server, url } once listening (or { code: USAGE } on a bad flag without
|
|
2393
|
+
// ever binding). `io.listen` is injectable so a test can confirm the wiring without
|
|
2394
|
+
// the runner picking a port itself; the default builds + listens on a real socket.
|
|
2395
|
+
function runServe(opts, io = {}) {
|
|
2396
|
+
const write = io.write || ((s) => process.stdout.write(s));
|
|
2397
|
+
const writeErr = io.writeErr || ((s) => process.stderr.write(s));
|
|
2398
|
+
|
|
2399
|
+
const port = opts.port == null ? SERVE_DEFAULT_PORT : opts.port;
|
|
2400
|
+
const host = opts.host == null ? SERVE_DEFAULT_HOST : opts.host;
|
|
2401
|
+
|
|
2402
|
+
const srv = (io.createServer || server.createServer)({ today: io.today });
|
|
2403
|
+
|
|
2404
|
+
return new Promise((resolve) => {
|
|
2405
|
+
// Guard against resolving twice: a bind failure fires 'error' and the listen
|
|
2406
|
+
// callback never runs, but a defensive flag keeps the two paths exclusive.
|
|
2407
|
+
let settled = false;
|
|
2408
|
+
|
|
2409
|
+
// Surface a bind failure (e.g. EADDRINUSE, EACCES on a privileged port, a bad
|
|
2410
|
+
// --host interface) as a clear IO error AND resolve the Promise with EXIT.IO so
|
|
2411
|
+
// the failure propagates to the process exit code. Without this resolve the
|
|
2412
|
+
// Promise would hang forever; on the real CLI path the failed server holds no
|
|
2413
|
+
// event-loop handles, so Node would exit ON ITS OWN with code 0 — collapsing
|
|
2414
|
+
// the IO(1) failure class into PASS(0). A supervisor / systemd / CI healthcheck
|
|
2415
|
+
// running `vh trust serve || alert` must see a non-zero code when the door
|
|
2416
|
+
// failed to bind.
|
|
2417
|
+
srv.on("error", (e) => {
|
|
2418
|
+
if (settled) return;
|
|
2419
|
+
settled = true;
|
|
2420
|
+
writeErr(`error: cannot start TrustLedger web door: ${e.message}\n`);
|
|
2421
|
+
resolve({ code: EXIT.IO, server: srv, url: null, error: e });
|
|
2422
|
+
});
|
|
2423
|
+
|
|
2424
|
+
srv.listen(port, host, () => {
|
|
2425
|
+
if (settled) return;
|
|
2426
|
+
settled = true;
|
|
2427
|
+
// When --port 0 was given the OS chose the actual port; report the real one.
|
|
2428
|
+
const bound = srv.address();
|
|
2429
|
+
const realPort = bound && typeof bound === "object" ? bound.port : port;
|
|
2430
|
+
const url = `http://${host}:${realPort}/`;
|
|
2431
|
+
// 0.0.0.0 (or ::) is a bind target, not a browsable address; tell an operator
|
|
2432
|
+
// who bound all interfaces to reach it via their machine's real address.
|
|
2433
|
+
const browseHint =
|
|
2434
|
+
host === "0.0.0.0" || host === "::"
|
|
2435
|
+
? " (0.0.0.0 binds ALL interfaces — browse via your machine's own address.)\n"
|
|
2436
|
+
: "";
|
|
2437
|
+
write(
|
|
2438
|
+
`TrustLedger web door listening on ${url}\n` +
|
|
2439
|
+
browseHint +
|
|
2440
|
+
" Files are processed IN MEMORY; nothing is written to disk server-side.\n" +
|
|
2441
|
+
" This binds to localhost — to expose it, put it behind YOUR nginx/Cloudflare\n" +
|
|
2442
|
+
" on YOUR own domain with TLS (a human deploy step; it is never auto-deployed).\n" +
|
|
2443
|
+
" Press Ctrl-C to stop.\n"
|
|
2444
|
+
);
|
|
2445
|
+
resolve({ code: EXIT.PASS, server: srv, url });
|
|
2446
|
+
});
|
|
2447
|
+
});
|
|
2448
|
+
}
|
|
2449
|
+
|
|
2450
|
+
// cmdServe: parse argv, then bind + print. The dispatcher (`vh trust`) awaits a
|
|
2451
|
+
// PLAIN exit code, so this resolves to a NUMBER:
|
|
2452
|
+
// * a bad flag resolves immediately to EXIT.USAGE (2) and the process exits, OR
|
|
2453
|
+
// * a BIND FAILURE (EADDRINUSE / EACCES / bad --host) resolves to EXIT.IO (1) so
|
|
2454
|
+
// the failed door propagates a non-zero exit instead of letting Node exit 0, OR
|
|
2455
|
+
// * on success it binds, prints the URL, and returns a Promise that NEVER
|
|
2456
|
+
// resolves — the open socket keeps the event loop alive so the door stays up
|
|
2457
|
+
// until the operator kills it (Ctrl-C), exactly like a normal server process.
|
|
2458
|
+
// Tests call `runServe` directly (which resolves with the live { server } handle)
|
|
2459
|
+
// for the success path so they can hit it and close it; the bind-failure path is
|
|
2460
|
+
// exercised through cmdServe to assert the EXIT.IO exit code.
|
|
2461
|
+
function cmdServe(argv, io = {}) {
|
|
2462
|
+
const writeErr = io.writeErr || ((s) => process.stderr.write(s));
|
|
2463
|
+
let opts;
|
|
2464
|
+
try {
|
|
2465
|
+
opts = parseServeArgs(argv);
|
|
2466
|
+
} catch (e) {
|
|
2467
|
+
writeErr(`error: ${e.message}\n`);
|
|
2468
|
+
return Promise.resolve(EXIT.USAGE);
|
|
2469
|
+
}
|
|
2470
|
+
return runServe(opts, io).then((res) => {
|
|
2471
|
+
// A non-PASS resolved code (a bind failure => EXIT.IO) maps STRAIGHT to that
|
|
2472
|
+
// number so the process exits non-zero. Only the listening (PASS) case holds
|
|
2473
|
+
// the process open forever on the live socket.
|
|
2474
|
+
if (res.code !== EXIT.PASS) return res.code;
|
|
2475
|
+
return new Promise(() => {});
|
|
2476
|
+
});
|
|
2477
|
+
}
|
|
2478
|
+
|
|
2479
|
+
// ---------------------------------------------------------------------------
|
|
2480
|
+
// `vh trust license issue | verify` (T-29.2) — mint + OFFLINE-verify a product
|
|
2481
|
+
// license. `issue` reads a HUMAN-supplied key (EXACTLY ONE of --key-env/--key-file,
|
|
2482
|
+
// reused-then-discarded, NEVER written/logged/echoed — the exact key-handling
|
|
2483
|
+
// posture of `vh dataset sign`), signs a license via the shared license core, and
|
|
2484
|
+
// prints ONLY the PUBLIC vendor address + the license summary + the path. `verify`
|
|
2485
|
+
// is read-only, OFFLINE, key-free: it prints VALID/INVALID + the precise reason +
|
|
2486
|
+
// entitlements + expiry, exiting 0 (valid) / 3 (invalid) just like verifyLicense.
|
|
2487
|
+
// ---------------------------------------------------------------------------
|
|
2488
|
+
|
|
2489
|
+
const coreAttestation = require("../cli/core/attestation");
|
|
2490
|
+
|
|
2491
|
+
// Parse `license issue` argv. EXACTLY-ONE-of key sources is enforced downstream by
|
|
2492
|
+
// loadSigningWallet (so neither/both error key-free); the parser only collects flags.
|
|
2493
|
+
function parseLicenseIssueArgs(argv) {
|
|
2494
|
+
const opts = {
|
|
2495
|
+
customer: undefined,
|
|
2496
|
+
plan: undefined,
|
|
2497
|
+
entitlements: undefined, // comma-separated -> array
|
|
2498
|
+
expires: undefined, // ISO instant
|
|
2499
|
+
issued: undefined, // OPTIONAL ISO instant; default "now" supplied by the command
|
|
2500
|
+
licenseId: undefined, // OPTIONAL; defaulted by the command when omitted
|
|
2501
|
+
keyEnv: undefined,
|
|
2502
|
+
keyFile: undefined,
|
|
2503
|
+
out: undefined,
|
|
2504
|
+
json: false,
|
|
2505
|
+
};
|
|
2506
|
+
for (let i = 0; i < argv.length; i++) {
|
|
2507
|
+
const a = argv[i];
|
|
2508
|
+
const need = () => {
|
|
2509
|
+
const v = argv[++i];
|
|
2510
|
+
if (v === undefined || String(v).startsWith("--")) {
|
|
2511
|
+
const e = new Error(`${a} requires a value`);
|
|
2512
|
+
e.usage = true;
|
|
2513
|
+
throw e;
|
|
2514
|
+
}
|
|
2515
|
+
return v;
|
|
2516
|
+
};
|
|
2517
|
+
switch (a) {
|
|
2518
|
+
case "--customer": opts.customer = need(); break;
|
|
2519
|
+
case "--plan": opts.plan = need(); break;
|
|
2520
|
+
case "--entitlements": opts.entitlements = need(); break;
|
|
2521
|
+
case "--expires": opts.expires = need(); break;
|
|
2522
|
+
case "--issued": opts.issued = need(); break;
|
|
2523
|
+
case "--license-id": opts.licenseId = need(); break;
|
|
2524
|
+
case "--key-env": opts.keyEnv = need(); break;
|
|
2525
|
+
case "--key-file": opts.keyFile = need(); break;
|
|
2526
|
+
case "--out": opts.out = need(); break;
|
|
2527
|
+
case "--json": opts.json = true; break;
|
|
2528
|
+
default: {
|
|
2529
|
+
const e = new Error(`unknown option: ${a}`);
|
|
2530
|
+
e.usage = true;
|
|
2531
|
+
throw e;
|
|
2532
|
+
}
|
|
2533
|
+
}
|
|
2534
|
+
}
|
|
2535
|
+
return opts;
|
|
2536
|
+
}
|
|
2537
|
+
|
|
2538
|
+
async function cmdLicenseIssue(argv, io = {}) {
|
|
2539
|
+
const write = io.write || ((s) => process.stdout.write(s));
|
|
2540
|
+
const writeErr = io.writeErr || ((s) => process.stderr.write(s));
|
|
2541
|
+
let opts;
|
|
2542
|
+
try {
|
|
2543
|
+
opts = parseLicenseIssueArgs(argv);
|
|
2544
|
+
} catch (e) {
|
|
2545
|
+
writeErr(`error: ${e.message}\n`);
|
|
2546
|
+
return EXIT.USAGE;
|
|
2547
|
+
}
|
|
2548
|
+
|
|
2549
|
+
// Required license fields (the key sources are validated by loadSigningWallet).
|
|
2550
|
+
for (const [flag, val] of [
|
|
2551
|
+
["--customer", opts.customer],
|
|
2552
|
+
["--plan", opts.plan],
|
|
2553
|
+
["--entitlements", opts.entitlements],
|
|
2554
|
+
["--expires", opts.expires],
|
|
2555
|
+
]) {
|
|
2556
|
+
if (val == null) {
|
|
2557
|
+
writeErr(`error: \`vh trust license issue\` requires ${flag}\n`);
|
|
2558
|
+
return EXIT.USAGE;
|
|
2559
|
+
}
|
|
2560
|
+
}
|
|
2561
|
+
|
|
2562
|
+
// Resolve the HUMAN-supplied key into an in-process Wallet FIRST, BEFORE building
|
|
2563
|
+
// anything — neither/both sources, a missing env var, an unreadable file, or a
|
|
2564
|
+
// malformed/zero key hard-errors here with a KEY-FREE message (the SAME core +
|
|
2565
|
+
// posture as `vh dataset sign`). The loop never holds a key.
|
|
2566
|
+
let wallet;
|
|
2567
|
+
try {
|
|
2568
|
+
({ wallet } = coreAttestation.loadSigningWallet({ keyEnv: opts.keyEnv, keyFile: opts.keyFile }));
|
|
2569
|
+
} catch (e) {
|
|
2570
|
+
writeErr(`error: ${e.message}\n`);
|
|
2571
|
+
return EXIT.USAGE;
|
|
2572
|
+
}
|
|
2573
|
+
|
|
2574
|
+
// Assemble the license fields. issuedAt defaults to the injectable clock (a real
|
|
2575
|
+
// ISO instant at runtime; a pinned one in tests). entitlements is a comma list.
|
|
2576
|
+
const issuedAt = opts.issued != null ? opts.issued : (io.nowISO || nowISO)();
|
|
2577
|
+
const entitlements = String(opts.entitlements)
|
|
2578
|
+
.split(",")
|
|
2579
|
+
.map((s) => s.trim())
|
|
2580
|
+
.filter((s) => s.length > 0);
|
|
2581
|
+
const licenseId =
|
|
2582
|
+
opts.licenseId != null && opts.licenseId !== ""
|
|
2583
|
+
? opts.licenseId
|
|
2584
|
+
: `LIC-${issuedAt}-${opts.plan}`;
|
|
2585
|
+
|
|
2586
|
+
let container;
|
|
2587
|
+
try {
|
|
2588
|
+
container = await license.buildLicense(
|
|
2589
|
+
{
|
|
2590
|
+
licenseId,
|
|
2591
|
+
customer: opts.customer,
|
|
2592
|
+
plan: opts.plan,
|
|
2593
|
+
entitlements,
|
|
2594
|
+
issuedAt,
|
|
2595
|
+
expiresAt: opts.expires,
|
|
2596
|
+
},
|
|
2597
|
+
wallet
|
|
2598
|
+
);
|
|
2599
|
+
} catch (e) {
|
|
2600
|
+
// A LicenseError (bad date, unknown entitlement, expiresAt<=issuedAt, …) is a
|
|
2601
|
+
// usage error — NEVER echo the key (a build error carries only the bad field).
|
|
2602
|
+
writeErr(`error: ${e.message}\n`);
|
|
2603
|
+
return EXIT.USAGE;
|
|
2604
|
+
}
|
|
2605
|
+
|
|
2606
|
+
const canonical = license.serializeSignedLicense(container);
|
|
2607
|
+
// The PUBLIC vendor address — recovered from the signature, never the key.
|
|
2608
|
+
const vendor = coreAttestation.recoverSigner(container);
|
|
2609
|
+
const payload = JSON.parse(container.attestation);
|
|
2610
|
+
|
|
2611
|
+
let outAbs = null;
|
|
2612
|
+
if (opts.out) {
|
|
2613
|
+
outAbs = path.resolve(opts.out);
|
|
2614
|
+
try {
|
|
2615
|
+
fs.writeFileSync(outAbs, canonical);
|
|
2616
|
+
} catch (e) {
|
|
2617
|
+
writeErr(`error: cannot write --out license file ${opts.out}: ${e.message}\n`);
|
|
2618
|
+
return EXIT.IO;
|
|
2619
|
+
}
|
|
2620
|
+
}
|
|
2621
|
+
|
|
2622
|
+
if (opts.json) {
|
|
2623
|
+
// ONLY public fields: vendor ADDRESS, the license summary, the path — NEVER the
|
|
2624
|
+
// key. With no --out the canonical bytes ride in `container` (artifact parity).
|
|
2625
|
+
write(
|
|
2626
|
+
JSON.stringify(
|
|
2627
|
+
{
|
|
2628
|
+
issued: true,
|
|
2629
|
+
vendor,
|
|
2630
|
+
licenseId: payload.licenseId,
|
|
2631
|
+
customer: payload.customer,
|
|
2632
|
+
plan: payload.plan,
|
|
2633
|
+
entitlements: payload.entitlements,
|
|
2634
|
+
issuedAt: payload.issuedAt,
|
|
2635
|
+
expiresAt: payload.expiresAt,
|
|
2636
|
+
out: outAbs,
|
|
2637
|
+
container: outAbs ? null : canonical,
|
|
2638
|
+
},
|
|
2639
|
+
null,
|
|
2640
|
+
2
|
|
2641
|
+
) + "\n"
|
|
2642
|
+
);
|
|
2643
|
+
} else {
|
|
2644
|
+
write(`issued TrustLedger license by vendor ${vendor}\n`);
|
|
2645
|
+
write(` licenseId: ${payload.licenseId}\n`);
|
|
2646
|
+
write(` customer: ${payload.customer}\n`);
|
|
2647
|
+
write(` plan: ${payload.plan}\n`);
|
|
2648
|
+
write(` entitlements: ${payload.entitlements.join(", ")}\n`);
|
|
2649
|
+
write(` issuedAt: ${payload.issuedAt}\n`);
|
|
2650
|
+
write(` expiresAt: ${payload.expiresAt}\n`);
|
|
2651
|
+
if (outAbs) {
|
|
2652
|
+
write(` written: ${outAbs}\n`);
|
|
2653
|
+
} else {
|
|
2654
|
+
// No --out: emit the canonical signed bytes after the human header.
|
|
2655
|
+
write(canonical);
|
|
2656
|
+
}
|
|
2657
|
+
}
|
|
2658
|
+
return EXIT.PASS;
|
|
2659
|
+
}
|
|
2660
|
+
|
|
2661
|
+
// ---------------------------------------------------------------------------
|
|
2662
|
+
// `vh trust license fulfill` (T-37.2) — the order -> license mapping as a command.
|
|
2663
|
+
//
|
|
2664
|
+
// The self-serve fulfillment seam: given the planId a customer bought (+ their
|
|
2665
|
+
// name, when the period is paid through), resolve it in the plan catalog, copy the
|
|
2666
|
+
// plan's entitlements VERBATIM, derive the [issuedAt, expiresAt] window, and emit
|
|
2667
|
+
// the SAME signed `*.vhlicense.json` the existing `verify` / reconcile gate already
|
|
2668
|
+
// accept — so a billing webhook's fulfillment handler is ONE command per sale.
|
|
2669
|
+
//
|
|
2670
|
+
// The catalog is the BUNDLED baseline by default (the seller's reviewed price-list,
|
|
2671
|
+
// shipped as a DRAFT skeleton — set YOUR price/term per planId), or an explicit
|
|
2672
|
+
// `--catalog <file>`. The key is read the EXACT read-used-discarded way `license
|
|
2673
|
+
// issue` / `vh dataset sign` read it (EXACTLY ONE of --key-env/--key-file; the loop
|
|
2674
|
+
// NEVER holds the key, NEVER echoes it). Entitlements are NEVER hand-typed here —
|
|
2675
|
+
// they come ONLY from the resolved plan, so a typo can never mis-entitle a sale.
|
|
2676
|
+
// ---------------------------------------------------------------------------
|
|
2677
|
+
|
|
2678
|
+
// The bundled DRAFT plan catalog `fulfill` resolves a plan against when no
|
|
2679
|
+
// --catalog is given. Read from THIS package's own fixtures dir — never a caller
|
|
2680
|
+
// path — so the default resolution is deterministic and self-contained.
|
|
2681
|
+
const BUNDLED_CATALOG = path.join(__dirname, "fixtures", "plans", "baseline.json");
|
|
2682
|
+
|
|
2683
|
+
// Parse `license fulfill` argv. EXACTLY-ONE-of key sources is enforced downstream
|
|
2684
|
+
// by loadSigningWallet (so neither/both error key-free); the parser only collects.
|
|
2685
|
+
function parseLicenseFulfillArgs(argv) {
|
|
2686
|
+
const opts = {
|
|
2687
|
+
plan: undefined, // a planId in the catalog
|
|
2688
|
+
customer: undefined,
|
|
2689
|
+
paidThrough: undefined, // OPTIONAL ISO instant; default = issuedAt + plan term
|
|
2690
|
+
issued: undefined, // OPTIONAL ISO instant; default "now" supplied by the command
|
|
2691
|
+
licenseId: undefined, // OPTIONAL; defaulted deterministically by fulfillOrder
|
|
2692
|
+
catalog: undefined, // OPTIONAL path to a plan catalog JSON; default = bundled baseline
|
|
2693
|
+
keyEnv: undefined,
|
|
2694
|
+
keyFile: undefined,
|
|
2695
|
+
out: undefined,
|
|
2696
|
+
json: false,
|
|
2697
|
+
};
|
|
2698
|
+
for (let i = 0; i < argv.length; i++) {
|
|
2699
|
+
const a = argv[i];
|
|
2700
|
+
const need = () => {
|
|
2701
|
+
const v = argv[++i];
|
|
2702
|
+
if (v === undefined || String(v).startsWith("--")) {
|
|
2703
|
+
const e = new Error(`${a} requires a value`);
|
|
2704
|
+
e.usage = true;
|
|
2705
|
+
throw e;
|
|
2706
|
+
}
|
|
2707
|
+
return v;
|
|
2708
|
+
};
|
|
2709
|
+
switch (a) {
|
|
2710
|
+
case "--plan": opts.plan = need(); break;
|
|
2711
|
+
case "--customer": opts.customer = need(); break;
|
|
2712
|
+
case "--paid-through": opts.paidThrough = need(); break;
|
|
2713
|
+
case "--issued": opts.issued = need(); break;
|
|
2714
|
+
case "--license-id": opts.licenseId = need(); break;
|
|
2715
|
+
case "--catalog": opts.catalog = need(); break;
|
|
2716
|
+
case "--key-env": opts.keyEnv = need(); break;
|
|
2717
|
+
case "--key-file": opts.keyFile = need(); break;
|
|
2718
|
+
case "--out": opts.out = need(); break;
|
|
2719
|
+
case "--json": opts.json = true; break;
|
|
2720
|
+
default: {
|
|
2721
|
+
const e = new Error(`unknown option: ${a}`);
|
|
2722
|
+
e.usage = true;
|
|
2723
|
+
throw e;
|
|
2724
|
+
}
|
|
2725
|
+
}
|
|
2726
|
+
}
|
|
2727
|
+
return opts;
|
|
2728
|
+
}
|
|
2729
|
+
|
|
2730
|
+
async function cmdLicenseFulfill(argv, io = {}) {
|
|
2731
|
+
const write = io.write || ((s) => process.stdout.write(s));
|
|
2732
|
+
const writeErr = io.writeErr || ((s) => process.stderr.write(s));
|
|
2733
|
+
let opts;
|
|
2734
|
+
try {
|
|
2735
|
+
opts = parseLicenseFulfillArgs(argv);
|
|
2736
|
+
} catch (e) {
|
|
2737
|
+
writeErr(`error: ${e.message}\n`);
|
|
2738
|
+
return EXIT.USAGE;
|
|
2739
|
+
}
|
|
2740
|
+
|
|
2741
|
+
// Required order fields (the key sources are validated by loadSigningWallet; the
|
|
2742
|
+
// plan is resolved against the catalog by fulfillOrder).
|
|
2743
|
+
for (const [flag, val] of [
|
|
2744
|
+
["--plan", opts.plan],
|
|
2745
|
+
["--customer", opts.customer],
|
|
2746
|
+
]) {
|
|
2747
|
+
if (val == null) {
|
|
2748
|
+
writeErr(`error: \`vh trust license fulfill\` requires ${flag}\n`);
|
|
2749
|
+
return EXIT.USAGE;
|
|
2750
|
+
}
|
|
2751
|
+
}
|
|
2752
|
+
|
|
2753
|
+
// Load + strictly validate the plan catalog (bundled baseline by default). A
|
|
2754
|
+
// malformed/unreadable catalog is a usage error (a bad data file, not an IO crash).
|
|
2755
|
+
const catalogPath = opts.catalog != null ? path.resolve(opts.catalog) : BUNDLED_CATALOG;
|
|
2756
|
+
let catalog;
|
|
2757
|
+
try {
|
|
2758
|
+
const text = fs.readFileSync(catalogPath, "utf8");
|
|
2759
|
+
catalog = plans.validatePlanCatalog(JSON.parse(text));
|
|
2760
|
+
} catch (e) {
|
|
2761
|
+
writeErr(`error: cannot load plan catalog ${catalogPath}: ${e.message}\n`);
|
|
2762
|
+
return EXIT.USAGE;
|
|
2763
|
+
}
|
|
2764
|
+
|
|
2765
|
+
// Resolve the HUMAN-supplied key into an in-process Wallet FIRST, BEFORE building
|
|
2766
|
+
// anything — neither/both sources, a missing env var, an unreadable file, or a
|
|
2767
|
+
// malformed/zero key hard-errors here with a KEY-FREE message (the SAME core +
|
|
2768
|
+
// posture as `license issue` / `vh dataset sign`). The loop never holds a key.
|
|
2769
|
+
let wallet;
|
|
2770
|
+
try {
|
|
2771
|
+
({ wallet } = coreAttestation.loadSigningWallet({ keyEnv: opts.keyEnv, keyFile: opts.keyFile }));
|
|
2772
|
+
} catch (e) {
|
|
2773
|
+
writeErr(`error: ${e.message}\n`);
|
|
2774
|
+
return EXIT.USAGE;
|
|
2775
|
+
}
|
|
2776
|
+
|
|
2777
|
+
// issuedAt defaults to the injectable clock (a real ISO instant at runtime; a
|
|
2778
|
+
// pinned one in tests). The order -> license-params mapping is PURE + deterministic.
|
|
2779
|
+
const issuedAt = opts.issued != null ? opts.issued : (io.nowISO || nowISO)();
|
|
2780
|
+
let params;
|
|
2781
|
+
try {
|
|
2782
|
+
params = license.fulfillOrder(
|
|
2783
|
+
{
|
|
2784
|
+
plan: opts.plan,
|
|
2785
|
+
customer: opts.customer,
|
|
2786
|
+
issuedAt,
|
|
2787
|
+
paidThrough: opts.paidThrough != null ? opts.paidThrough : undefined,
|
|
2788
|
+
licenseId: opts.licenseId != null && opts.licenseId !== "" ? opts.licenseId : undefined,
|
|
2789
|
+
},
|
|
2790
|
+
catalog
|
|
2791
|
+
);
|
|
2792
|
+
} catch (e) {
|
|
2793
|
+
// An unknown plan / paidThrough<=issuedAt / malformed date is a usage error —
|
|
2794
|
+
// NEVER echo the key (a mapping error carries only the bad order field).
|
|
2795
|
+
writeErr(`error: ${e.message}\n`);
|
|
2796
|
+
return EXIT.USAGE;
|
|
2797
|
+
}
|
|
2798
|
+
|
|
2799
|
+
// Sign the derived params into the SAME signed container `issue` mints — the
|
|
2800
|
+
// existing verify / gate accept it byte-for-byte. No key handling here; the key
|
|
2801
|
+
// lives only inside `wallet`.
|
|
2802
|
+
let container;
|
|
2803
|
+
try {
|
|
2804
|
+
container = await license.buildLicense(params, wallet);
|
|
2805
|
+
} catch (e) {
|
|
2806
|
+
writeErr(`error: ${e.message}\n`);
|
|
2807
|
+
return EXIT.USAGE;
|
|
2808
|
+
}
|
|
2809
|
+
|
|
2810
|
+
const canonical = license.serializeSignedLicense(container);
|
|
2811
|
+
// The PUBLIC vendor address — recovered from the signature, never the key.
|
|
2812
|
+
const vendor = coreAttestation.recoverSigner(container);
|
|
2813
|
+
const payload = JSON.parse(container.attestation);
|
|
2814
|
+
|
|
2815
|
+
let outAbs = null;
|
|
2816
|
+
if (opts.out) {
|
|
2817
|
+
outAbs = path.resolve(opts.out);
|
|
2818
|
+
try {
|
|
2819
|
+
fs.writeFileSync(outAbs, canonical);
|
|
2820
|
+
} catch (e) {
|
|
2821
|
+
writeErr(`error: cannot write --out license file ${opts.out}: ${e.message}\n`);
|
|
2822
|
+
return EXIT.IO;
|
|
2823
|
+
}
|
|
2824
|
+
}
|
|
2825
|
+
|
|
2826
|
+
if (opts.json) {
|
|
2827
|
+
// ONLY public fields: vendor ADDRESS, the license summary, the path — NEVER the
|
|
2828
|
+
// key. With no --out the canonical bytes ride in `container` (artifact parity).
|
|
2829
|
+
write(
|
|
2830
|
+
JSON.stringify(
|
|
2831
|
+
{
|
|
2832
|
+
fulfilled: true,
|
|
2833
|
+
vendor,
|
|
2834
|
+
licenseId: payload.licenseId,
|
|
2835
|
+
customer: payload.customer,
|
|
2836
|
+
plan: payload.plan,
|
|
2837
|
+
entitlements: payload.entitlements,
|
|
2838
|
+
issuedAt: payload.issuedAt,
|
|
2839
|
+
expiresAt: payload.expiresAt,
|
|
2840
|
+
out: outAbs,
|
|
2841
|
+
container: outAbs ? null : canonical,
|
|
2842
|
+
},
|
|
2843
|
+
null,
|
|
2844
|
+
2
|
|
2845
|
+
) + "\n"
|
|
2846
|
+
);
|
|
2847
|
+
} else {
|
|
2848
|
+
write(`fulfilled TrustLedger license for plan ${payload.plan} by vendor ${vendor}\n`);
|
|
2849
|
+
write(` licenseId: ${payload.licenseId}\n`);
|
|
2850
|
+
write(` customer: ${payload.customer}\n`);
|
|
2851
|
+
write(` plan: ${payload.plan}\n`);
|
|
2852
|
+
write(` entitlements: ${payload.entitlements.join(", ")}\n`);
|
|
2853
|
+
write(` issuedAt: ${payload.issuedAt}\n`);
|
|
2854
|
+
write(` expiresAt: ${payload.expiresAt}\n`);
|
|
2855
|
+
if (outAbs) {
|
|
2856
|
+
write(` written: ${outAbs}\n`);
|
|
2857
|
+
} else {
|
|
2858
|
+
// No --out: emit the canonical signed bytes after the human header.
|
|
2859
|
+
write(canonical);
|
|
2860
|
+
}
|
|
2861
|
+
}
|
|
2862
|
+
return EXIT.PASS;
|
|
2863
|
+
}
|
|
2864
|
+
|
|
2865
|
+
// Parse `license verify <file> --vendor <0xaddr> [--json] [--now <iso>]`.
|
|
2866
|
+
function parseLicenseVerifyArgs(argv) {
|
|
2867
|
+
const opts = { file: undefined, vendor: undefined, json: false, now: undefined, _positionals: [] };
|
|
2868
|
+
for (let i = 0; i < argv.length; i++) {
|
|
2869
|
+
const a = argv[i];
|
|
2870
|
+
switch (a) {
|
|
2871
|
+
case "--vendor": opts.vendor = argv[++i]; break;
|
|
2872
|
+
case "--now": opts.now = argv[++i]; break;
|
|
2873
|
+
case "--json": opts.json = true; break;
|
|
2874
|
+
default:
|
|
2875
|
+
if (a && a.startsWith("--")) {
|
|
2876
|
+
const e = new Error(`unknown option: ${a}`);
|
|
2877
|
+
e.usage = true;
|
|
2878
|
+
throw e;
|
|
2879
|
+
}
|
|
2880
|
+
opts._positionals.push(a);
|
|
2881
|
+
}
|
|
2882
|
+
}
|
|
2883
|
+
opts.file = opts._positionals[0];
|
|
2884
|
+
return opts;
|
|
2885
|
+
}
|
|
2886
|
+
|
|
2887
|
+
function cmdLicenseVerify(argv, io = {}) {
|
|
2888
|
+
const write = io.write || ((s) => process.stdout.write(s));
|
|
2889
|
+
const writeErr = io.writeErr || ((s) => process.stderr.write(s));
|
|
2890
|
+
let opts;
|
|
2891
|
+
try {
|
|
2892
|
+
opts = parseLicenseVerifyArgs(argv);
|
|
2893
|
+
} catch (e) {
|
|
2894
|
+
writeErr(`error: ${e.message}\n`);
|
|
2895
|
+
return EXIT.USAGE;
|
|
2896
|
+
}
|
|
2897
|
+
if (!opts.file) {
|
|
2898
|
+
writeErr("error: `vh trust license verify` requires a <file>\n");
|
|
2899
|
+
return EXIT.USAGE;
|
|
2900
|
+
}
|
|
2901
|
+
if (opts.vendor == null) {
|
|
2902
|
+
writeErr("error: `vh trust license verify` requires --vendor <0xaddr> (the issuer to pin to)\n");
|
|
2903
|
+
return EXIT.USAGE;
|
|
2904
|
+
}
|
|
2905
|
+
|
|
2906
|
+
let container;
|
|
2907
|
+
try {
|
|
2908
|
+
const text = fs.readFileSync(path.resolve(opts.file), "utf8");
|
|
2909
|
+
container = license.readLicense(text);
|
|
2910
|
+
} catch (e) {
|
|
2911
|
+
// A missing/garbled container is a malformed verdict (INVALID), not a crash, so
|
|
2912
|
+
// a scripted check sees the 3 exit + the reason — but we surface the IO cause.
|
|
2913
|
+
writeErr(`error: cannot read license file ${opts.file}: ${e.message}\n`);
|
|
2914
|
+
return EXIT.IO;
|
|
2915
|
+
}
|
|
2916
|
+
|
|
2917
|
+
// `now` is the injectable clock (a pinned instant in tests); default real now.
|
|
2918
|
+
const now = opts.now != null ? opts.now : (io.nowISO || nowISO)();
|
|
2919
|
+
let verdict;
|
|
2920
|
+
try {
|
|
2921
|
+
verdict = license.verifyLicense(container, { now, vendorAddress: opts.vendor });
|
|
2922
|
+
} catch (e) {
|
|
2923
|
+
// A malformed --vendor (or bad --now) is a caller error — usage, key-free.
|
|
2924
|
+
writeErr(`error: ${e.message}\n`);
|
|
2925
|
+
return EXIT.USAGE;
|
|
2926
|
+
}
|
|
2927
|
+
|
|
2928
|
+
// Read the embedded payload's expiry/entitlements for the report (present even on
|
|
2929
|
+
// an expired/wrong-issuer verdict, so the human sees WHAT was rejected).
|
|
2930
|
+
const payload = verdict.payload;
|
|
2931
|
+
if (opts.json) {
|
|
2932
|
+
write(
|
|
2933
|
+
JSON.stringify(
|
|
2934
|
+
{
|
|
2935
|
+
valid: verdict.valid,
|
|
2936
|
+
reason: verdict.reason, // EXACTLY as verifyLicense returns it
|
|
2937
|
+
vendor: verdict.vendorAddress,
|
|
2938
|
+
recoveredSigner: verdict.recoveredSigner,
|
|
2939
|
+
entitlements: payload ? payload.entitlements : [],
|
|
2940
|
+
issuedAt: payload ? payload.issuedAt : null,
|
|
2941
|
+
expiresAt: payload ? payload.expiresAt : null,
|
|
2942
|
+
now: verdict.now,
|
|
2943
|
+
},
|
|
2944
|
+
null,
|
|
2945
|
+
2
|
|
2946
|
+
) + "\n"
|
|
2947
|
+
);
|
|
2948
|
+
} else if (verdict.valid) {
|
|
2949
|
+
write("VALID\n");
|
|
2950
|
+
write(` vendor: ${verdict.vendorAddress}\n`);
|
|
2951
|
+
write(` customer: ${payload.customer}\n`);
|
|
2952
|
+
write(` plan: ${payload.plan}\n`);
|
|
2953
|
+
write(` entitlements: ${payload.entitlements.join(", ")}\n`);
|
|
2954
|
+
write(` expiresAt: ${payload.expiresAt}\n`);
|
|
2955
|
+
} else {
|
|
2956
|
+
write("INVALID\n");
|
|
2957
|
+
write(` reason: ${verdict.reason}\n`);
|
|
2958
|
+
if (payload) {
|
|
2959
|
+
write(` entitlements: ${payload.entitlements.join(", ")}\n`);
|
|
2960
|
+
write(` expiresAt: ${payload.expiresAt}\n`);
|
|
2961
|
+
}
|
|
2962
|
+
}
|
|
2963
|
+
// 0 valid / 3 invalid — the SAME verdict semantics as verifyLicense / verify-seal.
|
|
2964
|
+
return verdict.valid ? EXIT.PASS : EXIT.FAIL;
|
|
2965
|
+
}
|
|
2966
|
+
|
|
2967
|
+
// `vh trust license <issue|verify> ...` sub-dispatch.
|
|
2968
|
+
function cmdLicense(argv, io = {}) {
|
|
2969
|
+
const writeErr = io.writeErr || ((s) => process.stderr.write(s));
|
|
2970
|
+
const [sub, ...rest] = argv;
|
|
2971
|
+
if (sub === "issue") return cmdLicenseIssue(rest, io);
|
|
2972
|
+
if (sub === "fulfill") return cmdLicenseFulfill(rest, io);
|
|
2973
|
+
if (sub === "verify") return cmdLicenseVerify(rest, io);
|
|
2974
|
+
if (sub === "help" || sub === "-h" || sub === "--help") {
|
|
2975
|
+
(io.write || ((s) => process.stdout.write(s)))(licenseHelp());
|
|
2976
|
+
return EXIT.PASS;
|
|
2977
|
+
}
|
|
2978
|
+
writeErr(
|
|
2979
|
+
`error: unknown license subcommand: ${sub === undefined ? "(none)" : sub} ` +
|
|
2980
|
+
"(expected: issue, fulfill, verify)\n" +
|
|
2981
|
+
licenseHelp()
|
|
2982
|
+
);
|
|
2983
|
+
return EXIT.USAGE;
|
|
2984
|
+
}
|
|
2985
|
+
|
|
2986
|
+
function licenseHelp() {
|
|
2987
|
+
return [
|
|
2988
|
+
"vh trust license — issue + OFFLINE-verify a TrustLedger product license",
|
|
2989
|
+
"",
|
|
2990
|
+
" issue --customer <name> --plan <plan> --entitlements <a,b,c> --expires <ISO>",
|
|
2991
|
+
" (--key-env <VAR> | --key-file <path>) [--issued <ISO>] [--license-id <id>] [--out <file>] [--json]",
|
|
2992
|
+
" Sign a license with a key YOU supply at runtime (read-used-discarded, NEVER",
|
|
2993
|
+
" written/logged/echoed). Prints ONLY the public vendor address + the summary + path.",
|
|
2994
|
+
` Entitlements (closed set): ${license.ENTITLEMENT_FLAGS.join(", ")}.`,
|
|
2995
|
+
"",
|
|
2996
|
+
" fulfill --plan <id> --customer <name> [--paid-through <ISO>] [--catalog <file>]",
|
|
2997
|
+
" (--key-env <VAR> | --key-file <path>) [--issued <ISO>] [--license-id <id>] [--out <file>] [--json]",
|
|
2998
|
+
" The order -> license mapping: resolve <id> in the plan catalog (bundled DRAFT baseline",
|
|
2999
|
+
" by default, or --catalog), copy that plan's entitlements VERBATIM, derive the window",
|
|
3000
|
+
" (--paid-through, else issuedAt + the plan's term), and emit the SAME signed license",
|
|
3001
|
+
" `verify` / the reconcile gate accept. Entitlements are NEVER hand-typed — a typo can't",
|
|
3002
|
+
" mis-entitle a sale. Same key posture as `issue` (read-used-discarded, never echoed).",
|
|
3003
|
+
"",
|
|
3004
|
+
" verify <file> --vendor <0xaddr> [--json] [--now <ISO>]",
|
|
3005
|
+
" Read-only, OFFLINE, key-free. Prints VALID/INVALID + reason + entitlements + expiry.",
|
|
3006
|
+
" Exit 0 valid / 3 invalid (reason: malformed|bad_signature|wrong_issuer|not_yet_valid|expired).",
|
|
3007
|
+
"",
|
|
3008
|
+
"A license GATES the paid reconcile surfaces (--state/--policy, --seal): pass",
|
|
3009
|
+
"`vh trust reconcile ... --license <file> --vendor <0xaddr>` to unlock them. The FREE",
|
|
3010
|
+
"tier (baseline reconcile + `vh trust inspect`) needs no license.",
|
|
3011
|
+
"",
|
|
3012
|
+
].join("\n");
|
|
3013
|
+
}
|
|
3014
|
+
|
|
3015
|
+
// Real "now" as a canonical ISO-8601 UTC instant — the issuer/verify default clock,
|
|
3016
|
+
// isolated + injectable so the commands stay deterministic under test.
|
|
3017
|
+
function nowISO() {
|
|
3018
|
+
return new Date().toISOString();
|
|
3019
|
+
}
|
|
3020
|
+
|
|
3021
|
+
// ---------------------------------------------------------------------------
|
|
3022
|
+
// argv dispatch
|
|
3023
|
+
// ---------------------------------------------------------------------------
|
|
3024
|
+
|
|
3025
|
+
function cmdReconcile(argv, io = {}) {
|
|
3026
|
+
const writeErr = io.writeErr || ((s) => process.stderr.write(s));
|
|
3027
|
+
let opts;
|
|
3028
|
+
try {
|
|
3029
|
+
opts = parseReconcileArgs(argv);
|
|
3030
|
+
} catch (e) {
|
|
3031
|
+
writeErr(`error: ${e.message}\n`);
|
|
3032
|
+
return EXIT.USAGE;
|
|
3033
|
+
}
|
|
3034
|
+
const res = runReconcile(opts, io);
|
|
3035
|
+
return res.code;
|
|
3036
|
+
}
|
|
3037
|
+
|
|
3038
|
+
// `vh trust <sub> ...` dispatcher.
|
|
3039
|
+
function cmdTrust(argv, io = {}) {
|
|
3040
|
+
const writeErr = io.writeErr || ((s) => process.stderr.write(s));
|
|
3041
|
+
const [sub, ...rest] = argv;
|
|
3042
|
+
if (sub === "reconcile") {
|
|
3043
|
+
return cmdReconcile(rest, io);
|
|
3044
|
+
}
|
|
3045
|
+
if (sub === "inspect") {
|
|
3046
|
+
return cmdInspect(rest, io);
|
|
3047
|
+
}
|
|
3048
|
+
if (sub === "corpus") {
|
|
3049
|
+
return cmdCorpus(rest, io);
|
|
3050
|
+
}
|
|
3051
|
+
if (sub === "value-proof") {
|
|
3052
|
+
return cmdValueProof(rest, io);
|
|
3053
|
+
}
|
|
3054
|
+
if (sub === "verify-seal") {
|
|
3055
|
+
return cmdVerifySeal(rest, io);
|
|
3056
|
+
}
|
|
3057
|
+
if (sub === "serve") {
|
|
3058
|
+
return cmdServe(rest, io);
|
|
3059
|
+
}
|
|
3060
|
+
if (sub === "license") {
|
|
3061
|
+
return cmdLicense(rest, io);
|
|
3062
|
+
}
|
|
3063
|
+
if (sub === "help" || sub === "-h" || sub === "--help") {
|
|
3064
|
+
(io.write || ((s) => process.stdout.write(s)))(trustHelp());
|
|
3065
|
+
return EXIT.PASS;
|
|
3066
|
+
}
|
|
3067
|
+
writeErr(
|
|
3068
|
+
`error: unknown trust subcommand: ${sub === undefined ? "(none)" : sub} ` +
|
|
3069
|
+
`(expected: reconcile, inspect, corpus, value-proof, verify-seal, serve, license)\n` +
|
|
3070
|
+
trustHelp()
|
|
3071
|
+
);
|
|
3072
|
+
return EXIT.USAGE;
|
|
3073
|
+
}
|
|
3074
|
+
|
|
3075
|
+
// The in-band `vh trust` help — names the full command set (including the seal
|
|
3076
|
+
// commands) so the seal posture is discoverable without external docs.
|
|
3077
|
+
function trustHelp() {
|
|
3078
|
+
return [
|
|
3079
|
+
"vh trust — TrustLedger three-way trust-account reconciliation",
|
|
3080
|
+
"",
|
|
3081
|
+
"Subcommands:",
|
|
3082
|
+
" reconcile <bank> <ledger> <rentroll> [--out <dir>] [--seal [<file>]] [--license <f> --vendor <0xaddr>]",
|
|
3083
|
+
" run the whole pipeline -> a dated audit packet (HTML+CSV; PASS/FAIL exit).",
|
|
3084
|
+
" FREE: baseline-policy reconcile needs no license. PAID (require --license + --vendor):",
|
|
3085
|
+
" --state/--policy (multi-state policy packs) and --seal. Without a valid, vendor-pinned",
|
|
3086
|
+
" license carrying the matching entitlement those flags hard-error (exit 2) — see `license`.",
|
|
3087
|
+
" --seal [<file>] additionally writes a TAMPER-EVIDENT reconciliation seal AFTER",
|
|
3088
|
+
" the packet (binding the 3 source inputs + every emitted packet file, and the",
|
|
3089
|
+
" emitted close if --emit-close). --seal REQUIRES --out (no packet, nothing to seal).",
|
|
3090
|
+
" The 3 source inputs are sealed by BASENAME so the binding TRAVELS with the packet:",
|
|
3091
|
+
" ship each source NEXT TO the seal (same dir) and the handoff verifies anywhere.",
|
|
3092
|
+
" license issue|verify ...",
|
|
3093
|
+
" issue: mint a signed product license with a key YOU supply (read-used-discarded).",
|
|
3094
|
+
" verify: read-only, OFFLINE check of a license against --vendor (VALID/INVALID, 0/3).",
|
|
3095
|
+
" A valid license + matching --vendor unlocks reconcile's paid surfaces. `vh trust license -h`.",
|
|
3096
|
+
" inspect <file> --as <bank|ledger|rentroll>",
|
|
3097
|
+
" read-only validator/preview of ONE input file (writes nothing).",
|
|
3098
|
+
" corpus [--json]",
|
|
3099
|
+
" run the committed out-of-trust corpus and print a per-scenario table",
|
|
3100
|
+
" (id, control, trust-law principle, expected vs ACTUAL verdict, OK/MISMATCH)",
|
|
3101
|
+
" + a one-line summary. Read-only, writes nothing. Exit 0 when every scenario",
|
|
3102
|
+
" matches its recorded verdict, 3 on ANY mismatch (a gate regression / corpus",
|
|
3103
|
+
" drift). This is the one-command artifact to confirm the gate FAILs the exact",
|
|
3104
|
+
" frauds it claims to catch, without reading test/.",
|
|
3105
|
+
" value-proof <bank> <ledger> <rentroll> [--state/--policy <f> --license <f> --vendor <0xaddr>]",
|
|
3106
|
+
" [--prior-close <f>] [--map-file <f>] [--map <src>:<logical>=<header>] [--asserted-flagged]",
|
|
3107
|
+
" [--asserted-net <dollars>] [--json]",
|
|
3108
|
+
" run the partner's OWN already-closed period through the SAME reconcile gate and",
|
|
3109
|
+
" print what their manual close let through: a one-word OUTCOME, the headline, and",
|
|
3110
|
+
" the per-class dollar table — every number read verbatim off the gate's triage.",
|
|
3111
|
+
" Threads the SAME verdict-shaping inputs reconcile does: --state/--policy (license-gated,",
|
|
3112
|
+
" can ESCALATE a finding and FLIP the verdict), --prior-close (continuity), and",
|
|
3113
|
+
" --map-file/--map (non-default headers). The reported gate verdict + exit code EQUAL the",
|
|
3114
|
+
" production gate's for the SAME inputs — value-proof never claims clean on a period the",
|
|
3115
|
+
" licensed gate FAILs. Read-only, writes nothing. Exit 0 clean_confirmed / 3 out_of_trust_missed",
|
|
3116
|
+
" (a missed shortage) / 4 data_gap_only (fix-and-rerun, distinct from a real FAIL) / 2 usage / 1 IO.",
|
|
3117
|
+
" serve [--port <n>] [--host <h>]",
|
|
3118
|
+
" launch the LOCAL web front-door (default http://127.0.0.1:4173/) so a broker can drop",
|
|
3119
|
+
" the three files in a browser and watch the balances tie out. Files are processed IN",
|
|
3120
|
+
" MEMORY; NOTHING is written to disk server-side. Binds to localhost — exposing it (behind",
|
|
3121
|
+
" YOUR nginx/Cloudflare on YOUR domain with TLS) is a HUMAN deploy step, never auto-deployed.",
|
|
3122
|
+
" verify-seal <sealfile> [--dir <d>] [--inputs <d>] [--json]",
|
|
3123
|
+
" read-only, OFFLINE (NO key, NO network): re-derive each sealed file from disk and",
|
|
3124
|
+
" print ACCEPTED (0) only when EVERY file matches; else REJECTED (3) with the precise",
|
|
3125
|
+
" per-file CHANGED/MISSING/UNEXPECTED list. Output files resolve relative to the seal's",
|
|
3126
|
+
" directory (or --dir); the source inputs resolve there too (or --inputs <d>) since they",
|
|
3127
|
+
" are sealed by basename. A seal is TAMPER-EVIDENT, NOT a trusted timestamp; CPA review governs.",
|
|
3128
|
+
"",
|
|
3129
|
+
"Exit: 0 ok / 3 gate FAIL (does-not-tie-out or REJECTED) / 2 usage / 1 IO.",
|
|
3130
|
+
"",
|
|
3131
|
+
].join("\n");
|
|
3132
|
+
}
|
|
3133
|
+
|
|
3134
|
+
module.exports = {
|
|
3135
|
+
EXIT,
|
|
3136
|
+
parseReconcileArgs,
|
|
3137
|
+
runReconcile,
|
|
3138
|
+
cmdReconcile,
|
|
3139
|
+
parseInspectArgs,
|
|
3140
|
+
runInspect,
|
|
3141
|
+
cmdInspect,
|
|
3142
|
+
renderInspect,
|
|
3143
|
+
inspectHint,
|
|
3144
|
+
parseCorpusArgs,
|
|
3145
|
+
runCorpusCmd,
|
|
3146
|
+
cmdCorpus,
|
|
3147
|
+
renderCorpus,
|
|
3148
|
+
corpusSummaryLine,
|
|
3149
|
+
CORPUS_CAVEAT,
|
|
3150
|
+
parseValueProofArgs,
|
|
3151
|
+
runValueProof,
|
|
3152
|
+
cmdValueProof,
|
|
3153
|
+
renderValueProof,
|
|
3154
|
+
VALUE_PROOF_CAVEAT,
|
|
3155
|
+
VALUE_PROOF_EXIT,
|
|
3156
|
+
parseVerifySealArgs,
|
|
3157
|
+
runVerifySeal,
|
|
3158
|
+
cmdVerifySeal,
|
|
3159
|
+
renderVerifySeal,
|
|
3160
|
+
parseServeArgs,
|
|
3161
|
+
runServe,
|
|
3162
|
+
cmdServe,
|
|
3163
|
+
SERVE_DEFAULT_PORT,
|
|
3164
|
+
SERVE_DEFAULT_HOST,
|
|
3165
|
+
trustHelp,
|
|
3166
|
+
cmdTrust,
|
|
3167
|
+
parseLicenseIssueArgs,
|
|
3168
|
+
cmdLicenseIssue,
|
|
3169
|
+
parseLicenseFulfillArgs,
|
|
3170
|
+
cmdLicenseFulfill,
|
|
3171
|
+
parseLicenseVerifyArgs,
|
|
3172
|
+
cmdLicenseVerify,
|
|
3173
|
+
cmdLicense,
|
|
3174
|
+
licenseHelp,
|
|
3175
|
+
gateReconcile,
|
|
3176
|
+
PAID_FEATURE_ENTITLEMENTS,
|
|
3177
|
+
nowISO,
|
|
3178
|
+
todayISO,
|
|
3179
|
+
};
|