@did-btcr2/cli 0.13.0 → 0.15.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (52) hide show
  1. package/README.md +95 -14
  2. package/dist/.tsbuildinfo +1 -1
  3. package/dist/cjs/index.js +674 -54
  4. package/dist/esm/src/cli.js +25 -3
  5. package/dist/esm/src/cli.js.map +1 -1
  6. package/dist/esm/src/commands/config.js +124 -8
  7. package/dist/esm/src/commands/config.js.map +1 -1
  8. package/dist/esm/src/commands/create.js +10 -1
  9. package/dist/esm/src/commands/create.js.map +1 -1
  10. package/dist/esm/src/commands/deactivate.js +33 -6
  11. package/dist/esm/src/commands/deactivate.js.map +1 -1
  12. package/dist/esm/src/commands/profile.js +5 -3
  13. package/dist/esm/src/commands/profile.js.map +1 -1
  14. package/dist/esm/src/commands/update.js +33 -6
  15. package/dist/esm/src/commands/update.js.map +1 -1
  16. package/dist/esm/src/config-schema.js +149 -0
  17. package/dist/esm/src/config-schema.js.map +1 -0
  18. package/dist/esm/src/config.js +515 -35
  19. package/dist/esm/src/config.js.map +1 -1
  20. package/dist/esm/src/keystore/paths.js +3 -2
  21. package/dist/esm/src/keystore/paths.js.map +1 -1
  22. package/dist/esm/src/output.js +49 -0
  23. package/dist/esm/src/output.js.map +1 -1
  24. package/dist/esm/src/types.js +11 -0
  25. package/dist/esm/src/types.js.map +1 -1
  26. package/dist/types/src/cli.d.ts.map +1 -1
  27. package/dist/types/src/commands/config.d.ts.map +1 -1
  28. package/dist/types/src/commands/create.d.ts.map +1 -1
  29. package/dist/types/src/commands/deactivate.d.ts.map +1 -1
  30. package/dist/types/src/commands/profile.d.ts.map +1 -1
  31. package/dist/types/src/commands/update.d.ts.map +1 -1
  32. package/dist/types/src/config-schema.d.ts +24 -0
  33. package/dist/types/src/config-schema.d.ts.map +1 -0
  34. package/dist/types/src/config.d.ts +228 -6
  35. package/dist/types/src/config.d.ts.map +1 -1
  36. package/dist/types/src/keystore/paths.d.ts.map +1 -1
  37. package/dist/types/src/output.d.ts +22 -0
  38. package/dist/types/src/output.d.ts.map +1 -1
  39. package/dist/types/src/types.d.ts +33 -0
  40. package/dist/types/src/types.d.ts.map +1 -1
  41. package/package.json +4 -4
  42. package/src/cli.ts +27 -3
  43. package/src/commands/config.ts +135 -8
  44. package/src/commands/create.ts +13 -1
  45. package/src/commands/deactivate.ts +53 -6
  46. package/src/commands/profile.ts +5 -3
  47. package/src/commands/update.ts +53 -6
  48. package/src/config-schema.ts +178 -0
  49. package/src/config.ts +693 -43
  50. package/src/keystore/paths.ts +3 -2
  51. package/src/output.ts +53 -0
  52. package/src/types.ts +23 -0
package/src/config.ts CHANGED
@@ -1,5 +1,7 @@
1
- import { createApi, Identifier, type BitcoinApiConfig, type DidBtcr2Api } from '@did-btcr2/api';
1
+ import { createApi, DEFAULT_BITCOIN_NETWORK_CONFIG, DEFAULT_CAS_GATEWAY, Identifier, type BitcoinApiConfig, type CasConfig, type DidBtcr2Api } from '@did-btcr2/api';
2
2
  import type { KeyManager } from '@did-btcr2/key-manager';
3
+ import { StaticFeeEstimator } from '@did-btcr2/method';
4
+ import type { BroadcastOptions } from '@did-btcr2/method';
3
5
  import { readFileSync } from 'node:fs';
4
6
  import { homedir } from 'node:os';
5
7
  import { dirname, join } from 'node:path';
@@ -8,7 +10,7 @@ import { ensureDir, writeFileAtomic } from './keystore/atomic.js';
8
10
  import { FileBackedKeyManager } from './keystore/file-backed-key-manager.js';
9
11
  import { defaultKeystorePath } from './keystore/paths.js';
10
12
  import { acquirePassphrase } from './keystore/passphrase.js';
11
- import { SUPPORTED_NETWORKS, type NetworkOption, type OutputFormat } from './types.js';
13
+ import { blankToUndef, SUPPORTED_NETWORKS, type NetworkOption, type OutputFormat } from './types.js';
12
14
 
13
15
  /**
14
16
  * Endpoint overrides provided via CLI flags, env vars, or config file.
@@ -23,13 +25,28 @@ export type ConnectionOverrides = {
23
25
  btcRpcUrl? : string;
24
26
  btcRpcUser? : string;
25
27
  btcRpcPass? : string;
28
+ /** IPFS HTTP gateway for CAS reads (read-only). */
26
29
  casGateway? : string;
30
+ /** IPFS HTTP RPC endpoint for a writable CAS (reads + writes). */
31
+ casRpcUrl? : string;
32
+ /** Bitcoin REST/RPC request timeout in milliseconds (raw flag/env string). */
33
+ btcTimeout? : string;
34
+ /** CAS request timeout in milliseconds (raw flag/env string; `0` disables). */
35
+ casTimeout? : string;
36
+ /** Extra Bitcoin REST headers as raw `Key: Value` flag values (repeatable). */
37
+ btcRestHeader? : string[];
38
+ /** Bitcoin Core RPC wallet name for wallet-scoped RPCs. */
39
+ btcRpcWallet? : string;
40
+ /** Extra Bitcoin Core RPC headers as raw `Key: Value` flag values (repeatable). */
41
+ btcRpcHeader? : string[];
27
42
  config? : string;
28
43
  profile? : string;
29
44
  /** Keystore file path. Overrides the default `$XDG_DATA_HOME/btcr2/keystore.json`. */
30
45
  keystore? : string;
31
46
  /** Path to a file holding the keystore passphrase (for unattended use). */
32
47
  passphraseFile? : string;
48
+ /** Signing key reference (URN, fingerprint prefix, or name) from `--signing-key`. */
49
+ signingKey? : string;
33
50
  };
34
51
 
