@mikeargento/bitgraph-player 0.5.2 → 0.7.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 (57) hide show
  1. package/DOMAIN.md +119 -0
  2. package/README.md +25 -5
  3. package/dist/__tests__/check.test.js +3 -2
  4. package/dist/__tests__/check.test.js.map +1 -1
  5. package/dist/__tests__/domain.test.d.ts +2 -0
  6. package/dist/__tests__/domain.test.d.ts.map +1 -0
  7. package/dist/__tests__/domain.test.js +265 -0
  8. package/dist/__tests__/domain.test.js.map +1 -0
  9. package/dist/__tests__/fixtures.d.ts.map +1 -1
  10. package/dist/__tests__/fixtures.js +3 -1
  11. package/dist/__tests__/fixtures.js.map +1 -1
  12. package/dist/check.d.ts +46 -3
  13. package/dist/check.d.ts.map +1 -1
  14. package/dist/check.js +106 -13
  15. package/dist/check.js.map +1 -1
  16. package/dist/cli.js +194 -12
  17. package/dist/cli.js.map +1 -1
  18. package/dist/domain.d.ts +64 -0
  19. package/dist/domain.d.ts.map +1 -0
  20. package/dist/domain.js +212 -0
  21. package/dist/domain.js.map +1 -0
  22. package/dist/index.d.ts +5 -1
  23. package/dist/index.d.ts.map +1 -1
  24. package/dist/index.js +2 -0
  25. package/dist/index.js.map +1 -1
  26. package/dist/pin.d.ts +55 -0
  27. package/dist/pin.d.ts.map +1 -0
  28. package/dist/pin.js +126 -0
  29. package/dist/pin.js.map +1 -0
  30. package/dist/play.d.ts +6 -1
  31. package/dist/play.d.ts.map +1 -1
  32. package/dist/play.js +6 -1
  33. package/dist/play.js.map +1 -1
  34. package/dist/sig.d.ts +2 -0
  35. package/dist/sig.d.ts.map +1 -1
  36. package/dist/sig.js +1 -1
  37. package/dist/sig.js.map +1 -1
  38. package/dist/types.d.ts +7 -0
  39. package/dist/types.d.ts.map +1 -1
  40. package/dist/types.js +7 -0
  41. package/dist/types.js.map +1 -1
  42. package/dist/verdict.d.ts +1 -1
  43. package/dist/verdict.js +1 -1
  44. package/dist-web/verify.html +8 -8
  45. package/package.json +4 -3
  46. package/src/__tests__/check.test.ts +3 -2
  47. package/src/__tests__/domain.test.ts +321 -0
  48. package/src/__tests__/fixtures.ts +3 -1
  49. package/src/check.ts +162 -16
  50. package/src/cli.ts +199 -12
  51. package/src/domain.ts +245 -0
  52. package/src/index.ts +17 -1
  53. package/src/pin.ts +155 -0
  54. package/src/play.ts +6 -1
  55. package/src/sig.ts +1 -1
  56. package/src/types.ts +8 -0
  57. package/src/verdict.ts +1 -1
package/src/check.ts CHANGED
@@ -63,8 +63,9 @@ import type {
63
63
  SegmentBound,
64
64
  TemporalSegment,
65
65
  } from "@mikeargento/bitgraph-audit";
66
- import { auditIngest, AUDIT_VERSION } from "@mikeargento/bitgraph-audit";
66
+ import { auditIngest, AUDIT_VERSION, streamMatchedArtifacts } from "@mikeargento/bitgraph-audit";
67
67
  import type { ThreeValued } from "./types.js";
68
+ import { SIG_EVIDENCE_MAX_BYTES } from "./types.js";
68
69
  import { kleeneAll } from "./logic.js";
69
70
  import { PLAYER_VERSION } from "./verdict.js";
70
71
 
