@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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mikeargento/bitgraph-player",
3
- "version": "0.5.2",
3
+ "version": "0.7.0",
4
4
  "description": "Deterministic evaluation of causal rules over BitGraph proof bundles: a pure function from (rule, verified evidence) to a reproducible verdict.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -20,6 +20,7 @@
20
20
  "dist-web",
21
21
  "src",
22
22
  "SPEC.md",
23
+ "DOMAIN.md",
23
24
  "README.md",
24
25
  "LICENSE"
25
26
  ],
@@ -28,14 +29,14 @@
28
29
  "prepack": "tsc",
29
30
  "typecheck": "tsc --noEmit",
30
31
  "test": "tsc && node --test \"dist/__tests__/*.test.js\"",
31
- "build:web": "node scripts/build-verify-html.mjs --out ../folder/src/verify.html",
32
+ "build:web": "node scripts/build-verify-html.mjs --out ../../website/public/verify.html",
32
33
  "typecheck:web": "tsc -p web/tsconfig.json"
33
34
  },
34
35
  "engines": {
35
36
  "node": ">=20"
36
37
  },
37
38
  "dependencies": {
38
- "@mikeargento/bitgraph-audit": "^0.2.2",
39
+ "@mikeargento/bitgraph-audit": "^0.3.0",
39
40
  "@mikeargento/bitgraph-verify": "^1.3.0"
40
41
  },