35
52
  /**
@@ -49,7 +66,7 @@ export type ConnectionOverrides = {
49
66
  * },
50
67
  * "bitcoin": {
51
68
  * "btc": { "rest": "https://my-mempool/api" },
52
- * "cas": { "gateway": "https://ipfs.io" }
69
+ * "cas": { "gateway": "https://ipfs.io", "rpcUrl": "http://127.0.0.1:5001" }
53
70
  * }
54
71
  * }
55
72
  * }
@@ -65,14 +82,38 @@ export type ConfigFile = {
65
82
  output?: OutputFormat;
66
83
  };
67
84
  profiles?: Record<string, {
85
+ /**
86
+ * The Bitcoin network this profile's endpoints target. Declaring it lets a
87
+ * profile that is not named after a network (e.g. `production`) still fix
88
+ * the network its `create` runs encode, and lets the CLI warn when the
89
+ * network being encoded disagrees with the profile's endpoints.
90
+ */
91
+ network? : NetworkOption;
68
92
  btc?: {
69
- rest? : string;
70
- rpcUrl? : string;
71
- rpcUser? : string;
72
- rpcPass? : string;
93
+ rest? : string;
94
+ rpcUrl? : string;
95
+ rpcUser? : string;
96
+ rpcPass? : string;
97
+ /** Fee rate in sats/vByte for beacon transactions (update/deactivate). */
98
+ feeRate? : number;
99
+ /** Change address for beacon transactions (ADR 044 unlinkability opt-out). */
100
+ changeAddress? : string;
101
+ /** Request timeout in milliseconds for REST/RPC calls. No default (unbounded). */
102
+ timeoutMs? : number;
103
+ /** Extra headers sent on REST (Esplora) requests, e.g. an API key. */
104
+ headers? : Record<string, string>;
105
+ /** Bitcoin Core wallet name for wallet-scoped RPCs. */
106
+ wallet? : string;
107
+ /** Extra headers sent on Bitcoin Core RPC requests. */
108
+ rpcHeaders? : Record<string, string>;
73
109
  };
74
110
  cas?: {
111
+ /** IPFS HTTP gateway for CAS reads (read-only). */
75
112
  gateway?: string;
113
+ /** IPFS HTTP RPC endpoint for a writable CAS (reads + writes). */
114
+ rpcUrl?: string;
115
+ /** Request timeout in milliseconds for CAS operations. Default 30000; `0` disables. */
116
+ timeoutMs?: number;
76
117
  };
77
118
  /** Signing identity references. Never embeds key material; the secret lives in the keystore. */
78
119
  identity?: {
@@ -89,6 +130,11 @@ export const CONFIG_SCHEMA_VERSION = 1;
89
130
  * Read-modify-write a config file, preserving unknown keys. Reads the raw JSON
90
131
  * (so keys outside {@link ConfigFile} survive a rewrite), applies `mutate`,
91
132
  * stamps the schema version, and writes atomically (file 0600, dir 0700).
133
+ *
134
+ * A file that exists but cannot be parsed makes {@link readConfigFile} throw, so
135
+ * a write never starts from `{}` over a malformed-but-recoverable file and can
136
+ * never clobber the other profiles and defaults it still holds. A genuinely
137
+ * absent file (ENOENT) still starts from `{}`.
92
138
  */
93
139
  export function writeConfigFile(path: string, mutate: (raw: Record<string, unknown>) => void): void {
94
140
  const raw: Record<string, unknown> = (readConfigFile(path) as Record<string, unknown> | undefined) ?? {};
@@ -106,9 +152,20 @@ export function getConfigPath(config: Record<string, unknown>, path: string): un
106
152
  );
107
153
  }
108
154
 
155
+ /** Dotted-path segments that would let a write reach the prototype chain. */
156
+ const UNSAFE_KEYS = new Set([ '__proto__', 'constructor', 'prototype' ]);
157
+
158
+ /** Rejects a path segment that would let a `config set`/`unset` reach the prototype chain. */
159
+ function assertSafeKey(key: string, path: string): void {
160
+ if (UNSAFE_KEYS.has(key)) {
161
+ throw new CLIError(`Illegal config path segment "${key}" in "${path}".`, 'INVALID_ARGUMENT_ERROR', { path, key });
162
+ }
163
+ }
164
+
109
165
  /** Sets the value at a dotted path, creating intermediate objects. */
