@blockula/mcp 0.3.0 → 1.0.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.
@@ -0,0 +1,361 @@
1
+ // Which chain the MCP talks to, and how it makes sure it stays on it (WAL-4).
2
+ //
3
+ // The network is chosen once, at startup, by the user: `--network <name>` / BLOCKULA_NETWORK, or
4
+ // BLOCKULA_RPC. Nothing switches it later (no probing localhost, no tool that changes it). Each
5
+ // network pins a chain id and, where known, a genesis hash; BLOCKULA_CHAIN_ID and
6
+ // BLOCKULA_GENESIS_HASH pin them for any node. Whatever isn't pinned is pinned from the first
7
+ // status the node reports (trust on first use) and, except for throwaway local dev chains,
8
+ // remembered in BLOCKULA_HOME/known-chains.json, so a node that later reports another chain is
9
+ // refused, across restarts too. A node that says it's on blockula-1 is refused unless
10
+ // BLOCKULA_CHAIN_ID=blockula-1 pins that chain (`unpinnedBlockula1`).
11
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
12
+ import { dirname } from "node:path";
13
+ /**
14
+ * The chain id Blockula's mainnet was to have: no chain runs under it, so nothing is used under it
15
+ * by accident (`unpinnedBlockula1`), and where an address is needed an unproven name isn't signed
16
+ * on it (index.ts `onBlockula1`).
17
+ */
18
+ export const BLOCKULA_1 = "blockula-1";
19
+ export const LOCAL_RPC = "http://localhost:7070";
20
+ export const TESTNET_RPC = "https://blockula-testnet.fly.dev";
21
+ /**
22
+ * Nothing reads this: the MCP has no mainnet network. It stays because
23
+ * `scripts/pin-network.sh --mainnet --rpc` writes it and `scripts/test-pin-network.sh` checks
24
+ * that; it goes when that mode does.
25
+ */
26
+ export const MAINNET_RPCS = [];
27
+ /**
28
+ * Chains whose genesis is known: any node claiming one of these chain ids must report this
29
+ * genesis hash, whatever network it was configured as. A release adds a chain's here
30
+ * (scripts/pin-network.sh).
31
+ */
32
+ export const KNOWN_GENESIS = { "blockula-testnet-2": "b004e2f91b63429fc009088b16ec02b6d5388a03642699ae3dc6e325939f19b2" };
33
+ /**
34
+ * The genesis clock of chains whose genesis is known (`created_at_ms` and `block_time_ms` of the
35
+ * genesis.json that hashes to `KNOWN_GENESIS[id]`), added with the hash. With it the client
36
+ * refuses a node reporting a height the chain can't have reached by this machine's clock, before
37
+ * anything is signed (audit review-r3-clients-2; `Client.checkSigningHeight`).
38
+ */
39
+ export const KNOWN_CLOCKS = { "blockula-testnet-2": { createdAtMs: 1792328803000, blockTimeMs: 1000 } };
40
+ /**
41
+ * The genesis clock in a node's `/api/genesis` answer for chain `chainId`, or `undefined` when it
42
+ * isn't one (another chain's genesis, missing or malformed fields). The node's word: the MCP takes
43
+ * it on first use, as it does the genesis hash, and remembers it with that hash.
44
+ */
45
+ export function clockFrom(genesis, chainId) {
46
+ const g = genesis && typeof genesis === "object" ? genesis : {};
47
+ const { created_at_ms: createdAtMs, block_time_ms: blockTimeMs } = g;
48
+ if (g.chain_id !== chainId || !Number.isSafeInteger(createdAtMs) || createdAtMs < 0 || !Number.isSafeInteger(blockTimeMs) || blockTimeMs < 1)
49
+ return undefined;
50
+ return { createdAtMs: createdAtMs, blockTimeMs: blockTimeMs };
51
+ }
52
+ /** The networks the MCP knows by name. */
53
+ export const KNOWN_NETWORKS = {
54
+ // The protocol v1 public testnet (deploy/launch.sh): its own chain id (never reused) and the
55
+ // testnet prefix (DEC-7). Its genesis hash exists only once it launches: until a release adds it
56
+ // to KNOWN_GENESIS, the first status the node reports pins it (trust on first use, remembered).
57
+ testnet: { key: "testnet", label: "public testnet", rpc: TESTNET_RPC, pin: { chainId: "blockula-testnet-2", prefix: "tboo" }, remember: true },
58
+ local: { key: "local", label: "local dev node", rpc: LOCAL_RPC, pin: {}, remember: false },
59
+ };
60
+ /** The public RPCs of the chains whose public RPCs are built in, by chain id. */
61
+ const PUBLIC_RPCS = { "blockula-testnet-2": [TESTNET_RPC] };
62
+ /**
63
+ * The witnesses a node of `chainId` gets when BLOCKULA_WITNESSES is unset: the chain's public RPCs,
64
+ * unless the node is one of them. So your own node's answers about names and heights are checked
65
+ * against the public ones (OPERATOR-6). None for chains without public RPCs (dev chains, and the
66
+ * chains people launch themselves: there, BLOCKULA_WITNESSES lists other nodes).
67
+ */
68
+ export function defaultWitnesses(chainId, rpc) {
69
+ const rpcs = ((chainId && PUBLIC_RPCS[chainId]) || []).map(canonicalUrl);
70
+ // The same node however `rpc` is spelled: a node is never its own witness (audit MCK-1).
71
+ return rpcs.includes(canonicalUrl(rpc)) ? [] : rpcs;
72
+ }
73
+ /** The public network a chain id is, when it's one (for labels). */
74
+ const PUBLIC_LABEL = { "blockula-testnet-2": "the public testnet" };
75
+ /**
76
+ * What `status` calls the network: once the node's chain is known, a node on this machine that
77
+ * is on a public network is "your node on <it>", never a "local dev node".
78
+ */
79
+ export function networkLabel(net, chainId) {
80
+ const pub = chainId ? PUBLIC_LABEL[chainId] : undefined;
81
+ return pub && isLocalUrl(net.rpc) ? `your node on ${pub}` : net.label;
82
+ }
83
+ const isLocalUrl = (u) => {
84
+ try {
85
+ return ["localhost", "127.0.0.1", "[::1]"].includes(new URL(u).hostname);
86
+ }
87
+ catch {
88
+ return false;
89
+ }
90
+ };
91
+ /** This machine, or a private network (where plain http can't be intercepted from the internet). */
92
+ const isPrivateUrl = (u) => {
93
+ if (isLocalUrl(u))
94
+ return true;
95
+ const h = new URL(u).hostname;
96
+ return /^(10\.|192\.168\.|172\.(1[6-9]|2\d|3[01])\.)/.test(h) || /\.(internal|local|lan)$/.test(h);
97
+ };
98
+ /**
99
+ * A node URL in one spelling: the scheme and host lowercased, a default port and dot segments
100
+ * dropped (the URL parser's own normalization), no trailing dot on the host and no trailing
101
+ * slash. So a node compares equal however it's written: `https://Blockula-Testnet.fly.dev:443/.`
102
+ * is the public RPC, and is never its own witness (audit MCK-1). What isn't an http(s) URL comes
103
+ * back as it is.
104
+ */
105
+ export function canonicalUrl(u) {
106
+ try {
107
+ const url = new URL(u);
108
+ if (!/^https?:$/.test(url.protocol))
109
+ return u;
110
+ // `host` carries the port; a fully qualified name's trailing dot comes before it, or ends it.
111
+ const host = url.host.replace(/\.(?=:\d+$|$)/, "");
112
+ return `${url.protocol}//${host}${url.pathname.replace(/\/+$/, "")}`;
113
+ }
114
+ catch {
115
+ return u;
116
+ }
117
+ }
118
+ /**
119
+ * A node URL, spelled canonically (canonicalUrl): http(s) without credentials, a query or a
120
+ * fragment (the client appends /api/… to it); plain http only on this machine or a private network.
121
+ */
122
+ export function checkRpcUrl(u, what = "BLOCKULA_RPC") {
123
+ let url;
124
+ try {
125
+ url = new URL(u);
126
+ }
127
+ catch {
128
+ throw new Error(`${what} isn't a URL: ${u}`);
129
+ }
130
+ if (!/^https?:$/.test(url.protocol) || url.username || url.password)
131
+ throw new Error(`${what} must be an http(s) URL without credentials: ${u}`);
132
+ if (url.search || url.hash)
133
+ throw new Error(`${what} must be a node's URL, without a query or a fragment: ${u}`);
134
+ if (url.protocol === "http:" && !isPrivateUrl(u))
135
+ throw new Error(`${what} must use https unless the node is on this machine or a private network: ${u}`);
136
+ return canonicalUrl(u);
137
+ }
138
+ /**
139
+ * The chain ids this MCP pins, and keys its ledger (spend.ts `chainKey`), lock and nonce records
140
+ * and grant files by: 1 to 64 letters, digits, '-', '_' and '.'. A genesis built for a public
141
+ * network has letters, digits and '-' (node/src/genesis_builder.rs, at most 64: MAX_CHAIN_ID), so
142
+ * every real chain fits. A node reporting anything else (a path separator, a space) is refused
143
+ * when its chain is pinned (`unusableChain`), so nothing it names is ever used as a file name or
144
+ * a ledger key (audit MCK-3).
145
+ */
146
+ export const CHAIN_ID = /^[A-Za-z0-9._-]{1,64}$/;
147
+ /**
148
+ * Why a node's chain can't be pinned at all, or null: a chain id outside CHAIN_ID, or a genesis
149
+ * hash that isn't 64 hex characters, can't key what this MCP keeps per chain. (A node that reports
150
+ * no genesis hash is pinned by its chain id alone, as before; it can't sign, and `use_account`
151
+ * says that's why.)
152
+ */
153
+ export function unusableChain(status) {
154
+ const id = status.chain_id;
155
+ if (typeof id === "string" && !CHAIN_ID.test(id))
156
+ return `the node reports the chain id ${JSON.stringify(id.length > 80 ? `${id.slice(0, 80)}…` : id)}, and a chain id here is 1 to 64 letters, digits, '-', '_' or '.'`;
157
+ const g = status.genesis_hash;
158
+ if (g !== undefined && g !== null && (typeof g !== "string" || !/^[0-9a-fA-F]{64}$/.test(g)))
159
+ return "the node reports a genesis hash that isn't 64 hex characters";
160
+ return null;
161
+ }
162
+ /** `--network <name>` or `--network=<name>` from the command line. */
163
+ export function networkArg(argv) {
164
+ for (let i = 0; i < argv.length; i++) {
165
+ if (argv[i] === "--network")
166
+ return argv[i + 1];
167
+ if (argv[i].startsWith("--network="))
168
+ return argv[i].slice("--network=".length);
169
+ }
170
+ return undefined;
171
+ }
172
+ /**
173
+ * The network from the configuration: `--network` (or BLOCKULA_NETWORK) names one, BLOCKULA_RPC
174
+ * gives the node, BLOCKULA_CHAIN_ID / BLOCKULA_GENESIS_HASH add pins. With none of them, the
175
+ * public testnet. Contradictions are errors, never guesses.
176
+ */
177
+ export function resolveNetwork(env, argv = []) {
178
+ const named = (networkArg(argv) ?? env.BLOCKULA_NETWORK)?.trim().toLowerCase() || undefined;
179
+ if (named !== undefined && !["testnet", "local", "custom"].includes(named)) {
180
+ const hint = named === "mainnet" ? `: there's no mainnet network, and a node that says it's on ${BLOCKULA_1} is used only with BLOCKULA_CHAIN_ID=${BLOCKULA_1}` : "";
181
+ throw new Error(`unknown network "${named}" (testnet, local or custom)${hint}`);
182
+ }
183
+ const rpc = env.BLOCKULA_RPC?.trim() ? checkRpcUrl(env.BLOCKULA_RPC.trim()) : undefined;
184
+ let key;
185
+ if (named)
186
+ key = named;
187
+ else if (rpc)
188
+ key = Object.values(KNOWN_NETWORKS).find((n) => n.rpc && canonicalUrl(n.rpc) === rpc)?.key ?? (isLocalUrl(rpc) ? "local" : "custom");
189
+ else
190
+ key = "testnet";
191
+ const base = key === "custom" ? { key, label: "custom node", pin: {}, remember: true } : KNOWN_NETWORKS[key];
192
+ const url = rpc ?? (base.rpc ? canonicalUrl(base.rpc) : undefined);
193
+ if (!url)
194
+ throw new Error("the custom network needs BLOCKULA_RPC");
195
+ if (key === "local" && !isLocalUrl(url))
196
+ throw new Error(`the local network is a node on this machine, not ${url}`);
197
+ const pin = { ...base.pin };
198
+ const chainId = env.BLOCKULA_CHAIN_ID?.trim() || undefined;
199
+ const genesisHash = env.BLOCKULA_GENESIS_HASH?.trim().toLowerCase() || undefined;
200
+ if (chainId) {
201
+ if (!CHAIN_ID.test(chainId))
202
+ throw new Error(`BLOCKULA_CHAIN_ID must be a chain id: 1 to 64 letters, digits, '-', '_' or '.' (got ${JSON.stringify(chainId.slice(0, 80))})`);
203
+ if (pin.chainId && pin.chainId !== chainId)
204
+ throw new Error(`BLOCKULA_CHAIN_ID=${chainId} contradicts the ${base.label} (${pin.chainId})`);
205
+ pin.chainId = chainId;
206
+ }
207
+ if (genesisHash) {
208
+ if (!/^[0-9a-f]{64}$/.test(genesisHash))
209
+ throw new Error("BLOCKULA_GENESIS_HASH must be 64 hex characters");
210
+ pin.genesisHash = genesisHash;
211
+ }
212
+ const known = pin.chainId ? KNOWN_GENESIS[pin.chainId] : undefined;
213
+ if (known && pin.genesisHash && known !== pin.genesisHash)
214
+ throw new Error(`BLOCKULA_GENESIS_HASH contradicts ${pin.chainId}'s known genesis ${known}`);
215
+ pin.genesisHash ??= known;
216
+ // `none` opts out of the default witnesses; unset, a node that isn't its network's public RPC
217
+ // gets them (defaultWitnesses; a node whose chain isn't known yet, once it's pinned).
218
+ const listed = (env.BLOCKULA_WITNESSES ?? "").trim();
219
+ const witnessesSet = listed !== "";
220
+ const witnesses = listed.toLowerCase() === "none" ? [] : witnessesSet ? listed.split(",").map((w) => w.trim()).filter(Boolean).map((w) => checkRpcUrl(w, "BLOCKULA_WITNESSES")) : defaultWitnesses(pin.chainId, url);
221
+ // Every URL here is canonical (checkRpcUrl), so a node spelled two ways is still one node.
222
+ if (witnesses.includes(url))
223
+ throw new Error("BLOCKULA_WITNESSES must be other nodes than BLOCKULA_RPC's");
224
+ if (new Set(witnesses).size !== witnesses.length)
225
+ throw new Error("BLOCKULA_WITNESSES lists a node twice");
226
+ return { key, label: base.label, rpc: url, pin, remember: base.remember, witnesses, witnessesSet };
227
+ }
228
+ /** Why a node's status doesn't match a pin, or null if it does. Known chain ids must come with their known genesis. */
229
+ export function mismatch(pin, status) {
230
+ const chainId = typeof status.chain_id === "string" ? status.chain_id : undefined;
231
+ const genesis = typeof status.genesis_hash === "string" ? status.genesis_hash.toLowerCase() : undefined;
232
+ const prefix = typeof status.address_prefix === "string" ? status.address_prefix : undefined;
233
+ if (!chainId)
234
+ return "the node doesn't report a chain id";
235
+ if (pin.chainId && chainId !== pin.chainId)
236
+ return `the node is on chain "${chainId}", not "${pin.chainId}"`;
237
+ const wantGenesis = pin.genesisHash ?? KNOWN_GENESIS[chainId];
238
+ if (wantGenesis && genesis !== wantGenesis)
239
+ return `the node's "${chainId}" starts from genesis ${genesis ?? "(none reported)"}, not ${wantGenesis}`;
240
+ if (pin.prefix && prefix && prefix !== pin.prefix)
241
+ return `the node uses "${prefix}1…" addresses, not "${pin.prefix}1…"`;
242
+ return null;
243
+ }
244
+ /**
245
+ * Why a node that says it's on blockula-1 is refused, or null: the pin (configured, or remembered
246
+ * from a configuration that named it) isn't that chain. A node that reports blockula-1 is never
247
+ * trusted on first use, as the CLI doesn't (`cli_net`): set BLOCKULA_CHAIN_ID=blockula-1 to mean it.
248
+ * (A pin on another chain is refused by `mismatch` first, in its own words.)
249
+ */
250
+ export function unpinnedBlockula1(pin, status) {
251
+ if (status.chain_id !== BLOCKULA_1 || pin.chainId === BLOCKULA_1)
252
+ return null;
253
+ return `the node says it's on ${BLOCKULA_1}, and this MCP isn't pinned to that chain. If that's the chain you mean, the user sets BLOCKULA_CHAIN_ID=${BLOCKULA_1}`;
254
+ }
255
+ /** The pin after a first status: every field the node reported that wasn't pinned yet. */
256
+ export function completePin(pin, status) {
257
+ return {
258
+ chainId: pin.chainId ?? (typeof status.chain_id === "string" ? status.chain_id : undefined),
259
+ genesisHash: pin.genesisHash ?? (typeof status.genesis_hash === "string" ? status.genesis_hash.toLowerCase() : undefined),
260
+ prefix: pin.prefix ?? (typeof status.address_prefix === "string" ? status.address_prefix : undefined),
261
+ };
262
+ }
263
+ /** Chains remembered on first use, by network and node URL (BLOCKULA_HOME/known-chains.json). */
264
+ export class KnownChains {
265
+ file;
266
+ constructor(file) {
267
+ this.file = file;
268
+ }
269
+ all() {
270
+ try {
271
+ return existsSync(this.file) ? JSON.parse(readFileSync(this.file, "utf8")) : {};
272
+ }
273
+ catch {
274
+ throw new Error(`${this.file} is corrupt; fix or delete it (it pins which chain each node must be on)`);
275
+ }
276
+ }
277
+ static id(net) {
278
+ return `${net.key} ${net.rpc}`;
279
+ }
280
+ get(net) {
281
+ if (!net.remember)
282
+ return undefined;
283
+ const all = this.all();
284
+ const id = KnownChains.id(net);
285
+ if (all[id] !== undefined)
286
+ return all[id];
287
+ // A release before canonicalUrl kept a node's URL as it was typed (`custom https://Rpc.Example`):
288
+ // that entry is this node's under another spelling. Its pin holds, and moves to this spelling,
289
+ // so the node isn't pinned afresh on trust, and the entry an error names is the only one.
290
+ const old = Object.keys(all).filter((k) => {
291
+ const at = k.indexOf(" ");
292
+ return k !== id && at > 0 && k.slice(0, at) === net.key && canonicalUrl(k.slice(at + 1)) === net.rpc;
293
+ });
294
+ if (old.length === 0)
295
+ return undefined;
296
+ const pin = all[old[0]];
297
+ try {
298
+ this.write(id, pin, old);
299
+ }
300
+ catch {
301
+ /* the file can't be written: the pin still holds this session */
302
+ }
303
+ return pin;
304
+ }
305
+ save(net, pin) {
306
+ if (!net.remember)
307
+ return;
308
+ this.write(KnownChains.id(net), pin);
309
+ }
310
+ /** Genesis clocks are a property of the chain (its id and genesis hash), whichever node reported them. */
311
+ static clockId(chainId, genesisHash) {
312
+ return `clock ${chainId} ${genesisHash}`;
313
+ }
314
+ /** The genesis clock remembered for this chain (`clockFrom` of the first `/api/genesis` read for it). */
315
+ clock(net, chainId, genesisHash) {
316
+ if (!net.remember)
317
+ return undefined;
318
+ const c = this.all()[KnownChains.clockId(chainId, genesisHash)];
319
+ return clockFrom(c && typeof c === "object" ? { ...c, chain_id: chainId } : undefined, chainId);
320
+ }
321
+ saveClock(net, chainId, genesisHash, clock) {
322
+ if (!net.remember)
323
+ return;
324
+ this.write(KnownChains.clockId(chainId, genesisHash), { created_at_ms: clock.createdAtMs, block_time_ms: clock.blockTimeMs });
325
+ }
326
+ /** Sets one entry (and drops the entries `drop` names, in the same write). */
327
+ write(key, value, drop = []) {
328
+ const all = this.all();
329
+ for (const k of drop)
330
+ delete all[k];
331
+ all[key] = value;
332
+ mkdirSync(dirname(this.file), { recursive: true });
333
+ writeFileSync(this.file, JSON.stringify(all, null, 2), { mode: 0o600 });
334
+ }
335
+ }
336
+ /**
337
+ * The pin to enforce for a network: its configured pin, filled from what was remembered for it.
338
+ * A remembered field never overrides a configured one; a contradiction means the configuration
339
+ * changed, and the configuration wins. What was remembered for another chain id (the network was
340
+ * relaunched under a new one, which this release pins) isn't used at all: the new chain is
341
+ * pinned on first use instead of refused for the old one's genesis.
342
+ */
343
+ export function effectivePin(net, remembered) {
344
+ const r = remembered && (!net.pin.chainId || !remembered.chainId || remembered.chainId === net.pin.chainId) ? remembered : undefined;
345
+ return { chainId: net.pin.chainId ?? r?.chainId, genesisHash: net.pin.genesisHash ?? r?.genesisHash, prefix: net.pin.prefix ?? r?.prefix };
346
+ }
347
+ /**
348
+ * The error an agent sees when a node is on the wrong chain: what happened and what the user can
349
+ * do. `relaunched` is false for a node whose chain can't be pinned at all (`unusableChain`):
350
+ * forgetting what was remembered wouldn't help there.
351
+ */
352
+ export function mismatchError(net, why, knownChainsFile, relaunched = true) {
353
+ const fix = !relaunched
354
+ ? ""
355
+ : net.remember
356
+ ? ` If this network was relaunched on purpose, the user can remove its entry ("${KnownChains.id(net)}") from ${knownChainsFile}, or set BLOCKULA_GENESIS_HASH.`
357
+ : net.key === "local"
358
+ ? " A reset dev chain starts from a new genesis: call start_dev_node to use the one running now."
359
+ : "";
360
+ return new Error(`Refusing ${net.rpc} (${net.label}): ${why}. Nothing was signed or sent.${fix}`);
361
+ }
@@ -0,0 +1,79 @@
1
+ export declare const REPO = "jackdishman/blockula";
2
+ /**
3
+ * BLAKE3 of the release public key (docs/OPERATIONS.md "The release key"). Empty until the key
4
+ * ceremony fills it in, together with scripts/install.sh's and node/src/release_cli.rs's
5
+ * BUILTIN_KEY_ID (a test keeps the three equal); BLOCKULA_RELEASE_KEY_ID sets it meanwhile.
6
+ */
7
+ export declare const RELEASE_KEY_ID = "";
8
+ /** The release key id pin `blockula release install` writes (release_cli.rs PIN_FILE), in $BLOCKULA_HOME. */
9
+ export declare const PIN_FILE = "release-key-id";
10
+ /** Where install_node puts the node binary, and where `blockula release install` and install.sh put theirs (never overwritten by install_node). */
11
+ export declare function binPaths(home: string): {
12
+ mcp: string;
13
+ cli: string;
14
+ };
15
+ /** A node binary the MCP could run, and what its `--version` printed (undefined: it didn't run). */
16
+ export interface NodeBinary {
17
+ path: string;
18
+ version?: string;
19
+ }
20
+ /**
21
+ * The node binary start_dev_node and summon_chain run when BLOCKULA_BIN isn't set (audit
22
+ * review-r4-supply-3). `found` lists the installed ones, `blockula release install`'s first (it was
23
+ * checked against the pinned key), then install_node's. The first whose `--version` is this MCP's
24
+ * wins; else the first that runs; else the first; with none, `blockula` from PATH. `matches` says
25
+ * whether it's this MCP's version, so a stale binary is never run without saying so.
26
+ */
27
+ export declare function pickNodeBinary(found: NodeBinary[], mcpVersion: string): NodeBinary & {
28
+ matches: boolean;
29
+ };
30
+ /**
31
+ * The release key id pinned in `file` (`blockula release pin` / the first `release install`), or
32
+ * null if there's no pin. Same rules as release_cli.rs read_pin: one 64-hex key id (any case,
33
+ * surrounding whitespace ignored), never ignored when it's malformed, and refused when the group
34
+ * or others can write it (they could have swapped the key it vouches for).
35
+ */
36
+ export declare function readReleasePin(file: string): string | null;
37
+ /** A release tag. Only these are installed: `v*` tags are admin-only (deploy/github/ruleset-tags.json). */
38
+ export declare const RELEASE_TAG: RegExp;
39
+ /** The first line of a release's checksums.txt, before the tag (scripts/release-checksums.sh). */
40
+ export declare const TAG_LINE_PREFIX = "# blockula-release ";
41
+ /**
42
+ * The release key id to trust: the one pinned on this machine (`blockula release install`'s pin,
43
+ * `pinned`, read from `pinFile` by readReleasePin), BLOCKULA_RELEASE_KEY_ID (from an independent
44
+ * channel) or the one built in. Every one that's set must be the same key: a mismatch means one of
45
+ * them isn't genuine (or the pin moved by a rotation this MCP predates: update the MCP).
46
+ */
47
+ export declare function releaseKeyIdFrom(given: string | undefined, builtIn: string, pinned?: string | null, pinFile?: string): string;
48
+ /** The tag GitHub's /releases/latest redirected to (the final URL, …/releases/tag/<tag>). */
49
+ export declare function latestTagFrom(finalUrl: string): string;
50
+ /** Throws unless `blockula --version` printed release `tag`'s version. */
51
+ export declare function checkBinaryVersion(versionOutput: string, tag: string): void;
52
+ /** Throws unless checksums.txt names release `tag` on its first line. */
53
+ export declare function checkTagLine(sums: Buffer, tag: string): void;
54
+ export interface ReleaseDeps {
55
+ /** GET a release file; `null` if it doesn't exist (404). */
56
+ download(url: string): Promise<Buffer | null>;
57
+ /**
58
+ * Runs `gh attestation verify` on the archive's bytes, requiring provenance from `refs/tags/<tag>`:
59
+ * `"ok"`, `"failed"` (with the reason), or `"unavailable"` (no gh, or not logged in).
60
+ */
61
+ attest(archive: Buffer, name: string, tag: string): Promise<{
62
+ result: "ok" | "failed" | "unavailable";
63
+ detail?: string;
64
+ }>;
65
+ }
66
+ export interface VerifiedRelease {
67
+ archive: Buffer;
68
+ sha256: string;
69
+ /** What proved the release genuine (empty: only the checksum). */
70
+ verified: string[];
71
+ /** Shown to the user as-is. */
72
+ warning?: string;
73
+ }
74
+ /** Downloads `asset` from `base` (release `tag`'s download URL) and checks it as described above. Throws instead of returning anything unverified that must not be installed. The caller still checks the binary's `--version` (checkBinaryVersion). */
75
+ export declare function fetchVerifiedRelease(base: string, asset: string, opts: {
76
+ releaseKeyId: string;
77
+ requireVerify: boolean;
78
+ tag: string;
79
+ }, deps: ReleaseDeps): Promise<VerifiedRelease>;
@@ -0,0 +1,193 @@
1
+ // install_node's checks on a release download, the same as scripts/install.sh (docs/OPERATIONS.md
2
+ // "Releases"):
3
+ // 0. The version (a vX.Y.Z tag; `latest` is resolved to its tag first) is bound to everything
4
+ // below: checksums.txt must name it on its first line ("# blockula-release vX.Y.Z", covered
5
+ // by the signature), provenance must come from its tag (--source-ref), and install_node checks
6
+ // the extracted binary's `--version` before it installs it. An older, genuinely signed
7
+ // release served as the one asked for is refused (audit supply-chain-B-2).
8
+ // 1. SHA-256 against the release's checksums.txt. Always. It only catches a corrupted download:
9
+ // whoever can change the release can change checksums.txt too.
10
+ // 2. SLSA build provenance with a logged-in GitHub CLI (`gh attestation verify`): the archive was
11
+ // built by this repo's release workflow. A failed check is fatal; no gh skips it.
12
+ // 3. The offline ML-DSA-65 signature on checksums.txt (checksums.txt.sig), verified with the
13
+ // SDK against the release key id. Once a key id is known (pinned in
14
+ // $BLOCKULA_HOME/release-key-id by `blockula release install`, built in, or
15
+ // BLOCKULA_RELEASE_KEY_ID; all that are set must agree), every release must carry a valid
16
+ // signature: a missing or invalid .sig is fatal. Leaving the .sig out is exactly what a
17
+ // compromised GitHub account or release workflow would do, and it can't forge the offline
18
+ // signature (audit F69). With no key id (before the key ceremony, nothing pinned) there's
19
+ // nothing to check it against, so it's skipped.
20
+ // install_node installs to a path of its own ($BLOCKULA_HOME/mcp-bin/blockula), never over the
21
+ // binary `blockula release install` puts in $BLOCKULA_HOME/bin (audit
22
+ // review-r3-integrate-compose-1).
23
+ // The checks add up (AND): provenance and the signature each refuse a release they fail. If
24
+ // neither ran, the result says so loudly. BLOCKULA_REQUIRE_VERIFY=1 refuses a release it can only
25
+ // checksum, and once a key id is known it requires provenance too (both checks).
26
+ import { createHash } from "node:crypto";
27
+ import { readFileSync, statSync } from "node:fs";
28
+ import { join } from "node:path";
29
+ import { verifyReleaseSignature } from "@blockula/sdk";
30
+ export const REPO = "jackdishman/blockula";
31
+ /**
32
+ * BLAKE3 of the release public key (docs/OPERATIONS.md "The release key"). Empty until the key
33
+ * ceremony fills it in, together with scripts/install.sh's and node/src/release_cli.rs's
34
+ * BUILTIN_KEY_ID (a test keeps the three equal); BLOCKULA_RELEASE_KEY_ID sets it meanwhile.
35
+ */
36
+ export const RELEASE_KEY_ID = "";
37
+ /** The release key id pin `blockula release install` writes (release_cli.rs PIN_FILE), in $BLOCKULA_HOME. */
38
+ export const PIN_FILE = "release-key-id";
39
+ /** Where install_node puts the node binary, and where `blockula release install` and install.sh put theirs (never overwritten by install_node). */
40
+ export function binPaths(home) {
41
+ return { mcp: join(home, "mcp-bin", "blockula"), cli: join(home, "bin", "blockula") };
42
+ }
43
+ /**
44
+ * The node binary start_dev_node and summon_chain run when BLOCKULA_BIN isn't set (audit
45
+ * review-r4-supply-3). `found` lists the installed ones, `blockula release install`'s first (it was
46
+ * checked against the pinned key), then install_node's. The first whose `--version` is this MCP's
47
+ * wins; else the first that runs; else the first; with none, `blockula` from PATH. `matches` says
48
+ * whether it's this MCP's version, so a stale binary is never run without saying so.
49
+ */
50
+ export function pickNodeBinary(found, mcpVersion) {
51
+ const same = found.find((b) => b.version === `blockula ${mcpVersion}`);
52
+ if (same)
53
+ return { ...same, matches: true };
54
+ return { ...(found.find((b) => b.version !== undefined) ?? found[0] ?? { path: "blockula" }), matches: false };
55
+ }
56
+ /**
57
+ * The release key id pinned in `file` (`blockula release pin` / the first `release install`), or
58
+ * null if there's no pin. Same rules as release_cli.rs read_pin: one 64-hex key id (any case,
59
+ * surrounding whitespace ignored), never ignored when it's malformed, and refused when the group
60
+ * or others can write it (they could have swapped the key it vouches for).
61
+ */
62
+ export function readReleasePin(file) {
63
+ let text;
64
+ try {
65
+ text = readFileSync(file, "utf8");
66
+ }
67
+ catch (e) {
68
+ if (e.code === "ENOENT")
69
+ return null;
70
+ throw new Error(`reading ${file}: ${e.message}`);
71
+ }
72
+ if (process.platform !== "win32") {
73
+ const mode = statSync(file).mode;
74
+ if (mode & 0o022)
75
+ throw new Error(`${file} is writable by others (mode ${(mode & 0o777).toString(8)}), so it can't vouch for a release key: chmod 644 it once you've checked it holds your key id; not installing`);
76
+ }
77
+ const k = text.trim().toLowerCase();
78
+ if (!/^[0-9a-f]{64}$/.test(k))
79
+ throw new Error(`${file} isn't a release key id pin: fix it or remove it; not installing`);
80
+ return k;
81
+ }
82
+ /** A release tag. Only these are installed: `v*` tags are admin-only (deploy/github/ruleset-tags.json). */
83
+ export const RELEASE_TAG = /^v\d+\.\d+\.\d+$/;
84
+ /** The first line of a release's checksums.txt, before the tag (scripts/release-checksums.sh). */
85
+ export const TAG_LINE_PREFIX = "# blockula-release ";
86
+ /**
87
+ * The release key id to trust: the one pinned on this machine (`blockula release install`'s pin,
88
+ * `pinned`, read from `pinFile` by readReleasePin), BLOCKULA_RELEASE_KEY_ID (from an independent
89
+ * channel) or the one built in. Every one that's set must be the same key: a mismatch means one of
90
+ * them isn't genuine (or the pin moved by a rotation this MCP predates: update the MCP).
91
+ */
92
+ export function releaseKeyIdFrom(given, builtIn, pinned = null, pinFile = PIN_FILE) {
93
+ const norm = (k, what) => {
94
+ const v = (k ?? "").trim().toLowerCase();
95
+ if (v && !/^[0-9a-f]{64}$/.test(v))
96
+ throw new Error(`${what} must be 64 hex characters`);
97
+ return v;
98
+ };
99
+ const g = norm(given, "BLOCKULA_RELEASE_KEY_ID");
100
+ const b = norm(builtIn, "the built-in release key id");
101
+ const p = norm(pinned, `the release key id pinned in ${pinFile}`);
102
+ if (p && g && g !== p)
103
+ throw new Error(`the release key id pinned in ${pinFile} is ${p}, not BLOCKULA_RELEASE_KEY_ID (${g}); not installing. A new release key is accepted only through a rotation statement the pinned key signed (blockula release pin --key-id <new> --rotation …, docs/OPERATIONS.md "The release key").`);
104
+ if (p && b && b !== p)
105
+ throw new Error(`this MCP was built with release key ${b}, but ${pinFile} pins ${p}; not installing. One of them isn't genuine, or the key was rotated after this MCP was released: check the pin (blockula release pin) against SECURITY.md and your operators' channel, and update the MCP.`);
106
+ if (g && b && g !== b)
107
+ throw new Error(`this MCP trusts release key ${b}, which isn't BLOCKULA_RELEASE_KEY_ID (${g}); not installing. Check both against SECURITY.md and the website.`);
108
+ return p || g || b;
109
+ }
110
+ /** The tag GitHub's /releases/latest redirected to (the final URL, …/releases/tag/<tag>). */
111
+ export function latestTagFrom(finalUrl) {
112
+ const m = /\/releases\/tag\/([^/?#]+)$/.exec(finalUrl);
113
+ const tag = m ? decodeURIComponent(m[1]) : "";
114
+ if (!RELEASE_TAG.test(tag))
115
+ throw new Error(`the latest release (${finalUrl}) isn't a release tag (vX.Y.Z); pass a version`);
116
+ return tag;
117
+ }
118
+ /** Throws unless `blockula --version` printed release `tag`'s version. */
119
+ export function checkBinaryVersion(versionOutput, tag) {
120
+ const got = versionOutput.trim();
121
+ if (got !== `blockula ${tag.slice(1)}`)
122
+ throw new Error(`the downloaded binary is ${got || "unrunnable here"}, not ${tag.slice(1)}; not installing`);
123
+ }
124
+ /** Throws unless checksums.txt names release `tag` on its first line. */
125
+ export function checkTagLine(sums, tag) {
126
+ const first = sums.toString("utf8").split("\n", 1)[0];
127
+ if (first === TAG_LINE_PREFIX + tag)
128
+ return;
129
+ if (first.startsWith(TAG_LINE_PREFIX))
130
+ throw new Error(`this checksums.txt is for ${first.slice(TAG_LINE_PREFIX.length)}, not ${tag}: an older or other release served as the one asked for; not installing`);
131
+ throw new Error(`this checksums.txt names no release (releases before v0.5.0 don't), so it can't be bound to ${tag}; not installing`);
132
+ }
133
+ const sha256 = (b) => createHash("sha256").update(b).digest("hex");
134
+ /** Downloads `asset` from `base` (release `tag`'s download URL) and checks it as described above. Throws instead of returning anything unverified that must not be installed. The caller still checks the binary's `--version` (checkBinaryVersion). */
135
+ export async function fetchVerifiedRelease(base, asset, opts, deps) {
136
+ if (!RELEASE_TAG.test(opts.tag))
137
+ throw new Error(`a release is a vX.Y.Z tag, got ${opts.tag}`);
138
+ const [archive, sums] = await Promise.all([deps.download(`${base}/${asset}`), deps.download(`${base}/checksums.txt`)]);
139
+ if (!archive)
140
+ throw new Error(`${asset} isn't in this release`);
141
+ if (!sums)
142
+ throw new Error("this release has no checksums.txt; not installing");
143
+ // 0. The release checksums.txt names (signed with it, below).
144
+ checkTagLine(sums, opts.tag);
145
+ // 1. Checksum.
146
+ const expected = sums.toString("utf8").split("\n").map((l) => l.trim().split(/\s+/)).find(([, f]) => f === asset)?.[0];
147
+ const actual = sha256(archive);
148
+ if (!expected || expected !== actual)
149
+ throw new Error(`checksum mismatch for ${asset}; not installing`);
150
+ const verified = [];
151
+ // 2. Build provenance.
152
+ const att = await deps.attest(archive, asset, opts.tag);
153
+ if (att.result === "failed")
154
+ throw new Error(`build provenance check FAILED for ${asset} (not built by the release workflow from tag ${opts.tag}); not installing${att.detail ? ` (${att.detail.trim().split("\n")[0]})` : ""}. Releases up to v0.4.x predate provenance and have no attestation.`);
155
+ if (att.result === "ok")
156
+ verified.push(`build provenance (GitHub attestation, ${REPO} release workflow, tag ${opts.tag})`);
157
+ // 3. Release signature, against the pinned key id (never one taken from the download). Required
158
+ // once a key id is known.
159
+ if (opts.releaseKeyId) {
160
+ const sigFile = await deps.download(`${base}/checksums.txt.sig`);
161
+ if (!sigFile) {
162
+ throw new Error(`this release has no checksums.txt.sig, but releases are signed offline with the release key ${opts.releaseKeyId.slice(0, 16)}…; not installing. An unsigned release is what a compromised GitHub account or release workflow would publish.`);
163
+ }
164
+ let sig;
165
+ try {
166
+ sig = JSON.parse(sigFile.toString("utf8"));
167
+ }
168
+ catch {
169
+ throw new Error("checksums.txt.sig isn't valid JSON; not installing");
170
+ }
171
+ try {
172
+ verifyReleaseSignature(sig, new Uint8Array(sums), opts.releaseKeyId);
173
+ }
174
+ catch (e) {
175
+ throw new Error(`checksums.txt signature check FAILED: ${e.message}; not installing`);
176
+ }
177
+ verified.push(`release signature (ML-DSA-65 key ${opts.releaseKeyId.slice(0, 16)}…)`);
178
+ if (opts.requireVerify && att.result !== "ok") {
179
+ throw new Error("BLOCKULA_REQUIRE_VERIFY=1 requires build provenance as well as the release signature, and provenance couldn't be checked (log in to the GitHub CLI with `gh auth login`); not installing");
180
+ }
181
+ }
182
+ if (verified.length)
183
+ return { archive, sha256: actual, verified };
184
+ if (opts.requireVerify)
185
+ throw new Error("BLOCKULA_REQUIRE_VERIFY=1 and only the checksum could be verified (log in to the GitHub CLI with `gh auth login`, or set the release key id); not installing");
186
+ return {
187
+ archive,
188
+ sha256: actual,
189
+ verified,
190
+ warning: "ONLY THE CHECKSUM WAS VERIFIED. That proves the download wasn't corrupted, not that this release is genuine: anyone who can change the release can change checksums.txt too. " +
191
+ "To verify it, log in to the GitHub CLI (gh auth login) and install again, or check it by hand (docs/OPERATIONS.md \"Releases\"). Set BLOCKULA_REQUIRE_VERIFY=1 to refuse unverified installs. Don't run a validator on an unverified binary.",
192
+ };
193
+ }