verifyhash 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (154) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +883 -0
  3. package/cli/abi/ContributionRegistry.json +881 -0
  4. package/cli/agent.js +2173 -0
  5. package/cli/anchor-artifact.js +853 -0
  6. package/cli/anchor.js +400 -0
  7. package/cli/claim.js +881 -0
  8. package/cli/core/agent-commit.js +448 -0
  9. package/cli/core/agent-session.js +598 -0
  10. package/cli/core/anchor-binding.js +663 -0
  11. package/cli/core/attestation.js +580 -0
  12. package/cli/core/evidence-plans.js +495 -0
  13. package/cli/core/fixtures/evidence-plans/baseline.json +19 -0
  14. package/cli/core/fulfill-intake.js +1082 -0
  15. package/cli/core/go-live-preflight.js +481 -0
  16. package/cli/core/license.js +534 -0
  17. package/cli/core/manifest.js +243 -0
  18. package/cli/core/packetseal.js +591 -0
  19. package/cli/core/registryArtifact.js +49 -0
  20. package/cli/core/revocation.js +539 -0
  21. package/cli/core/rfc3161.js +389 -0
  22. package/cli/core/timestamp.js +482 -0
  23. package/cli/core/trust-asof.js +479 -0
  24. package/cli/dataset.js +2950 -0
  25. package/cli/evidence.js +2227 -0
  26. package/cli/fulfill-webhook-http.js +438 -0
  27. package/cli/git.js +220 -0
  28. package/cli/hash.js +550 -0
  29. package/cli/identity.js +1072 -0
  30. package/cli/journal-cli.js +1110 -0
  31. package/cli/journal-log.js +454 -0
  32. package/cli/journal.js +334 -0
  33. package/cli/lineage.js +447 -0
  34. package/cli/list.js +287 -0
  35. package/cli/parcel.js +1509 -0
  36. package/cli/proof.js +578 -0
  37. package/cli/prove.js +300 -0
  38. package/cli/receipt.js +631 -0
  39. package/cli/registry.js +331 -0
  40. package/cli/reputation.js +344 -0
  41. package/cli/revocation.js +495 -0
  42. package/cli/serve-verify-http.js +298 -0
  43. package/cli/serve-verify.js +333 -0
  44. package/cli/show.js +339 -0
  45. package/cli/verify.js +383 -0
  46. package/cli/vh.js +3927 -0
  47. package/docs/ADOPT.md +183 -0
  48. package/docs/ADOPTION.json +11 -0
  49. package/docs/AGENTTRACE.md +247 -0
  50. package/docs/ANCHORING.md +167 -0
  51. package/docs/AUDIT.md +55 -0
  52. package/docs/CONFORMANCE.md +107 -0
  53. package/docs/DATALEDGER.md +638 -0
  54. package/docs/DECIDE.md +47 -0
  55. package/docs/DECISIONS-PENDING.md +27 -0
  56. package/docs/DEPLOY-PUBLIC-SITE.md +301 -0
  57. package/docs/ENGINE-LEDGER.json +12 -0
  58. package/docs/EVIDENCE.md +519 -0
  59. package/docs/GO-LIVE.md +66 -0
  60. package/docs/IDENTITY.md +123 -0
  61. package/docs/INDEPENDENT-VERIFICATION.md +377 -0
  62. package/docs/INTEGRITY-JOURNAL.md +337 -0
  63. package/docs/KEY-LIFECYCLE.md +179 -0
  64. package/docs/LICENSING.md +46 -0
  65. package/docs/LINEAGE.md +307 -0
  66. package/docs/LOOP-AUDIT-2026-07-03.json +580 -0
  67. package/docs/LOOP-HARDENING-PLAN.md +44 -0
  68. package/docs/MERKLE-LEAVES.md +113 -0
  69. package/docs/METRICS.jsonl +31 -0
  70. package/docs/MORNING.md +204 -0
  71. package/docs/PILOT.md +444 -0
  72. package/docs/PROOFPARCEL.md +227 -0
  73. package/docs/PROOFS.md +262 -0
  74. package/docs/RECEIPTS.md +341 -0
  75. package/docs/REPUTATION.md +158 -0
  76. package/docs/SDK.md +301 -0
  77. package/docs/STRATEGY-ARCHIVE.md +5055 -0
  78. package/docs/SUPERVISOR-RUNBOOK.md +52 -0
  79. package/docs/TRUST-BOUNDARIES.md +335 -0
  80. package/docs/TRUSTLEDGER.md +1976 -0
  81. package/docs/USAGE-BUDGET.json +121 -0
  82. package/docs/VERIFY-SERVICE.md +168 -0
  83. package/index.js +160 -0
  84. package/package.json +41 -0
  85. package/trustledger/build-standalone.js +796 -0
  86. package/trustledger/cli.js +3179 -0
  87. package/trustledger/close.js +391 -0
  88. package/trustledger/corpus.js +159 -0
  89. package/trustledger/dist/BUILD-PROVENANCE.json +99 -0
  90. package/trustledger/dist/trustledger-standalone.html +6197 -0
  91. package/trustledger/dist/trustledger-standalone.html.sha256 +1 -0
  92. package/trustledger/door-core.js +442 -0
  93. package/trustledger/fixtures/bank.csv +7 -0
  94. package/trustledger/fixtures/bank.malformed.csv +3 -0
  95. package/trustledger/fixtures/bank.noalias.csv +5 -0
  96. package/trustledger/fixtures/bank.ofx +34 -0
  97. package/trustledger/fixtures/bank.real.csv +5 -0
  98. package/trustledger/fixtures/corpus/_shared/prior-close.json +22 -0
  99. package/trustledger/fixtures/corpus/bank-book-mismatch--benign-twin/inputs.json +14 -0
  100. package/trustledger/fixtures/corpus/bank-book-mismatch--benign-twin/meta.json +7 -0
  101. package/trustledger/fixtures/corpus/bank-book-mismatch--out-of-trust/inputs.json +14 -0
  102. package/trustledger/fixtures/corpus/bank-book-mismatch--out-of-trust/meta.json +7 -0
  103. package/trustledger/fixtures/corpus/continuity-break--benign-twin/inputs.json +15 -0
  104. package/trustledger/fixtures/corpus/continuity-break--benign-twin/meta.json +7 -0
  105. package/trustledger/fixtures/corpus/continuity-break--out-of-trust/inputs.json +15 -0
  106. package/trustledger/fixtures/corpus/continuity-break--out-of-trust/meta.json +7 -0
  107. package/trustledger/fixtures/corpus/negative-tenant-ledger--benign-twin/inputs.json +13 -0
  108. package/trustledger/fixtures/corpus/negative-tenant-ledger--benign-twin/meta.json +7 -0
  109. package/trustledger/fixtures/corpus/negative-tenant-ledger--out-of-trust/inputs.json +13 -0
  110. package/trustledger/fixtures/corpus/negative-tenant-ledger--out-of-trust/meta.json +7 -0
  111. package/trustledger/fixtures/corpus/owner-overdraw--benign-twin/inputs.json +15 -0
  112. package/trustledger/fixtures/corpus/owner-overdraw--benign-twin/meta.json +7 -0
  113. package/trustledger/fixtures/corpus/owner-overdraw--out-of-trust/inputs.json +15 -0
  114. package/trustledger/fixtures/corpus/owner-overdraw--out-of-trust/meta.json +7 -0
  115. package/trustledger/fixtures/corpus/security-deposit-segregation--benign-twin/inputs.json +16 -0
  116. package/trustledger/fixtures/corpus/security-deposit-segregation--benign-twin/meta.json +7 -0
  117. package/trustledger/fixtures/corpus/security-deposit-segregation--out-of-trust/inputs.json +13 -0
  118. package/trustledger/fixtures/corpus/security-deposit-segregation--out-of-trust/meta.json +7 -0
  119. package/trustledger/fixtures/corpus/subledger-out-of-balance--benign-twin/inputs.json +13 -0
  120. package/trustledger/fixtures/corpus/subledger-out-of-balance--benign-twin/meta.json +7 -0
  121. package/trustledger/fixtures/corpus/subledger-out-of-balance--out-of-trust/inputs.json +13 -0
  122. package/trustledger/fixtures/corpus/subledger-out-of-balance--out-of-trust/meta.json +7 -0
  123. package/trustledger/fixtures/e2e/bank.aliased.csv +4 -0
  124. package/trustledger/fixtures/e2e/bank.csv +4 -0
  125. package/trustledger/fixtures/e2e/bank.nsf.csv +4 -0
  126. package/trustledger/fixtures/e2e/quickbooks.csv +6 -0
  127. package/trustledger/fixtures/e2e/quickbooks.nsf.csv +8 -0
  128. package/trustledger/fixtures/e2e/rentroll.csv +6 -0
  129. package/trustledger/fixtures/e2e/rentroll.nsf.csv +8 -0
  130. package/trustledger/fixtures/e2e/rentroll.short.csv +5 -0
  131. package/trustledger/fixtures/plans/baseline.json +25 -0
  132. package/trustledger/fixtures/plans/price-binding.example.json +27 -0
  133. package/trustledger/fixtures/policy/ambiguous-deposit-example.json +12 -0
  134. package/trustledger/fixtures/policy/baseline.json +19 -0
  135. package/trustledger/fixtures/policy/ca-example.json +12 -0
  136. package/trustledger/fixtures/policy/negative-tenant-ledger-example.json +12 -0
  137. package/trustledger/fixtures/policy/owner-overdraw-example.json +12 -0
  138. package/trustledger/fixtures/quickbooks.csv +7 -0
  139. package/trustledger/fixtures/quickbooks.real.csv +5 -0
  140. package/trustledger/fixtures/rentroll.csv +6 -0
  141. package/trustledger/fixtures/rentroll.real.csv +4 -0
  142. package/trustledger/ingest.js +1163 -0
  143. package/trustledger/lib/policy-bundled-loader.js +44 -0
  144. package/trustledger/lib/sha256-vendored.js +227 -0
  145. package/trustledger/license.js +563 -0
  146. package/trustledger/match.js +551 -0
  147. package/trustledger/plans.js +551 -0
  148. package/trustledger/policy.js +398 -0
  149. package/trustledger/public/index.html +512 -0
  150. package/trustledger/reconcile.js +1486 -0
  151. package/trustledger/report.js +887 -0
  152. package/trustledger/seal.js +854 -0
  153. package/trustledger/server.js +391 -0
  154. package/trustledger/valueproof.js +350 -0