41
42
  "devDependencies": {
@@ -116,10 +116,11 @@ describe("check: real export fixture (disk)", () => {
116
116
 
117
117
  it("brackets the recording between the two verified block headers", () => {
118
118
  const b = report.recordings[0]!.bounds;
119
- assert.equal(b.status, "bracketed");
119
+ assert.equal(b.status, "lower-bounded-with-following-anchor");
120
120
  assert.equal(b.notBefore?.blockNumber, "25735831");
121
121
  assert.equal(b.notAfter?.blockNumber, "25735832");
122
- assert.match(b.detail, /after Ethereum block 25735831 and before block 25735832/);
122
+ assert.match(b.detail, /after Ethereum block 25735831 \(header verified in this bundle\); precedes an anchor that consumed block 25735832/);
123
+ assert.match(b.detail, /rather than an upper bound/);
123
124
  });
124
125
 
125
126
  it("orders anchors by causal position, not file name", () => {
@@ -0,0 +1,321 @@
1
+ // Copyright (c) 2024-2026 Mike Argento. Licensed under the MIT License. See LICENSE.
2
+
3
+ /**
4
+ * BitGraph Domain: parseDomainFile, fingerprints, the pin store, the
5
+ * fetch, and check --from's "domain" line.
6
+ *
7
+ * The invariants under test:
8
+ * - The domain line is TRUE or UNDETERMINED, never FALSE: absence of
9
+ * domain evidence contradicts nothing (SPEC §9.3's open-world rule).
10
+ * - Fingerprints are always derived from key material, never read from
11
+ * the file; for es256 the fingerprint IS the actor keyId, proved
12
+ * against the real declared recording (fixtures/declared-12010).
13
+ * - A malformed file is refused at the pin, and a redirect cannot store
14
+ * one party's file under another party's name (the `domain` field
15
+ * must equal the domain the reader asked for).
16
+ * - A report without --from stays bitgraph-check/1 with no `from` key.
17
+ *
18
+ * Everything runs in memory or under a temp dir. The fetch is injected;
19
+ * no network, no ledger writes, ever.
20
+ */
21
+
22
+ import { strict as assert } from "node:assert";
23
+ import { createHash, generateKeyPairSync, sign as cryptoSign } from "node:crypto";
24
+ import { mkdtempSync, readFileSync, rmSync } from "node:fs";
25
+ import { tmpdir } from "node:os";
26
+ import { join } from "node:path";
27
+ import { test } from "node:test";
28
+ import { ingestEntries } from "@mikeargento/bitgraph-audit";
29
+ import type { CheckRecording, CheckReport } from "../check.js";
30
+ import { checkIngest } from "../check.js";
31
+ import {
32
+ checkDomain,
33
+ diffDomainFiles,
34
+ DomainFileError,
35
+ domainKeyRefs,
36
+ isDomainName,
37
+ keyFingerprint,
38
+ parseDomainFile,
39
+ } from "../domain.js";
40
+ import { fetchDomainFile, forgetPin, listPins, readPin, writePin } from "../pin.js";
41
+ import type { FetchLike } from "../pin.js";
42
+ import { sigMessage } from "../sig.js";
43
+
44
+ // ---------------------------------------------------------------------------
45
+ // The real declared recording: actor key ee0c6517…, provider passkey
46
+ // ---------------------------------------------------------------------------
47
+
48
+ const proofBytes = readFileSync(new URL("../../src/__tests__/fixtures/declared-12010/proof.json", import.meta.url));
49
+ const proofJson = JSON.parse(proofBytes.toString("utf8")) as {
50
+ agency: { actor: { publicKeyB64: string; keyId: string } };
51
+ artifact: { digestB64: string };
52
+ };
53
+ const ACTOR_KEY_B64 = proofJson.agency.actor.publicKeyB64;
54
+ const ACTOR_KEY_ID = proofJson.agency.actor.keyId;
55
+ const DIGEST_HEX = Buffer.from(proofJson.artifact.digestB64, "base64").toString("hex");
56
+
57
+ function domainText(overrides?: Record<string, unknown>): string {
58
+ return JSON.stringify({
59
+ version: "bitgraph-domain/1",
60
+ domain: "acme.com",
61
+ party: "Acme Corp",
62
+ keys: { studio: { alg: "es256", publicKey: ACTOR_KEY_B64 } },
63
+ ...overrides,
64
+ });
65
+ }
66
+
67
+ async function ingestProof(bytes: Uint8Array = proofBytes) {
68
+ return ingestEntries([{ path: "proof.json", open: () => Promise.resolve(new Uint8Array(bytes)) }]);
69
+ }
70
+
71
+ function onlyRecording(report: CheckReport): CheckRecording {
72
+ assert.equal(report.recordings.length, 1);
73
+ return report.recordings[0] as CheckRecording;
74
+ }
75
+
76
+ function domainLineOf(recording: CheckRecording) {
77
+ const line = recording.lines.find((l) => l.name === "domain");
78
+ assert.notEqual(line, undefined, "expected a domain line");
79
+ return line as NonNullable<typeof line>;
80
+ }
81
+
82
+ // ---------------------------------------------------------------------------
83
+ // Parsing and fingerprints
84
+ // ---------------------------------------------------------------------------
85
+
86
+ test("parseDomainFile accepts a valid file and derives the party/keys", () => {
87
+ const file = parseDomainFile(domainText(), "acme.com");
88
+ assert.equal(file.domain, "acme.com");
89
+ assert.equal(file.party, "Acme Corp");
90
+ assert.deepEqual(Object.keys(file.keys), ["studio"]);
91
+ });
92
+
93
+ test("isDomainName: lowercase dotted hostnames only", () => {
94
+ assert.equal(isDomainName("acme.com"), true);
95
+ assert.equal(isDomainName("sub.acme-corp.co.uk"), true);
96
+ assert.equal(isDomainName("ACME.com"), false);
97
+ assert.equal(isDomainName("acme"), false);
98
+ assert.equal(isDomainName("acme.com/path"), false);
99
+ assert.equal(isDomainName("https://acme.com"), false);
100
+ assert.equal(isDomainName("acme.com:443"), false);
101
+ assert.equal(isDomainName(""), false);
102
+ });
103
+
104
+ test("parseDomainFile refuses malformed files with every issue named", () => {
105
+ const bad = (text: string, expected?: string): string[] => {
106
+ try {
107
+ parseDomainFile(text, expected);
108
+ } catch (err) {
109
+ assert.ok(err instanceof DomainFileError);
110
+ return [...err.issues];
111
+ }
112
+ assert.fail("expected DomainFileError");
113
+ };
114
+ assert.match(bad(domainText({ version: "bitgraph-domain/2" })).join(" "), /"version" must be exactly/);
115
+ assert.match(bad(domainText({ extra: 1 })).join(" "), /unknown field "extra"/);
116
+ assert.match(bad(domainText(), "other.example").join(" "), /refusing to store one party's file/);
117
+ assert.match(bad(domainText({ party: " " })).join(" "), /"party" is required/);
118
+ assert.match(bad(domainText({ keys: {} })).join(" "), /at least one key/);
119
+ assert.match(bad(domainText({ keys: { "1234": { alg: "es256", publicKey: ACTOR_KEY_B64 } } })).join(" "), /non-digit/);
120
+ assert.match(
121
+ bad(domainText({ keys: { studio: { alg: "es256", publicKey: ACTOR_KEY_B64, note: "x" } } })).join(" "),
122
+ /unknown field "note"/
123
+ );
124
+ assert.match(bad(domainText({ keys: { studio: { alg: "rsa", publicKey: ACTOR_KEY_B64 } } })).join(" "), /"alg" must be/);
125
+ assert.match(bad(domainText({ keys: { studio: { alg: "es256", publicKey: "!!!" } } })).join(" "), /does not decode/);
126
+ assert.match(
127
+ bad(domainText({ keys: { press: { alg: "ed25519", publicKey: Buffer.alloc(16).toString("base64") } } })).join(" "),
128
+ /does not decode/
129
+ );
130
+ assert.match(bad(domainText({ domain: "https://acme.com" })).join(" "), /lowercase hostname/);
131
+ // Oversize input is refused before parsing.
132
+ const big = new Uint8Array(70_000);
133
+ assert.throws(() => parseDomainFile(big), DomainFileError);
134
+ });
135
+
136
+ test("an es256 fingerprint IS the actor keyId (derived, never assigned)", () => {
137
+ assert.equal(keyFingerprint({ alg: "es256", publicKey: ACTOR_KEY_B64 }), ACTOR_KEY_ID);
138
+ const refs = domainKeyRefs(parseDomainFile(domainText()));
139
+ assert.deepEqual(refs.map((r) => [r.name, r.fingerprint]), [["studio", ACTOR_KEY_ID]]);
140
+ });
141
+
142
+ test("checkDomain resolves actor keyIds case-insensitively, es256 only", () => {
143
+ const from = checkDomain(parseDomainFile(domainText()));
144
+ assert.equal(from.actorKeyName(ACTOR_KEY_ID), "studio");
145
+ assert.equal(from.actorKeyName(ACTOR_KEY_ID.toUpperCase()), "studio");
146
+ assert.equal(from.actorKeyName("00".repeat(32)), undefined);
147
+ // An ed25519 key never actor-matches: actors are P-256.
148
+ const edPub = generateKeyPairSync("ed25519").publicKey.export({ type: "spki", format: "der" }) as Buffer;
149
+ const edOnly = parseDomainFile(
150
+ domainText({ keys: { press: { alg: "ed25519", publicKey: edPub.subarray(edPub.length - 32).toString("base64") } } })
151
+ );
152
+ const edFingerprint = keyFingerprint(edOnly.keys["press"] as { alg: "ed25519"; publicKey: string });
153
+ assert.equal(checkDomain(edOnly).actorKeyName(edFingerprint as string), undefined);
154
+ });
155
+
156
+ // ---------------------------------------------------------------------------
157
+ // check --from: the domain line
158
+ // ---------------------------------------------------------------------------
159
+
160
+ test("check --from: the real declared recording reads TRUE under its published key", async () => {
161
+ const report = await checkIngest(await ingestProof(), { from: checkDomain(parseDomainFile(domainText())) });
162
+ assert.equal(report.check, "bitgraph-check/2");
163
+ assert.deepEqual(report.from, { domain: "acme.com", party: "Acme Corp" });
164
+ const line = domainLineOf(onlyRecording(report));
165
+ assert.equal(line.result, "TRUE");
166
+ assert.match(line.detail, /actor key "studio" · published by acme\.com \(Acme Corp\)/);
167
+ assert.ok(report.notChecked.some((n) => n.includes("pinned")));
168
+ });
169
+
170
+ test("check --from: a domain that never published the key reads UNDETERMINED, never FALSE", async () => {
171
+ const otherKey = generateKeyPairSync("ec", { namedCurve: "P-256" })
172
+ .publicKey.export({ type: "spki", format: "der" }) as Buffer;
173
+ const from = checkDomain(
174
+ parseDomainFile(domainText({ keys: { invoices: { alg: "es256", publicKey: otherKey.toString("base64") } } }))
175
+ );
176
+ const report = await checkIngest(await ingestProof(), { from });
177
+ const line = domainLineOf(onlyRecording(report));
178
+ assert.equal(line.result, "UNDETERMINED");
179
+ assert.match(line.detail, /is not among the 1 key\(s\) acme\.com publishes/);
180
+ });
181
+
182
+ test("check --from: a recording that does not verify gets an UNDETERMINED domain line", async () => {
183
+ const stripped = JSON.parse(proofBytes.toString("utf8")) as Record<string, unknown>;
184
+ delete stripped["agency"];
185
+ const report = await checkIngest(await ingestProof(Buffer.from(JSON.stringify(stripped))), {
186
+ from: checkDomain(parseDomainFile(domainText())),
187
+ });
188
+ const recording = onlyRecording(report);
189
+ assert.equal(recording.lines.find((l) => l.name === "signature")?.result, "FALSE");
190
+ const line = domainLineOf(recording);
191
+ assert.equal(line.result, "UNDETERMINED");
192
+ assert.match(line.detail, /is not verified here/);
193
+ });
194
+
195
+ test("check --from: a detached bitgraph-sig/1 under a published ed25519 key reads TRUE", async () => {
196
+ const { publicKey, privateKey } = generateKeyPairSync("ed25519");
197
+ const spki = publicKey.export({ type: "spki", format: "der" }) as Buffer;
198
+ const rawB64 = spki.subarray(spki.length - 32).toString("base64");
199
+ const signature = cryptoSign(null, sigMessage(DIGEST_HEX), privateKey).toString("base64");
200
+ const sigBytes = Buffer.from(
201
+ JSON.stringify({
202
+ sig: "bitgraph-sig/1",
203
+ over: `sha256:${DIGEST_HEX}`,
204
+ alg: "ed25519",
205
+ publicKey: rawB64,
206
+ signature,
207
+ })
208
+ );
209
+ const from = checkDomain(parseDomainFile(domainText({ keys: { press: { alg: "ed25519", publicKey: rawB64 } } })));
210
+ const sigEvidence = new Map([[createHash("sha256").update(sigBytes).digest("hex"), new Uint8Array(sigBytes)]]);
211
+ const report = await checkIngest(await ingestProof(), { from, sigEvidence });
212
+ const line = domainLineOf(onlyRecording(report));
213
+ assert.equal(line.result, "TRUE");
214
+ assert.match(line.detail, /signature by "press" · published by acme\.com/);
215
+ });
216
+
217
+ test("without --from the report stays bitgraph-check/1 with no from key and no domain line", async () => {
218
+ const report = await checkIngest(await ingestProof());
219
+ assert.equal(report.check, "bitgraph-check/1");
220
+ assert.equal("from" in report, false);
221
+ assert.equal(onlyRecording(report).lines.some((l) => l.name === "domain"), false);
222
+ });
223
+
224
+ // ---------------------------------------------------------------------------
225
+ // The pin store
226
+ // ---------------------------------------------------------------------------
227
+
228
+ test("pin store: write, read, list, forget; malformed pins are refused at read", () => {
229
+ const dir = mkdtempSync(join(tmpdir(), "bitgraph-pins-"));
230
+ try {
231
+ const path = writePin("acme.com", Buffer.from(domainText()), dir);
232
+ assert.ok(path.endsWith("acme.com"));
233
+ const pin = readPin("acme.com", dir);
234
+ assert.equal(pin?.file.party, "Acme Corp");
235
+ assert.equal(pin?.bytes.toString("utf8"), domainText());
236
+
237
+ // A stored file naming a different domain is refused: the store binds
238
+ // name to statement, so a bad write cannot impersonate.
239
+ writePin("evil.example", Buffer.from(domainText()), dir);
240
+ assert.throws(() => readPin("evil.example", dir), DomainFileError);
241
+
242
+ writePin("broken.example", Buffer.from("{"), dir);
243
+ assert.throws(() => readPin("broken.example", dir), DomainFileError);
244
+
245
+ const listed = listPins(dir);
246
+ assert.deepEqual(listed.map((p) => [p.domain, p.malformed]), [
247
+ ["acme.com", false],
248
+ ["broken.example", true],
249
+ ["evil.example", true],
250
+ ]);
251
+ assert.equal(listed[0]?.party, "Acme Corp");
252
+ assert.equal(listed[0]?.keyCount, 1);
253
+
254
+ assert.equal(forgetPin("acme.com", dir), true);
255
+ assert.equal(readPin("acme.com", dir), undefined);
256
+ assert.equal(forgetPin("acme.com", dir), false);
257
+ } finally {
258
+ rmSync(dir, { recursive: true, force: true });
259
+ }
260
+ });
261
+
262
+ // ---------------------------------------------------------------------------
263
+ // The fetch (injected; the only networked verb)
264
+ // ---------------------------------------------------------------------------
265
+
266
+ function fakeFetch(body: string | Buffer, status = 200): { impl: FetchLike; urls: string[] } {
267
+ const urls: string[] = [];
268
+ const impl: FetchLike = (url) => {
269
+ urls.push(url);
270
+ return Promise.resolve({
271
+ ok: status >= 200 && status < 300,
272
+ status,
273
+ headers: { get: () => null },
274
+ arrayBuffer: () => {
275
+ const buf = Buffer.from(body);
276
+ return Promise.resolve(buf.buffer.slice(buf.byteOffset, buf.byteOffset + buf.byteLength));
277
+ },
278
+ });
279
+ };
280
+ return { impl, urls };
281
+ }
282
+
283
+ test("fetchDomainFile: fixed well-known path, verbatim bytes, strict domain binding", async () => {
284
+ const { impl, urls } = fakeFetch(domainText());
285
+ const fetched = await fetchDomainFile("acme.com", impl);
286
+ assert.deepEqual(urls, ["https://acme.com/.well-known/bitgraph"]);
287
+ assert.equal(fetched.file.party, "Acme Corp");
288
+ assert.equal(fetched.bytes.toString("utf8"), domainText());
289
+
290
+ // The served file names acme.com; pinning it as another.example must
291
+ // refuse: a redirect cannot repoint the name.
292
+ await assert.rejects(fetchDomainFile("another.example", fakeFetch(domainText()).impl), DomainFileError);
293
+ await assert.rejects(fetchDomainFile("acme.com", fakeFetch("nope", 404).impl), /HTTP 404/);
294
+ await assert.rejects(fetchDomainFile("acme.com", fakeFetch(Buffer.alloc(70_000)).impl), /the cap is/);
295
+ await assert.rejects(fetchDomainFile("https://acme.com", fakeFetch(domainText()).impl), /bare hostname/);
296
+ });
297
+
298
+ // ---------------------------------------------------------------------------
299
+ // Re-pin diffs
300
+ // ---------------------------------------------------------------------------
301
+
302
+ test("diffDomainFiles reports added, removed, changed, and a renamed party", () => {
303
+ const edPub = generateKeyPairSync("ed25519").publicKey.export({ type: "spki", format: "der" }) as Buffer;
304
+ const edB64 = edPub.subarray(edPub.length - 32).toString("base64");
305
+ const before = parseDomainFile(domainText());
306
+ const after = parseDomainFile(
307
+ domainText({
308
+ party: "Acme Corporation",
309
+ keys: {
310
+ studio: { alg: "ed25519", publicKey: edB64 },
311
+ press: { alg: "ed25519", publicKey: edB64 },
312
+ },
313
+ })
314
+ );
315
+ const diff = diffDomainFiles(before, after);
316
+ assert.deepEqual(diff.partyChanged, { before: "Acme Corp", after: "Acme Corporation" });
317
+ assert.deepEqual(diff.added.map((r) => r.name), ["press"]);
318
+ assert.deepEqual(diff.removed.map((r) => r.name), []);
319
+ assert.deepEqual(diff.changed.map((c) => c.name), ["studio"]);
320
+ assert.equal(diff.unchanged, 0);
321
+ });
@@ -94,6 +94,8 @@ export function makeBound(partial: Partial<SegmentBound>): SegmentBound {
94
94
  weaker: partial.weaker ?? false,
95
95
  basis: partial.basis ?? "block-hash-unpredictability",
96
96
  claim: partial.claim ?? "fixture bound",
97
+ // Required since bitgraph-audit 0.3.0: a not-after bound is an assumption, never evidence.
98
+ boundClass: partial.boundClass ?? ((partial.kind ?? "not-before") === "not-before" ? "evidence" : "assumption"),
97
99
  ...(partial.blockNumber !== undefined ? { blockNumber: partial.blockNumber } : {}),
98
100
  };
99
101
  }
@@ -126,7 +128,7 @@ export function makeAudit(spec: FixtureAuditSpec): AuditResult {
126
128
  const segments = (spec.segments ?? []).map((s) => ({
127
129
  partition: { publicKeyB64: "key-A", chainId: "bitgraph:main" },
128
130
  memberProofHashes: s.members.map(proofHashOf),
129
- status: "bracketed",
131
+ status: "lower-bounded-with-following-anchor",
130
132
  lowerBounds: (s.lowerBounds ?? []).map((b) => makeBound({ ...b, kind: "not-before" })),
131
133
  upperBounds: (s.upperBounds ?? []).map((b) => makeBound({ ...b, kind: "not-after" })),
132
134
  }));