@@ -107,7 +108,7 @@ export const KNOWN_ENCLAVE_MEASUREMENTS: ReadonlyArray<{ pcr0: string; label: st
107
108
 
108
109
  /** One checked property, three-valued, with a plain-language reason. */
109
110
  export interface CheckLine {
110
- name: "file" | "signature" | "attestation" | "enclave" | "witness" | "contradiction";
111
+ name: "file" | "signature" | "attestation" | "enclave" | "witness" | "contradiction" | "domain";
111
112
  result: ThreeValued;
112
113
  detail: string;
113
114
  }
@@ -124,7 +125,7 @@ export interface CheckBound {
124
125
  }
125
126
 
126
127
  export interface CheckBounds {
127
- status: "bracketed" | "lower-bounded" | "upper-bounded" | "unanchored";
128
+ status: "lower-bounded-with-following-anchor" | "lower-bounded" | "upper-bounded" | "unanchored";
128
129
  notBefore?: CheckBound;
129
130
  notAfter?: CheckBound;
130
131
  detail: string;
@@ -162,7 +163,10 @@ export interface CheckAnchor {
162
163
  }
163
164
 
164
165
  export interface CheckReport {
165
- check: "bitgraph-check/1";
166
+ /** /2 exactly when the report was built against a pinned domain (`from`). */
167
+ check: "bitgraph-check/1" | "bitgraph-check/2";
168
+ /** The pinned domain this report was checked against, when one was. */
169
+ from?: { domain: string; party: string };
166
170
  result: ThreeValued;
167
171
  /** One-sentence plain-language conclusion, deterministic. */
168
172
  summary: string;
@@ -178,6 +182,31 @@ export interface CheckReport {
178
182
  network: "none";
179
183
  }
180
184
 
185
+ /**
186
+ * A pinned BitGraph Domain, as the report builder consults it: one line
187
+ * per recording, TRUE or UNDETERMINED, never FALSE (absence of domain
188
+ * evidence contradicts nothing; the open-world rule of SPEC §9.3).
189
+ *
190
+ * An interface here rather than an import from domain.ts, deliberately:
191
+ * this module is also built into the browser verifier, and the crypto the
192
+ * adapter needs (key decoding, signature verification) stays behind it.
193
+ * domain.ts's `checkDomain(file)` is the implementation; embedders may
194
+ * supply their own.
195
+ */
196
+ export interface CheckDomain {
197
+ domain: string;
198
+ party: string;
199
+ keyCount: number;
200
+ /** Key name for an actor keyId (es256 fingerprint match), if published. */
201
+ actorKeyName(keyId: string): string | undefined;
202
+ /**
203
+ * Key name of the first pinned key a candidate bitgraph-sig/1 file
204
+ * verifies under, over this digest (lowercase hex). Deterministic:
205
+ * candidates ascending by content hash, keys in name order.
206
+ */
207
+ signatureKeyName(targetSha256Hex: string, evidence: ReadonlyMap<string, Uint8Array>): string | undefined;
208
+ }
209
+
181
210
  export interface CheckOptions {
182
211
  /**
183
212
  * False when the environment cannot run the attestation's ECDSA P-384
@@ -185,6 +214,19 @@ export interface CheckOptions {
185
214
  * with that reason instead of a false FALSE. Defaults to true.
186
215
  */
187
216
  webCryptoAvailable?: boolean;
217
+ /**
218
+ * Check against a pinned domain: adds one "domain" line per recording
219
+ * and stamps the report bitgraph-check/2. The check itself stays
220
+ * offline; the pin was the one fetch, and it already happened.
221
+ */
222
+ from?: CheckDomain;
223
+ /**
224
+ * Candidate signature bytes by content sha256 hex, for the domain
225
+ * line's detached-signature path (SPEC §9.4 discipline). checkIngest
226
+ * collects this from the bundle's matched artifacts when absent;
227
+ * embedders may supply additional candidates.
228
+ */
229
+ sigEvidence?: ReadonlyMap<string, Uint8Array>;
188
230
  }
189
231
 
190
232
  // ---------------------------------------------------------------------------
@@ -215,8 +257,20 @@ const EXCERPT_NORMAL_CODES: ReadonlySet<string> = new Set([
215
257
  * browser page call this, so they cannot drift.
216
258
  */
217
259
  export async function checkIngest(ingest: IngestResult, options?: CheckOptions): Promise<CheckReport> {
260
+ let opts = options;
261
+ // Domain checking's detached-signature path wants the same candidate set
262
+ // the evaluator uses (SPEC §9.4: matched artifacts, size-capped). Collect
263
+ // it here, where the ingest is in hand, unless the embedder supplied one.
264
+ if (opts?.from !== undefined && opts.sigEvidence === undefined) {
265
+ const collected = new Map<string, Uint8Array>();
266
+ for await (const matched of streamMatchedArtifacts(ingest)) {
267
+ if (matched.bytes.length > SIG_EVIDENCE_MAX_BYTES) continue;
268
+ if (!collected.has(matched.sha256Hex)) collected.set(matched.sha256Hex, matched.bytes);
269
+ }
270
+ opts = { ...opts, sigEvidence: collected };
271
+ }
218
272
  const audit = await auditIngest(ingest, { startedAt: "" });
219
- return buildCheckReport(audit, options);
273
+ return buildCheckReport(audit, opts);
220
274
  }
221
275
 
222
276
  /** The pure report builder over an AuditResult. */
@@ -260,7 +314,9 @@ export function buildCheckReport(audit: AuditResult, options?: CheckOptions): Ch
260
314
  attestationByHash.get(proof.proofHash),
261
315
  segmentByHash.get(proof.proofHash),
262
316
  artifactPathByProof.get(proof.proofHash),
263
- webCrypto
317
+ webCrypto,
318
+ options?.from,
319
+ options?.sigEvidence
264
320
  )
265
321
  );
266
322
  }
@@ -270,7 +326,7 @@ export function buildCheckReport(audit: AuditResult, options?: CheckOptions): Ch
270
326
  sortByPosition(anchors);
271
327
  const contradictions = collectContradictions(audit);
272
328
  const notes = collectNotes(audit, recordings, anchors);
273
- const notChecked = collectNotChecked(anchors.length > 0);
329
+ const notChecked = collectNotChecked(anchors.length > 0, options?.from?.domain);
274
330
 
275
331
  const allLines: ThreeValued[] = [
276
332
  ...recordings.map((r) => r.result),
@@ -280,7 +336,10 @@ export function buildCheckReport(audit: AuditResult, options?: CheckOptions): Ch
280
336
  const result: ThreeValued = allLines.length === 0 ? "UNDETERMINED" : kleeneAll(allLines);
281
337
 
282
338
  return {
283
- check: "bitgraph-check/1",
339
+ check: options?.from !== undefined ? "bitgraph-check/2" : "bitgraph-check/1",
340
+ ...(options?.from !== undefined
341
+ ? { from: { domain: options.from.domain, party: options.from.party } }
342
+ : {}),
284
343
  result,
285
344
  summary: summarize(result, recordings, anchors, contradictions),
286
345
  recordings,
@@ -322,7 +381,9 @@ function buildRecording(
322
381
  attestation: ProofAttestationRecord | undefined,
323
382
  segment: TemporalSegment | undefined,
324
383
  filePath: string | undefined,
325
- webCrypto: boolean
384
+ webCrypto: boolean,
385
+ from?: CheckDomain,
386
+ sigEvidence?: ReadonlyMap<string, Uint8Array>
326
387
  ): CheckRecording {
327
388
  const lines: CheckLine[] = [];
328
389
  const v = proof.verification;
@@ -374,6 +435,19 @@ function buildRecording(
374
435
  // enclave: the attested PCR0 is a published BitGraph measurement.
375
436
  lines.push(enclaveLine(att.attestedPcr0, att.line.result));
376
437
 
438
+ // domain: a key the pinned domain published stands behind this
439
+ // recording. TRUE or UNDETERMINED, never FALSE: domain evidence can
440
+ // exist outside any bundle, so its absence contradicts nothing
441
+ // (SPEC §9.3's open-world rule); tampering already reads FALSE on the
442
+ // lines above, and this line does not restate them.
443
+ if (from !== undefined) {
444
+ // Verified exactly when the signature line above reads TRUE: the
445
+ // integrity-tier "artifact-unavailable" status is a PASS (proof-only
446
+ // bundles verify; whether bytes are in hand is the file line's job).
447
+ const verified = v !== undefined && v.status !== "failed";
448
+ lines.push(domainLine(proof, verified, from, sigEvidence ?? EMPTY_EVIDENCE));
449
+ }
450
+
377
451
  const result = kleeneAll(lines.map((l) => l.result));
378
452
 
379
453
  return {
@@ -453,6 +527,64 @@ function attestationLine(
453
527
  };
454
528
  }
455
529
 
530
+ const EMPTY_EVIDENCE: ReadonlyMap<string, Uint8Array> = new Map();
531
+
532
+ function domainLine(
533
+ proof: ObservedProof,
534
+ verified: boolean,
535
+ from: CheckDomain,
536
+ sigEvidence: ReadonlyMap<string, Uint8Array>
537
+ ): CheckLine {
538
+ if (!verified) {
539
+ return {
540
+ name: "domain",
541
+ result: "UNDETERMINED",
542
+ detail: `the recording is not verified here, so nothing binds it to ${from.domain}`,
543
+ };
544
+ }
545
+ const actorKeyId = proof.proof.agency?.actor?.keyId;
546
+ if (actorKeyId !== undefined) {
547
+ const name = from.actorKeyName(actorKeyId);
548
+ if (name !== undefined) {
549
+ return {
550
+ name: "domain",
551
+ result: "TRUE",
552
+ detail: `actor key "${name}" · published by ${from.domain} (${from.party})`,
553
+ };
554
+ }
555
+ }
556
+ const targetBytes = decodeDigestB64(proof.proof.artifact.digestB64);
557
+ if (targetBytes !== undefined && sigEvidence.size > 0) {
558
+ const name = from.signatureKeyName(targetBytes.toString("hex"), sigEvidence);
559
+ if (name !== undefined) {
560
+ return {
561
+ name: "domain",
562
+ result: "TRUE",
563
+ detail: `signature by "${name}" · published by ${from.domain} (${from.party})`,
564
+ };
565
+ }
566
+ }
567
+ if (actorKeyId !== undefined) {
568
+ return {
569
+ name: "domain",
570
+ result: "UNDETERMINED",
571
+ detail: `actor key ${actorKeyId.slice(0, 12)}… is not among the ${from.keyCount} key(s) ${from.domain} publishes`,
572
+ };
573
+ }
574
+ return {
575
+ name: "domain",
576
+ result: "UNDETERMINED",
577
+ detail: `no evidence binds this recording to ${from.domain}`,
578
+ };
579
+ }
580
+
581
+ /** Standard-base64 digest to bytes; undefined when it does not round-trip. */
582
+ function decodeDigestB64(digestB64: string): Buffer | undefined {
583
+ if (!/^[A-Za-z0-9+/]+=*$/.test(digestB64)) return undefined;
584
+ const bytes = Buffer.from(digestB64, "base64");
585
+ return bytes.toString("base64") === digestB64 ? bytes : undefined;
586
+ }
587
+
456
588
  function enclaveLine(attestedPcr0: string | undefined, attestationResult: ThreeValued): CheckLine {
457
589
  if (attestationResult !== "TRUE" || attestedPcr0 === undefined) {
458
590
  return {
@@ -486,7 +618,7 @@ function boundsFor(segment: TemporalSegment | undefined): CheckBounds {
486
618
  const notAfter = upper === undefined ? undefined : toBound(upper);
487
619
  const status: CheckBounds["status"] =
488
620
  notBefore !== undefined && notAfter !== undefined
489
- ? "bracketed"
621
+ ? "lower-bounded-with-following-anchor"
490
622
  : notBefore !== undefined
491
623
  ? "lower-bounded"
492
624
  : notAfter !== undefined
@@ -504,16 +636,22 @@ function boundsFor(segment: TemporalSegment | undefined): CheckBounds {
504
636
  };
505
637
  }
506
638
 
507
- /** "after Ethereum block A and before block B (both headers verified)…", shared by bounds.detail and the summary. */
639
+ /**
640
+ * "after Ethereum block A; precedes an anchor that consumed block B", shared by bounds.detail and the summary.
641
+ * The following anchor is deliberately NOT rendered as "before block B": a proof precedes the anchor's
642
+ * commit, and block B's timestamp bounds that commit from below, not the proof from above. Reading it as
643
+ * a ceiling assumes the anchor consumed a recently published block. An inbound anchor cannot carry a
644
+ * proof of an upper bound, so the phrase says what the evidence supports and names the assumption.
645
+ */
508
646
  function boundsPhrase(
509
647
  status: CheckBounds["status"],
510
648
  notBefore: CheckBound | undefined,
511
649
  notAfter: CheckBound | undefined
512
650
  ): string {
513
651
  switch (status) {
514
- case "bracketed":
652
+ case "lower-bounded-with-following-anchor":
515
653
  return (
516
- `after Ethereum block ${blockRef(notBefore as CheckBound)} and before block ${blockRef(notAfter as CheckBound)} (both headers verified in this bundle)` +
654
+ `after Ethereum block ${blockRef(notBefore as CheckBound)} (header verified in this bundle); precedes an anchor that consumed block ${blockRef(notAfter as CheckBound)} (header verified), an anchor-latency assumption rather than an upper bound on this recording` +
517
655
  weakerSuffix((notBefore as CheckBound).weaker || (notAfter as CheckBound).weaker)
518
656
  );
519
657
  case "lower-bounded":
@@ -557,8 +695,8 @@ function toBound(b: SegmentBound): CheckBound {
557
695
  /** The headline form of the bounds: one clause, no qualifiers (those live in bounds.detail). */
558
696
  function shortBoundsPhrase(b: CheckBounds): string {
559
697
  switch (b.status) {
560
- case "bracketed":
561
- return `between Ethereum blocks ${blockRef(b.notBefore as CheckBound)} and ${blockRef(b.notAfter as CheckBound)}`;
698
+ case "lower-bounded-with-following-anchor":
699
+ return `after Ethereum block ${blockRef(b.notBefore as CheckBound)}, then an anchor at block ${blockRef(b.notAfter as CheckBound)}`;
562
700
  case "lower-bounded":
563
701
  return `after Ethereum block ${blockRef(b.notBefore as CheckBound)}`;
564
702
  case "upper-bounded":
@@ -751,13 +889,18 @@ function collectNotes(audit: AuditResult, recordings: CheckRecording[], anchors:
751
889
  return notes;
752
890
  }
753
891
 
754
- function collectNotChecked(hasAnchors: boolean): string[] {
892
+ function collectNotChecked(hasAnchors: boolean, fromDomain?: string): string[] {
755
893
  const out: string[] = [];
756
894
  if (hasAnchors) {
757
895
  out.push(
758
896
  "whether the anchored Ethereum blocks are canonical: their headers are recomputed here, but canonicality needs an Ethereum node or a block explorer"
759
897
  );
760
898
  }
899
+ if (fromDomain !== undefined) {
900
+ out.push(
901
+ `whether ${fromDomain}'s published key file has changed since it was pinned: a pin is read as stored; pin the domain again to refresh it`
902
+ );
903
+ }
761
904
  out.push(
762
905
  "whether the public ledger holds these exact recordings at these positions: this is an offline check; drop the file on bitgraph.ing to compare against the ledger"
763
906
  );
@@ -812,6 +955,9 @@ export function renderCheckText(report: CheckReport): string {
812
955
  const out: string[] = [];
813
956
  const mark = (r: ThreeValued): string => (r === "TRUE" ? "TRUE " : r === "FALSE" ? "FALSE" : "UNDET");
814
957
  out.push(report.summary);
958
+ if (report.from !== undefined) {
959
+ out.push(`checked against ${report.from.domain} (${report.from.party}), from the stored pin`);
960
+ }
815
961
  out.push("");
816
962
  report.recordings.forEach((rec, i) => {
817
963
  out.push(
package/src/cli.ts CHANGED
@@ -4,7 +4,8 @@
4
4
  /**
5
5
  * bitgraph-play <rule.json> <bundle> [--out <file>] [--summary]
6
6
  * bitgraph-play init <file>... [--out <rule.json>]
7
- * bitgraph-play check <bundle-or-file>... [--json] [--out <file>]
7
+ * bitgraph-play check <bundle-or-file>... [--json] [--out <file>] [--from <domain> [--pins <dir>]]
8
+ * bitgraph-play pin [<domain>] [--forget <domain>] [--pins <dir>] [--yes]
8
9
  *
9
10
  * Evaluate: runs a bitgraph-player/1 rule against a proof bundle
10
11
  * (directory, .tar, .tar.gz, or .tgz) and writes the verdict JSON to
@@ -22,6 +23,13 @@
22
23
  * what bounds it. Human text on stdout by default; --json for the
23
24
  * bitgraph-check/1 report. Offline. See check.ts for the vocabulary.
24
25
  *
26
+ * Pin: fetches https://<domain>/.well-known/bitgraph once (the ONLY
27
+ * network access anywhere in this package, and only when invoked), shows
28
+ * the party and every key's fingerprint, and stores the bytes verbatim
29
+ * after confirmation. `check --from <domain>` then adds one three-valued
30
+ * "domain" line per recording, offline, from the stored pin: TRUE or
31
+ * UNDETERMINED, never FALSE. Format and semantics: DOMAIN.md.
32
+ *
25
33
  * Exit codes: 0 TRUE, 1 FALSE, 2 UNDETERMINED, 3 error (init: 0 or 3).
26
34
  * Diagnostics go to stderr; stdout carries the verdict (or skeleton, or
27
35
  * check report) bytes only.
@@ -35,10 +43,14 @@ import { createHash } from "node:crypto";
35
43
  import { createReadStream, existsSync, writeFileSync } from "node:fs";
36
44
  import { readFile, stat } from "node:fs/promises";
37
45
  import { basename } from "node:path";
46
+ import { createInterface } from "node:readline/promises";
38
47
  import { pipeline } from "node:stream/promises";
39
48
  import type { AuditResult, BundleEntrySource } from "@mikeargento/bitgraph-audit";
40
49
  import { ingestBundle, ingestEntries } from "@mikeargento/bitgraph-audit";
50
+ import type { CheckOptions } from "./check.js";
41
51
  import { checkIngest, renderCheckText, serializeCheckReport } from "./check.js";
52
+ import { checkDomain, diffDomainFiles, domainKeyRefs, DomainFileError, isDomainName } from "./domain.js";
53
+ import { defaultPinsDir, fetchDomainFile, forgetPin, listPins, readPin, writePin } from "./pin.js";
42
54
  import { scaffoldRule } from "./init.js";
43
55
  import type { ScaffoldEntry } from "./init.js";
44
56
  import { play, PlayError } from "./play.js";
@@ -49,8 +61,12 @@ function usage(): number {
49
61
  "usage: bitgraph-play <rule.json> <bundle> [--out <file>] [--summary]\n" +
50
62
  " bitgraph-play init <file>... [--out <rule.json>]\n" +
51
63
  " bitgraph-play check <bundle-or-file>... [--json] [--out <file>]\n" +
52
- ' "--" ends option parsing; a rule file literally named "init" or\n' +
53
- ' "check" is evaluated with: bitgraph-play -- init <bundle>\n' +
64
+ " [--from <domain> [--pins <dir>]]\n" +
65
+ " bitgraph-play pin [<domain>] [--forget <domain>] [--pins <dir>] [--yes]\n" +
66
+ ' "--" ends option parsing; a rule file literally named "init",\n' +
67
+ ' "check" or "pin" is evaluated with: bitgraph-play -- init <bundle>\n' +
68
+ " pin is the only command that touches the network; check --from\n" +
69
+ " reads the stored pin and runs offline\n" +
54
70
  " exit codes: 0 TRUE, 1 FALSE, 2 UNDETERMINED, 3 error\n"
55
71
  );
56
72
  return 3;
@@ -91,10 +107,18 @@ interface ParsedArgs {
91
107
  outFile?: string;
92
108
  summary: boolean;
93
109
  json: boolean;
110
+ from?: string;
111
+ pinsDir?: string;
112
+ forget?: string;
113
+ yes: boolean;
94
114
  }
95
115
 
96
116
  function parseArgs(args: string[]): ParsedArgs | undefined {
97
- const parsed: ParsedArgs = { positional: [], summary: false, json: false };
117
+ const parsed: ParsedArgs = { positional: [], summary: false, json: false, yes: false };
118
+ const valueFor = (i: number): string | undefined => {
119
+ const next = args[i];
120
+ return next === undefined || next.startsWith("-") ? undefined : next;
121
+ };
98
122
  for (let i = 0; i < args.length; i++) {
99
123
  const arg = args[i] as string;
100
124
  if (arg === "--") {
@@ -102,13 +126,27 @@ function parseArgs(args: string[]): ParsedArgs | undefined {
102
126
  parsed.positional.push(...args.slice(i + 1));
103
127
  break;
104
128
  } else if (arg === "--out") {
105
- const next = args[++i];
106
- if (next === undefined || next.startsWith("-")) return undefined;
129
+ const next = valueFor(++i);
130
+ if (next === undefined) return undefined;
107
131
  parsed.outFile = next;
132
+ } else if (arg === "--from") {
133
+ const next = valueFor(++i);
134
+ if (next === undefined) return undefined;
135
+ parsed.from = next;
136
+ } else if (arg === "--pins") {
137
+ const next = valueFor(++i);
138
+ if (next === undefined) return undefined;
139
+ parsed.pinsDir = next;
140
+ } else if (arg === "--forget") {
141
+ const next = valueFor(++i);
142
+ if (next === undefined) return undefined;
143
+ parsed.forget = next;
108
144
  } else if (arg === "--summary") {
109
145
  parsed.summary = true;
110
146
  } else if (arg === "--json") {
111
147
  parsed.json = true;
148
+ } else if (arg === "--yes") {
149
+ parsed.yes = true;
112
150
  } else if (arg.startsWith("-")) {
113
151
  return undefined;
114
152
  } else {
@@ -125,10 +163,43 @@ function parseArgs(args: string[]): ParsedArgs | undefined {
125
163
  * `bitgraph-play check proof.json photo.jpg` works without a folder.
126
164
  */
127
165
  async function runCheck(args: ParsedArgs): Promise<number> {
128
- if (args.summary) return usage();
166
+ if (args.summary || args.yes || args.forget !== undefined) return usage();
167
+ if (args.pinsDir !== undefined && args.from === undefined) return usage();
129
168
  const targets = args.positional;
130
169
  if (targets.length === 0) return usage();
131
170
 
171
+ // Resolve the pin before touching the bundle: a missing pin should fail
172
+ // in milliseconds, with its remedy, not after a long ingest. check
173
+ // itself NEVER fetches; the pin was the one network step, already done.
174
+ let options: CheckOptions | undefined;
175
+ if (args.from !== undefined) {
176
+ const domain = args.from.toLowerCase();
177
+ if (!isDomainName(domain)) {
178
+ process.stderr.write(`error: not a domain name: ${args.from}\n`);
179
+ return 3;
180
+ }
181
+ const pinsDir = args.pinsDir ?? defaultPinsDir();
182
+ let pin;
183
+ try {
184
+ pin = readPin(domain, pinsDir);
185
+ } catch (err) {
186
+ process.stderr.write(`error: the stored pin for ${domain} is malformed`);
187
+ if (err instanceof DomainFileError && err.issues[0] !== undefined) {
188
+ process.stderr.write(`: ${err.issues[0]}`);
189
+ }
190
+ process.stderr.write(`\n pin it again: bitgraph-play pin ${domain}\n`);
191
+ return 3;
192
+ }
193
+ if (pin === undefined) {
194
+ process.stderr.write(
195
+ `error: no pin for ${domain}\n` +
196
+ ` pin it once (the only step that needs the network): bitgraph-play pin ${domain}\n`
197
+ );
198
+ return 3;
199
+ }
200
+ options = { from: checkDomain(pin.file) };
201
+ }
202
+
132
203
  let ingest;
133
204
  try {
134
205
  const single = targets.length === 1 ? await stat(targets[0] as string) : undefined;
@@ -153,7 +224,7 @@ async function runCheck(args: ParsedArgs): Promise<number> {
153
224
  return 3;
154
225
  }
155
226
 
156
- const report = await checkIngest(ingest);
227
+ const report = await checkIngest(ingest, options);
157
228
  const bytes = args.json ? serializeCheckReport(report) : renderCheckText(report);
158
229
  if (args.outFile !== undefined) {
159
230
  writeFileSync(args.outFile, bytes);
@@ -169,7 +240,7 @@ function looksLikeArchive(path: string): boolean {
169
240
  }
170
241
 
171
242
  async function runInit(args: ParsedArgs): Promise<number> {
172
- if (args.summary || args.json) return usage();
243
+ if (args.summary || args.json || args.from !== undefined || args.pinsDir !== undefined || args.yes || args.forget !== undefined) return usage();
173
244
  const files = args.positional;
174
245
  if (files.length === 0) return usage();
175
246
  if (args.outFile !== undefined && existsSync(args.outFile)) {
@@ -225,7 +296,8 @@ async function runInit(args: ParsedArgs): Promise<number> {
225
296
  }
226
297
 
227
298
  async function runEvaluate(args: ParsedArgs): Promise<number> {
228
- if (args.json || args.positional.length !== 2) return usage();
299
+ if (args.json || args.from !== undefined || args.pinsDir !== undefined || args.yes || args.forget !== undefined) return usage();
300
+ if (args.positional.length !== 2) return usage();
229
301
  const [rulePath, bundlePath] = args.positional as [string, string];
230
302
 
231
303
  let result;
@@ -252,12 +324,126 @@ async function runEvaluate(args: ParsedArgs): Promise<number> {
252
324
  return result.exitCode;
253
325
  }
254
326
 
327
+ /**
328
+ * pin: the only command in this package that touches the network, and
329
+ * only when invoked. Conversational output goes to stderr (there are no
330
+ * report bytes); the pin listing, which IS the output, goes to stdout.
331
+ */
332
+ async function runPin(args: ParsedArgs): Promise<number> {
333
+ if (args.summary || args.json || args.outFile !== undefined || args.from !== undefined) return usage();
334
+ const pinsDir = args.pinsDir ?? defaultPinsDir();
335
+
336
+ if (args.forget !== undefined) {
337
+ if (args.positional.length !== 0) return usage();
338
+ const domain = args.forget.toLowerCase();
339
+ if (!isDomainName(domain)) {
340
+ process.stderr.write(`error: not a domain name: ${args.forget}\n`);
341
+ return 3;
342
+ }
343
+ if (forgetPin(domain, pinsDir)) {
344
+ process.stderr.write(`forgot ${domain}\n`);
345
+ return 0;
346
+ }
347
+ process.stderr.write(`error: no pin for ${domain}\n`);
348
+ return 3;
349
+ }
350
+
351
+ if (args.positional.length === 0) {
352
+ const pins = listPins(pinsDir);
353
+ if (pins.length === 0) {
354
+ process.stderr.write(
355
+ "no pins yet\n pin a domain (the only step that needs the network): bitgraph-play pin <domain>\n"
356
+ );
357
+ return 0;
358
+ }
359
+ for (const pin of pins) {
360
+ process.stdout.write(
361
+ pin.malformed
362
+ ? `${pin.domain} (malformed pin; pin it again or --forget it)\n`
363
+ : `${pin.domain} ${pin.party as string} ${pin.keyCount as number} key(s) pinned ${pin.pinnedAt.toISOString().slice(0, 10)}\n`
364
+ );
365
+ }
366
+ return 0;
367
+ }
368
+
369
+ if (args.positional.length !== 1) return usage();
370
+ const domain = (args.positional[0] as string).toLowerCase();
371
+
372
+ let fetched;
373
+ try {
374
+ fetched = await fetchDomainFile(domain);
375
+ } catch (err) {
376
+ if (err instanceof DomainFileError) {
377
+ process.stderr.write(
378
+ `error: the file at https://${domain}/.well-known/bitgraph is not a valid bitgraph-domain/1 file:\n`
379
+ );
380
+ for (const issue of err.issues) process.stderr.write(` - ${issue}\n`);
381
+ } else {
382
+ process.stderr.write(`error: ${(err as Error).message}\n`);
383
+ }
384
+ return 3;
385
+ }
386
+
387
+ const refs = domainKeyRefs(fetched.file);
388
+ const nameWidth = refs.reduce((w, r) => Math.max(w, r.name.length), 4);
389
+ process.stderr.write(`\n${domain} · ${fetched.file.party}\n`);
390
+ for (const ref of refs) {
391
+ process.stderr.write(` ${ref.name.padEnd(nameWidth)} ${ref.key.alg.padEnd(7)} ${ref.fingerprint}\n`);
392
+ }
393
+
394
+ let existing;
395
+ let existingMalformed = false;
396
+ try {
397
+ existing = readPin(domain, pinsDir);
398
+ } catch {
399
+ existingMalformed = true;
400
+ }
401
+ if (existingMalformed) {
402
+ process.stderr.write(`\nthe stored pin for ${domain} is malformed and will be replaced\n`);
403
+ } else if (existing !== undefined) {
404
+ const diff = diffDomainFiles(existing.file, fetched.file);
405
+ const changes: string[] = [];
406
+ if (diff.partyChanged !== undefined) {
407
+ changes.push(` party: "${diff.partyChanged.before}" is now "${diff.partyChanged.after}"`);
408
+ }
409
+ for (const ref of diff.added) changes.push(` + ${ref.name} ${ref.key.alg} ${ref.fingerprint}`);
410
+ for (const ref of diff.removed) changes.push(` - ${ref.name} ${ref.key.alg} ${ref.fingerprint}`);
411
+ for (const ch of diff.changed) changes.push(` ~ ${ch.name} now ${ch.after.key.alg} ${ch.after.fingerprint}`);
412
+ const pinnedOn = existing.pinnedAt.toISOString().slice(0, 10);
413
+ process.stderr.write(
414
+ changes.length === 0
415
+ ? `\nunchanged since the stored pin (${pinnedOn})\n`
416
+ : `\nchanges since the stored pin (${pinnedOn}):\n${changes.join("\n")}\n`
417
+ );
418
+ }
419
+
420
+ if (!args.yes) {
421
+ if (!process.stdin.isTTY) {
422
+ process.stderr.write("\nerror: not a terminal; pass --yes to pin non-interactively\n");
423
+ return 3;
424
+ }
425
+ const rl = createInterface({ input: process.stdin, output: process.stderr });
426
+ const answer = (await rl.question(`\npin ${refs.length} key(s) for ${domain}? [y/N] `)).trim().toLowerCase();
427
+ rl.close();
428
+ if (answer !== "y" && answer !== "yes") {
429
+ process.stderr.write("not pinned\n");
430
+ return 3;
431
+ }
432
+ }
433
+
434
+ const path = writePin(domain, fetched.bytes, pinsDir);
435
+ process.stderr.write(
436
+ `pinned: ${path}\nchecks now run offline: bitgraph-play check <export> --from ${domain}\n`
437
+ );
438
+ return 0;
439
+ }
440
+
255
441
  async function main(): Promise<number> {
256
442
  const argv = process.argv.slice(2);
257
443
  // "--" as the first token forces evaluate mode: parseArgs treats
258
444
  // everything after it as positional, so a rule file literally named
259
- // "init" or "check" is reachable as `bitgraph-play -- init <bundle>`.
260
- const subcommand = argv[0] === "init" || argv[0] === "check" ? argv[0] : undefined;
445
+ // "init", "check" or "pin" is reachable as `bitgraph-play -- init <bundle>`.
446
+ const subcommand = argv[0] === "init" || argv[0] === "check" || argv[0] === "pin" ? argv[0] : undefined;
261
447
  if (subcommand !== undefined && existsSync(subcommand)) {
262
448
  // Both readings are plausible here; a silent pick would hand a
263
449
  // 0.1.1 caller a skeleton with exit 0 where the published contract
@@ -273,6 +459,7 @@ async function main(): Promise<number> {
273
459
  if (parsed === undefined) return usage();
274
460
  if (subcommand === "init") return runInit(parsed);
275
461
  if (subcommand === "check") return runCheck(parsed);
462
+ if (subcommand === "pin") return runPin(parsed);
276
463
  return runEvaluate(parsed);
277
464
  }
278
465