@sanning/proof 0.4.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/dist/cli.js ADDED
@@ -0,0 +1,569 @@
1
+ #!/usr/bin/env node
2
+ // npx @sanning/proof verify <bundle.json> [comma,sep,gateways]
3
+ //
4
+ // One turnkey verifier for both producer bundles in the stack:
5
+ // - sanning.evidence/v1 (body sanning.anchor.trace/v1) — the @ar.io/anchor SDK trace
6
+ // - sanning.agent.proof/v1 — the agent inclusion proof
7
+ // Dispatch is by sniffed spec_version; no flag tells the CLI which it is.
8
+ //
9
+ // Pinned exit codes (mirror `sanningd verify-status`):
10
+ // 0 verified
11
+ // 1 a real verification failure (bad signature / tamper / broken inclusion)
12
+ // 2 malformed bundle (unparseable, unknown spec major, body_hash mismatch)
13
+ // 3 gateway-unavailable when an on-chain re-fetch was requested
14
+ // `payloadHashOk === null` (a withheld record) is semantics-undetermined and
15
+ // surfaces as a per-event note — it does NOT fail the run.
16
+ import { readFile, writeFile } from "node:fs/promises";
17
+ import { realpathSync } from "node:fs";
18
+ import { fileURLToPath } from "node:url";
19
+ import { isAgentProofSpec, verifyAgentProofBundle } from "./agent-proof.js";
20
+ import { composeExport } from "./compose.js";
21
+ import { utf8 } from "./crypto.js";
22
+ import { verifyEvidenceBundle } from "./evidence.js";
23
+ const EXIT_VERIFIED = 0;
24
+ const EXIT_FAILED = 1;
25
+ const EXIT_MALFORMED = 2;
26
+ const EXIT_GATEWAY_UNAVAILABLE = 3;
27
+ const G = "\x1b[32m";
28
+ const R = "\x1b[31m";
29
+ const Y = "\x1b[33m";
30
+ const DIM = "\x1b[2m";
31
+ const B = "\x1b[1m";
32
+ const X = "\x1b[0m";
33
+ function useColor() {
34
+ // Honor NO_COLOR; otherwise color only a TTY.
35
+ if (process.env.NO_COLOR)
36
+ return false;
37
+ return Boolean(process.stdout.isTTY);
38
+ }
39
+ function mark(ok) {
40
+ const c = useColor();
41
+ if (ok === null)
42
+ return c ? `${Y}~${X}` : "~";
43
+ if (ok)
44
+ return c ? `${G}✓${X}` : "✓";
45
+ return c ? `${R}✗${X}` : "✗";
46
+ }
47
+ function paint(s, c) {
48
+ return useColor() ? `${c}${s}${X}` : s;
49
+ }
50
+ export async function runCli(argv, io) {
51
+ const [command, ...rest] = argv;
52
+ if (command === undefined || command === "help" || command === "--help" || command === "-h") {
53
+ printUsage(io);
54
+ return command === undefined ? EXIT_MALFORMED : EXIT_VERIFIED;
55
+ }
56
+ if (command === "version" || command === "--version" || command === "-v") {
57
+ io.out("@sanning/proof verify CLI");
58
+ return EXIT_VERIFIED;
59
+ }
60
+ if (command === "export") {
61
+ return runExport(rest, io);
62
+ }
63
+ if (command !== "verify") {
64
+ io.err(`unknown command: ${command}`);
65
+ printUsage(io);
66
+ return EXIT_MALFORMED;
67
+ }
68
+ // Pull the optional `--logs <file>` flag out of the args; the rest are
69
+ // positional (<bundle> [gateways]). --logs feeds disclosed raw-log bytes for
70
+ // content verification (the evidence-bundle path only).
71
+ let logsPath;
72
+ const positional = [];
73
+ for (let i = 0; i < rest.length; i++) {
74
+ const a = rest[i];
75
+ if (a === "--logs") {
76
+ const next = rest[i + 1];
77
+ if (next === undefined) {
78
+ io.err("verify: --logs requires a file path");
79
+ printUsage(io);
80
+ return EXIT_MALFORMED;
81
+ }
82
+ logsPath = next;
83
+ i++;
84
+ continue;
85
+ }
86
+ positional.push(a);
87
+ }
88
+ const [bundlePath, gatewayArg] = positional;
89
+ if (!bundlePath) {
90
+ io.err("verify: a bundle file path is required");
91
+ printUsage(io);
92
+ return EXIT_MALFORMED;
93
+ }
94
+ const gateways = (gatewayArg ?? "")
95
+ .split(",")
96
+ .map((g) => g.trim())
97
+ .filter((g) => g.length > 0);
98
+ let content;
99
+ if (logsPath !== undefined) {
100
+ let logsRaw;
101
+ try {
102
+ logsRaw = await readFile(logsPath, "utf8");
103
+ }
104
+ catch (e) {
105
+ io.err(`cannot read --logs ${logsPath}: ${e instanceof Error ? e.message : String(e)}`);
106
+ return EXIT_MALFORMED;
107
+ }
108
+ let logsParsed;
109
+ try {
110
+ logsParsed = JSON.parse(logsRaw);
111
+ }
112
+ catch (e) {
113
+ io.err(`--logs ${logsPath} is not valid JSON: ${e instanceof Error ? e.message : String(e)}`);
114
+ return EXIT_MALFORMED;
115
+ }
116
+ if (logsParsed === null || typeof logsParsed !== "object" || Array.isArray(logsParsed)) {
117
+ io.err(`--logs ${logsPath} must be a JSON object mapping event_id → disclosed bytes`);
118
+ return EXIT_MALFORMED;
119
+ }
120
+ content = {};
121
+ for (const [k, v] of Object.entries(logsParsed)) {
122
+ if (typeof v === "string")
123
+ content[k] = decodeLogValue(v);
124
+ }
125
+ }
126
+ let raw;
127
+ try {
128
+ raw = await readFile(bundlePath, "utf8");
129
+ }
130
+ catch (e) {
131
+ io.err(`cannot read ${bundlePath}: ${e instanceof Error ? e.message : String(e)}`);
132
+ return EXIT_MALFORMED;
133
+ }
134
+ let parsed;
135
+ try {
136
+ parsed = JSON.parse(raw);
137
+ }
138
+ catch (e) {
139
+ io.err(`${bundlePath} is not valid JSON: ${e instanceof Error ? e.message : String(e)}`);
140
+ return EXIT_MALFORMED;
141
+ }
142
+ const specVersion = parsed && typeof parsed === "object" && !Array.isArray(parsed)
143
+ ? parsed.spec_version
144
+ : undefined;
145
+ if (isAgentProofSpec(specVersion)) {
146
+ if (content) {
147
+ // --logs binds raw logs to anchor-trace events; an agent inclusion proof
148
+ // has no such per-event content. Say so rather than silently ignoring it.
149
+ io.err("note: --logs has no effect on a sanning.agent.proof/v1 bundle (ignored)");
150
+ }
151
+ const result = await verifyAgentProofBundle(parsed, {
152
+ gateways,
153
+ ...(io.fetchImpl ? { fetchImpl: io.fetchImpl } : {}),
154
+ });
155
+ return reportAgentProof(result, bundlePath, gateways, io);
156
+ }
157
+ // Default: treat anything else as an evidence bundle. verifyEvidenceBundle
158
+ // rejects an unknown major as "malformed" so a genuinely unknown shape still
159
+ // exits 2 with a clear message.
160
+ const result = await verifyEvidenceBundle(parsed, {
161
+ gateways,
162
+ ...(content ? { content } : {}),
163
+ ...(io.fetchImpl ? { fetchImpl: io.fetchImpl } : {}),
164
+ });
165
+ return reportEvidence(result, bundlePath, gateways, io);
166
+ }
167
+ // `proof export <source-bundle.json> --attestations <f> --key <f> [-o <out>]`
168
+ //
169
+ // Compose a signed, offline-verifiable sanning.evidence.export/v1 artifact from a
170
+ // source sanning.anchor.trace/v1 bundle + operator attestation records + an exporter
171
+ // Ed25519 key (evidence-export.md §5, composer). Writes the export to `-o <out>`
172
+ // (or stdout when omitted, so it can be piped straight into `proof verify`).
173
+ //
174
+ // Exit codes: 0 composed OK · 1 the recomputed export is FAILED (a bad source or
175
+ // forged/mis-bound attestation — the composer refuses to emit a green artifact
176
+ // over failing evidence) · 2 usage / malformed input (missing flag, unreadable
177
+ // or non-JSON input, bad exporter key, malformed attestation record).
178
+ async function runExport(rest, io) {
179
+ let attestationsPath;
180
+ let keyPath;
181
+ let outPath;
182
+ const opts = {};
183
+ const positional = [];
184
+ for (let i = 0; i < rest.length; i++) {
185
+ const a = rest[i];
186
+ const takeNext = (flag) => {
187
+ const next = rest[i + 1];
188
+ if (next === undefined) {
189
+ io.err(`export: ${flag} requires a value`);
190
+ return undefined;
191
+ }
192
+ i++;
193
+ return next;
194
+ };
195
+ if (a === "--attestations" || a === "-a") {
196
+ const v = takeNext(a);
197
+ if (v === undefined)
198
+ return usageExport(io);
199
+ attestationsPath = v;
200
+ }
201
+ else if (a === "--key" || a === "-k") {
202
+ const v = takeNext(a);
203
+ if (v === undefined)
204
+ return usageExport(io);
205
+ keyPath = v;
206
+ }
207
+ else if (a === "--out" || a === "-o") {
208
+ const v = takeNext(a);
209
+ if (v === undefined)
210
+ return usageExport(io);
211
+ outPath = v;
212
+ }
213
+ else if (a === "--issuer-id") {
214
+ const v = takeNext(a);
215
+ if (v === undefined)
216
+ return usageExport(io);
217
+ opts.issuerId = v;
218
+ }
219
+ else if (a === "--gateway") {
220
+ const v = takeNext(a);
221
+ if (v === undefined)
222
+ return usageExport(io);
223
+ opts.gateway = v;
224
+ }
225
+ else if (a === "--previous-hash") {
226
+ const v = takeNext(a);
227
+ if (v === undefined)
228
+ return usageExport(io);
229
+ opts.previousHash = v;
230
+ }
231
+ else if (a === "--generated-at") {
232
+ const v = takeNext(a);
233
+ if (v === undefined)
234
+ return usageExport(io);
235
+ opts.generatedAt = v;
236
+ }
237
+ else {
238
+ positional.push(a);
239
+ }
240
+ }
241
+ const [bundlePath] = positional;
242
+ if (!bundlePath) {
243
+ io.err("export: a source bundle file path is required");
244
+ return usageExport(io);
245
+ }
246
+ if (!attestationsPath) {
247
+ io.err("export: --attestations <file> is required");
248
+ return usageExport(io);
249
+ }
250
+ if (!keyPath) {
251
+ io.err("export: --key <file> is required");
252
+ return usageExport(io);
253
+ }
254
+ const sourceBundle = await readJson(bundlePath, io);
255
+ if (sourceBundle === undefined)
256
+ return EXIT_MALFORMED;
257
+ const attestationsRaw = await readJson(attestationsPath, io);
258
+ if (attestationsRaw === undefined)
259
+ return EXIT_MALFORMED;
260
+ const attestations = coerceAttestations(attestationsRaw);
261
+ if (attestations === undefined) {
262
+ io.err(`export: ${attestationsPath} must be a JSON array of attestation records ` +
263
+ `(or an object with an "attestations" array)`);
264
+ return EXIT_MALFORMED;
265
+ }
266
+ const privateKey = await readExporterKey(keyPath, io);
267
+ if (privateKey === undefined)
268
+ return EXIT_MALFORMED;
269
+ let composed;
270
+ try {
271
+ composed = await composeExport(sourceBundle, attestations, { privateKey }, opts);
272
+ }
273
+ catch (e) {
274
+ io.err(`export: cannot compose — ${e instanceof Error ? e.message : String(e)}`);
275
+ return EXIT_MALFORMED;
276
+ }
277
+ // The composer refuses to hand back a green artifact over failing evidence: a
278
+ // FAILED recompute (bad source / forged / mis-bound attestation) exits 1 and
279
+ // still writes the artifact so the caller can inspect it.
280
+ const serialized = JSON.stringify(composed.bundle, null, 2) + "\n";
281
+ if (outPath) {
282
+ try {
283
+ await writeFile(outPath, serialized, "utf8");
284
+ }
285
+ catch (e) {
286
+ io.err(`export: cannot write ${outPath}: ${e instanceof Error ? e.message : String(e)}`);
287
+ return EXIT_MALFORMED;
288
+ }
289
+ io.out(`${paint("exported", composed.status === "failed" ? R : G)} ${outPath} ` +
290
+ `status ${composed.status} ` +
291
+ `attestations ${composed.boundAttestations}/${attestations.length} bound`);
292
+ }
293
+ else {
294
+ io.out(serialized.replace(/\n$/, ""));
295
+ }
296
+ return composed.status === "failed" ? EXIT_FAILED : EXIT_VERIFIED;
297
+ }
298
+ function usageExport(io) {
299
+ io.out("Usage: npx @sanning/proof export <source-bundle.json> --attestations <file> --key <file> [-o <out.json>]");
300
+ io.out("");
301
+ io.out("Compose a signed, offline-verifiable sanning.evidence.export/v1 artifact from an");
302
+ io.out("sanning.anchor.trace/v1 source bundle + operator attestation records + an exporter");
303
+ io.out("Ed25519 key. The output verifies with `proof verify <out.json>`.");
304
+ io.out("");
305
+ io.out("--attestations <file> JSON array of attestation records (or {attestations:[...]}).");
306
+ io.out("--key <file> Exporter Ed25519 key: a 32-byte hex seed, or JSON");
307
+ io.out(' {"privateKey":"<hex>"} / {"seed":"<hex>"}.');
308
+ io.out("-o, --out <file> Write the export here (default: stdout).");
309
+ io.out("--issuer-id <id> Wrapper issuer.issuer_id (default sanning-verify).");
310
+ io.out("--gateway <url> Named delivery surface (not trusted).");
311
+ io.out("--previous-hash <h> Custody pointer (default GENESIS).");
312
+ io.out("--generated-at <ts> RFC 3339 compose time (default now).");
313
+ return EXIT_MALFORMED;
314
+ }
315
+ // Read + parse a JSON file; report a clear error and return undefined on any
316
+ // failure (mirrors the verify path's read/parse error handling → exit 2).
317
+ async function readJson(path, io) {
318
+ let raw;
319
+ try {
320
+ raw = await readFile(path, "utf8");
321
+ }
322
+ catch (e) {
323
+ io.err(`cannot read ${path}: ${e instanceof Error ? e.message : String(e)}`);
324
+ return undefined;
325
+ }
326
+ try {
327
+ return JSON.parse(raw);
328
+ }
329
+ catch (e) {
330
+ io.err(`${path} is not valid JSON: ${e instanceof Error ? e.message : String(e)}`);
331
+ return undefined;
332
+ }
333
+ }
334
+ // Accept either a bare array of attestation records or an object wrapping one
335
+ // under `attestations`. Returns undefined for anything else.
336
+ function coerceAttestations(raw) {
337
+ if (Array.isArray(raw))
338
+ return raw;
339
+ if (raw !== null && typeof raw === "object") {
340
+ const inner = raw.attestations;
341
+ if (Array.isArray(inner))
342
+ return inner;
343
+ }
344
+ return undefined;
345
+ }
346
+ // Read the exporter Ed25519 key: a file that is either a bare 32-byte hex seed
347
+ // (whitespace-trimmed) or a JSON object carrying it under `privateKey` / `seed` /
348
+ // `private_key`. Validates the 64-hex-char (32-byte) shape.
349
+ async function readExporterKey(path, io) {
350
+ let raw;
351
+ try {
352
+ raw = (await readFile(path, "utf8")).trim();
353
+ }
354
+ catch (e) {
355
+ io.err(`export: cannot read --key ${path}: ${e instanceof Error ? e.message : String(e)}`);
356
+ return undefined;
357
+ }
358
+ let seed = raw;
359
+ if (raw.startsWith("{")) {
360
+ try {
361
+ const obj = JSON.parse(raw);
362
+ const found = obj.privateKey ?? obj.seed ?? obj.private_key;
363
+ if (typeof found !== "string") {
364
+ io.err(`export: --key ${path} JSON needs a string "privateKey"/"seed" field`);
365
+ return undefined;
366
+ }
367
+ seed = found.trim();
368
+ }
369
+ catch (e) {
370
+ io.err(`export: --key ${path} is not valid JSON: ${e instanceof Error ? e.message : String(e)}`);
371
+ return undefined;
372
+ }
373
+ }
374
+ if (!/^[0-9a-fA-F]{64}$/.test(seed)) {
375
+ io.err(`export: --key must be a 32-byte (64 hex char) Ed25519 seed`);
376
+ return undefined;
377
+ }
378
+ return seed.toLowerCase();
379
+ }
380
+ // The --logs side-input lane decides each disclosed value's encoding: a clean,
381
+ // even-length, lowercase-hex string is hex bytes; anything else is utf8 text.
382
+ // (The verifier itself only distinguishes "string ⇒ hex" from "Uint8Array ⇒
383
+ // raw bytes" — the human-facing encoding choice is made here.)
384
+ function decodeLogValue(v) {
385
+ if (v.length > 0 && v.length % 2 === 0 && /^[0-9a-f]+$/.test(v))
386
+ return v; // hex passthrough
387
+ return utf8(v); // raw utf8 bytes
388
+ }
389
+ function printUsage(io) {
390
+ io.out("Usage: npx @sanning/proof <command> [args]");
391
+ io.out("");
392
+ io.out("Commands:");
393
+ io.out(" verify <bundle.json> [gateways] [--logs <logs.json>] Verify a bundle/export");
394
+ io.out(" export <source.json> --attestations <f> --key <f> [-o] Compose an attested export");
395
+ io.out("");
396
+ io.out("Verify a sanning.evidence/v1 (sanning.anchor.trace/v1) bundle, an");
397
+ io.out("sanning.evidence.export/v1 attested export, or a sanning.agent.proof/v1");
398
+ io.out("inclusion bundle, fully offline. Supply a comma-separated gateway list");
399
+ io.out("to also re-fetch each checkpoint on-chain.");
400
+ io.out("");
401
+ io.out("--logs <logs.json> Bind disclosed raw logs to each event's committed");
402
+ io.out(" content_hash. JSON object mapping event_id → bytes");
403
+ io.out(" (even-length lowercase hex ⇒ hex, else utf8 text).");
404
+ io.out(" In-body events[].content is verified automatically.");
405
+ io.out("");
406
+ io.out("Exit codes: 0 verified · 1 failed · 2 malformed · 3 gateway-unavailable");
407
+ }
408
+ function reportEvidence(result, path, gateways, io) {
409
+ if (result.status === "malformed") {
410
+ io.err(paint("MALFORMED", R) + ` ${path}`);
411
+ for (const e of result.errors)
412
+ io.err(` - ${e}`);
413
+ return EXIT_MALFORMED;
414
+ }
415
+ io.out(paint("Evidence bundle", B) + ` ${path}`);
416
+ io.out(` body_type ${result.bodyType ?? "<unknown>"}`);
417
+ io.out(` wrapper sig ${mark(result.signatureOk)} body_hash ${mark(result.bodyHashOk)} spec ${mark(result.specVersionOk)}`);
418
+ if (result.checkpoints.length > 0) {
419
+ io.out("");
420
+ io.out(paint(" Checkpoints", DIM));
421
+ for (const cp of result.checkpoints) {
422
+ const onChain = cp.onChainOk === null ? "" : ` on-chain ${mark(cp.onChainOk)}`;
423
+ io.out(` ${mark(cp.ok)} ${shortTx(cp.txId)} envelope ${mark(cp.envelopeOk)} root ${mark(cp.merkleRootOk)}${onChain}`);
424
+ for (const e of cp.errors)
425
+ io.out(paint(` - ${e}`, R));
426
+ }
427
+ }
428
+ if (result.events.length > 0) {
429
+ io.out("");
430
+ io.out(paint(" Events", DIM));
431
+ for (const ev of result.events) {
432
+ const binding = ev.payloadBindingOk === null
433
+ ? paint("~ record withheld", Y)
434
+ : `record ${mark(ev.payloadBindingOk)}`;
435
+ // Show the content (logs) segment only when content was disclosed and
436
+ // evaluated (non-null) — undisclosed content is the default and would just
437
+ // be noise on every line.
438
+ const logs = ev.contentOk === null ? "" : ` logs ${mark(ev.contentOk)}`;
439
+ io.out(` ${mark(ev.ok)} ${shortId(ev.eventId)} sig ${mark(ev.envelopeOk)} ${binding} inclusion ${mark(ev.inclusionOk)}${logs}`);
440
+ for (const e of ev.errors)
441
+ io.out(paint(` - ${e}`, R));
442
+ }
443
+ }
444
+ if (result.export) {
445
+ reportExport(result.export, io);
446
+ }
447
+ io.out("");
448
+ printRollup(result.status, result.assertedStatus, gateways, result.onChainChecked, io);
449
+ for (const e of result.errors)
450
+ io.out(paint(` note: ${e}`, DIM));
451
+ const disclosed = result.events.filter((e) => e.contentOk !== null);
452
+ if (disclosed.length > 0) {
453
+ const verified = disclosed.filter((e) => e.contentOk === true).length;
454
+ io.out(paint(` logs: ${verified}/${disclosed.length} disclosed verified`, DIM));
455
+ }
456
+ return mapStatusToExit(result.status, result.errors);
457
+ }
458
+ // Render the export-specific block (evidence-export.md §5): source-bundle
459
+ // linkage, cached-vs-recomputed verdict agreement, and each embedded operator
460
+ // attestation (RSA-PSS sig · operator-address binding · data_hash binding).
461
+ function reportExport(exp, io) {
462
+ io.out("");
463
+ io.out(paint(" Attested export", DIM));
464
+ io.out(` source linkage ${mark(exp.sourceLinkageOk)} verdict agreement ${mark(exp.verdictAgreementOk)}`);
465
+ if (exp.attestations.length > 0) {
466
+ io.out(paint(" Operator attestations", DIM));
467
+ for (const a of exp.attestations) {
468
+ const subj = a.subjectRefOk === null ? "" : ` subject ${mark(a.subjectRefOk)}`;
469
+ io.out(` ${mark(a.ok)} ${shortId(a.operator)} sig ${mark(a.signatureOk)}` +
470
+ ` operator ${mark(a.operatorAddressBound)} data_hash ${mark(a.dataHashBound)}${subj}`);
471
+ for (const e of a.errors)
472
+ io.out(paint(` - ${e}`, R));
473
+ }
474
+ }
475
+ }
476
+ function reportAgentProof(result, path, gateways, io) {
477
+ if (result.status === "malformed") {
478
+ io.err(paint("MALFORMED", R) + ` ${path}`);
479
+ for (const e of result.errors)
480
+ io.err(` - ${e}`);
481
+ return EXIT_MALFORMED;
482
+ }
483
+ io.out(paint("Agent inclusion proof", B) + ` ${path}`);
484
+ io.out(` spec_version sanning.agent.proof/v1`);
485
+ io.out(` checkpoint ${shortTx(result.checkpointTxId ?? "")} envelope ${mark(result.checkpointOk)}`);
486
+ const onChain = result.onChainOk === null ? "" : ` on-chain ${mark(result.onChainOk)}`;
487
+ io.out(` leaf index ${result.leafIndex} / ${result.leafCount ?? "?"} inclusion ${mark(result.inclusionOk)}${onChain}`);
488
+ io.out("");
489
+ printRollup(result.status, null, gateways, result.onChainChecked, io);
490
+ for (const e of result.errors)
491
+ io.out(paint(` note: ${e}`, DIM));
492
+ return mapStatusToExit(result.status, result.errors);
493
+ }
494
+ function printRollup(status, asserted, gateways, onChainChecked, io) {
495
+ const scope = onChainChecked
496
+ ? `verified offline + on-chain against ${gateways.length} gateway(s)`
497
+ : "verified offline (no gateway re-fetch requested)";
498
+ let head;
499
+ switch (status) {
500
+ case "verified":
501
+ head = paint("VERIFIED", G);
502
+ break;
503
+ case "partial":
504
+ head = paint("PARTIAL", Y);
505
+ break;
506
+ case "failed":
507
+ head = paint("FAILED", R);
508
+ break;
509
+ default:
510
+ head = paint(status.toUpperCase(), R);
511
+ }
512
+ io.out(`${head} ${paint(scope, DIM)}`);
513
+ if (asserted && asserted !== status) {
514
+ io.out(paint(` (producer asserted "${asserted}"; recomputed verdict is "${status}" — recomputed wins)`, DIM));
515
+ }
516
+ }
517
+ function mapStatusToExit(status, errors) {
518
+ if (status === "failed")
519
+ return EXIT_FAILED;
520
+ if (status === "malformed")
521
+ return EXIT_MALFORMED;
522
+ // "partial" caused specifically by an unreachable gateway — or an export
523
+ // source_bundle_ref whose bytes are unavailable offline (evidence-export.md
524
+ // §5 step 3) — maps to exit 3 (undetermined, network-dependent).
525
+ if (status === "partial" &&
526
+ errors.some((e) => /unreachable|could not be re-fetched|unavailable offline/.test(e))) {
527
+ return EXIT_GATEWAY_UNAVAILABLE;
528
+ }
529
+ // "verified" → 0. "partial" from a withheld record (semantics-undetermined,
530
+ // not a failure) also exits 0 — the bundle is cryptographically sound.
531
+ return EXIT_VERIFIED;
532
+ }
533
+ function shortTx(tx) {
534
+ if (tx.length <= 16)
535
+ return tx;
536
+ return `${tx.slice(0, 8)}…${tx.slice(-4)}`;
537
+ }
538
+ function shortId(id) {
539
+ if (id.length <= 12)
540
+ return id;
541
+ return `${id.slice(0, 8)}…`;
542
+ }
543
+ // Entry point when run as a binary (not when imported by tests).
544
+ const isMain = (() => {
545
+ try {
546
+ if (!process.argv[1])
547
+ return false;
548
+ // realpath BOTH sides: when run via a bin symlink (npx / node_modules/.bin/proof
549
+ // -> dist/cli.js), process.argv[1] is the symlink and import.meta.url is the target,
550
+ // so a naive compare skips main(). Resolving both to the real path makes them match.
551
+ return realpathSync(process.argv[1]) === realpathSync(fileURLToPath(import.meta.url));
552
+ }
553
+ catch {
554
+ return false;
555
+ }
556
+ })();
557
+ if (isMain) {
558
+ runCli(process.argv.slice(2), {
559
+ out: (s) => process.stdout.write(s + "\n"),
560
+ err: (s) => process.stderr.write(s + "\n"),
561
+ })
562
+ .then((code) => {
563
+ process.exitCode = code;
564
+ })
565
+ .catch((e) => {
566
+ process.stderr.write(`fatal: ${e instanceof Error ? e.stack ?? e.message : String(e)}\n`);
567
+ process.exitCode = EXIT_MALFORMED;
568
+ });
569
+ }
@@ -0,0 +1,19 @@
1
+ import type { AttestationRecord, EvidenceBundle, EvidenceStatus } from "./evidence.js";
2
+ export interface ExporterKey {
3
+ privateKey: string;
4
+ publicKey?: string;
5
+ }
6
+ export interface ComposeExportOptions {
7
+ issuerId?: string;
8
+ gateway?: string | null;
9
+ previousHash?: string;
10
+ generatedAt?: string;
11
+ }
12
+ export interface ComposeExportResult {
13
+ bundle: EvidenceBundle;
14
+ status: EvidenceStatus;
15
+ sourceBundleHash: string;
16
+ boundAttestations: number;
17
+ }
18
+ export declare function attestationSignatureToHex(signature: string): string;
19
+ export declare function composeExport(sourceBundle: EvidenceBundle, attestations: AttestationRecord[], exporterKey: ExporterKey, options?: ComposeExportOptions): Promise<ComposeExportResult>;