@mikeargento/bitgraph-mcp 0.6.0 → 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.
package/src/server.ts CHANGED
@@ -19,24 +19,17 @@ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
19
19
  import type { RequestHandlerExtra } from "@modelcontextprotocol/sdk/shared/protocol.js";
20
20
  import type { ServerNotification, ServerRequest } from "@modelcontextprotocol/sdk/types.js";
21
21
  import { z } from "zod";
22
- import { readFile } from "node:fs/promises";
23
- import { FuseError, MAX_SET_MEMBERS, builderFor, fuse, fuseSet, fusedNamesFor, type FuseSetMember, type FuseSetProgress } from "@mikeargento/bitgraph";
22
+ import { FuseError, MAX_SET_MEMBERS, type FuseSetProgress } from "@mikeargento/bitgraph";
24
23
  import {
25
- ApiError,
26
- batchCheck,
27
- configFromEnv,
28
- getProofDetail,
29
- indexSetMembers,
30
- search,
31
- type ApiConfig,
32
- } from "./api.js";
33
- import {
34
- fromUrlSafeB64,
35
- looksLikeDigest,
36
- mapConcurrent,
37
- sha256FileB64,
38
- toUrlSafeB64,
39
- } from "./encoding.js";
24
+ ApiError, batchCheck, configFromEnv, getProofDetail, indexSetMembers, search, type ApiConfig,
25
+ fromUrlSafeB64, looksLikeDigest, mapConcurrent, sha256FileB64, toUrlSafeB64,
26
+ expandPaths, scanFile, type ScannedFile,
27
+ type CarrierWindowView,
28
+ classifyPath, fuseFilePipeline, fuseSetPipeline, MAX_LOADED_BYTES,
29
+ type CarrierRow, type FuseFileFn, type FuseSetFn, type FusedSummary, type SetSummary,
30
+ SLOT_TTL_SECONDS, beginTask, decodeTaskToken, sealTask, writeProofBeside,
31
+ type BitGraphProof, type ProofDetailResponse,
32
+ } from "@mikeargento/bitgraph-sdk";
40
33
  import {
41
34
  capJson,
42
35
  positionOf,
@@ -48,13 +41,11 @@ import {
48
41
  type RecordOutcome,
49
42
  type SetOutcome,
50
43
  } from "./format.js";
51
- import { expandPaths, fusedDigestFor, scanFile, sniffC2paBytes, type ScannedFile } from "./scan.js";
52
- import { readCarrierFile, sniffCarrierTail, type CarrierWindowView } from "./carrier-io.js";
53
- import { stat } from "node:fs/promises";
54
- import { SLOT_TTL_SECONDS, TASK_INSTRUCTIONS, beginTask, decodeTaskToken, sealTask, writeProofBeside } from "./task.js";
55
- import type { BitGraphProof, ProofDetailResponse } from "./types.js";
44
+ import { TASK_INSTRUCTIONS } from "./instructions.js";
45
+
46
+ export type { FuseFileFn, FuseSetFn, FusedSummary, SetSummary } from "@mikeargento/bitgraph-sdk";
56
47
 
57
- export const SERVER_VERSION = "0.6.0";
48
+ export const SERVER_VERSION = "0.7.0";
58
49
 
59
50
  const SCAN_CONCURRENCY = 4;
60
51
  /** Paths per call; a directory counts once and expands to its files. */
@@ -64,7 +55,6 @@ export const MAX_MEMBERS = 100_000;
64
55
  /** Files one check may cover after directories expand. */
65
56
  const MAX_CHECK_FILES = 10_000;
66
57
  /** A file whose length changed while it was read is fused from its bytes instead; above this it is left out rather than held in memory. */
67
- const MAX_LOADED_BYTES = 256 * 1024 * 1024;
68
58
  /** A single file up to this size is fused on its own, in memory, with its Frame; a larger one is a set of one, never held. */
69
59
  const MAX_SOLO_BYTES = 256 * 1024 * 1024;
70
60
  /** Rows the structured result lists in full; every fused row shares the set's position. */
@@ -73,42 +63,6 @@ export const ROW_CAP = 500;
73
63
  export const SET_INDEX_CHUNK = 2500;
74
64
 
75
65
  /** What one set yields, in the shape the tool reports; tests inject a stand-in. */
76
- export interface SetSummary {
77
- set: "set/1" | "set/2";
78
- proof: BitGraphProof;
79
- /** Standard base64: the committed artifact's digest. */
80
- artifactDigestB64: string;
81
- count: number;
82
- manifestEchoed: boolean;
83
- recovered: boolean;
84
- /** In the order the files were given. */
85
- members: Array<{
86
- index: number;
87
- /** The row's ordinal in the committed artifact. */
88
- manifestIndex: number;
89
- placement: string;
90
- originDigestB64: string;
91
- artifactDigestB64: string;
92
- /** set/2 only: the member's evidence, for the site's index. */
93
- memberProof?: unknown;
94
- }>;
95
- }
96
- export type FuseSetFn = (
97
- files: readonly ScannedFile[],
98
- config: ApiConfig,
99
- opts: { set: "set/1" | "set/2"; onProgress?: (p: FuseSetProgress) => void }
100
- ) => Promise<SetSummary>;
101
-
102
- /** What fusing one file on its own yields. */
103
- export interface FusedSummary {
104
- proof: BitGraphProof;
105
- frame: unknown;
106
- placement: string;
107
- artifactDigestB64: string;
108
- originDigestB64: string;
109
- }
110
- export type FuseFileFn = (file: ScannedFile, config: ApiConfig) => Promise<FusedSummary>;
111
-
112
66
  export interface ServerDeps {
113
67
  /** The set pipeline; tests inject a stand-in. Default: the core package's fuseSet() against the configured site. */
114
68
  fuseSet?: FuseSetFn;
@@ -116,121 +70,6 @@ export interface ServerDeps {
116
70
  fuseFile?: FuseFileFn;
117
71
  }
118
72
 
119
- /**
120
- * A single file, the way a single drop on the site goes: the bytes in hand,
121
- * the placement chosen from them, one slot, the fused bytes built in memory
122
- * and hashed, committed under that exact slot, verified against the bytes,
123
- * and a Frame returned. The fused bytes are not kept.
124
- */
125
- async function fuseFileDefault(file: ScannedFile, config: ApiConfig): Promise<FusedSummary> {
126
- const bytes = new Uint8Array(await readFile(file.path));
127
- const placement = file.placement;
128
- const { fusedName } = fusedNamesFor(file.name, placement);
129
- const r = await fuse(builderFor(placement, bytes), {
130
- placement,
131
- original: bytes,
132
- fusedFile: fusedName,
133
- keepFused: false,
134
- transport: { baseUrl: config.baseUrl, ...(config.apiKey ? { apiKey: config.apiKey } : {}) },
135
- });
136
- return {
137
- proof: r.proof as unknown as BitGraphProof,
138
- frame: r.frame,
139
- placement,
140
- artifactDigestB64: r.artifactDigestB64,
141
- originDigestB64: r.originDigestB64 ?? file.digestB64,
142
- };
143
- }
144
-
145
- /**
146
- * The default pipeline, the one the site's drop runs: one slot for the set,
147
- * every member a hashed member whose fused digest is finished from the
148
- * scan's open hasher with its placement's suffix for that slot, the set's
149
- * manifest (or, for a set/2, its root document) committed under the same
150
- * slot, and the returned proof verified against the committed artifact with
151
- * every member bound to it by digest. A file whose length changed during the
152
- * scan is a loaded member: read again when it is its turn, checked against
153
- * the scan's digest, fused in memory, hashed and released.
154
- */
155
- async function fuseSetDefault(
156
- files: readonly ScannedFile[],
157
- config: ApiConfig,
158
- opts: { set: "set/1" | "set/2"; onProgress?: (p: FuseSetProgress) => void }
159
- ): Promise<SetSummary> {
160
- const members: FuseSetMember[] = files.map((f) =>
161
- f.state !== null
162
- ? { originDigest: f.originDigest, placement: f.placement, name: f.name, fusedDigest: ({ commitment }) => fusedDigestFor(f, commitment) }
163
- : { load: async () => new Uint8Array(await readFile(f.path)), originDigest: f.originDigest, placement: f.placement, name: f.name }
164
- );
165
- const r = await fuseSet(members, {
166
- set: opts.set,
167
- keepFused: false,
168
- ...(opts.onProgress !== undefined ? { onProgress: opts.onProgress } : {}),
169
- transport: { baseUrl: config.baseUrl, ...(config.apiKey ? { apiKey: config.apiKey } : {}) },
170
- });
171
- return {
172
- set: r.set,
173
- proof: r.proof as unknown as BitGraphProof,
174
- artifactDigestB64: r.artifactDigestB64,
175
- count: r.members.length,
176
- manifestEchoed: r.manifestEchoed,
177
- recovered: r.recovered,
178
- members: r.members.map((m) => ({
179
- index: m.index,
180
- manifestIndex: m.manifestIndex,
181
- placement: m.placement,
182
- originDigestB64: m.originDigestB64,
183
- artifactDigestB64: m.artifactDigestB64,
184
- ...(m.memberProof !== undefined ? { memberProof: m.memberProof } : {}),
185
- })),
186
- };
187
- }
188
-
189
- /**
190
- * A path is either plain bytes to scan or a BitGraphed file (bitgraph-carrier/1),
191
- * decided by one 8-byte tail read. A BitGraphed file is judged OFFLINE from the
192
- * proof it carries, its lookups use the digest of the committed bytes inside,
193
- * and it is never minted: the envelope is not the recorded thing, the bytes
194
- * inside are. That is the same structural guarantee the site's drop box gives.
195
- */
196
- interface CarrierRow {
197
- kind: "carrier";
198
- path: string;
199
- status: "ok" | "corrupt" | "too-large";
200
- /** Standard base64 SHA-256 of the committed bytes inside (status "ok"). */
201
- innerDigestB64: string | null;
202
- view: CarrierWindowView | null;
203
- /** The carried bitgraph/1 proof, for offline answers when the ledger has no row. */
204
- proof: unknown | null;
205
- c2pa: boolean;
206
- reason: string | null;
207
- }
208
- type ClassifiedPath = { kind: "plain"; file: ScannedFile } | CarrierRow;
209
-
210
- async function classifyPath(p: string): Promise<ClassifiedPath> {
211
- const info = await stat(p);
212
- if (info.isFile() && (await sniffCarrierTail(p, info.size))) {
213
- if (info.size > MAX_LOADED_BYTES) {
214
- return {
215
- kind: "carrier", path: p, status: "too-large", innerDigestB64: null, view: null, proof: null, c2pa: false,
216
- reason: `a BitGraphed file larger than this tool loads (${Math.round(MAX_LOADED_BYTES / (1024 * 1024))} MiB); judge it offline with: npx @mikeargento/bitgraph-audit "${p}"`,
217
- };
218
- }
219
- const r = await readCarrierFile(p);
220
- if (r.status === "corrupt" || r.innerDigestB64 === null) {
221
- return {
222
- kind: "carrier", path: p, status: "corrupt", innerDigestB64: null, view: null, proof: null, c2pa: false,
223
- reason: `a carrier block was found at the end of the file but is unreadable: ${r.corruptReason ?? "unspecified"}. A corrupted block, not a forgery; nothing was minted for it.`,
224
- };
225
- }
226
- return {
227
- kind: "carrier", path: p, status: "ok", innerDigestB64: r.innerDigestB64, view: r.view,
228
- proof: r.result?.payload?.proof ?? null, c2pa: r.inner !== null && sniffC2paBytes(r.inner), reason: null,
229
- };
230
- }
231
- return { kind: "plain", file: await scanFile(p) };
232
- }
233
-
234
73
  /** Hash the given paths (bounded concurrency). Throws before any network call. */
235
74
  async function hashPaths(paths: readonly string[]): Promise<string[]> {
236
75
  const failures: string[] = [];
@@ -370,8 +209,8 @@ async function flushIndex(config: ApiConfig, report: Report): Promise<{ written:
370
209
  }
371
210
 
372
211
  export function buildServer(deps: ServerDeps = {}): McpServer {
373
- const fuseSetPipeline = deps.fuseSet ?? fuseSetDefault;
374
- const fuseFilePipeline = deps.fuseFile ?? fuseFileDefault;
212
+ const runFuseSet = deps.fuseSet ?? fuseSetPipeline;
213
+ const runFuseFile = deps.fuseFile ?? fuseFilePipeline;
375
214
  const server = new McpServer(
376
215
  {
377
216
  name: "bitgraph-mcp-server",
@@ -501,14 +340,14 @@ export function buildServer(deps: ServerDeps = {}): McpServer {
501
340
  // One file, as a single drop on the site goes: its own slot, its own Frame.
502
341
  report(0, 1, "fusing");
503
342
  try {
504
- solo = { ...(await fuseFilePipeline(one, config)), file: one };
343
+ solo = { ...(await runFuseFile(one, config)), file: one };
505
344
  } catch (err) {
506
345
  failure = setFailureText(err);
507
346
  }
508
347
  } else if (toMint.length > 0) {
509
348
  const kind: "set/1" | "set/2" = toMint.length > MAX_SET_MEMBERS ? "set/2" : "set/1";
510
349
  try {
511
- made = await fuseSetPipeline(toMint, config, {
350
+ made = await runFuseSet(toMint, config, {
512
351
  set: kind,
513
352
  onProgress: (p) => report(p.done, p.total, `${PHASES[p.phase]} ${p.done} of ${p.total}`),
514
353
  });
@@ -1,64 +0,0 @@
1
- // Copyright (c) 2024-2026 Argento Computing Inc. Licensed under the MIT License. See LICENSE.
2
-
3
- import { test } from "node:test";
4
- import { strict as assert } from "node:assert";
5
- import { createHash } from "node:crypto";
6
- import { writeFile, mkdtemp } from "node:fs/promises";
7
- import { tmpdir } from "node:os";
8
- import { join } from "node:path";
9
- import {
10
- fromUrlSafeB64,
11
- looksLikeDigest,
12
- mapConcurrent,
13
- sha256FileB64,
14
- toUrlSafeB64,
15
- } from "../encoding.js";
16
-
17
- // A real 32-byte digest with + and / to exercise both substitutions.
18
- const STANDARD = createHash("sha256").update("bitgraph").digest("base64");
19
-
20
- test("url-safe round trip preserves the digest", () => {
21
- const safe = toUrlSafeB64(STANDARD);
22
- assert.ok(!safe.includes("+") && !safe.includes("/") && !safe.includes("="));
23
- assert.equal(fromUrlSafeB64(safe), STANDARD);
24
- });
25
-
26
- test("fromUrlSafeB64 is a no-op on standard base64", () => {
27
- assert.equal(fromUrlSafeB64(STANDARD), STANDARD);
28
- });
29
-
30
- test("looksLikeDigest accepts both forms, rejects junk", () => {
31
- assert.ok(looksLikeDigest(STANDARD));
32
- assert.ok(looksLikeDigest(toUrlSafeB64(STANDARD)));
33
- assert.ok(!looksLikeDigest("not-a-digest"));
34
- assert.ok(!looksLikeDigest(""));
35
- assert.ok(!looksLikeDigest(Buffer.alloc(16).toString("base64"))); // 16 bytes, not 32
36
- });
37
-
38
- test("sha256FileB64 matches crypto over the same bytes", async () => {
39
- const dir = await mkdtemp(join(tmpdir(), "bitgraph-mcp-"));
40
- const file = join(dir, "sample.bin");
41
- const bytes = Buffer.from("BitGraph proves causal ordering.\n");
42
- await writeFile(file, bytes);
43
- const expected = createHash("sha256").update(bytes).digest("base64");
44
- assert.equal(await sha256FileB64(file), expected);
45
- });
46
-
47
- test("sha256FileB64 rejects directories and missing files", async () => {
48
- await assert.rejects(() => sha256FileB64(tmpdir()), /Not a regular file/);
49
- await assert.rejects(() => sha256FileB64(join(tmpdir(), "definitely-missing-9x7")));
50
- });
51
-
52
- test("mapConcurrent preserves order and honors the limit", async () => {
53
- let inFlight = 0;
54
- let peak = 0;
55
- const out = await mapConcurrent([1, 2, 3, 4, 5, 6, 7, 8], 3, async (n) => {
56
- inFlight += 1;
57
- peak = Math.max(peak, inFlight);
58
- await new Promise((r) => setTimeout(r, 5));
59
- inFlight -= 1;
60
- return n * 2;
61
- });
62
- assert.deepEqual(out, [2, 4, 6, 8, 10, 12, 14, 16]);
63
- assert.ok(peak <= 3, `peak concurrency ${peak} exceeded 3`);
64
- });
@@ -1,80 +0,0 @@
1
- // Copyright (c) 2024-2026 Argento Computing Inc. Licensed under the MIT License. See LICENSE.
2
-
3
- /**
4
- * The scan's two promises: the digest is the file's own, and a state
5
- * finished with the placement's suffix for a slot is the hash of the
6
- * placement's own build for that slot. Both are pinned across sizes on both
7
- * sides of every boundary that matters (the 64-byte sniff, the tar block,
8
- * the 64 KB stream chunk) for both placements the scan makes.
9
- */
10
-
11
- import { test, before } from "node:test";
12
- import { strict as assert } from "node:assert";
13
- import { createHash, randomBytes } from "node:crypto";
14
- import { mkdtemp, mkdir, symlink, writeFile } from "node:fs/promises";
15
- import { tmpdir } from "node:os";
16
- import { join } from "node:path";
17
- import { placementForBytes } from "@mikeargento/bitgraph";
18
- import { getPlacement } from "@mikeargento/bitgraph-verify";
19
- import { expandPaths, fusedDigestFor, scanFile } from "../scan.js";
20
-
21
- let root = "";
22
- before(async () => {
23
- root = await mkdtemp(join(tmpdir(), "bitgraph-mcp-scan-"));
24
- });
25
-
26
- const PNG = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
27
- const SIZES = [0, 1, 8, 63, 64, 65, 511, 512, 513, 1024, 65_535, 65_536, 65_537, 200_000];
28
-
29
- test("a scan's digest is the file's own and its state finishes to the placement's own build", async () => {
30
- let checked = 0;
31
- for (const size of SIZES) {
32
- for (const kind of ["png", "txt"] as const) {
33
- const lead = kind === "png" ? PNG : Buffer.from("txt:");
34
- const bytes = Buffer.concat([lead, randomBytes(Math.max(0, size - lead.length))]).subarray(0, size);
35
- const path = join(root, `${kind}-${size}.bin`);
36
- await writeFile(path, bytes);
37
- const s = await scanFile(path);
38
- assert.equal(s.size, size);
39
- assert.equal(s.digestB64, createHash("sha256").update(bytes).digest("base64"), `${kind} ${size}: digest`);
40
- assert.equal(s.placement, placementForBytes(new Uint8Array(bytes)), `${kind} ${size}: the core's own placement choice`);
41
- assert.ok(s.state !== null, "a file that did not change keeps its state");
42
- const placement = getPlacement(s.placement);
43
- if (placement === undefined) throw new Error("placement missing");
44
- // Two slots, both finished from the same saved state: the copy leaves the state intact.
45
- for (const commitment of [randomBytes(32), randomBytes(32)]) {
46
- const built = placement.build({ original: new Uint8Array(bytes), originDigest: s.originDigest, commitment: new Uint8Array(commitment) });
47
- const expected = createHash("sha256").update(built).digest();
48
- assert.deepEqual(Buffer.from(fusedDigestFor(s, new Uint8Array(commitment))), expected, `${kind} ${size}: state finished == sha256(build)`);
49
- checked += 1;
50
- }
51
- }
52
- }
53
- assert.equal(checked, SIZES.length * 4);
54
- });
55
-
56
- test("expandPaths: files, directories, hidden entries, links, duplicates, limits, missing paths", async () => {
57
- const lone = join(root, "lone.txt");
58
- await writeFile(lone, "lone");
59
- const dir = join(root, "tree");
60
- await mkdir(join(dir, "sub", ".secret"), { recursive: true });
61
- await writeFile(join(dir, "a.txt"), "a");
62
- await writeFile(join(dir, ".DS_Store"), "junk");
63
- await writeFile(join(dir, "sub", "b.txt"), "b");
64
- await writeFile(join(dir, "sub", ".secret", "c.txt"), "c");
65
- await symlink(join(dir, "a.txt"), join(dir, "link.txt"));
66
-
67
- const e = await expandPaths([lone, dir], 100);
68
- assert.deepEqual(e.files, [lone, join(dir, "a.txt"), join(dir, "sub", "b.txt")]);
69
- assert.equal(e.directories, 1);
70
-
71
- const twice = await expandPaths([lone, lone, dir, join(dir, "a.txt")], 100);
72
- assert.deepEqual(twice.files, [lone, join(dir, "a.txt"), join(dir, "sub", "b.txt")], "the same file twice is one file");
73
-
74
- await assert.rejects(expandPaths([dir], 1), /more than 1 files/);
75
- await assert.rejects(expandPaths([lone, join(root, "missing.txt")], 100), (err: Error) => {
76
- assert.ok(err.message.includes("nothing was BitGraphed"));
77
- assert.ok(err.message.includes(join(root, "missing.txt")));
78
- return true;
79
- });
80
- });
package/src/api.ts DELETED
@@ -1,170 +0,0 @@
1
- // Copyright (c) 2024-2026 Argento Computing Inc. Licensed under the MIT License. See LICENSE.
2
-
3
- /**
4
- * @mikeargento/bitgraph-mcp: HTTP client for the hosted BitGraph API.
5
- *
6
- * The reads: the batch lookup, the proof detail, the number search. Making a
7
- * BitGraph goes through the core package's own transport against the same
8
- * site (its /api/fuse routes), which is what maintains the per-position
9
- * by-digest index that makes every member of a set discoverable afterward;
10
- * the one write here is a set/2's member evidence, indexed after the commit.
11
- */
12
-
13
- import { mapConcurrent } from "./encoding.js";
14
- import type { BatchCheckResponse, ProofDetailResponse, SearchResponse, SetIndexResponse } from "./types.js";
15
-
16
- export interface ApiConfig {
17
- baseUrl: string;
18
- apiKey?: string;
19
- }
20
-
21
- /** Read configuration from the environment at call time (testable via env). */
22
- export function configFromEnv(): ApiConfig {
23
- const baseUrl = (process.env["BITGRAPH_API_URL"] ?? "https://bitgraph.ing").replace(/\/+$/, "");
24
- const apiKey = process.env["BITGRAPH_API_KEY"];
25
- return apiKey ? { baseUrl, apiKey } : { baseUrl };
26
- }
27
-
28
- /** Matches the batch endpoint's MAX_DIGESTS. */
29
- export const BATCH_CHECK_LIMIT = 500;
30
- /** Batch lookups in flight at once: a large folder is many pages. */
31
- const BATCH_CHECK_CONCURRENCY = 4;
32
-
33
- const CHECK_TIMEOUT_MS = 30_000;
34
- const SET_INDEX_TIMEOUT_MS = 60_000;
35
-
36
- export class ApiError extends Error {
37
- readonly status: number;
38
- readonly retryAfterSec: number | null;
39
-
40
- constructor(status: number, message: string, retryAfterSec: number | null = null) {
41
- super(message);
42
- this.status = status;
43
- this.retryAfterSec = retryAfterSec;
44
- }
45
- }
46
-
47
- async function parseError(res: Response): Promise<ApiError> {
48
- let message = res.statusText || `HTTP ${res.status}`;
49
- try {
50
- const body = (await res.json()) as { error?: string };
51
- if (typeof body.error === "string" && body.error.length > 0) message = body.error;
52
- } catch {
53
- /* non-JSON error body */
54
- }
55
- const retryAfterRaw = res.headers.get("retry-after");
56
- const retryAfterSec = retryAfterRaw !== null ? Number.parseInt(retryAfterRaw, 10) : null;
57
- return new ApiError(res.status, message, Number.isFinite(retryAfterSec) ? retryAfterSec : null);
58
- }
59
-
60
- /**
61
- * Redirects are refused everywhere: an Authorization header must never follow
62
- * one to a different host, and the canonical endpoint serves without them.
63
- */
64
- function redirectHint(err: unknown): never {
65
- if (err instanceof TypeError && /redirect/i.test(err.message)) {
66
- throw new Error(
67
- "The BitGraph endpoint redirected. Set BITGRAPH_API_URL to the canonical https://bitgraph.ing (apex, no www)."
68
- );
69
- }
70
- throw err;
71
- }
72
-
73
- async function getJson<T>(config: ApiConfig, path: string, timeoutMs: number): Promise<T> {
74
- const res = await fetch(`${config.baseUrl}${path}`, {
75
- method: "GET",
76
- redirect: "error",
77
- signal: AbortSignal.timeout(timeoutMs),
78
- }).catch(redirectHint);
79
- if (!res.ok) throw await parseError(res);
80
- return (await res.json()) as T;
81
- }
82
-
83
- async function postJson<T>(
84
- config: ApiConfig,
85
- path: string,
86
- body: unknown,
87
- timeoutMs: number,
88
- authenticated: boolean
89
- ): Promise<T> {
90
- const headers: Record<string, string> = { "Content-Type": "application/json" };
91
- if (authenticated && config.apiKey) headers["Authorization"] = `Bearer ${config.apiKey}`;
92
- const res = await fetch(`${config.baseUrl}${path}`, {
93
- method: "POST",
94
- headers,
95
- body: JSON.stringify(body),
96
- redirect: "error",
97
- signal: AbortSignal.timeout(timeoutMs),
98
- }).catch(redirectHint);
99
- if (!res.ok) throw await parseError(res);
100
- return (await res.json()) as T;
101
- }
102
-
103
- /**
104
- * Look up which digests are on record. Input digests must be URL-safe base64;
105
- * the response is keyed by the exact strings sent. Pages of up to 500, a few
106
- * in flight at once.
107
- */
108
- export async function batchCheck(
109
- config: ApiConfig,
110
- urlSafeDigests: readonly string[]
111
- ): Promise<BatchCheckResponse> {
112
- const chunks: string[][] = [];
113
- for (let offset = 0; offset < urlSafeDigests.length; offset += BATCH_CHECK_LIMIT) {
114
- chunks.push(urlSafeDigests.slice(offset, offset + BATCH_CHECK_LIMIT));
115
- }
116
- const pages = await mapConcurrent(chunks, BATCH_CHECK_CONCURRENCY, (chunk) =>
117
- postJson<BatchCheckResponse>(config, "/api/proofs/batch", { digests: chunk }, CHECK_TIMEOUT_MS, false)
118
- );
119
- const merged: BatchCheckResponse = { results: {} };
120
- for (const page of pages) Object.assign(merged.results, page.results);
121
- return merged;
122
- }
123
-
124
- /** One request's worth of a set/2's member evidence, for the site to index. */
125
- export interface SetIndexRequest {
126
- /** The set proof's artifact digest, URL-safe base64. */
127
- setDigest: string;
128
- /** The set proof's epoch id, URL-safe base64. */
129
- epoch: string;
130
- /** The set proof's commit counter. */
131
- counter: string;
132
- members: unknown[];
133
- }
134
-
135
- /**
136
- * Index members of a set/2 from their evidence. The site reads the set proof
137
- * from its own position, binds the root document, and checks every member's
138
- * path before writing a key; a row that does not bind is rejected, never
139
- * written.
140
- */
141
- export async function indexSetMembers(config: ApiConfig, body: SetIndexRequest): Promise<SetIndexResponse> {
142
- return postJson<SetIndexResponse>(config, "/api/fuse/set-index", body, SET_INDEX_TIMEOUT_MS, false);
143
- }
144
-
145
- /** Full detail for one digest: proof, all causal positions, anchor window. */
146
- export async function getProofDetail(
147
- config: ApiConfig,
148
- urlSafeDigest: string,
149
- counter?: string,
150
- urlSafeEpoch?: string
151
- ): Promise<ProofDetailResponse> {
152
- const params = new URLSearchParams();
153
- if (counter !== undefined) params.set("counter", counter);
154
- if (urlSafeEpoch !== undefined) params.set("epoch", urlSafeEpoch);
155
- const query = params.size > 0 ? `?${params.toString()}` : "";
156
- return getJson<ProofDetailResponse>(
157
- config,
158
- `/api/proofs/digest/${encodeURIComponent(urlSafeDigest)}${query}`,
159
- CHECK_TIMEOUT_MS
160
- );
161
- }
162
-
163
- /** Resolve a BitGraph number (or digest string) to a digest via /api/search. */
164
- export async function search(config: ApiConfig, q: string): Promise<SearchResponse> {
165
- return getJson<SearchResponse>(
166
- config,
167
- `/api/search?q=${encodeURIComponent(q)}`,
168
- CHECK_TIMEOUT_MS
169
- );
170
- }