@@ -0,0 +1,438 @@
1
+ "use strict";
2
+
3
+ // cli/fulfill-webhook-http.js — the loopback-only HTTP FULFILLMENT WEBHOOK for `vh fulfill-webhook` (T-62.2).
4
+ //
5
+ // WHAT THIS IS
6
+ // A tiny, dependency-free (Node-core `http` ONLY) HTTP server that wires the PURE self-serve
7
+ // fulfillment-INTAKE core (`cli/core/fulfill-intake.js`, T-62.1) to the shipped evidence license
8
+ // fulfiller. It is the DROP-IN that removes the human's last CODE step between a billing provider's
9
+ // webhook and a delivered evidence license: on each authenticated POST it AUTHENTICATES the raw event
10
+ // (Stripe-style HMAC), MAPS the provider's price onto OUR plan via a validated price binding, MINTS the
11
+ // signed license `vh evidence license fulfill` would mint, and DELIVERS it to `--out` — idempotently.
12
+ //
13
+ // verifyProviderSignature -> parseEvidenceEvent -> normalizeEvidenceEvent -> fulfillEvidenceOrder ->
14
+ // evidence.buildLicense. It invents NO crypto, NO plan logic, NO license format — every seam is the
15
+ // shipped, tested core, reused VERBATIM. This file is only the HTTP transport + the idempotent delivery.
16
+ //
17
+ // FAIL CLOSED (the ONLY thing between "a paid event" and "anyone who can POST forges a license")
18
+ // An UNSIGNED / FORGED / STALE / MALFORMED request is a 4xx with the localized reason and delivers
19
+ // NOTHING. Signature verification runs FIRST, in constant time (the core), before ANY parse/mint/write.
20
+ // A request that cannot be authenticated is never fulfilled.
21
+ //
22
+ // IDEMPOTENT (at-least-once delivery is the norm for webhooks)
23
+ // Delivery is keyed on `intakeDedupKey(event)` — a hash of the event's retry-stable content (provider,
24
+ // type, priceId, customer, periodEnd), NOT the wall clock. The license is written to a deterministic
25
+ // `<dedup>.vhlicense.json` under `--out`; a RE-DELIVERED event returns the SAME licenseId (read back from
26
+ // the existing file) with HTTP 200 and mints NO second license. The write is exclusive-create ('wx') so a
27
+ // racing duplicate delivery collapses to one license, not two.
28
+ //
29
+ // KEY / SECRET HYGIENE (load-bearing, and grepped by the tests)
30
+ // This transport HOLDS the vendor signing key IN MEMORY (as an ethers signer object) for the process
31
+ // lifetime — a signing webhook must — and uses the signing secret ONLY to HMAC-verify the request. It
32
+ // NEVER reads a key/secret from env or disk itself (the CLI layer does that from --key-env/--key-file/
33
+ // --secret-env and hands in the objects), NEVER writes a key/secret to disk (a delivered license carries
34
+ // ONLY public bytes: the signature + the signer ADDRESS), and NEVER logs one. It makes NO outbound
35
+ // network request (no http.request/https/net/dns/fetch) — it only LISTENS.
36
+ //
37
+ // LOOPBACK BY DEFAULT (a human deploy step to expose)
38
+ // The default bind host is 127.0.0.1. A non-loopback interface is NOT served unless the operator passes
39
+ // --host. Exposing this publicly (your provider's REAL webhook secret, your REAL vendor key, your domain +
40
+ // TLS) is an explicit HUMAN deploy step; it is NEVER auto-deployed.
41
+ //
42
+ // HONEST POSTURE
43
+ // A delivered license is an ACCESS credential for delivered software value — NOT a token/coin/NFT, not
44
+ // tradeable, and NOT a trusted timestamp (P-3). The `--binding` is an operator-maintained routing table
45
+ // and makes NO claim of regulatory compliance; the subscription agreement governs.
46
+
47
+ const http = require("http");
48
+ const fs = require("fs");
49
+ const path = require("path");
50
+
51
+ const intake = require("./core/fulfill-intake");
52
+ const evidencePlans = require("./core/evidence-plans");
53
+ const evidence = require("./evidence");
54
+
55
+ // The default bind host is LOOPBACK. The default port is arbitrary-but-memorable and does NOT collide with
56
+ // the TrustLedger browser door (4173) or the serve-verify door (4180).
57
+ const DEFAULT_HOST = "127.0.0.1";
58
+ const DEFAULT_PORT = 4190;
59
+
60
+ // The wire-level body cap, enforced AS bytes arrive (a 413 before the body is ever fully buffered). Defaults
61
+ // to the intake core's own byte cap so the two layers agree; the operator may lower it via --max-body.
62
+ const DEFAULT_MAX_BODY_BYTES = intake.DEFAULT_MAX_BODY_BYTES;
63
+
64
+ // The single fulfillment route + a liveness route. A provider posts each event to FULFILL_PATH.
65
+ const FULFILL_PATH = "/fulfill";
66
+ const HEALTH_PATH = "/healthz";
67
+
68
+ // The request header a Stripe-compatible provider signs each delivery with (Node lowercases header names).
69
+ const SIGNATURE_HEADER = "stripe-signature";
70
+
71
+ // HTTP status codes we map onto. 422 (Unprocessable Entity) is the RIGHT code for "authenticated event the
72
+ // server understood, but it maps to NO sellable plan" — distinct from 400 (the request body itself is bad).
73
+ const STATUS = Object.freeze({
74
+ OK: 200,
75
+ BAD_REQUEST: 400,
76
+ UNAUTHORIZED: 401,
77
+ NOT_FOUND: 404,
78
+ METHOD_NOT_ALLOWED: 405,
79
+ PAYLOAD_TOO_LARGE: 413,
80
+ UNPROCESSABLE: 422,
81
+ INTERNAL: 500,
82
+ });
83
+
84
+ // Map each localized signature-reason code to a 4xx. A missing/forged/stale signature is an authentication
85
+ // failure (401); a malformed header is a bad request (400). EVERY signature reject is a 4xx that delivers
86
+ // nothing — fail closed.
87
+ const SIGNATURE_REASON_STATUS = Object.freeze({
88
+ [intake.SIGNATURE_REASONS.MISSING_HEADER]: STATUS.UNAUTHORIZED,
89
+ [intake.SIGNATURE_REASONS.MALFORMED_HEADER]: STATUS.BAD_REQUEST,
90
+ [intake.SIGNATURE_REASONS.SIGNATURE_MISMATCH]: STATUS.UNAUTHORIZED,
91
+ [intake.SIGNATURE_REASONS.TIMESTAMP_OUT_OF_TOLERANCE]: STATUS.UNAUTHORIZED,
92
+ });
93
+
94
+ // A localized, human message per signature reason (the reason CODE is the machine-stable localization; this
95
+ // is the operator-facing prose). Every one states that NOTHING was delivered.
96
+ const SIGNATURE_REASON_MESSAGE = Object.freeze({
97
+ [intake.SIGNATURE_REASONS.MISSING_HEADER]:
98
+ "no Stripe-Signature header: the request is UNSIGNED — delivering nothing (fail-closed)",
99
+ [intake.SIGNATURE_REASONS.MALFORMED_HEADER]:
100
+ "the Stripe-Signature header is malformed (expected t=<unix>,v1=<hmac>) — delivering nothing (fail-closed)",
101
+ [intake.SIGNATURE_REASONS.SIGNATURE_MISMATCH]:
102
+ "the signature does NOT match the signing secret (forged or wrong secret) — delivering nothing (fail-closed)",
103
+ [intake.SIGNATURE_REASONS.TIMESTAMP_OUT_OF_TOLERANCE]:
104
+ "the signature timestamp is outside the replay window (stale/replayed) — delivering nothing (fail-closed)",
105
+ });
106
+
107
+ // The verbatim caveat block the CLI banner AND the /healthz body carry. These lines are the acceptance-pinned
108
+ // posture: authenticate+deliver, loopback, fail-closed, access-credential-not-a-token, human-deploy. Kept as
109
+ // an array of exact strings so a test can assert each line VERBATIM and the wording can never silently drift.
110
+ const CAVEATS = Object.freeze([
111
+ "fulfillment webhook: it AUTHENTICATES a signed provider event and DELIVERS a signed license to --out; it holds the vendor signing key IN MEMORY and writes NO key/secret to disk or logs.",
112
+ "loopback by default: it binds 127.0.0.1 — a non-loopback interface is NOT served unless you pass --host.",
113
+ "fail-closed: an UNSIGNED / FORGED / STALE / MALFORMED request is a 4xx with the localized reason and delivers NOTHING.",
114
+ "a delivered license is an ACCESS credential for delivered software value — NOT a token/coin/NFT, not tradeable, and NOT a trusted timestamp (P-3).",
115
+ "exposing it publicly (your provider's real webhook secret, your real vendor key, your domain + TLS) is a HUMAN deploy step — never auto-deployed.",
116
+ ]);
117
+
118
+ // ---------------------------------------------------------------------------------------------------
119
+ // A named, HTTP-status-bearing error for the request layer (mirrors serve-verify-http.js).
120
+ // ---------------------------------------------------------------------------------------------------
121
+
122
+ class HttpError extends Error {
123
+ constructor(status, code, message) {
124
+ super(message);
125
+ this.name = "HttpError";
126
+ this.status = status;
127
+ this.code = code;
128
+ }
129
+ }
130
+
131
+ // A hard ceiling on how many bytes we DRAIN past the cap before tearing the socket down (bounded memory).
132
+ const OVERSIZE_DRAIN_LIMIT = 4 * 1024 * 1024;
133
+
134
+ // Read the request body with a HARD byte cap enforced AS chunks arrive, so an oversized body never pins
135
+ // unbounded memory. Resolves to `{ raw }` (a Buffer, within the cap) or `{ tooLarge:true, maxBytes }` once
136
+ // the cap is exceeded (the caller maps that to a clean 413). Rejects with an HttpError on a transport error
137
+ // or a hostile client that keeps streaming far past the cap.
138
+ function readBody(req, maxBytes) {
139
+ return new Promise((resolve, reject) => {
140
+ const chunks = [];
141
+ let size = 0;
142
+ let over = false;
143
+ let done = false;
144
+ const finish = (fn, arg) => {
145
+ if (done) return;
146
+ done = true;
147
+ fn(arg);
148
+ };
149
+ req.on("data", (chunk) => {
150
+ size += chunk.length;
151
+ if (!over && size > maxBytes) {
152
+ over = true;
153
+ chunks.length = 0; // drop what we have so memory can't grow past the cap
154
+ }
155
+ if (over) {
156
+ if (size > maxBytes + OVERSIZE_DRAIN_LIMIT) {
157
+ req.destroy();
158
+ finish(
159
+ reject,
160
+ new HttpError(STATUS.PAYLOAD_TOO_LARGE, "payload_too_large", `request body exceeds the ${maxBytes}-byte limit`)
161
+ );
162
+ }
163
+ return; // drop the chunk
164
+ }
165
+ chunks.push(chunk);
166
+ });
167
+ req.on("end", () =>
168
+ finish(resolve, over ? { tooLarge: true, maxBytes } : { raw: Buffer.concat(chunks) })
169
+ );
170
+ req.on("error", (e) => finish(reject, new HttpError(STATUS.BAD_REQUEST, "request_error", e.message)));
171
+ });
172
+ }
173
+
174
+ // Write a JSON response with a stable shape. The body is ALWAYS JSON (never an HTML page or a stack trace).
175
+ function sendJson(res, status, obj) {
176
+ const body = JSON.stringify(obj);
177
+ res.writeHead(status, {
178
+ "content-type": "application/json; charset=utf-8",
179
+ "content-length": Buffer.byteLength(body),
180
+ "x-content-type-options": "nosniff",
181
+ });
182
+ res.end(body);
183
+ }
184
+
185
+ // Send a NAMED JSON error — never a stack trace. An HttpError carries its own status + code; anything else is
186
+ // a defensive 500 with a generic message (its .message is NOT leaked, since it could be unexpected).
187
+ function sendError(res, err) {
188
+ if (err instanceof HttpError) {
189
+ sendJson(res, err.status, { error: err.code, message: err.message });
190
+ return;
191
+ }
192
+ sendJson(res, STATUS.INTERNAL, { error: "internal_error", message: "an internal error occurred" });
193
+ }
194
+
195
+ // The dedup key is `vh-ev-intake:sha256:<hex>`; the hex tail is a filesystem-safe deterministic filename.
196
+ function outPathForDedupKey(outDir, dedupKey) {
197
+ const hex = dedupKey.slice(dedupKey.lastIndexOf(":") + 1);
198
+ return path.join(outDir, `${hex}.vhlicense.json`);
199
+ }
200
+
201
+ // Read an ALREADY-delivered license file back and return its licenseId (for an idempotent replay). The file
202
+ // is one we wrote, so it re-validates through the SAME strict reader the gate uses.
203
+ function licenseIdOfDelivered(filePath) {
204
+ const text = fs.readFileSync(filePath, "utf8");
205
+ const container = evidence.readLicense(text);
206
+ const payload = JSON.parse(container.attestation);
207
+ return payload.licenseId;
208
+ }
209
+
210
+ // ---------------------------------------------------------------------------------------------------
211
+ // The core per-request fulfillment. PURE of module-level state except the injected `now` clock. Given the
212
+ // already-read raw body + signature header, it authenticates, maps, mints, and delivers idempotently, or
213
+ // throws an HttpError (never a stack trace) that the handler maps to a status. `wallet` is the signer object
214
+ // (held in memory); `secret` HMAC-verifies; neither is ever written or logged.
215
+ // ---------------------------------------------------------------------------------------------------
216
+
217
+ async function fulfillRequest(rawBuf, sigHeader, cfg) {
218
+ const { wallet, secret, binding, catalog, outDir, maxBytes, toleranceSec, now } = cfg;
219
+
220
+ // One clock read per request, used for BOTH the replay-window check and the license issuedAt.
221
+ const nowMs = now();
222
+ const nowSec = Math.floor(nowMs / 1000);
223
+ const issuedAt = new Date(nowMs).toISOString();
224
+
225
+ const rawStr = rawBuf.toString("utf8");
226
+
227
+ // (1) AUTHENTICATE FIRST — constant-time HMAC over the exact bytes, inside the replay window. An unsigned/
228
+ // forged/stale/malformed signature is a localized 4xx that delivers NOTHING (fail-closed).
229
+ const sig = intake.verifyProviderSignature(rawStr, sigHeader, secret, { nowSec, toleranceSec });
230
+ if (!sig.ok) {
231
+ const status = SIGNATURE_REASON_STATUS[sig.reason] || STATUS.UNAUTHORIZED;
232
+ const message = SIGNATURE_REASON_MESSAGE[sig.reason] || "signature rejected — delivering nothing";
233
+ throw new HttpError(status, "signature_rejected", message);
234
+ }
235
+
236
+ // (2) PARSE the authenticated body -> normalized envelope. A malformed / unknown-type / duplicate-field
237
+ // body is a NAMED 400 (the request body itself is bad).
238
+ let event;
239
+ try {
240
+ event = intake.parseEvidenceEvent(rawStr, { maxBytes, binding });
241
+ } catch (e) {
242
+ throw new HttpError(STATUS.BAD_REQUEST, "invalid_event", e && e.message ? e.message : String(e));
243
+ }
244
+
245
+ // (3) IDEMPOTENCY — key on the event's retry-stable content, NOT the clock. If we already delivered this
246
+ // event, return the SAME licenseId with 200 and mint NO second license.
247
+ const dedupKey = intake.intakeDedupKey(event);
248
+ const hex = dedupKey.slice(dedupKey.lastIndexOf(":") + 1);
249
+ const outPath = path.join(outDir, `${hex}.vhlicense.json`);
250
+ if (fs.existsSync(outPath)) {
251
+ return {
252
+ status: STATUS.OK,
253
+ body: { delivered: true, idempotent: true, licenseId: licenseIdOfDelivered(outPath), out: outPath },
254
+ };
255
+ }
256
+
257
+ // (4) MAP the event onto OUR order and MINT the license params. An event that maps to NO plan (unmapped
258
+ // price) is an authenticated-but-UNPROCESSABLE 422 that delivers nothing. The licenseId is derived
259
+ // DETERMINISTICALLY from the event's dedup key (NOT the clock) so it is STABLE across retries yet
260
+ // DISTINCT per (customer, price, period) — two different customers on the same plan never collide.
261
+ let params;
262
+ try {
263
+ const order = intake.normalizeEvidenceEvent(event, binding, { issuedAt });
264
+ order.licenseId = `LIC-${hex.slice(0, 24)}`;
265
+ params = evidencePlans.fulfillEvidenceOrder(order, catalog);
266
+ } catch (e) {
267
+ throw new HttpError(STATUS.UNPROCESSABLE, "unfulfillable", e && e.message ? e.message : String(e));
268
+ }
269
+
270
+ // (5) SIGN the params into the SAME signed container the gate accepts, then DELIVER it. The key lives ONLY
271
+ // inside `wallet`; the written bytes carry only the signature + the signer ADDRESS (public).
272
+ let canonical;
273
+ let vendor;
274
+ try {
275
+ const container = await evidence.buildLicense(params, wallet);
276
+ canonical = evidence.serializeSignedLicense(container);
277
+ vendor = container.signature.signer; // validated lowercase 0x-address (public; NEVER the key)
278
+ } catch (e) {
279
+ throw new HttpError(STATUS.INTERNAL, "sign_error", "could not sign the license");
280
+ }
281
+
282
+ // Exclusive-create so a racing duplicate delivery collapses to ONE license: on EEXIST we read the winner
283
+ // back and return its licenseId (still 200, still idempotent) rather than a duplicate.
284
+ try {
285
+ fs.writeFileSync(outPath, canonical, { flag: "wx" });
286
+ } catch (e) {
287
+ if (e && e.code === "EEXIST") {
288
+ return {
289
+ status: STATUS.OK,
290
+ body: { delivered: true, idempotent: true, licenseId: licenseIdOfDelivered(outPath), out: outPath },
291
+ };
292
+ }
293
+ throw new HttpError(STATUS.INTERNAL, "io_error", `could not write the license to --out: ${e.message}`);
294
+ }
295
+
296
+ return {
297
+ status: STATUS.OK,
298
+ body: {
299
+ delivered: true,
300
+ idempotent: false,
301
+ licenseId: params.licenseId,
302
+ plan: params.plan,
303
+ customer: params.customer,
304
+ vendor,
305
+ out: outPath,
306
+ },
307
+ };
308
+ }
309
+
310
+ // ---------------------------------------------------------------------------------------------------
311
+ // The request handler. Routes on method + path; delegates ALL auth/map/mint to the shipped cores.
312
+ // ---------------------------------------------------------------------------------------------------
313
+
314
+ function makeHandler(opts = {}) {
315
+ if (opts.wallet == null || typeof opts.wallet.signMessage !== "function") {
316
+ throw new Error("fulfill-webhook handler requires a signer object (opts.wallet with signMessage())");
317
+ }
318
+ if (typeof opts.secret !== "string" || opts.secret.length === 0) {
319
+ throw new Error("fulfill-webhook handler requires a non-empty signing secret (opts.secret)");
320
+ }
321
+ if (opts.binding == null || opts.binding._byKey == null) {
322
+ throw new Error("fulfill-webhook handler requires a validated price binding (opts.binding)");
323
+ }
324
+ if (opts.catalog == null || opts.catalog.plansById == null) {
325
+ throw new Error("fulfill-webhook handler requires a validated plan catalog (opts.catalog)");
326
+ }
327
+ if (typeof opts.outDir !== "string" || opts.outDir.length === 0) {
328
+ throw new Error("fulfill-webhook handler requires an output directory (opts.outDir)");
329
+ }
330
+ const cfg = {
331
+ wallet: opts.wallet,
332
+ secret: opts.secret,
333
+ binding: opts.binding,
334
+ catalog: opts.catalog,
335
+ outDir: opts.outDir,
336
+ maxBytes:
337
+ opts.maxBodyBytes != null && Number.isFinite(opts.maxBodyBytes) ? opts.maxBodyBytes : DEFAULT_MAX_BODY_BYTES,
338
+ toleranceSec:
339
+ opts.toleranceSec != null && Number.isFinite(opts.toleranceSec)
340
+ ? opts.toleranceSec
341
+ : intake.DEFAULT_TOLERANCE_SEC,
342
+ // Injected clock (ms). Deterministic under test; the core NEVER reads the system clock itself.
343
+ now: typeof opts.now === "function" ? opts.now : () => Date.now(),
344
+ };
345
+
346
+ return function handler(req, res) {
347
+ const url = req.url || "/";
348
+ const pathOnly = url.split("?")[0];
349
+ const method = req.method || "GET";
350
+
351
+ // GET /healthz -> 200 { ok:true } (+ honest metadata + the caveats). It signs, so holdsKey is TRUE; the
352
+ // key is in memory only and never written/logged.
353
+ if (pathOnly === HEALTH_PATH) {
354
+ if (method !== "GET") {
355
+ sendError(res, new HttpError(STATUS.METHOD_NOT_ALLOWED, "method_not_allowed", `${HEALTH_PATH} accepts only GET`));
356
+ return;
357
+ }
358
+ sendJson(res, STATUS.OK, {
359
+ ok: true,
360
+ service: "vh-fulfill-webhook",
361
+ holdsKey: true,
362
+ writesKeyToDisk: false,
363
+ makesOutboundRequest: false,
364
+ caveats: CAVEATS,
365
+ });
366
+ return;
367
+ }
368
+
369
+ // POST /fulfill -> authenticate, map, mint, deliver (idempotent).
370
+ if (pathOnly === FULFILL_PATH) {
371
+ if (method !== "POST") {
372
+ sendError(res, new HttpError(STATUS.METHOD_NOT_ALLOWED, "method_not_allowed", `${FULFILL_PATH} accepts only POST`));
373
+ return;
374
+ }
375
+ readBody(req, cfg.maxBytes)
376
+ .then((body) => {
377
+ if (body.tooLarge) {
378
+ // An oversized body is a clean 413 BEFORE any auth/mint — never a crash, never a delivery.
379
+ throw new HttpError(STATUS.PAYLOAD_TOO_LARGE, "payload_too_large", `request body exceeds the ${body.maxBytes}-byte limit`);
380
+ }
381
+ const sigHeader = req.headers[SIGNATURE_HEADER];
382
+ return fulfillRequest(body.raw, sigHeader, cfg).then((out) => sendJson(res, out.status, out.body));
383
+ })
384
+ .catch((err) => sendError(res, err));
385
+ return;
386
+ }
387
+
388
+ // Any other path/method: a named 404 (JSON, never an HTML error page).
389
+ sendError(res, new HttpError(STATUS.NOT_FOUND, "not_found", `no route for ${method} ${pathOnly}`));
390
+ };
391
+ }
392
+
393
+ // Build (but do NOT listen on) an http.Server. Keeping creation + listening separate lets a test bind an
394
+ // EPHEMERAL port (0) on 127.0.0.1 and close cleanly, and lets a real deploy choose its own port/host.
395
+ function createServer(opts = {}) {
396
+ return http.createServer(makeHandler(opts));
397
+ }
398
+
399
+ // The verbatim CLI banner printed when the server starts. LEADS with the caveats (each VERBATIM) so an
400
+ // operator sees the posture — and the honesty boundary — before use.
401
+ function banner(url, host, outDir) {
402
+ const browseHint =
403
+ host === "0.0.0.0" || host === "::"
404
+ ? ` (${host} binds ALL interfaces — you chose to expose it; secure it with your own auth/TLS.)\n`
405
+ : "";
406
+ return (
407
+ `vh fulfill-webhook listening on ${url}\n` +
408
+ browseHint +
409
+ ` POST ${FULFILL_PATH} -> authenticate a signed event, deliver a signed *.vhlicense.json to ${outDir}\n` +
410
+ ` (200 { delivered, licenseId } / 401|400 unsigned|malformed / 422 unmappable / 413 too large)\n` +
411
+ ` GET ${HEALTH_PATH} -> { ok:true }\n` +
412
+ CAVEATS.map((c) => ` - ${c}`).join("\n") +
413
+ "\n" +
414
+ " Press Ctrl-C to stop.\n"
415
+ );
416
+ }
417
+
418
+ module.exports = {
419
+ createServer,
420
+ makeHandler,
421
+ fulfillRequest,
422
+ banner,
423
+ readBody,
424
+ sendJson,
425
+ sendError,
426
+ outPathForDedupKey,
427
+ HttpError,
428
+ CAVEATS,
429
+ DEFAULT_HOST,
430
+ DEFAULT_PORT,
431
+ DEFAULT_MAX_BODY_BYTES,
432
+ FULFILL_PATH,
433
+ HEALTH_PATH,
434
+ SIGNATURE_HEADER,
435
+ SIGNATURE_REASON_STATUS,
436
+ SIGNATURE_REASON_MESSAGE,
437
+ STATUS,
438
+ };
package/cli/git.js ADDED
@@ -0,0 +1,220 @@
1
+ "use strict";
2
+
3
+ // Pure helpers over the `git` binary for the verifyhash CLI.
4
+ //
5
+ // WHY THIS EXISTS. `vh hash <dir>` walks the filesystem and hashes every regular file it finds,
6
+ // including untracked junk (`node_modules/`, `.env`, build artifacts, editor scratch files). For a
7
+ // real repository that makes the directory root depend on whatever happens to be sitting in the work
8
+ // tree, so two clones of the same commit can produce different roots. `vh hash <path> --git` instead
9
+ // hashes EXACTLY the set of files git tracks at a given commit — a reproducible, content-addressed
10
+ // snapshot that ignores untracked noise. These helpers expose that set as pure functions over `git`.
11
+ //
12
+ // SECURITY / INJECTION. Every git invocation runs via child_process.execFileSync with an explicit
13
+ // argv ARRAY and `cwd` set to the caller's directory. We NEVER build a shell command string from
14
+ // user input, so a ref or path containing shell metacharacters (`;`, `$(...)`, spaces, quotes) can
15
+ // never be interpreted by a shell — the value is passed as a single literal argv element. `shell` is
16
+ // left at its default (false). All git output that can contain arbitrary path bytes is read with
17
+ // `-z` (NUL-delimited) so paths with newlines, quotes, or other special characters are handled
18
+ // deterministically rather than going through git's default C-quoting of "unusual" path names.
19
+
20
+ const { execFileSync } = require("child_process");
21
+ const fs = require("fs");
22
+ const path = require("path");
23
+
24
+ // A hard cap on git's stdout so a pathological repo can't exhaust memory; ls-tree of a normal repo is
25
+ // far under this. Buffer overflow throws (ENOBUFS), which surfaces as a clear error to the caller.
26
+ const MAX_GIT_OUTPUT = 64 * 1024 * 1024; // 64 MiB
27
+
28
+ /**
29
+ * Run `git <args...>` in `cwd` and return stdout. Pure-ish: it shells out but never through a shell
30
+ * string — `args` is an argv array passed verbatim, so user-supplied refs/paths cannot inject.
31
+ *
32
+ * @param {string} cwd directory to run git in (the work tree / a path inside the repo)
33
+ * @param {string[]} args git arguments as a literal argv array (NO shell string)
34
+ * @param {{ encoding?: "utf8"|"buffer" }} [opts]
35
+ * @returns {string|Buffer} stdout (utf8 string by default; Buffer if encoding:"buffer")
36
+ */
37
+ function runGit(cwd, args, opts = {}) {
38
+ const encoding = opts.encoding === "buffer" ? "buffer" : "utf8";
39
+ try {
40
+ return execFileSync("git", args, {
41
+ cwd,
42
+ encoding,
43
+ maxBuffer: MAX_GIT_OUTPUT,
44
+ // shell defaults to false: args are NOT interpreted by a shell. Do not set it.
45
+ stdio: ["ignore", "pipe", "pipe"],
46
+ });
47
+ } catch (e) {
48
+ // execFileSync attaches stderr on failure; surface it (trimmed) so callers can wrap it.
49
+ const stderr = e.stderr
50
+ ? Buffer.isBuffer(e.stderr)
51
+ ? e.stderr.toString("utf8")
52
+ : String(e.stderr)
53
+ : "";
54
+ const err = new Error(stderr.trim() || e.message);
55
+ err.gitStderr = stderr;
56
+ err.gitCode = typeof e.status === "number" ? e.status : undefined;
57
+ throw err;
58
+ }
59
+ }
60
+
61
+ /**
62
+ * Resolve the top-level directory of the git work tree containing `dir`.
63
+ *
64
+ * Errors clearly (a single, actionable message) if `dir` is not inside a git work tree — this is the
65
+ * guard that lets `vh hash --git` REFUSE to run on a non-git directory rather than silently falling
66
+ * back to the filesystem walk.
67
+ *
68
+ * @param {string} dir a directory (or a path inside the repo)
69
+ * @returns {string} absolute path to the repository top-level
70
+ */
71
+ function repoRoot(dir) {
72
+ let out;
73
+ try {
74
+ out = runGit(dir, ["rev-parse", "--show-toplevel"]);
75
+ } catch (e) {
76
+ throw new Error(
77
+ `not a git repository (or any parent up to the mount point): ${dir}\n` +
78
+ ` (git said: ${firstLine(e.message)})`
79
+ );
80
+ }
81
+ const root = out.trim();
82
+ if (!root) {
83
+ throw new Error(`not a git work tree: ${dir}`);
84
+ }
85
+ return root;
86
+ }
87
+
88
+ /**
89
+ * Resolve a ref / `HEAD` / short oid to a FULL 40-hex commit object id, erroring on an unknown ref.
90
+ *
91
+ * Uses `git rev-parse --verify --end-of-options <ref>^{commit}`: the `^{commit}` peel forces the ref
92
+ * to name (or dereference to) a commit, and `--verify` makes git exit non-zero — rather than echoing
93
+ * the input — when the ref does not exist or does not resolve to a commit. `--end-of-options` stops a
94
+ * ref that begins with `-` from being mis-parsed as a flag. The result is always a 40-char lowercase
95
+ * hex oid (verified before returning).
96
+ *
97
+ * @param {string} dir a directory inside the repo
98
+ * @param {string} [ref] the ref to resolve (default "HEAD")
99
+ * @returns {string} a full 40-hex commit oid (lowercase)
100
+ */
101
+ function resolveCommit(dir, ref) {
102
+ const r = ref === undefined || ref === null || ref === "" ? "HEAD" : String(ref);
103
+ let out;
104
+ try {
105
+ out = runGit(dir, ["rev-parse", "--verify", "--end-of-options", `${r}^{commit}`]);
106
+ } catch (e) {
107
+ throw new Error(
108
+ `unknown git ref: ${r}\n (git could not resolve it to a commit: ${firstLine(e.message)})`
109
+ );
110
+ }
111
+ const oid = out.trim();
112
+ if (!/^[0-9a-f]{40}$/.test(oid)) {
113
+ // rev-parse --verify of a real commit always yields a 40-hex oid; anything else is anomalous.
114
+ throw new Error(`git did not resolve ref '${r}' to a 40-hex commit oid (got: ${oid})`);
115
+ }
116
+ return oid;
117
+ }
118
+
119
+ /**
120
+ * List the repo-relative POSIX paths that git tracks at `ref` (default HEAD), sorted ascending.
121
+ *
122
+ * Uses `git ls-tree -r -z --name-only --full-tree <oid>`:
123
+ * - `-r` recurses into subtrees so the list is the full flat set of tracked blob paths,
124
+ * - `--name-only` returns just the paths (no mode/type/oid columns),
125
+ * - `-z` makes the output NUL-delimited so paths containing newlines, quotes, spaces, or other
126
+ * special characters are emitted VERBATIM (git's default would C-quote such "unusual" paths,
127
+ * which would corrupt the path we bind into each Merkle leaf),
128
+ * - `--full-tree` makes the listing relative to the repo root regardless of `cwd`,
129
+ * - resolving `ref` to a concrete oid first means the listing is taken from the COMMIT's tree, not
130
+ * from the index/work tree, so it is independent of staged/unstaged changes.
131
+ * git already emits ls-tree paths as repo-root-relative forward-slash paths, but we sort them
132
+ * ourselves (deterministic, locale-independent) so the result order does not depend on git's.
133
+ *
134
+ * Submodules (commit/gitlink entries) are NOT regular files and have no blob content; they are
135
+ * excluded so the caller only ever tries to read real tracked file bytes.
136
+ *
137
+ * @param {string} dir a directory inside the repo
138
+ * @param {string} [ref] the ref/commit to list (default "HEAD")
139
+ * @returns {string[]} sorted repo-relative POSIX paths of tracked files at that commit
140
+ */
141
+ function listTrackedFiles(dir, ref) {
142
+ const oid = resolveCommit(dir, ref);
143
+ // Read as a Buffer so NUL-delimited splitting is byte-exact; git paths are UTF-8 by default.
144
+ const buf = runGit(dir, ["ls-tree", "-r", "-z", "--name-only", "--full-tree", oid], {
145
+ encoding: "buffer",
146
+ });
147
+ const text = buf.toString("utf8");
148
+ // Split on NUL; the last element is an empty trailing chunk (git terminates each path with \0).
149
+ const paths = text.split("\0").filter((p) => p.length > 0);
150
+ // Deterministic, locale-independent sort by code unit (matches the CLI's leaf-sort independence:
151
+ // the tree is sorted by leaf value anyway, but a stable path order keeps output reproducible).
152
+ paths.sort();
153
+ return paths;
154
+ }
155
+
156
+ /**
157
+ * Resolve the GIT PROVENANCE of a directory: the full commit oid at `ref`, and the repo-relative,
158
+ * POSIX-slashed "scope" — the path of `dir` relative to the repository top-level. The scope is "."
159
+ * when `dir` IS the repo root.
160
+ *
161
+ * This is the untrusted-convenience hint T-8.2 carries into anchor/claim receipts: it records WHICH
162
+ * commit produced a root and WHERE in the repo the operator pointed `vh`, so a later reader can
163
+ * reproduce the same `--git --ref <oid>` enumeration. It is purely descriptive — the authoritative
164
+ * verdict is still the recomputed root vs the on-chain record (docs/TRUST-BOUNDARIES.md). Note the
165
+ * current `--git` enumeration is whole-repo (`ls-tree --full-tree`), so the scope documents the
166
+ * operator's vantage point rather than narrowing the tracked set.
167
+ *
168
+ * @param {string} dir a directory inside the repo
169
+ * @param {string} [ref] the ref/commit to resolve (default "HEAD")
170
+ * @returns {{ commit: string, scope: string }} full 40-hex commit oid + repo-relative POSIX scope
171
+ */
172
+ function gitProvenance(dir, ref) {
173
+ const root = repoRoot(dir); // errors clearly if `dir` is not in a git work tree
174
+ const commit = resolveCommit(dir, ref); // errors clearly on an unknown ref
175
+ const scope = repoRelativeScope(root, dir);
176
+ return { commit, scope };
177
+ }
178
+
179
+ /**
180
+ * Compute the repo-relative, POSIX-slashed path of `dir` under the repository top-level `root`.
181
+ * Returns "." when `dir` is the repo root itself. Both inputs are realpath'd so /tmp -> /private/tmp
182
+ * style symlinks (macOS) don't make an in-repo path look like it escapes the tree.
183
+ *
184
+ * @param {string} root absolute repository top-level
185
+ * @param {string} dir absolute directory inside the repo
186
+ * @returns {string} "." or a forward-slash repo-relative path
187
+ */
188
+ function repoRelativeScope(root, dir) {
189
+ const realRoot = safeRealpath(root);
190
+ const realDir = safeRealpath(dir);
191
+ let rel = path.relative(realRoot, realDir);
192
+ if (rel === "" || rel === ".") return ".";
193
+ rel = rel.split(path.sep).join("/");
194
+ return rel;
195
+ }
196
+
197
+ /** realpathSync that falls back to the input if the path cannot be resolved (e.g. does not exist). */
198
+ function safeRealpath(p) {
199
+ try {
200
+ return fs.realpathSync(p);
201
+ } catch (_) {
202
+ return p;
203
+ }
204
+ }
205
+
206
+ /** First line of a possibly-multiline string, trimmed. */
207
+ function firstLine(s) {
208
+ const str = String(s || "");
209
+ const nl = str.indexOf("\n");
210
+ return (nl === -1 ? str : str.slice(0, nl)).trim();
211
+ }
212
+
213
+ module.exports = {
214
+ runGit,
215
+ repoRoot,
216
+ resolveCommit,
217
+ listTrackedFiles,
218
+ gitProvenance,
219
+ repoRelativeScope,
220
+ };