@mikeargento/bitgraph-mcp 0.5.2 → 0.6.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/__tests__/carrier.test.d.ts +2 -0
- package/dist/__tests__/carrier.test.d.ts.map +1 -0
- package/dist/__tests__/carrier.test.js +243 -0
- package/dist/__tests__/carrier.test.js.map +1 -0
- package/dist/__tests__/format.test.js +2 -2
- package/dist/__tests__/format.test.js.map +1 -1
- package/dist/__tests__/tools.test.js +5 -4
- package/dist/__tests__/tools.test.js.map +1 -1
- package/dist/carrier-io.d.ts +52 -0
- package/dist/carrier-io.d.ts.map +1 -0
- package/dist/carrier-io.js +91 -0
- package/dist/carrier-io.js.map +1 -0
- package/dist/format.d.ts +17 -3
- package/dist/format.d.ts.map +1 -1
- package/dist/format.js +31 -10
- package/dist/format.js.map +1 -1
- package/dist/scan.d.ts +4 -0
- package/dist/scan.d.ts.map +1 -1
- package/dist/scan.js +43 -0
- package/dist/scan.js.map +1 -1
- package/dist/server.d.ts +1 -1
- package/dist/server.d.ts.map +1 -1
- package/dist/server.js +143 -21
- package/dist/server.js.map +1 -1
- package/package.json +5 -5
- package/src/__tests__/carrier.test.ts +271 -0
- package/src/__tests__/format.test.ts +2 -2
- package/src/__tests__/tools.test.ts +5 -4
- package/src/carrier-io.ts +120 -0
- package/src/format.ts +51 -14
- package/src/scan.ts +47 -0
- package/src/server.ts +161 -26
|
@@ -0,0 +1,271 @@
|
|
|
1
|
+
// Copyright (c) 2024-2026 Argento Computing Inc. Licensed under the MIT License. See LICENSE.
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* BitGraphed files through the MCP tools, end to end: a real client over an
|
|
5
|
+
* in-memory transport, a mock bitgraph.ing, and REAL carriers built from the
|
|
6
|
+
* verify package's fixture material (real proofs, real anchors, real block
|
|
7
|
+
* headers; nothing minted, no network). The two rules under test:
|
|
8
|
+
*
|
|
9
|
+
* 1. The lookups use the digest of the COMMITTED bytes inside, never the
|
|
10
|
+
* envelope's, and the carried proof is judged offline (verdict, window).
|
|
11
|
+
* 2. bitgraph_record NEVER mints carrier bytes: the envelope is not the
|
|
12
|
+
* recorded thing. The fake pipelines fail the test if a carrier reaches
|
|
13
|
+
* them.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { test, before, after } from "node:test";
|
|
17
|
+
import { strict as assert } from "node:assert";
|
|
18
|
+
import { createHash } from "node:crypto";
|
|
19
|
+
import { createServer, type Server } from "node:http";
|
|
20
|
+
import { readFileSync } from "node:fs";
|
|
21
|
+
import { writeFile, mkdtemp } from "node:fs/promises";
|
|
22
|
+
import { tmpdir } from "node:os";
|
|
23
|
+
import { join } from "node:path";
|
|
24
|
+
import { fileURLToPath } from "node:url";
|
|
25
|
+
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
|
|
26
|
+
import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js";
|
|
27
|
+
import {
|
|
28
|
+
buildCarrier,
|
|
29
|
+
type CarrierPayload,
|
|
30
|
+
type CarrierProof,
|
|
31
|
+
type CarrierWitness,
|
|
32
|
+
} from "@mikeargento/bitgraph-verify";
|
|
33
|
+
import { buildServer, type FuseFileFn, type FuseSetFn } from "../server.js";
|
|
34
|
+
import { toUrlSafeB64 } from "../encoding.js";
|
|
35
|
+
import { sniffC2paBytes, scanFile } from "../scan.js";
|
|
36
|
+
|
|
37
|
+
const fixtures = fileURLToPath(new URL("../../../verify/src/__tests__/fixtures/carrier/", import.meta.url));
|
|
38
|
+
const readJson = <T>(name: string): T => JSON.parse(readFileSync(join(fixtures, name), "utf-8")) as T;
|
|
39
|
+
|
|
40
|
+
let mock: Server;
|
|
41
|
+
let completePath = ""; // demo2: floor + ceiling inside
|
|
42
|
+
let undevPath = ""; // demo1: floor inside, ceiling unfetched
|
|
43
|
+
let corruptPath = ""; // demo2 carrier with a broken payload byte
|
|
44
|
+
let plainPath = ""; // an ordinary file that should still mint
|
|
45
|
+
let c2paPath = ""; // an ordinary file carrying a synthetic C2PA marker
|
|
46
|
+
let innerDigest2 = ""; // standard b64 of demo2's committed bytes
|
|
47
|
+
let innerDigest1 = "";
|
|
48
|
+
const EPOCH = createHash("sha256").update("carrier-epoch").digest("base64");
|
|
49
|
+
|
|
50
|
+
before(async () => {
|
|
51
|
+
const root = await mkdtemp(join(tmpdir(), "bitgraph-mcp-carrier-"));
|
|
52
|
+
|
|
53
|
+
const demo2 = new Uint8Array(readFileSync(join(fixtures, "demo2.txt")));
|
|
54
|
+
const demo1 = new Uint8Array(readFileSync(join(fixtures, "demo1.txt")));
|
|
55
|
+
innerDigest2 = createHash("sha256").update(demo2).digest("base64");
|
|
56
|
+
innerDigest1 = createHash("sha256").update(demo1).digest("base64");
|
|
57
|
+
|
|
58
|
+
const complete: CarrierPayload = {
|
|
59
|
+
carrier: "bitgraph-carrier/1",
|
|
60
|
+
proof: readJson<CarrierProof>("demo2.proof.json"),
|
|
61
|
+
floor: { status: "present", anchor: readJson<CarrierProof>("floor2.anchor.json"), witness: readJson<CarrierWitness>("floor2.witness.json") },
|
|
62
|
+
ceiling: { status: "present", basis: "counter-order", anchor: readJson<CarrierProof>("ceiling2.anchor.json"), witness: readJson<CarrierWitness>("ceiling2.witness.json") },
|
|
63
|
+
};
|
|
64
|
+
const undeveloped: CarrierPayload = {
|
|
65
|
+
carrier: "bitgraph-carrier/1",
|
|
66
|
+
proof: readJson<CarrierProof>("demo1.proof.json"),
|
|
67
|
+
floor: { status: "present", anchor: readJson<CarrierProof>("floor1.anchor.json"), witness: readJson<CarrierWitness>("floor1.witness.json") },
|
|
68
|
+
ceiling: { status: "unfetched" },
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
completePath = join(root, "photo.bitgraph.txt");
|
|
72
|
+
await writeFile(completePath, buildCarrier(demo2, complete));
|
|
73
|
+
undevPath = join(root, "doc.bitgraph.txt");
|
|
74
|
+
await writeFile(undevPath, buildCarrier(demo1, undeveloped));
|
|
75
|
+
|
|
76
|
+
// Corrupt: the frame intact (tail magic still matches), one byte of the
|
|
77
|
+
// JSON payload broken, so the block is found but does not parse.
|
|
78
|
+
const corrupt = Buffer.from(buildCarrier(demo2, complete));
|
|
79
|
+
corrupt[demo2.length + 12] = 0x58; // the payload's first byte, "{" -> "X"
|
|
80
|
+
corruptPath = join(root, "broken.bitgraph.txt");
|
|
81
|
+
await writeFile(corruptPath, corrupt);
|
|
82
|
+
|
|
83
|
+
plainPath = join(root, "fresh.txt");
|
|
84
|
+
await writeFile(plainPath, "fresh bytes that should mint");
|
|
85
|
+
|
|
86
|
+
// A JUMBF description box shape: "jumd", a 16-byte type, a toggle, the "c2pa" label.
|
|
87
|
+
const c2pa = Buffer.concat([
|
|
88
|
+
Buffer.from("leading bytes "),
|
|
89
|
+
Buffer.from("jumd"),
|
|
90
|
+
Buffer.alloc(17, 3),
|
|
91
|
+
Buffer.from("c2pa\0and the rest of the file"),
|
|
92
|
+
]);
|
|
93
|
+
c2paPath = join(root, "credentialed.bin");
|
|
94
|
+
await writeFile(c2paPath, c2pa);
|
|
95
|
+
|
|
96
|
+
mock = createServer((req, res) => {
|
|
97
|
+
let raw = "";
|
|
98
|
+
req.on("data", (d) => (raw += d));
|
|
99
|
+
req.on("end", () => {
|
|
100
|
+
const url = new URL(req.url ?? "/", "http://localhost");
|
|
101
|
+
const body = raw.length > 0 ? (JSON.parse(raw) as unknown) : null;
|
|
102
|
+
const send = (status: number, payload: unknown) => {
|
|
103
|
+
res.writeHead(status, { "Content-Type": "application/json" });
|
|
104
|
+
res.end(JSON.stringify(payload));
|
|
105
|
+
};
|
|
106
|
+
if (url.pathname === "/api/proofs/batch") {
|
|
107
|
+
const digests = (body as { digests: string[] }).digests;
|
|
108
|
+
const results: Record<string, { proofs: Array<{ proof: unknown; member?: unknown }> }> = {};
|
|
109
|
+
for (const d of digests) {
|
|
110
|
+
// Only demo2's COMMITTED bytes are in this ledger; the envelope digests never are.
|
|
111
|
+
results[d] =
|
|
112
|
+
d === toUrlSafeB64(innerDigest2)
|
|
113
|
+
? { proofs: [{ proof: { artifact: { digestB64: innerDigest2 }, commit: { counter: "2874", epochId: EPOCH } } }] }
|
|
114
|
+
: { proofs: [] };
|
|
115
|
+
}
|
|
116
|
+
send(200, { results });
|
|
117
|
+
} else if (url.pathname.startsWith("/api/proofs/digest/")) {
|
|
118
|
+
const digest = decodeURIComponent(url.pathname.split("/").pop() ?? "");
|
|
119
|
+
if (digest === toUrlSafeB64(innerDigest2)) {
|
|
120
|
+
send(200, {
|
|
121
|
+
proofs: [{ proof: { artifact: { digestB64: innerDigest2 }, commit: { counter: "2874", epochId: EPOCH } } }],
|
|
122
|
+
positions: [{ counter: "2874", epoch: toUrlSafeB64(EPOCH), lowerTime: null, upperTime: null }],
|
|
123
|
+
causalWindow: null,
|
|
124
|
+
});
|
|
125
|
+
} else {
|
|
126
|
+
send(200, { proofs: [] });
|
|
127
|
+
}
|
|
128
|
+
} else {
|
|
129
|
+
send(404, { error: `unexpected ${url.pathname}` });
|
|
130
|
+
}
|
|
131
|
+
});
|
|
132
|
+
});
|
|
133
|
+
await new Promise<void>((resolve) => mock.listen(0, "127.0.0.1", resolve));
|
|
134
|
+
const address = mock.address();
|
|
135
|
+
if (address === null || typeof address === "string") throw new Error("no port");
|
|
136
|
+
process.env["BITGRAPH_API_URL"] = `http://127.0.0.1:${address.port}`;
|
|
137
|
+
process.env["BITGRAPH_API_KEY"] = "test-key-123";
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
after(() => {
|
|
141
|
+
mock.close();
|
|
142
|
+
delete process.env["BITGRAPH_API_URL"];
|
|
143
|
+
delete process.env["BITGRAPH_API_KEY"];
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
/** The pipelines fail the test when carrier bytes reach them. */
|
|
147
|
+
const fusedNames: string[] = [];
|
|
148
|
+
const guardFuseFile: FuseFileFn = async (file) => {
|
|
149
|
+
assert.ok(!file.name.includes(".bitgraph."), `a BitGraphed file reached the mint: ${file.name}`);
|
|
150
|
+
fusedNames.push(file.name);
|
|
151
|
+
const artifactDigestB64 = createHash("sha256").update("fused:" + file.digestB64).digest("base64");
|
|
152
|
+
return {
|
|
153
|
+
proof: { version: "bitgraph/1", artifact: { digestB64: artifactDigestB64 }, commit: { counter: "500", epochId: EPOCH } },
|
|
154
|
+
frame: { type: "bitgraph-fuse/1" },
|
|
155
|
+
placement: file.placement,
|
|
156
|
+
artifactDigestB64,
|
|
157
|
+
originDigestB64: file.digestB64,
|
|
158
|
+
};
|
|
159
|
+
};
|
|
160
|
+
const guardFuseSet: FuseSetFn = async (files) => {
|
|
161
|
+
for (const f of files) assert.ok(!f.name.includes(".bitgraph."), `a BitGraphed file reached the set mint: ${f.name}`);
|
|
162
|
+
throw new Error("no set expected in these tests");
|
|
163
|
+
};
|
|
164
|
+
|
|
165
|
+
async function connectedClient(): Promise<Client> {
|
|
166
|
+
const server = buildServer({ fuseSet: guardFuseSet, fuseFile: guardFuseFile });
|
|
167
|
+
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
|
|
168
|
+
const client = new Client({ name: "test-client", version: "0.0.0" });
|
|
169
|
+
await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]);
|
|
170
|
+
return client;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
const textOf = (result: unknown): string => ((result as { content: Array<{ text: string }> }).content[0] as { text: string }).text;
|
|
174
|
+
const structuredOf = <T>(result: unknown): T => (result as { structuredContent: T }).structuredContent;
|
|
175
|
+
|
|
176
|
+
test("check: a BitGraphed file is judged offline and looked up by its committed bytes", async () => {
|
|
177
|
+
const client = await connectedClient();
|
|
178
|
+
const result = await client.callTool({ name: "bitgraph_check", arguments: { paths: [completePath, undevPath, corruptPath, c2paPath] } });
|
|
179
|
+
const text = textOf(result);
|
|
180
|
+
const { results } = structuredOf<{ results: Array<Record<string, unknown>> }>(result);
|
|
181
|
+
|
|
182
|
+
const complete = results.find((r) => r["input"] === completePath) as Record<string, unknown>;
|
|
183
|
+
assert.equal(complete["on_record"], true);
|
|
184
|
+
assert.equal(complete["digest"], toUrlSafeB64(innerDigest2), "the lookup digest is the committed bytes', not the envelope's");
|
|
185
|
+
const view = complete["carrier"] as { verdict: string; not_before: { block: number }; not_after: { block: number } };
|
|
186
|
+
assert.equal(view.verdict, "TRUE");
|
|
187
|
+
assert.ok(view.not_before.block > 0 && view.not_after.block > view.not_before.block);
|
|
188
|
+
|
|
189
|
+
const undev = results.find((r) => r["input"] === undevPath) as Record<string, unknown>;
|
|
190
|
+
assert.equal(undev["on_record"], false);
|
|
191
|
+
const undevView = undev["carrier"] as { verdict: string; not_after: unknown; ceiling: string };
|
|
192
|
+
assert.equal(undevView.verdict, "TRUE");
|
|
193
|
+
assert.equal(undevView.not_after, null);
|
|
194
|
+
assert.equal(undevView.ceiling, "unfetched");
|
|
195
|
+
assert.ok(text.includes("not in this ledger"), "a foreign carrier is stated, not called unrecorded");
|
|
196
|
+
assert.ok(text.includes("NOT FETCHED"), "an absent ceiling is stated in those words");
|
|
197
|
+
|
|
198
|
+
const corrupt = results.find((r) => r["input"] === corruptPath) as Record<string, unknown>;
|
|
199
|
+
assert.equal(corrupt["on_record"], false);
|
|
200
|
+
assert.ok(String(corrupt["note"]).includes("unreadable"), "a corrupt block is a stated condition");
|
|
201
|
+
assert.ok(text.includes("not judged"), "a corrupt carrier is not judged, not 'not on record'");
|
|
202
|
+
|
|
203
|
+
const credentialed = results.find((r) => r["input"] === c2paPath) as Record<string, unknown>;
|
|
204
|
+
assert.equal(credentialed["c2pa"], true);
|
|
205
|
+
assert.ok(text.includes("Content Credentials (C2PA) detected"));
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
test("record: carrier bytes never mint; the plain file still does", async () => {
|
|
209
|
+
fusedNames.length = 0;
|
|
210
|
+
const client = await connectedClient();
|
|
211
|
+
const result = await client.callTool({ name: "bitgraph_record", arguments: { paths: [completePath, undevPath, plainPath] } });
|
|
212
|
+
const text = textOf(result);
|
|
213
|
+
const { summary, results } = structuredOf<{ summary: Record<string, number>; results: Array<Record<string, unknown>> }>(result);
|
|
214
|
+
|
|
215
|
+
assert.deepEqual(fusedNames, ["fresh.txt"], "exactly the plain file reached the mint");
|
|
216
|
+
assert.equal(summary["fused"], 1);
|
|
217
|
+
assert.equal(summary["on_record"], 1, "the ledger-confirmed carrier reports as on record");
|
|
218
|
+
assert.equal(summary["carried"], 1, "the foreign carrier reports as carried, nothing minted");
|
|
219
|
+
assert.equal(summary["not_fused"], 0);
|
|
220
|
+
|
|
221
|
+
const onRecord = results.find((r) => r["outcome"] === "on record") as Record<string, unknown>;
|
|
222
|
+
assert.equal(onRecord["digest"], toUrlSafeB64(innerDigest2));
|
|
223
|
+
assert.equal((onRecord["carrier"] as { verdict: string }).verdict, "TRUE");
|
|
224
|
+
|
|
225
|
+
const carried = results.find((r) => r["outcome"] === "carried") as Record<string, unknown>;
|
|
226
|
+
assert.equal(carried["digest"], toUrlSafeB64(innerDigest1));
|
|
227
|
+
assert.ok(text.includes("nothing minted"), "the markdown states the envelope rule");
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
test("record: a corrupt carrier is refused loudly and never minted", async () => {
|
|
231
|
+
fusedNames.length = 0;
|
|
232
|
+
const client = await connectedClient();
|
|
233
|
+
const result = await client.callTool({ name: "bitgraph_record", arguments: { paths: [corruptPath] } });
|
|
234
|
+
assert.equal((result as { isError?: boolean }).isError, true);
|
|
235
|
+
assert.deepEqual(fusedNames, [], "nothing reached the mint");
|
|
236
|
+
assert.ok(textOf(result).includes("unreadable"), "the reason names the corrupt block");
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
test("get_proof: a path to a foreign BitGraphed file answers offline from the proof inside", async () => {
|
|
240
|
+
const client = await connectedClient();
|
|
241
|
+
const result = await client.callTool({ name: "bitgraph_get_proof", arguments: { path: undevPath } });
|
|
242
|
+
const text = textOf(result);
|
|
243
|
+
assert.ok(text.includes("Judged offline from the proof this BitGraphed file carries"), text);
|
|
244
|
+
assert.ok(text.includes("carried proof TRUE"));
|
|
245
|
+
assert.ok(text.includes("NOT FETCHED"));
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
test("get_proof: a ledger-known BitGraphed file uses the ledger and states the carried verdict", async () => {
|
|
249
|
+
const client = await connectedClient();
|
|
250
|
+
const result = await client.callTool({ name: "bitgraph_get_proof", arguments: { path: completePath } });
|
|
251
|
+
const text = textOf(result);
|
|
252
|
+
assert.ok(text.includes("#2874"), text);
|
|
253
|
+
assert.ok(text.includes("carries its own proof (carried proof TRUE"), text);
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
test("c2pa sniff: the pair must be jumd then c2pa, near, in that order", async () => {
|
|
257
|
+
assert.equal(sniffC2paBytes(Buffer.concat([Buffer.from("jumd"), Buffer.alloc(17), Buffer.from("c2pa")])), true);
|
|
258
|
+
assert.equal(sniffC2paBytes(Buffer.from("c2pa then jumd the wrong way round")), false);
|
|
259
|
+
assert.equal(sniffC2paBytes(Buffer.from("jumd alone")), false);
|
|
260
|
+
assert.equal(sniffC2paBytes(Buffer.concat([Buffer.from("jumd"), Buffer.alloc(200), Buffer.from("c2pa")])), false, "too far apart");
|
|
261
|
+
// Streaming: the pair split across a 64 KiB read boundary is still seen.
|
|
262
|
+
const size = 64 * 1024;
|
|
263
|
+
const split = Buffer.alloc(size + 40);
|
|
264
|
+
Buffer.from("jumd").copy(split, size - 2);
|
|
265
|
+
Buffer.from("c2pa").copy(split, size + 19);
|
|
266
|
+
const root = await mkdtemp(join(tmpdir(), "bitgraph-mcp-c2pa-"));
|
|
267
|
+
const p = join(root, "split.bin");
|
|
268
|
+
await writeFile(p, split);
|
|
269
|
+
const scanned = await scanFile(p);
|
|
270
|
+
assert.equal(scanned.c2pa, true, "a pair straddling the chunk boundary is detected");
|
|
271
|
+
});
|
|
@@ -62,7 +62,7 @@ test("record markdown names the set once and its members by row", () => {
|
|
|
62
62
|
assert.ok(waiting.includes("The set proof beside the originals is the record either way."), waiting);
|
|
63
63
|
});
|
|
64
64
|
|
|
65
|
-
test("proof markdown states the
|
|
65
|
+
test("proof markdown states the floor in time and the ceiling in position", () => {
|
|
66
66
|
const detail: ProofDetailResponse = {
|
|
67
67
|
proofs: [{ proof: { artifact: { digestB64: DIGEST }, commit: { counter: "42", epochId: EPOCH } } }],
|
|
68
68
|
positions: [{ counter: "42", epoch: toUrlSafeB64(EPOCH), lowerTime: null, upperTime: null }],
|
|
@@ -73,7 +73,7 @@ test("proof markdown states the window as between lower and upper", () => {
|
|
|
73
73
|
};
|
|
74
74
|
const md = renderProofMarkdown(detail, "https://bitgraph.ing");
|
|
75
75
|
assert.ok(
|
|
76
|
-
md.includes("BitGraphed
|
|
76
|
+
md.includes("BitGraphed after 2026-07-01T00:00:00.000Z (Ethereum block 100), and before the anchoring of block 101 (that block mined 2026-07-01T00:00:12.000Z)."),
|
|
77
77
|
md
|
|
78
78
|
);
|
|
79
79
|
assert.ok(md.includes("# BitGraph #42"));
|
|
@@ -253,7 +253,7 @@ test("record makes ONE set of the fresh files and leaves on-record ones alone",
|
|
|
253
253
|
const batchBody = batch.body as { digests: string[] };
|
|
254
254
|
assert.ok(batchBody.digests.every((d) => !d.includes("+") && !d.includes("=")), "check uses url-safe digests");
|
|
255
255
|
const structured = result.structuredContent as RecordStructured;
|
|
256
|
-
assert.deepEqual(structured.summary, { files: 3, directories: 0, fused: 2, on_record: 1, not_fused: 0 });
|
|
256
|
+
assert.deepEqual(structured.summary, { files: 3, directories: 0, fused: 2, on_record: 1, carried: 0, not_fused: 0 });
|
|
257
257
|
assert.ok(structured.set, "the set is reported");
|
|
258
258
|
assert.equal(structured.set?.count, 2);
|
|
259
259
|
assert.ok(structured.set?.proof_url.includes("/proof/") && structured.set?.proof_url.includes("counter="), "the set's proof page is pinned to its position");
|
|
@@ -311,7 +311,7 @@ test("a directory is its regular files, hidden entries and links left out", asyn
|
|
|
311
311
|
assert.ok(!result.isError, JSON.stringify(result.content));
|
|
312
312
|
assert.deepEqual(setCalls[0]?.names, ["a1.txt", "a2.txt"]);
|
|
313
313
|
const structured = result.structuredContent as RecordStructured;
|
|
314
|
-
assert.deepEqual(structured.summary, { files: 2, directories: 1, fused: 2, on_record: 0, not_fused: 0 });
|
|
314
|
+
assert.deepEqual(structured.summary, { files: 2, directories: 1, fused: 2, on_record: 0, carried: 0, not_fused: 0 });
|
|
315
315
|
assert.equal(structured.set?.count, 2);
|
|
316
316
|
});
|
|
317
317
|
|
|
@@ -414,7 +414,7 @@ test("a set failure labels every attempted file 'not fused', never 'on record'",
|
|
|
414
414
|
const structured = result.structuredContent as RecordStructured;
|
|
415
415
|
assert.equal(structured.set, null);
|
|
416
416
|
assert.ok(structured.results.every((r) => r.outcome === "not fused" && r.proof_url === null));
|
|
417
|
-
assert.deepEqual(structured.summary, { files: 2, directories: 0, fused: 0, on_record: 0, not_fused: 2 });
|
|
417
|
+
assert.deepEqual(structured.summary, { files: 2, directories: 0, fused: 0, on_record: 0, carried: 0, not_fused: 2 });
|
|
418
418
|
} finally {
|
|
419
419
|
fuseMode = "ok";
|
|
420
420
|
}
|
|
@@ -485,7 +485,8 @@ test("get_proof by number resolves through search and renders the window", async
|
|
|
485
485
|
assert.ok(!result.isError, JSON.stringify(result.content));
|
|
486
486
|
const text = textOf(result);
|
|
487
487
|
assert.ok(text.includes("# BitGraph #10"));
|
|
488
|
-
assert.ok(text.includes("BitGraphed
|
|
488
|
+
assert.ok(text.includes("BitGraphed after 2026-07-01T00:00:00.000Z"));
|
|
489
|
+
assert.ok(text.includes("before the anchoring of block 2"), "the ceiling is stated in position, never as the block's mine time");
|
|
489
490
|
assert.ok(text.includes("Causal positions (2)"));
|
|
490
491
|
assert.ok(text.includes("/proof/"), "includes the proof page url");
|
|
491
492
|
});
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
// Copyright (c) 2024-2026 Argento Computing Inc. Licensed under the MIT License. See LICENSE.
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* @mikeargento/bitgraph-mcp: BitGraphed files (bitgraph-carrier/1).
|
|
5
|
+
*
|
|
6
|
+
* A BitGraphed file is committed bytes followed by one structural block that
|
|
7
|
+
* carries the proof and the anchor evidence. The block is found from the END
|
|
8
|
+
* of the file, by structure, never by scanning. Everything here is local:
|
|
9
|
+
* an 8-byte tail read decides whether a file is a carrier at all, and the
|
|
10
|
+
* full judgment runs offline through @mikeargento/bitgraph-verify.
|
|
11
|
+
*
|
|
12
|
+
* The one rule that cannot bend: the envelope is never the recorded thing,
|
|
13
|
+
* the bytes inside it are. So the tools look up the INNER digest, and
|
|
14
|
+
* bitgraph_record never mints carrier bytes, the same structural guarantee
|
|
15
|
+
* the site's drop box gives.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { open } from "node:fs/promises";
|
|
19
|
+
import {
|
|
20
|
+
CARRIER_MAGIC, CARRIER_OVERHEAD, parseCarrier, verifyCarrier,
|
|
21
|
+
type CarrierVerifyResult,
|
|
22
|
+
} from "@mikeargento/bitgraph-verify";
|
|
23
|
+
|
|
24
|
+
/** ISO string for a witness timestamp (seconds), or null when the header was unreadable. */
|
|
25
|
+
const iso = (ts: number | null): string | null => (ts === null ? null : new Date(ts * 1000).toISOString());
|
|
26
|
+
|
|
27
|
+
/** The window as the tools state it. not_after null = NOT FETCHED, in those words. */
|
|
28
|
+
export interface CarrierWindowView {
|
|
29
|
+
verdict: "TRUE" | "FALSE" | "UNDETERMINED";
|
|
30
|
+
not_before: { block: number; hash: string; time: string | null } | null;
|
|
31
|
+
/** null when the ceiling has not been fetched into the file yet. */
|
|
32
|
+
not_after: { block: number; hash: string; time: string | null } | null;
|
|
33
|
+
ceiling: "present" | "unfetched" | null;
|
|
34
|
+
reasons: string[];
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function carrierWindowView(r: CarrierVerifyResult): CarrierWindowView {
|
|
38
|
+
return {
|
|
39
|
+
verdict: r.verdict,
|
|
40
|
+
not_before: r.bounds ? { block: r.bounds.notBefore.blockNumber, hash: r.bounds.notBefore.blockHash, time: iso(r.bounds.notBefore.timestamp) } : null,
|
|
41
|
+
not_after: r.bounds?.notAfter ? { block: r.bounds.notAfter.blockNumber, hash: r.bounds.notAfter.blockHash, time: iso(r.bounds.notAfter.timestamp) } : null,
|
|
42
|
+
ceiling: r.ceiling,
|
|
43
|
+
reasons: r.reasons,
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* One line for a markdown row: the verdict and the window, in the stated
|
|
49
|
+
* words. The floor is temporal (after the block's own time, cryptographically);
|
|
50
|
+
* the ceiling is POSITIONAL: committed before the ANCHORING of the later
|
|
51
|
+
* block, never before that block's mine time, because an anchor is built
|
|
52
|
+
* after the block it carries. Canon §3.5/§3.6: floor in time, ceiling in
|
|
53
|
+
* position; converting the positional ceiling into a clock reading is the
|
|
54
|
+
* one error.
|
|
55
|
+
*/
|
|
56
|
+
export function carrierLine(view: CarrierWindowView): string {
|
|
57
|
+
const parts: string[] = [`carried proof ${view.verdict}`];
|
|
58
|
+
if (view.not_before) parts.push(`after block ${view.not_before.block}${view.not_before.time ? ` (mined ${view.not_before.time})` : ""}`);
|
|
59
|
+
parts.push(
|
|
60
|
+
view.not_after
|
|
61
|
+
? `before the anchoring of block ${view.not_after.block}${view.not_after.time ? ` (block mined ${view.not_after.time})` : ""}`
|
|
62
|
+
: "closing anchor NOT FETCHED (drop the file on bitgraph.ing to complete it)"
|
|
63
|
+
);
|
|
64
|
+
return parts.join(" · ");
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* True when the file's last 8 bytes are the carrier magic. One positioned
|
|
69
|
+
* read; no file that is not a carrier pays more than that.
|
|
70
|
+
*/
|
|
71
|
+
export async function sniffCarrierTail(path: string, size: number): Promise<boolean> {
|
|
72
|
+
if (size < CARRIER_OVERHEAD) return false;
|
|
73
|
+
const fd = await open(path, "r");
|
|
74
|
+
try {
|
|
75
|
+
const tail = Buffer.alloc(CARRIER_MAGIC.length);
|
|
76
|
+
await fd.read(tail, 0, tail.length, size - tail.length);
|
|
77
|
+
return tail.equals(Buffer.from(CARRIER_MAGIC));
|
|
78
|
+
} finally {
|
|
79
|
+
await fd.close();
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export interface CarrierFileResult {
|
|
84
|
+
/** "ok": readable block, judged. "corrupt": block found but unreadable. */
|
|
85
|
+
status: "ok" | "corrupt";
|
|
86
|
+
/** Standard base64 SHA-256 of the COMMITTED bytes inside; the digest every lookup uses. */
|
|
87
|
+
innerDigestB64: string | null;
|
|
88
|
+
/** The committed bytes, for callers that go on to sniff or serve them. */
|
|
89
|
+
inner: Uint8Array | null;
|
|
90
|
+
result: CarrierVerifyResult | null;
|
|
91
|
+
view: CarrierWindowView | null;
|
|
92
|
+
corruptReason: string | null;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Read a tail-confirmed carrier whole and judge it offline: the carried
|
|
97
|
+
* proof, the floor by identity, the witnesses, the ceiling when present.
|
|
98
|
+
* Proof-level judgment (attestation depth stays the audit CLI's job).
|
|
99
|
+
*/
|
|
100
|
+
export async function readCarrierFile(path: string): Promise<CarrierFileResult> {
|
|
101
|
+
const fd = await open(path, "r");
|
|
102
|
+
let bytes: Uint8Array;
|
|
103
|
+
try {
|
|
104
|
+
const info = await fd.stat();
|
|
105
|
+
const buf = Buffer.alloc(info.size);
|
|
106
|
+
await fd.read(buf, 0, info.size, 0);
|
|
107
|
+
bytes = new Uint8Array(buf);
|
|
108
|
+
} finally {
|
|
109
|
+
await fd.close();
|
|
110
|
+
}
|
|
111
|
+
const parsed = parseCarrier(bytes);
|
|
112
|
+
if (parsed.kind !== "carrier") {
|
|
113
|
+
const reason = parsed.kind === "corrupt" ? parsed.reason : "the tail magic matched but no block parsed";
|
|
114
|
+
return { status: "corrupt", innerDigestB64: null, inner: null, result: null, view: null, corruptReason: reason };
|
|
115
|
+
}
|
|
116
|
+
const result = await verifyCarrier(bytes);
|
|
117
|
+
const { createHash } = await import("node:crypto");
|
|
118
|
+
const innerDigestB64 = createHash("sha256").update(parsed.inner).digest("base64");
|
|
119
|
+
return { status: "ok", innerDigestB64, inner: parsed.inner, result, view: carrierWindowView(result), corruptReason: null };
|
|
120
|
+
}
|
package/src/format.ts
CHANGED
|
@@ -4,11 +4,15 @@
|
|
|
4
4
|
* @mikeargento/bitgraph-mcp: output shaping.
|
|
5
5
|
*
|
|
6
6
|
* Markdown for human-facing summaries, JSON for complete structured data.
|
|
7
|
-
* Time statements come from the Ethereum anchor bracket
|
|
8
|
-
*
|
|
7
|
+
* Time statements come from the Ethereum anchor bracket, never from advisory
|
|
8
|
+
* clock fields, and they keep the canon's units: the floor is temporal
|
|
9
|
+
* ("after T", cryptographic), the ceiling is POSITIONAL ("before the
|
|
10
|
+
* anchoring of block M"), because an anchor is built after the block it
|
|
11
|
+
* carries, so a block's mine time is never an upper clock bound.
|
|
9
12
|
*/
|
|
10
13
|
|
|
11
14
|
import { toUrlSafeB64 } from "./encoding.js";
|
|
15
|
+
import { carrierLine, type CarrierWindowView } from "./carrier-io.js";
|
|
12
16
|
import type { BitGraphProof, PositionView, ProofDetailResponse, SetMemberView } from "./types.js";
|
|
13
17
|
|
|
14
18
|
export const CHARACTER_LIMIT = 25_000;
|
|
@@ -38,13 +42,19 @@ export function proofUrl(
|
|
|
38
42
|
* committed artifact. "on record": the bytes already had a recording or a
|
|
39
43
|
* fused artifact naming them as origin, and nothing was made. "not fused":
|
|
40
44
|
* the attempt failed or the file was left out; never claim "on record" for
|
|
41
|
-
* bytes that have no proof.
|
|
45
|
+
* bytes that have no proof. "carried": the file is a BitGraphed file, its
|
|
46
|
+
* proof travels inside it; the committed bytes it holds were judged offline
|
|
47
|
+
* and NOTHING was minted, because the envelope is never the recorded thing.
|
|
42
48
|
*/
|
|
43
49
|
export interface RecordOutcome {
|
|
44
50
|
path: string;
|
|
45
|
-
/** The file's own digest (URL-safe): the origin of its fused bytes. */
|
|
51
|
+
/** The file's own digest (URL-safe): the origin of its fused bytes. For a "carried" row, the COMMITTED bytes' digest. */
|
|
46
52
|
digest: string;
|
|
47
|
-
outcome: "fused" | "on record" | "not fused";
|
|
53
|
+
outcome: "fused" | "on record" | "not fused" | "carried";
|
|
54
|
+
/** A BitGraphed file's offline judgment: the carried proof's verdict and window. */
|
|
55
|
+
carrier?: CarrierWindowView;
|
|
56
|
+
/** True when the bytes carry Content Credentials (a C2PA manifest); the proof page displays them. */
|
|
57
|
+
c2pa?: boolean;
|
|
48
58
|
/** The member's fused digest (URL-safe), present on a "fused" outcome. */
|
|
49
59
|
artifact_digest: string | null;
|
|
50
60
|
placement: string | null;
|
|
@@ -78,10 +88,17 @@ export interface SetOutcome {
|
|
|
78
88
|
|
|
79
89
|
export interface CheckOutcome {
|
|
80
90
|
input: string;
|
|
81
|
-
digest
|
|
91
|
+
/** URL-safe. For a BitGraphed file, the digest of the COMMITTED bytes inside, never the envelope's. */
|
|
92
|
+
digest: string;
|
|
82
93
|
on_record: boolean;
|
|
83
94
|
positions: Array<{ counter: string | null; epoch: string | null; member?: SetMemberView }>;
|
|
84
95
|
proof_url: string | null;
|
|
96
|
+
/** Present when the input is a BitGraphed file: the carried proof's offline judgment. */
|
|
97
|
+
carrier?: CarrierWindowView;
|
|
98
|
+
/** True when the bytes carry Content Credentials (a C2PA manifest). */
|
|
99
|
+
c2pa?: boolean;
|
|
100
|
+
/** A stated condition (an unreadable carrier block, an oversized file), never a verdict. */
|
|
101
|
+
note?: string;
|
|
85
102
|
}
|
|
86
103
|
|
|
87
104
|
export function positionOf(proof: BitGraphProof): { counter: string | null; epoch: string | null } {
|
|
@@ -96,9 +113,14 @@ function memberNote(m: SetMemberView | undefined): string {
|
|
|
96
113
|
return m ? ` (member ${fmt(m.index + 1)} of ${fmt(m.count)})` : "";
|
|
97
114
|
}
|
|
98
115
|
|
|
116
|
+
/** The C2PA note a row earns, or an empty string. */
|
|
117
|
+
const c2paNote = (o: { c2pa?: boolean }): string =>
|
|
118
|
+
o.c2pa === true ? `\n Content Credentials (C2PA) detected in the file; the proof page displays them.` : "";
|
|
119
|
+
|
|
99
120
|
export function renderRecordMarkdown(outcomes: readonly RecordOutcome[], set: SetOutcome | null = null, omitted = 0): string {
|
|
100
121
|
const fused = outcomes.filter((o) => o.outcome === "fused");
|
|
101
122
|
const onRecord = outcomes.filter((o) => o.outcome === "on record");
|
|
123
|
+
const carried = outcomes.filter((o) => o.outcome === "carried");
|
|
102
124
|
const notFused = outcomes.filter((o) => o.outcome === "not fused");
|
|
103
125
|
const lines: string[] = [];
|
|
104
126
|
const parts: string[] = [];
|
|
@@ -108,6 +130,7 @@ export function renderRecordMarkdown(outcomes: readonly RecordOutcome[], set: Se
|
|
|
108
130
|
parts.push(`${fmt(fused.length)} fused`);
|
|
109
131
|
}
|
|
110
132
|
parts.push(`${fmt(onRecord.length)} already on record`);
|
|
133
|
+
if (carried.length > 0) parts.push(`${fmt(carried.length)} BitGraphed file${carried.length === 1 ? "" : "s"} (proof inside, nothing minted)`);
|
|
111
134
|
if (notFused.length > 0) parts.push(`${fmt(notFused.length)} NOT fused`);
|
|
112
135
|
lines.push(`${parts.join(", ")}.`);
|
|
113
136
|
if (set !== null && fused.length > 0) {
|
|
@@ -133,16 +156,25 @@ export function renderRecordMarkdown(outcomes: readonly RecordOutcome[], set: Se
|
|
|
133
156
|
(o) => {
|
|
134
157
|
const note = o.total_positions > 1 ? ` (${fmt(o.total_positions)} positions, earliest shown)` : "";
|
|
135
158
|
const row = o.member !== null && o.member_count !== null ? ` (member ${fmt(o.member)} of ${fmt(o.member_count)})` : "";
|
|
136
|
-
|
|
159
|
+
const carrier = o.carrier ? `\n ${carrierLine(o.carrier)}` : "";
|
|
160
|
+
return `- on record · #${o.counter ?? "?"}${row} · ${o.path}${note}${carrier}${c2paNote(o)}\n ${o.proof_url}`;
|
|
137
161
|
},
|
|
138
162
|
(n) => `and ${fmt(n)} more already on record`
|
|
139
163
|
);
|
|
164
|
+
group(
|
|
165
|
+
carried,
|
|
166
|
+
(o) => {
|
|
167
|
+
const where = o.proof_url !== null ? `\n ${o.proof_url}` : "\n Not in this ledger; the proof travels in the file itself.";
|
|
168
|
+
return `- BitGraphed file · ${o.path}\n ${o.carrier ? carrierLine(o.carrier) : "carried proof"} · nothing minted: the envelope is never the recorded thing, the bytes inside are${c2paNote(o)}${where}`;
|
|
169
|
+
},
|
|
170
|
+
(n) => `and ${fmt(n)} more BitGraphed files`
|
|
171
|
+
);
|
|
140
172
|
group(
|
|
141
173
|
fused,
|
|
142
174
|
(o) =>
|
|
143
175
|
o.member === null
|
|
144
|
-
? `- fused · #${o.counter ?? "?"} · ${o.path} (${o.placement ?? "?"})\n ${o.proof_url}`
|
|
145
|
-
: `- fused · ${o.path} (${fmt(o.member)} of ${fmt(o.member_count ?? 0)}, ${o.placement ?? "?"})`,
|
|
176
|
+
? `- fused · #${o.counter ?? "?"} · ${o.path} (${o.placement ?? "?"})${c2paNote(o)}\n ${o.proof_url}`
|
|
177
|
+
: `- fused · ${o.path} (${fmt(o.member)} of ${fmt(o.member_count ?? 0)}, ${o.placement ?? "?"})${c2paNote(o)}`,
|
|
146
178
|
(n) => `and ${fmt(n)} more files in the same set`
|
|
147
179
|
);
|
|
148
180
|
if (set !== null && fused.length > 0) {
|
|
@@ -169,12 +201,17 @@ export function renderCheckMarkdown(outcomes: readonly CheckOutcome[]): string {
|
|
|
169
201
|
const found = outcomes.filter((o) => o.on_record).length;
|
|
170
202
|
const lines: string[] = [`${fmt(found)} of ${fmt(outcomes.length)} on record.`];
|
|
171
203
|
for (const o of outcomes.slice(0, MARKDOWN_ROWS)) {
|
|
204
|
+
const carrier = o.carrier ? `\n BitGraphed file: ${carrierLine(o.carrier)} (judged offline from the proof inside)` : "";
|
|
172
205
|
if (o.on_record) {
|
|
173
206
|
const first = o.positions[0];
|
|
174
207
|
const extra = o.positions.length > 1 ? ` and ${fmt(o.positions.length - 1)} more position(s)` : "";
|
|
175
|
-
lines.push(`- on record · #${first?.counter ?? "?"}${memberNote(first?.member)}${extra} · ${o.input}\n ${o.proof_url}`);
|
|
208
|
+
lines.push(`- on record · #${first?.counter ?? "?"}${memberNote(first?.member)}${extra} · ${o.input}${carrier}${c2paNote(o)}\n ${o.proof_url}`);
|
|
209
|
+
} else if (o.carrier) {
|
|
210
|
+
lines.push(`- not in this ledger · ${o.input}${carrier}${c2paNote(o)}`);
|
|
211
|
+
} else if (o.note !== undefined) {
|
|
212
|
+
lines.push(`- not judged · ${o.input}\n ${o.note}`);
|
|
176
213
|
} else {
|
|
177
|
-
lines.push(`- not on record · ${o.input}`);
|
|
214
|
+
lines.push(`- not on record · ${o.input}${c2paNote(o)}`);
|
|
178
215
|
}
|
|
179
216
|
}
|
|
180
217
|
if (outcomes.length > MARKDOWN_ROWS) lines.push(`- and ${fmt(outcomes.length - MARKDOWN_ROWS)} more; the structured result lists every one`);
|
|
@@ -189,9 +226,9 @@ function renderWindow(detail: ProofDetailResponse): string | null {
|
|
|
189
226
|
const lowerBlock = w.anchorBefore?.blockNumber ?? null;
|
|
190
227
|
const upperBlock = w.anchorAfter?.blockNumber ?? null;
|
|
191
228
|
if (lower && upper) {
|
|
192
|
-
return `BitGraphed
|
|
229
|
+
return `BitGraphed after ${lower} (Ethereum block ${lowerBlock ?? "?"}), and before the anchoring of block ${upperBlock ?? "?"} (that block mined ${upper}).`;
|
|
193
230
|
}
|
|
194
|
-
if (upper) return `BitGraphed before
|
|
231
|
+
if (upper) return `BitGraphed before the anchoring of Ethereum block ${upperBlock ?? "?"} (that block mined ${upper}).`;
|
|
195
232
|
if (lower) return `BitGraphed after ${lower} (Ethereum block ${lowerBlock ?? "?"}).`;
|
|
196
233
|
return null;
|
|
197
234
|
}
|
|
@@ -236,7 +273,7 @@ export function renderProofMarkdown(
|
|
|
236
273
|
positions.forEach((p, i) => {
|
|
237
274
|
const label = i === 0 ? " · original" : "";
|
|
238
275
|
const bracket =
|
|
239
|
-
p.lowerTime && p.upperTime ? ` ·
|
|
276
|
+
p.lowerTime && p.upperTime ? ` · after ${p.lowerTime}, before the closing anchor (its block mined ${p.upperTime})` : "";
|
|
240
277
|
lines.push(`- #${p.counter ?? "?"}${label}${p.member ? ` · set of ${fmt(p.member.count)}` : ""}${bracket}`);
|
|
241
278
|
});
|
|
242
279
|
}
|