110
166
  export function setConfigPath(config: Record<string, unknown>, path: string, value: unknown): void {
111
167
  const keys = path.split('.');
168
+ keys.forEach(key => assertSafeKey(key, path));
112
169
  const last = keys.pop();
113
170
  if (!last) throw new CLIError('Config path must be non-empty.', 'INVALID_ARGUMENT_ERROR');
114
171
  let node = config;
@@ -122,6 +179,7 @@ export function setConfigPath(config: Record<string, unknown>, path: string, val
122
179
  /** Deletes the value at a dotted path. No-op if the path does not exist. */
123
180
  export function unsetConfigPath(config: Record<string, unknown>, path: string): void {
124
181
  const keys = path.split('.');
182
+ keys.forEach(key => assertSafeKey(key, path));
125
183
  const last = keys.pop();
126
184
  if (!last) return;
127
185
  let node: Record<string, unknown> | undefined = config;
@@ -153,6 +211,10 @@ export type ApiFactory = (network?: NetworkOption, overrides?: ConnectionOverrid
153
211
  * | `BTCR2_BTC_RPC_USER` | `--btc-rpc-user` |
154
212
  * | `BTCR2_BTC_RPC_PASS` | `--btc-rpc-pass` |
155
213
  * | `BTCR2_CAS_GATEWAY` | `--cas-gateway` |
214
+ * | `BTCR2_CAS_RPC_URL` | `--cas-rpc-url` |
215
+ * | `BTCR2_BTC_TIMEOUT` | `--btc-timeout` |
216
+ * | `BTCR2_CAS_TIMEOUT` | `--cas-timeout` |
217
+ * | `BTCR2_FEE_RATE` | `--fee-rate` |
156
218
  */
157
219
  export const ENV_VARS = {
158
220
  BTC_REST : 'BTCR2_BTC_REST',
@@ -160,6 +222,10 @@ export const ENV_VARS = {
160
222
  BTC_RPC_USER : 'BTCR2_BTC_RPC_USER',
161
223
  BTC_RPC_PASS : 'BTCR2_BTC_RPC_PASS',
162
224
  CAS_GATEWAY : 'BTCR2_CAS_GATEWAY',
225
+ CAS_RPC_URL : 'BTCR2_CAS_RPC_URL',
226
+ BTC_TIMEOUT : 'BTCR2_BTC_TIMEOUT',
227
+ CAS_TIMEOUT : 'BTCR2_CAS_TIMEOUT',
228
+ FEE_RATE : 'BTCR2_FEE_RATE',
163
229
  } as const;
164
230
 
165
231
  /**
@@ -174,6 +240,7 @@ export function readEnvOverrides(): ConnectionOverrides {
174
240
  btcRpcUser : env(ENV_VARS.BTC_RPC_USER),
175
241
  btcRpcPass : env(ENV_VARS.BTC_RPC_PASS),
176
242
  casGateway : env(ENV_VARS.CAS_GATEWAY),
243
+ casRpcUrl : env(ENV_VARS.CAS_RPC_URL),
177
244
  };
178
245
  }
179
246
 
@@ -186,22 +253,75 @@ export function readEnvOverrides(): ConnectionOverrides {
186
253
  * 3. `~/.config/btcr2/config.json` (fallback)
187
254
  */
188
255
  export function defaultConfigPath(): string {
189
- const base = process.env.XDG_CONFIG_HOME
190
- ?? process.env.APPDATA
256
+ const base = blankToUndef(process.env.XDG_CONFIG_HOME)
257
+ ?? blankToUndef(process.env.APPDATA)
191
258
  ?? join(homedir(), '.config');
192
259
  return join(base, 'btcr2', 'config.json');
193
260
  }
194
261
 
195
262
  /**
196
- * Reads and parses a config file. Returns `undefined` if the file does
197
- * not exist or cannot be parsed.
263
+ * Reads and JSON-parses a config file without applying the schema-version
264
+ * ceiling check. Returns `undefined` only for a genuinely absent file (ENOENT).
265
+ * Any other read failure, and any JSON parse failure, throws a {@link CLIError}
266
+ * that names the file. Used by `config validate`, which reports a newer-than-
267
+ * supported `schemaVersion` as a finding rather than aborting on it.
198
268
  */
199
- export function readConfigFile(path: string): ConfigFile | undefined {
269
+ export function parseConfigFileRaw(path: string): Record<string, unknown> | undefined {
270
+ let content: string;
200
271
  try {
201
- const content = readFileSync(path, 'utf-8');
202
- return JSON.parse(content) as ConfigFile;
203
- } catch {
204
- return undefined;
272
+ content = readFileSync(path, 'utf-8');
273
+ } catch (error: unknown) {
274
+ if ((error as { code?: string }).code === 'ENOENT') return undefined;
275
+ throw new CLIError(
276
+ `Failed to read config file at ${path}: ${(error as Error).message}`,
277
+ 'CONFIG_READ_ERROR',
278
+ { path },
279
+ );
280
+ }
281
+ try {
282
+ return JSON.parse(content) as Record<string, unknown>;
283
+ } catch (error: unknown) {
284
+ throw new CLIError(
285
+ `Config file at ${path} is not valid JSON: ${(error as Error).message}. `
286
+ + 'Fix the file by hand; the CLI will not overwrite it while it is unparseable.',
287
+ 'CONFIG_PARSE_ERROR',
288
+ { path },
289
+ );
290
+ }
291
+ }
292
+
293
+ /**
294
+ * Reads and parses a config file. Returns `undefined` only when the file is
295
+ * genuinely absent (ENOENT), so callers can safely treat "no file" as "use
296
+ * defaults". Any other read failure, and any JSON parse failure, throws a
297
+ * {@link CLIError} that names the file, rather than silently degrading to the
298
+ * public network defaults. A file written by a newer CLI (higher `schemaVersion`)
299
+ * is also refused.
300
+ */
301
+ export function readConfigFile(path: string): ConfigFile | undefined {
302
+ const parsed = parseConfigFileRaw(path);
303
+ if (parsed === undefined) return undefined;
304
+ migrateConfigShape(parsed, path);
305
+ return parsed as ConfigFile;
306
+ }
307
+
308
+ /**
309
+ * Validates a parsed config's `schemaVersion` against {@link CONFIG_SCHEMA_VERSION}
310
+ * and brings an older shape up to the current version in place. A file written by
311
+ * a newer CLI is refused so today's assumptions are never applied blindly to an
312
+ * unknown shape. An absent version is treated as the earliest and migrated
313
+ * forward. No structural migrations are registered yet (the schema is at version
314
+ * 1); future versions register their transforms here in ascending order.
315
+ */
316
+ function migrateConfigShape(raw: Record<string, unknown>, path: string): void {
317
+ const version = typeof raw.schemaVersion === 'number' ? raw.schemaVersion : 0;
318
+ if (version > CONFIG_SCHEMA_VERSION) {
319
+ throw new CLIError(
320
+ `Config file at ${path} has schemaVersion ${version}, but this CLI supports up to `
321
+ + `${CONFIG_SCHEMA_VERSION}. Upgrade the btcr2 CLI to read it.`,
322
+ 'CONFIG_SCHEMA_VERSION_ERROR',
323
+ { path, fileVersion: version, supported: CONFIG_SCHEMA_VERSION },
324
+ );
205
325
  }
206
326
  }
207
327
 
@@ -221,14 +341,40 @@ export function profileToOverrides(
221
341
  btcRpcUser : profile.btc?.rpcUser,
222
342
  btcRpcPass : profile.btc?.rpcPass,
223
343
  casGateway : profile.cas?.gateway,
344
+ casRpcUrl : profile.cas?.rpcUrl,
224
345
  };
225
346
  }
226
347
 
348
+ /**
349
+ * Resolves the active profile name and the network it targets, shared by
350
+ * {@link resolveDefaultNetwork} and {@link resolveConnectionConfig} so the two
351
+ * can never disagree about which profile is active or which network it means.
352
+ *
353
+ * The active profile name is the explicit `--profile` flag, else the config
354
+ * file's `defaults.profile`. The network is the profile's own `network` field
355
+ * when set to a supported value, else the profile name itself when it is a
356
+ * network name (the historical convention). A profile that declares no network
357
+ * and is not named after one yields `network: undefined`.
358
+ */
359
+ export function resolveActiveProfile(
360
+ file : ConfigFile | undefined,
361
+ overrides?: ConnectionOverrides,
362
+ ): { name: string | undefined; network: NetworkOption | undefined } {
363
+ const name = blankToUndef(overrides?.profile) ?? file?.defaults?.profile;
364
+ if (!name) return { name: undefined, network: undefined };
365
+
366
+ const declared = file?.profiles?.[name]?.network;
367
+ const network = (declared && SUPPORTED_NETWORKS.includes(declared))
368
+ ? declared
369
+ : (SUPPORTED_NETWORKS.includes(name as NetworkOption) ? name as NetworkOption : undefined);
370
+ return { name, network };
371
+ }
372
+
227
373
  /**
228
374
  * Resolves the default Bitcoin network for offline identifier creation when no
229
375
  * `--network` flag is given. Resolution order: the config file's
230
- * `defaults.network`, then an active profile named for a network (an explicit
231
- * `--profile` flag or `defaults.profile`), then `regtest` as the development
376
+ * `defaults.network`, then the active profile's network (its explicit `network`
377
+ * field, else its network-derived name), then `regtest` as the development
232
378
  * fallback. Generation itself is offline; this only fixes which network the
233
379
  * identifier encodes.
234
380
  */
@@ -239,14 +385,102 @@ export function resolveDefaultNetwork(overrides?: ConnectionOverrides): NetworkO
239
385
  const explicit = file?.defaults?.network;
240
386
  if (explicit && SUPPORTED_NETWORKS.includes(explicit)) return explicit;
241
387
 
242
- const profile = overrides?.profile ?? file?.defaults?.profile;
243
- if (profile && SUPPORTED_NETWORKS.includes(profile as NetworkOption)) {
244
- return profile as NetworkOption;
245
- }
388
+ const { network } = resolveActiveProfile(file, overrides);
389
+ if (network) return network;
246
390
 
247
391
  return 'regtest';
248
392
  }
249
393
 
394
+ /**
395
+ * Reports a coherence conflict between the network a `create` run is about to
396
+ * encode and the network the active profile declares, so the CLI can warn
397
+ * instead of silently minting an identifier on one network while wiring
398
+ * endpoints for another. Returns `undefined` when the active profile declares
399
+ * no network or agrees with the one being encoded.
400
+ */
401
+ export function profileNetworkMismatch(
402
+ network : NetworkOption,
403
+ overrides?: ConnectionOverrides,
404
+ ): { profile: string; declared: NetworkOption } | undefined {
405
+ // This drives only a warning, so a malformed config must not break an otherwise
406
+ // offline `create`; a genuinely broken config is still surfaced loudly by any
407
+ // command that actually resolves a connection.
408
+ let file: ConfigFile | undefined;
409
+ try {
410
+ file = readConfigFile(overrides?.config ?? defaultConfigPath());
411
+ } catch {
412
+ return undefined;
413
+ }
414
+ const { name, network: declared } = resolveActiveProfile(file, overrides);
415
+ if (name && declared && declared !== network) return { profile: name, declared };
416
+ return undefined;
417
+ }
418
+
419
+ /**
420
+ * Resolves the effective output format: the `-o/--output` flag, then the
421
+ * `BTCR2_OUTPUT` environment variable, then the config file's `defaults.output`,
422
+ * then `'text'`. A malformed config never blocks output resolution (the command's
423
+ * own read path surfaces it); output format falls back to `'text'` instead.
424
+ */
425
+ export function resolveOutputFormat(options: { output?: string; config?: string }): OutputFormat {
426
+ const candidates: Array<string | undefined> = [
427
+ blankToUndef(options.output),
428
+ process.env.BTCR2_OUTPUT || undefined,
429
+ ];
430
+ try {
431
+ candidates.push(readConfigFile(options.config ?? defaultConfigPath())?.defaults?.output);
432
+ } catch {
433
+ // Output format is cosmetic; a broken config is reported by the command
434
+ // itself rather than aborting here (which would block a recovery command).
435
+ }
436
+ for (const candidate of candidates) {
437
+ if (candidate === 'json' || candidate === 'text') return candidate;
438
+ }
439
+ return 'text';
440
+ }
441
+
442
+ /**
443
+ * The resolved RPC credential unit: url, user, and pass drawn from a single
444
+ * precedence layer, tagged with that layer's provenance. `pass` is kept raw so a
445
+ * secret-ref (`env:`/`file:`) can be resolved by the caller.
446
+ */
447
+ interface RpcUnit {
448
+ src : Provenance;
449
+ url? : string;
450
+ user? : string;
451
+ pass? : string;
452
+ }
453
+
454
+ /**
455
+ * Resolves the RPC credential unit atomically: the highest-precedence layer that
456
+ * supplies a url, else the highest that supplies a username or password. url,
457
+ * user, and pass therefore always come from one layer, so a host from one layer
458
+ * is never handed another layer's credentials (ADR 074). When no layer supplies a
459
+ * url, the credentials still resolve (so they reach the SDK's per-network default
460
+ * host, e.g. regtest's, without the url being restated). Returns `undefined` when
461
+ * no layer supplies a url or a credential.
462
+ */
463
+ function resolveRpcUnit(
464
+ overrides? : ConnectionOverrides,
465
+ env? : ConnectionOverrides,
466
+ fileOverrides?: ConnectionOverrides,
467
+ ): RpcUnit | undefined {
468
+ const layers: Array<{ src: Provenance; url?: string; user?: string; pass?: string }> = [
469
+ { src: 'flag', url: overrides?.btcRpcUrl, user: overrides?.btcRpcUser, pass: overrides?.btcRpcPass },
470
+ { src: 'env', url: env?.btcRpcUrl, user: env?.btcRpcUser, pass: env?.btcRpcPass },
471
+ { src: 'file', url: fileOverrides?.btcRpcUrl, user: fileOverrides?.btcRpcUser, pass: fileOverrides?.btcRpcPass },
472
+ ];
473
+ const withUrl = layers.find(l => blankToUndef(l.url) !== undefined);
474
+ if (withUrl) {
475
+ return { src: withUrl.src, url: blankToUndef(withUrl.url), user: blankToUndef(withUrl.user), pass: blankToUndef(withUrl.pass) };
476
+ }
477
+ const withCreds = layers.find(l => blankToUndef(l.user) !== undefined || blankToUndef(l.pass) !== undefined);
478
+ if (withCreds) {
479
+ return { src: withCreds.src, url: undefined, user: blankToUndef(withCreds.user), pass: blankToUndef(withCreds.pass) };
480
+ }
481
+ return undefined;
482
+ }
483
+
250
484
  /**
251
485
  * Resolves the Bitcoin and CAS connection config for a network by merging,
252
486
  * in precedence order, CLI flags, environment variables, and the config-file
@@ -258,47 +492,433 @@ export function resolveDefaultNetwork(overrides?: ConnectionOverrides): NetworkO
258
492
  * When no `--profile` is given, the network name is used as the profile key
259
493
  * (e.g. a regtest DID auto-selects the `"regtest"` profile).
260
494
  */
261
- function resolveConnectionConfig(
495
+ export function resolveConnectionConfig(
262
496
  network? : NetworkOption,
263
497
  overrides?: ConnectionOverrides,
264
- ): { btc?: BitcoinApiConfig; cas?: { gateway: string } } {
498
+ ): { btc?: BitcoinApiConfig; cas?: CasConfig } {
265
499
  if (!network) return {};
266
500
 
267
- // Layer 1: Config file profile (lowest precedence of the three override layers)
501
+ // Layer 1: Config file profile (lowest precedence of the three override layers).
502
+ // The active-profile name is resolved through the same shared helper as
503
+ // resolveDefaultNetwork so the two cannot disagree about which profile is live.
268
504
  const configPath = overrides?.config ?? defaultConfigPath();
269
505
  const file = readConfigFile(configPath);
270
- const profileName = overrides?.profile ?? file?.defaults?.profile ?? network;
506
+ const { name: activeProfile } = resolveActiveProfile(file, overrides);
507
+ const profileName = activeProfile ?? network;
271
508
  const fileOverrides = file ? profileToOverrides(file, profileName) : {};
272
509
 
273
510
  // Layer 2: Environment variables
274
511
  const env = readEnvOverrides();
275
512
 
276
- // Merge: CLI flags -> env vars -> config file -> (network defaults handled by BitcoinConnection)
277
- const merged: ConnectionOverrides = {
278
- btcRest : overrides?.btcRest ?? env.btcRest ?? fileOverrides.btcRest,
279
- btcRpcUrl : overrides?.btcRpcUrl ?? env.btcRpcUrl ?? fileOverrides.btcRpcUrl,
280
- btcRpcUser : overrides?.btcRpcUser ?? env.btcRpcUser ?? fileOverrides.btcRpcUser,
281
- btcRpcPass : overrides?.btcRpcPass ?? env.btcRpcPass ?? fileOverrides.btcRpcPass,
282
- casGateway : overrides?.casGateway ?? env.casGateway ?? fileOverrides.casGateway,
283
- };
513
+ // Blank-aware precedence merge: CLI flag -> env var -> config file. A blank at
514
+ // any layer defers to the next instead of masking it (mirrors the env layer's
515
+ // `|| undefined`), so an empty flag or profile field no longer silently reverts
516
+ // resolution to the SDK network default.
517
+ const pick = (flag?: string, envVal?: string, fileVal?: string): string | undefined =>
518
+ blankToUndef(flag) ?? blankToUndef(envVal) ?? blankToUndef(fileVal);
519
+
520
+ const profileBtc = file?.profiles?.[profileName]?.btc;
521
+ const profileCas = file?.profiles?.[profileName]?.cas;
284
522
 
285
523
  const btc: BitcoinApiConfig = { network };
286
524
 
287
- if (merged.btcRest) {
288
- btc.rest = { host: merged.btcRest };
525
+ // REST host and headers. Headers apply even without a host override, layering
526
+ // onto the per-network default host inside the api's config merge, so an
527
+ // authenticated Esplora/mempool endpoint can be reached with the default host.
528
+ const btcRest = pick(overrides?.btcRest, env.btcRest, fileOverrides.btcRest);
529
+ const restHeaders = mergeHeaders(profileBtc?.headers, parseHeaderList(overrides?.btcRestHeader, '--btc-rest-header'));
530
+ if (btcRest || restHeaders) {
531
+ btc.rest = { ...(btcRest ? { host: btcRest } : {}), ...(restHeaders ? { headers: restHeaders } : {}) };
289
532
  }
290
533
 
291
- if (merged.btcRpcUrl) {
534
+ // Resolve the RPC endpoint as one atomic credential unit (url + user + pass from
535
+ // one layer), plus the orthogonal wallet and header augmentations.
536
+ const rpcUnit = resolveRpcUnit(overrides, env, fileOverrides);
537
+ const rpcWallet = pick(overrides?.btcRpcWallet, undefined, profileBtc?.wallet);
538
+ const rpcHeaders = mergeHeaders(profileBtc?.rpcHeaders, parseHeaderList(overrides?.btcRpcHeader, '--btc-rpc-header'));
539
+
540
+ // Build an RPC config only when a host will actually exist to talk to: either
541
+ // the unit supplies a url, or the network has a default RPC host (regtest).
542
+ // Wallet/header (or credential) knobs alone with no host would otherwise point
543
+ // a phantom client at the default 127.0.0.1:8332 and, on public networks, flip
544
+ // the connection to "has RPC" spuriously (and could leak a header credential
545
+ // meant for a remote proxy). The pass-file fallback is read lazily inside this
546
+ // block, so a set-but-unreadable BTCR2_BTC_RPC_PASS_FILE never aborts a command
547
+ // that uses no RPC at all.
548
+ const networkHasDefaultRpc = DEFAULT_BITCOIN_NETWORK_CONFIG[network].rpc !== undefined;
549
+ const wantsRpc = rpcUnit !== undefined || rpcWallet !== undefined || rpcHeaders !== undefined;
550
+ const hasRpcHost = rpcUnit?.url !== undefined || networkHasDefaultRpc;
551
+ if (wantsRpc && hasRpcHost) {
552
+ const password = resolveSecretRef(rpcUnit?.pass) ?? readRpcPassFile();
292
553
  btc.rpc = {
293
- host : merged.btcRpcUrl,
294
- username : merged.btcRpcUser,
295
- password : merged.btcRpcPass,
554
+ ...(rpcUnit?.url !== undefined ? { host: rpcUnit.url } : {}),
555
+ ...(rpcUnit?.user !== undefined ? { username: rpcUnit.user } : {}),
556
+ ...(password !== undefined ? { password } : {}),
557
+ ...(rpcWallet ? { wallet: rpcWallet } : {}),
558
+ ...(rpcHeaders ? { headers: rpcHeaders } : {}),
296
559
  };
297
560
  }
298
561
 
299
- const cas = merged.casGateway ? { gateway: merged.casGateway } : undefined;
562
+ // Bitcoin request timeout. No default: honored only when explicitly set, so
563
+ // callers that rely on unbounded waits are unaffected (ADR 076).
564
+ const btcTimeout = resolveTimeout(overrides?.btcTimeout, process.env[ENV_VARS.BTC_TIMEOUT], profileBtc?.timeoutMs, '--btc-timeout', 1);
565
+ if (btcTimeout !== undefined) btc.timeoutMs = btcTimeout;
566
+
567
+ // A configured RPC endpoint is writable and takes precedence over the
568
+ // read-only gateway (matching the api's CasConfig priority: rpcUrl > gateway).
569
+ // Both may be set; the api selects one executor from them.
570
+ const casGateway = pick(overrides?.casGateway, env.casGateway, fileOverrides.casGateway);
571
+ const casRpcUrl = pick(overrides?.casRpcUrl, env.casRpcUrl, fileOverrides.casRpcUrl);
572
+ const casTimeout = resolveTimeout(overrides?.casTimeout, process.env[ENV_VARS.CAS_TIMEOUT], profileCas?.timeoutMs, '--cas-timeout');
573
+ const cas: CasConfig = {};
574
+ if (casGateway) cas.gateway = casGateway;
575
+ if (casRpcUrl) cas.rpcUrl = casRpcUrl;
576
+ if (casTimeout !== undefined) {
577
+ cas.timeoutMs = casTimeout;
578
+ // A timeout needs an endpoint to attach to. When none is configured, fall
579
+ // back to the same default gateway the api would otherwise apply, so the
580
+ // timeout is honored rather than dropped (the api only defaults the gateway
581
+ // when the whole cas config is absent).
582
+ if (!casGateway && !casRpcUrl) cas.gateway = DEFAULT_CAS_GATEWAY;
583
+ }
584
+ const hasCas = casGateway || casRpcUrl || casTimeout !== undefined;
300
585
 
301
- return { btc, ...(cas && { cas }) };
586
+ return { btc, ...(hasCas && { cas }) };
587
+ }
588
+
589
+ /**
590
+ * Resolves a millisecond timeout from a flag string, an env string, then a
591
+ * config-file number, in precedence order. Returns `undefined` when none is set
592
+ * (preserving unbounded behavior). Throws a {@link CLIError} for a value below
593
+ * `min` or not a finite number. `min` is `0` for CAS (where `0` disables the
594
+ * timeout) and `1` for the Bitcoin timeout (where `0` would abort every request
595
+ * immediately, which is never intended).
596
+ */
597
+ function resolveTimeout(flag?: string, envVal?: string, fileVal?: number, flagName = 'timeout', min = 0): number | undefined {
598
+ const raw = blankToUndef(flag) ?? blankToUndef(envVal) ?? (typeof fileVal === 'number' ? String(fileVal) : undefined);
599
+ if (raw === undefined) return undefined;
600
+ const ms = Number(raw);
601
+ if (!Number.isFinite(ms) || ms < min) {
602
+ throw new CLIError(
603
+ `Invalid ${flagName} value "${raw}": expected a number of milliseconds >= ${min}.`,
604
+ 'INVALID_ARGUMENT_ERROR',
605
+ { value: raw },
606
+ );
607
+ }
608
+ return ms;
609
+ }
610
+
611
+ /**
612
+ * Parses repeatable `Key: Value` header flag values into a header map. Returns
613
+ * `undefined` for an empty list. Throws a {@link CLIError} for an entry missing a
614
+ * colon or with an empty key.
615
+ */
616
+ export function parseHeaderList(list?: string[], flagName = '--header'): Record<string, string> | undefined {
617
+ if (!list || list.length === 0) return undefined;
618
+ const headers: Record<string, string> = {};
619
+ for (const entry of list) {
620
+ const idx = entry.indexOf(':');
621
+ const key = idx === -1 ? '' : entry.slice(0, idx).trim();
622
+ if (idx === -1 || key === '') {
623
+ throw new CLIError(
624
+ `Invalid ${flagName} "${entry}": expected "Key: Value".`,
625
+ 'INVALID_ARGUMENT_ERROR',
626
+ { header: entry },
627
+ );
628
+ }
629
+ headers[key] = entry.slice(idx + 1).trim();
630
+ }
631
+ return headers;
632
+ }
633
+
634
+ /**
635
+ * Merges a base header map (from the config-file profile) with an override map
636
+ * (from flags), with the override winning per key. Returns `undefined` when both
637
+ * are absent so callers can skip attaching an empty header map.
638
+ */
639
+ function mergeHeaders(
640
+ base? : Record<string, string>,
641
+ override? : Record<string, string>,
642
+ ): Record<string, string> | undefined {
643
+ if (!base && !override) return undefined;
644
+ return { ...base, ...override };
645
+ }
646
+
647
+ /** Environment variable naming a file whose contents are the Bitcoin Core RPC password. */
648
+ export const ENV_RPC_PASS_FILE = 'BTCR2_BTC_RPC_PASS_FILE';
649
+
650
+ /** Removes at most one trailing newline, matching the keystore-passphrase normalization. */
651
+ function trimTrailingNewline(value: string): string {
652
+ return value.replace(/\r?\n$/, '');
653
+ }
654
+
655
+ /** Reads a secret file, throwing a {@link CLIError} (not a raw Node error) that names the path and source. */
656
+ function readSecretFile(path: string, source: string): string {
657
+ try {
658
+ return trimTrailingNewline(readFileSync(path, 'utf-8'));
659
+ } catch (error: unknown) {
660
+ throw new CLIError(
661
+ `Could not read the RPC password ${source} at ${path}: ${(error as Error).message}`,
662
+ 'CONFIG_READ_ERROR',
663
+ { path },
664
+ );
665
+ }
666
+ }
667
+
668
+ /**
669
+ * Resolves an RPC-password secret reference to its literal value: `env:<VAR>`
670
+ * reads the named environment variable, `file:<path>` reads the file, and any
671
+ * other value is returned as-is. A trailing newline is trimmed from file/env
672
+ * sources so a secret written by `echo` matches an inline value (ADR 077).
673
+ */
674
+ export function resolveSecretRef(value?: string): string | undefined {
675
+ if (value === undefined) return undefined;
676
+ if (value.startsWith('env:')) {
677
+ const fromEnv = process.env[value.slice(4)];
678
+ return fromEnv === undefined ? undefined : trimTrailingNewline(fromEnv);
679
+ }
680
+ if (value.startsWith('file:')) {
681
+ return readSecretFile(value.slice(5), 'file reference');
682
+ }
683
+ return value;
684
+ }
685
+
686
+ /** Reads the RPC password from an {@link ENV_RPC_PASS_FILE}-named file, if set. */
687
+ function readRpcPassFile(): string | undefined {
688
+ const path = process.env[ENV_RPC_PASS_FILE];
689
+ if (!path) return undefined;
690
+ return readSecretFile(path, `file named by ${ENV_RPC_PASS_FILE}`);
691
+ }
692
+
693
+ /**
694
+ * Resolves the beacon {@link BroadcastOptions} for an update/deactivate from the
695
+ * fee-rate and change-address knobs, following the CLI precedence chain.
696
+ *
697
+ * - Fee rate: `--fee-rate` flag, then `BTCR2_FEE_RATE`, then profile
698
+ * `btc.feeRate`. A positive sats/vByte value wrapped in a `StaticFeeEstimator`.
699
+ * - Change address: `--change-address` flag, then profile `btc.changeAddress`
700
+ * (no env, since a change address is DID/network-specific). Validated against
701
+ * the DID network by the beacon at broadcast time.
702
+ *
703
+ * Returns `undefined` when neither is set, so the SDK defaults (5 sat/vB, change
704
+ * back to the beacon address) still apply.
705
+ */
706
+ export function resolveBroadcastOptions(
707
+ network : NetworkOption,
708
+ overrides: ConnectionOverrides | undefined,
709
+ flags : { feeRate?: string; changeAddress?: string },
710
+ ): BroadcastOptions | undefined {
711
+ const file = readConfigFile(overrides?.config ?? defaultConfigPath());
712
+ const { name: activeProfile } = resolveActiveProfile(file, overrides);
713
+ const profileBtc = file?.profiles?.[activeProfile ?? network]?.btc;
714
+
715
+ const options: BroadcastOptions = {};
716
+
717
+ const feeRateRaw = blankToUndef(flags.feeRate)
718
+ ?? blankToUndef(process.env[ENV_VARS.FEE_RATE])
719
+ ?? (typeof profileBtc?.feeRate === 'number' ? String(profileBtc.feeRate) : undefined);
720
+ if (feeRateRaw !== undefined) options.feeEstimator = new StaticFeeEstimator(parseFeeRate(feeRateRaw));
721
+
722
+ const changeAddress = blankToUndef(flags.changeAddress) ?? blankToUndef(profileBtc?.changeAddress);
723
+ if (changeAddress) options.changeAddress = changeAddress;
724
+
725
+ return options.feeEstimator || options.changeAddress ? options : undefined;
726
+ }
727
+
728
+ /** Parses a positive sats/vByte fee rate, throwing a {@link CLIError} otherwise. */
729
+ function parseFeeRate(raw: string): number {
730
+ const rate = Number(raw);
731
+ if (!Number.isFinite(rate) || rate <= 0) {
732
+ throw new CLIError(
733
+ `Invalid --fee-rate "${raw}": expected a positive number of sats per vByte.`,
734
+ 'INVALID_ARGUMENT_ERROR',
735
+ { value: raw },
736
+ );
737
+ }
738
+ return rate;
739
+ }
740
+
741
+ /** Which precedence layer a resolved value came from. */
742
+ export type Provenance = 'flag' | 'env' | 'file' | 'default';
743
+
744
+ /** A resolved value paired with the layer it came from. */
745
+ export interface EffectiveEntry {
746
+ value : string | number | undefined;
747
+ source : Provenance;
748
+ }
749
+
750
+ /**
751
+ * The resolved connection config with per-value provenance, the shape behind
752
+ * `config effective`. Values are read through the real resolver (the constructed
753
+ * api and {@link resolveConnectionConfig}) so they cannot drift from what a live
754
+ * command would use; the `source` tag names the layer the merge selected.
755
+ */
756
+ export interface EffectiveConfig {
757
+ network : NetworkOption;
758
+ profile : string | undefined;
759
+ btc : {
760
+ rest : EffectiveEntry;
761
+ rpcUrl : EffectiveEntry;
762
+ rpcUser : EffectiveEntry;
763
+ rpcPass : EffectiveEntry;
764
+ rpcWallet : EffectiveEntry;
765
+ timeoutMs : EffectiveEntry;
766
+ };
767
+ cas : {
768
+ gateway : EffectiveEntry;
769
+ rpcUrl : EffectiveEntry;
770
+ timeoutMs : EffectiveEntry;
771
+ };
772
+ }
773
+
774
+ /**
775
+ * Resolves the effective connection config with provenance for `config effective`.
776
+ * The btc values are read back from the constructed api (so SDK network defaults
777
+ * are reflected), the cas and timeout values from {@link resolveConnectionConfig},
778
+ * and each `source` is derived by the same precedence order the merge uses.
779
+ */
780
+ export function resolveEffectiveConfig(network: NetworkOption, overrides?: ConnectionOverrides): EffectiveConfig {
781
+ const file = readConfigFile(overrides?.config ?? defaultConfigPath());
782
+ const { name: activeProfile } = resolveActiveProfile(file, overrides);
783
+ const profileName = activeProfile ?? network;
784
+ const fileOv = file ? profileToOverrides(file, profileName) : {};
785
+ const profileBtc = file?.profiles?.[profileName]?.btc;
786
+ const profileCas = file?.profiles?.[profileName]?.cas;
787
+ const env = readEnvOverrides();
788
+
789
+ const api = defaultApiFactory(network, overrides);
790
+ const restCfg = api.btc.connection.rest.config;
791
+ const rpcCfg = api.btc.connection.rpc?.config;
792
+ const conn = resolveConnectionConfig(network, overrides);
793
+
794
+ const src = (flag?: string, envVal?: string, fileVal?: unknown): Provenance =>
795
+ blankToUndef(flag) !== undefined ? 'flag'
796
+ : blankToUndef(envVal) !== undefined ? 'env'
797
+ : (fileVal !== undefined && fileVal !== null && String(fileVal).trim() !== '') ? 'file'
798
+ : 'default';
799
+
800
+ // CAS has no client to read back from; derive its resolved endpoint from the
801
+ // connection resolver, defaulting the gateway the way the api would.
802
+ const casGatewayVal = conn.cas?.gateway ?? (conn.cas?.rpcUrl ? undefined : DEFAULT_CAS_GATEWAY);
803
+
804
+ // RPC url/user/pass provenance follows the atomic-credential unit, so a value's
805
+ // reported source is the layer the merge actually bound it to (never an
806
+ // independent per-field guess that could disagree with the resolver). A password
807
+ // taken from BTCR2_BTC_RPC_PASS_FILE when the unit supplied none is env-sourced.
808
+ const rpcUnit = resolveRpcUnit(overrides, env, fileOv);
809
+ const rpcSrc = rpcUnit?.src ?? 'default';
810
+ const passFromFile = rpcUnit?.pass === undefined
811
+ && process.env[ENV_RPC_PASS_FILE] !== undefined
812
+ && rpcCfg?.password !== undefined;
813
+
814
+ return {
815
+ network,
816
+ profile : activeProfile,
817
+ btc : {
818
+ rest : { value: restCfg.host, source: src(overrides?.btcRest, env.btcRest, fileOv.btcRest) },
819
+ rpcUrl : { value: rpcCfg?.host, source: rpcUnit?.url !== undefined ? rpcSrc : 'default' },
820
+ rpcUser : { value: rpcCfg?.username, source: rpcUnit?.user !== undefined ? rpcSrc : 'default' },
821
+ rpcPass : { value: rpcCfg?.password, source: rpcUnit?.pass !== undefined ? rpcSrc : (passFromFile ? 'env' : 'default') },
822
+ rpcWallet : { value: rpcCfg?.wallet, source: src(overrides?.btcRpcWallet, undefined, profileBtc?.wallet) },
823
+ timeoutMs : { value: conn.btc?.timeoutMs, source: src(overrides?.btcTimeout, process.env[ENV_VARS.BTC_TIMEOUT], profileBtc?.timeoutMs) },
824
+ },
825
+ cas : {
826
+ gateway : { value: casGatewayVal, source: src(overrides?.casGateway, env.casGateway, fileOv.casGateway) },
827
+ rpcUrl : { value: conn.cas?.rpcUrl, source: src(overrides?.casRpcUrl, env.casRpcUrl, fileOv.casRpcUrl) },
828
+ timeoutMs : { value: conn.cas?.timeoutMs, source: src(overrides?.casTimeout, process.env[ENV_VARS.CAS_TIMEOUT], profileCas?.timeoutMs) },
829
+ },
830
+ };
831
+ }
832
+
833
+ /** One endpoint reachability check produced by `config doctor`. */
834
+ export interface DoctorCheck {
835
+ endpoint : 'btc-rest' | 'btc-rpc' | 'cas';
836
+ target : string;
837
+ ok : boolean;
838
+ detail? : string;
839
+ }
840
+
841
+ /** Result of `config doctor`: per-endpoint reachability and any coherence warning. */
842
+ export interface DoctorReport {
843
+ checks : DoctorCheck[];
844
+ coherence? : { profile: string; declared: NetworkOption; encoding: NetworkOption };
845
+ }
846
+
847
+ /** Default per-probe timeout (ms) for `config doctor`. */
848
+ const DOCTOR_PROBE_TIMEOUT_MS = 5000;
849
+
850
+ /** Fetches a URL with a bounded timeout, reporting reachability rather than throwing. */
851
+ async function probeEndpoint(
852
+ endpoint : DoctorCheck['endpoint'],
853
+ target : string,
854
+ url : string,
855
+ opts? : { method?: 'GET' | 'POST'; headers?: Record<string, string> },
856
+ ): Promise<DoctorCheck> {
857
+ try {
858
+ const res = await fetch(url, {
859
+ method : opts?.method ?? 'GET',
860
+ headers : opts?.headers,
861
+ signal : AbortSignal.timeout(DOCTOR_PROBE_TIMEOUT_MS),
862
+ });
863
+ return res.ok
864
+ ? { endpoint, target, ok: true }
865
+ : { endpoint, target, ok: false, detail: `HTTP ${res.status}` };
866
+ } catch (error) {
867
+ return { endpoint, target, ok: false, detail: (error as Error).message };
868
+ }
869
+ }
870
+
871
+ /** Races a promise against a timeout so a stalled RPC call cannot hang `doctor`. */
872
+ function withProbeTimeout<T>(promise: Promise<T>, ms: number): Promise<T> {
873
+ let timer: ReturnType<typeof setTimeout>;
874
+ const timeout = new Promise<never>((_, reject) => {
875
+ timer = setTimeout(() => reject(new Error(`timed out after ${ms}ms`)), ms);
876
+ });
877
+ return Promise.race([ promise, timeout ]).finally(() => clearTimeout(timer));
878
+ }
879
+
880
+ /**
881
+ * Probes reachability of the resolved endpoints for `config doctor`: a
882
+ * lightweight REST call against btc-rest, a `getblockchaininfo` against btc-rpc
883
+ * when configured, and a reachability check against the resolved CAS. Also
884
+ * surfaces the profile/network coherence warning. Reads and touches the network;
885
+ * never writes.
886
+ */
887
+ export async function runDoctor(network: NetworkOption, overrides?: ConnectionOverrides): Promise<DoctorReport> {
888
+ const api = defaultApiFactory(network, overrides);
889
+ const checks: DoctorCheck[] = [];
890
+
891
+ const restHost = api.btc.connection.rest.config.host.replace(/\/+$/, '');
892
+ checks.push(await probeEndpoint('btc-rest', restHost, `${restHost}/blocks/tip/height`, { headers: api.btc.connection.rest.config.headers }));
893
+
894
+ const rpc = api.btc.connection.rpc;
895
+ if (rpc) {
896
+ const target = rpc.config.host ?? '(default rpc)';
897
+ try {
898
+ await withProbeTimeout(rpc.getBlockchainInfo(), DOCTOR_PROBE_TIMEOUT_MS);
899
+ checks.push({ endpoint: 'btc-rpc', target, ok: true });
900
+ } catch (error) {
901
+ checks.push({ endpoint: 'btc-rpc', target, ok: false, detail: (error as Error).message });
902
+ }
903
+ }
904
+
905
+ // A writable IPFS RPC (Kubo) answers only POST, so a bare GET would falsely
906
+ // report a healthy node as down; probe its version endpoint with POST. A
907
+ // read-only gateway answers a plain GET on its base URL.
908
+ const conn = resolveConnectionConfig(network, overrides);
909
+ if (conn.cas?.rpcUrl) {
910
+ const base = conn.cas.rpcUrl.replace(/\/+$/, '');
911
+ checks.push(await probeEndpoint('cas', conn.cas.rpcUrl, `${base}/api/v0/version`, { method: 'POST' }));
912
+ } else {
913
+ const gateway = (conn.cas?.gateway ?? DEFAULT_CAS_GATEWAY).replace(/\/+$/, '');
914
+ checks.push(await probeEndpoint('cas', gateway, gateway));
915
+ }
916
+
917
+ const mismatch = profileNetworkMismatch(network, overrides);
918
+ return {
919
+ checks,
920
+ ...(mismatch ? { coherence: { profile: mismatch.profile, declared: mismatch.declared, encoding: network } } : {}),
921
+ };
302
922
  }
303
923
 
304
924
  /**
@@ -323,11 +943,41 @@ export function defaultApiFactory(network?: NetworkOption, overrides?: Connectio
323
943
  */
324
944
  function buildKeystoreKms(overrides?: ConnectionOverrides): KeyManager {
325
945
  return new FileBackedKeyManager({
326
- path : overrides?.keystore ?? defaultKeystorePath(),
946
+ path : resolveKeystorePath(overrides),
327
947
  getPassphrase : () => acquirePassphrase({ passphraseFile: overrides?.passphraseFile }),
328
948
  });
329
949
  }
330
950
 
951
+ /**
952
+ * Resolves the keystore file path: the `--keystore` flag, else the active
953
+ * profile's `identity.keystore`, else the default XDG keystore path. The flag
954
+ * always wins over the profile default.
955
+ */
956
+ export function resolveKeystorePath(overrides?: ConnectionOverrides): string {
957
+ return overrides?.keystore ?? activeProfileIdentity(overrides)?.keystore ?? defaultKeystorePath();
958
+ }
959
+
960
+ /**
961
+ * Reads the active profile's `identity` block (keystore + default signing key),
962
+ * or `undefined` when no profile is active. A profile is active only when
963
+ * selected by `--profile` or the config's `defaults.profile`.
964
+ */
965
+ function activeProfileIdentity(overrides?: ConnectionOverrides): { keystore?: string; default?: string } | undefined {
966
+ const file = readConfigFile(overrides?.config ?? defaultConfigPath());
967
+ const { name } = resolveActiveProfile(file, overrides);
968
+ return name ? file?.profiles?.[name]?.identity : undefined;
969
+ }
970
+
971
+ /**
972
+ * Resolves the signing-key reference for update/deactivate: the `--signing-key`
973
+ * flag, else the active profile's `identity.default`, else `undefined` (letting
974
+ * the KMS fall back to its active key). The flag always wins over the profile
975
+ * default, consistent with the flag -> profile precedence used elsewhere.
976
+ */
977
+ export function resolveSigningKeyRef(overrides?: ConnectionOverrides): string | undefined {
978
+ return blankToUndef(overrides?.signingKey) ?? blankToUndef(activeProfileIdentity(overrides)?.default);
979
+ }
980
+
331
981
  /**
332
982
  * Keystore-aware {@link ApiFactory} for commands that need a signing identity
333
983
  * (key management, update, deactivate). Identical to {@link defaultApiFactory}