nostr-bray 3.4.1 → 3.4.3

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.
@@ -16,6 +16,24 @@ import { BunkerSigner } from 'nostr-tools/nip46';
16
16
  import { SimplePool } from 'nostr-tools/pool';
17
17
  import type { PublicIdentity, SignFn } from './types.js';
18
18
  import type { SigningContext } from './signing-context.js';
19
+ /** Bound the NIP-46 connect handshake. nostr-tools' BunkerSigner has no
20
+ * per-request timeout, so an offline signer or unreachable relays make the
21
+ * connect hang forever. A hardware signer (e.g. Heartwood) may also wait on
22
+ * a physical button press -- roughly a 30-second window -- so this must sit
23
+ * comfortably above that, not at or under it. */
24
+ export declare const CONNECT_TIMEOUT_MS = 60000;
25
+ /** Bound individual signer round-trips (sign_event, get_public_key, nip44).
26
+ * Same button-press headroom as {@link CONNECT_TIMEOUT_MS}. */
27
+ export declare const REQUEST_TIMEOUT_MS = 60000;
28
+ /**
29
+ * Thrown by {@link withTimeout} when the wrapped promise does not settle in
30
+ * time. A distinct class (rather than a plain `Error`) lets callers tell a
31
+ * timeout apart from a genuine rejection -- e.g. `HeartwoodContext.probe()`
32
+ * treats a timed-out request differently from a denied one.
33
+ */
34
+ export declare class TimeoutError extends Error {
35
+ constructor(message: string);
36
+ }
19
37
  /**
20
38
  * Reject if `promise` does not settle within `ms`. Converts a silent NIP-46
21
39
  * hang into a catchable error so callers can fail fast or retry with backoff
@@ -21,10 +21,25 @@ import { readStateFile, writeStateFile } from './state.js';
21
21
  useWebSocketImplementation(WebSocket);
22
22
  /** Bound the NIP-46 connect handshake. nostr-tools' BunkerSigner has no
23
23
  * per-request timeout, so an offline signer or unreachable relays make the
24
- * connect hang forever. */
25
- const CONNECT_TIMEOUT_MS = 15_000;
26
- /** Bound individual signer round-trips (sign_event, get_public_key, nip44). */
27
- const REQUEST_TIMEOUT_MS = 30_000;
24
+ * connect hang forever. A hardware signer (e.g. Heartwood) may also wait on
25
+ * a physical button press -- roughly a 30-second window -- so this must sit
26
+ * comfortably above that, not at or under it. */
27
+ export const CONNECT_TIMEOUT_MS = 60_000;
28
+ /** Bound individual signer round-trips (sign_event, get_public_key, nip44).
29
+ * Same button-press headroom as {@link CONNECT_TIMEOUT_MS}. */
30
+ export const REQUEST_TIMEOUT_MS = 60_000;
31
+ /**
32
+ * Thrown by {@link withTimeout} when the wrapped promise does not settle in
33
+ * time. A distinct class (rather than a plain `Error`) lets callers tell a
34
+ * timeout apart from a genuine rejection -- e.g. `HeartwoodContext.probe()`
35
+ * treats a timed-out request differently from a denied one.
36
+ */
37
+ export class TimeoutError extends Error {
38
+ constructor(message) {
39
+ super(message);
40
+ this.name = 'TimeoutError';
41
+ }
42
+ }
28
43
  /**
29
44
  * Reject if `promise` does not settle within `ms`. Converts a silent NIP-46
30
45
  * hang into a catchable error so callers can fail fast or retry with backoff
@@ -34,7 +49,7 @@ const REQUEST_TIMEOUT_MS = 30_000;
34
49
  export function withTimeout(promise, ms, label) {
35
50
  let timer;
36
51
  const timeout = new Promise((_resolve, reject) => {
37
- timer = setTimeout(() => reject(new Error(`${label} timed out after ${ms}ms`)), ms);
52
+ timer = setTimeout(() => reject(new TimeoutError(`${label} timed out after ${ms}ms`)), ms);
38
53
  });
39
54
  return Promise.race([promise, timeout]).finally(() => clearTimeout(timer));
40
55
  }
package/dist/cli/index.js CHANGED
@@ -382,6 +382,8 @@ if (config.bunkerUri) {
382
382
  if (hw)
383
383
  ctx = hw;
384
384
  console.error(`Connected to ${hw ? 'Heartwood' : 'bunker'} — signing as ${ctx.activeNpub}`);
385
+ if (hw && !hw.listingAvailable)
386
+ console.error(hw.listingUnavailableMessage);
385
387
  }
386
388
  else {
387
389
  ctx = new IdentityContext(config.secretKey, config.secretFormat, { forbidPubkeys: config.forbidPubkeys });
@@ -9,22 +9,68 @@ import { BunkerContext } from './bunker-context.js';
9
9
  import type { LinkageProof } from 'nsec-tree';
10
10
  import type { PublicIdentity } from './types.js';
11
11
  import type { ExtendedSigningContext } from './signing-context.js';
12
+ /**
13
+ * Why {@link HeartwoodContext.listIdentities} is unavailable for this
14
+ * pairing, set by {@link HeartwoodContext.probe} when it could not confirm
15
+ * identity listing works.
16
+ *
17
+ * - `'denied'` -- the device (or a strict-policy slot) refused
18
+ * `heartwood_list_identities` with an unauthorised/denied error.
19
+ * - `'pending-approval'` -- the probe's `heartwood_list_identities` request
20
+ * timed out, most likely because it is awaiting a button press on the
21
+ * device right now.
22
+ */
23
+ export type HeartwoodListingUnavailableReason = 'denied' | 'pending-approval';
12
24
  export declare class HeartwoodContext extends BunkerContext implements ExtendedSigningContext {
25
+ /** Whether `listIdentities()` (and thus features that depend on the
26
+ * device's identity list) is expected to work for this pairing. `true`
27
+ * unless `probe()` could not confirm listing works. */
28
+ listingAvailable: boolean;
29
+ /** Set by `probe()` alongside `listingAvailable = false`; explains why. */
30
+ listingUnavailableReason?: HeartwoodListingUnavailableReason;
31
+ /** Human-readable explanation of `listingUnavailableReason`, suitable for
32
+ * logging or surfacing to a caller. Undefined when listing is available. */
33
+ listingUnavailableMessage?: string;
13
34
  /**
14
35
  * Probe a connected BunkerContext for Heartwood extensions.
15
- * Returns a HeartwoodContext if extensions are available, null otherwise.
16
36
  *
17
- * The probe sends a single heartwood_list_identities request. If the remote
18
- * signer responds with a result (not an error), the extensions are available.
37
+ * Returns `null` when the remote signer does not speak the Heartwood
38
+ * dialect at all (method unknown/unsupported, or a response that doesn't
39
+ * look like an identity list) -- the base context stays a plain bunker,
40
+ * silently, as before.
41
+ *
42
+ * Returns a `HeartwoodContext` in every other case where the device IS a
43
+ * Heartwood but identity listing could not be confirmed:
44
+ * - the request was refused by policy (`unauthorised`/denied -- e.g. a
45
+ * strict slot that doesn't list `heartwood_list_identities`), or
46
+ * - the request timed out, most likely because it's awaiting a button
47
+ * press on the device right now.
48
+ *
49
+ * In both of those cases `listingAvailable` is set to `false` and
50
+ * `listingUnavailableReason` / `listingUnavailableMessage` explain why.
51
+ * Callers that only check "is this a Heartwood" (`hw !== null` /
52
+ * `instanceof HeartwoodContext`) see no change in behaviour.
19
53
  */
20
54
  static probe(base: BunkerContext): Promise<HeartwoodContext | null>;
21
- /** Derive a child identity by purpose and index on the Heartwood device. */
55
+ /** Re-class `base` as a HeartwoodContext with listing marked unavailable. */
56
+ private static upgrade;
57
+ /** Derive a child identity by purpose and index on the Heartwood device.
58
+ * Does not depend on `heartwood_list_identities` -- works even when
59
+ * `listingAvailable` is `false`. */
22
60
  derive(purpose: string, index: number): Promise<PublicIdentity>;
23
- /** Derive a named persona on the Heartwood device. */
61
+ /** Derive a named persona on the Heartwood device.
62
+ * Does not depend on `heartwood_list_identities` -- works even when
63
+ * `listingAvailable` is `false`. */
24
64
  derivePersona(name: string, index: number): Promise<PublicIdentity>;
25
- /** List all known identities on the Heartwood device. */
65
+ /** List all known identities on the Heartwood device.
66
+ *
67
+ * Throws immediately, without a device round trip, when `probe()` already
68
+ * established that listing isn't available for this pairing -- retrying
69
+ * would just re-hit the same denial or wait out the same timeout. */
26
70
  listIdentities(): Promise<PublicIdentity[]>;
27
- /** Switch the active identity on the Heartwood device. */
71
+ /** Switch the active identity on the Heartwood device.
72
+ * Does not depend on `heartwood_list_identities` -- works even when
73
+ * `listingAvailable` is `false`. */
28
74
  switch(purposeOrName: string, index?: number): Promise<void>;
29
75
  /** Create a linkage proof on the Heartwood device. */
30
76
  prove(mode?: 'blind' | 'full'): Promise<LinkageProof>;
@@ -5,50 +5,148 @@
5
5
  * extension methods for identity derivation, switching, proofs, and recovery.
6
6
  * HeartwoodContext auto-detects these extensions by probing the remote signer.
7
7
  */
8
- import { BunkerContext } from './bunker-context.js';
8
+ import { BunkerContext, withTimeout, TimeoutError, REQUEST_TIMEOUT_MS } from './bunker-context.js';
9
+ /** Matches an error whose text indicates the remote signer does not know the method at all. */
10
+ function isMethodUnknownError(message) {
11
+ return /unsupported method|method not found|unknown method|no such method|not implemented/i.test(message);
12
+ }
13
+ /** Matches an error whose text indicates the request was understood but refused by policy. */
14
+ function isUnauthorisedError(message) {
15
+ return /unauthori[sz]ed|denied|not permitted|forbidden/i.test(message);
16
+ }
17
+ /** How long a Heartwood request runs before we hint that it's probably
18
+ * waiting on a device button press. Well under REQUEST_TIMEOUT_MS so the
19
+ * hint has a chance to show before the request either completes or times out. */
20
+ const APPROVAL_HINT_MS = 3_000;
21
+ /**
22
+ * Wrap a Heartwood NIP-46 request with the standard 60s timeout, and log a
23
+ * one-off hint to stderr if it's still pending after {@link APPROVAL_HINT_MS}.
24
+ * bray is an MCP server over stdio, so this MUST go to stderr (console.error,
25
+ * same channel every other log line in this codebase uses) and never stdout,
26
+ * which carries the JSON-RPC transport.
27
+ */
28
+ function withApprovalHint(promise, label) {
29
+ const hint = setTimeout(() => {
30
+ console.error(`Waiting for approval on your Heartwood (${label})...`);
31
+ }, APPROVAL_HINT_MS);
32
+ return withTimeout(promise, REQUEST_TIMEOUT_MS, label).finally(() => clearTimeout(hint));
33
+ }
34
+ /** Extract a human-readable message from whatever `sendRequest` rejected with.
35
+ * NIP-46 error responses reject with a bare string (the signer's `error`
36
+ * field); our own code (timeouts, network failures) rejects with an `Error`. */
37
+ function errorMessage(e) {
38
+ if (typeof e === 'string')
39
+ return e;
40
+ if (e instanceof Error)
41
+ return e.message;
42
+ return String(e);
43
+ }
9
44
  export class HeartwoodContext extends BunkerContext {
45
+ /** Whether `listIdentities()` (and thus features that depend on the
46
+ * device's identity list) is expected to work for this pairing. `true`
47
+ * unless `probe()` could not confirm listing works. */
48
+ listingAvailable = true;
49
+ /** Set by `probe()` alongside `listingAvailable = false`; explains why. */
50
+ listingUnavailableReason;
51
+ /** Human-readable explanation of `listingUnavailableReason`, suitable for
52
+ * logging or surfacing to a caller. Undefined when listing is available. */
53
+ listingUnavailableMessage;
10
54
  /**
11
55
  * Probe a connected BunkerContext for Heartwood extensions.
12
- * Returns a HeartwoodContext if extensions are available, null otherwise.
13
56
  *
14
- * The probe sends a single heartwood_list_identities request. If the remote
15
- * signer responds with a result (not an error), the extensions are available.
57
+ * Returns `null` when the remote signer does not speak the Heartwood
58
+ * dialect at all (method unknown/unsupported, or a response that doesn't
59
+ * look like an identity list) -- the base context stays a plain bunker,
60
+ * silently, as before.
61
+ *
62
+ * Returns a `HeartwoodContext` in every other case where the device IS a
63
+ * Heartwood but identity listing could not be confirmed:
64
+ * - the request was refused by policy (`unauthorised`/denied -- e.g. a
65
+ * strict slot that doesn't list `heartwood_list_identities`), or
66
+ * - the request timed out, most likely because it's awaiting a button
67
+ * press on the device right now.
68
+ *
69
+ * In both of those cases `listingAvailable` is set to `false` and
70
+ * `listingUnavailableReason` / `listingUnavailableMessage` explain why.
71
+ * Callers that only check "is this a Heartwood" (`hw !== null` /
72
+ * `instanceof HeartwoodContext`) see no change in behaviour.
16
73
  */
17
74
  static async probe(base) {
75
+ // Cast to access protected signer — safe because HeartwoodContext extends BunkerContext
76
+ const signer = base.signer;
77
+ let result;
18
78
  try {
19
- // Cast to access protected signer — safe because HeartwoodContext extends BunkerContext
20
- const signer = base.signer;
21
- const result = await signer.sendRequest('heartwood_list_identities', []);
22
- if (!isHeartwoodIdentitiesResponse(result))
23
- return null;
24
- // Re-class the base context as HeartwoodContext
25
- return Object.setPrototypeOf(base, HeartwoodContext.prototype);
79
+ result = await withTimeout(signer.sendRequest('heartwood_list_identities', []), REQUEST_TIMEOUT_MS, 'heartwood probe (heartwood_list_identities)');
26
80
  }
27
- catch {
81
+ catch (e) {
82
+ const message = errorMessage(e);
83
+ if (e instanceof TimeoutError) {
84
+ return HeartwoodContext.upgrade(base, 'pending-approval', 'Heartwood is awaiting approval on the device for identity listing ' +
85
+ '(heartwood_list_identities timed out) -- approve the pending request on the device.');
86
+ }
87
+ if (isUnauthorisedError(message)) {
88
+ return HeartwoodContext.upgrade(base, 'denied', 'Heartwood denied identity listing for this pairing; allow heartwood_list_identities for this app in Sapwood.');
89
+ }
90
+ if (isMethodUnknownError(message))
91
+ return null;
92
+ // Anything else is genuinely ambiguous -- keep the previous, conservative
93
+ // behaviour of treating it as "not a Heartwood" rather than guessing.
28
94
  return null;
29
95
  }
96
+ if (!isHeartwoodIdentitiesResponse(result))
97
+ return null;
98
+ // Re-class the base context as HeartwoodContext. Object.setPrototypeOf
99
+ // only swaps the prototype -- it does NOT re-run class field
100
+ // initialisers on an object that was constructed as a plain
101
+ // BunkerContext, so `listingAvailable` must be set explicitly here.
102
+ const hw = Object.setPrototypeOf(base, HeartwoodContext.prototype);
103
+ hw.listingAvailable = true;
104
+ hw.listingUnavailableReason = undefined;
105
+ hw.listingUnavailableMessage = undefined;
106
+ return hw;
107
+ }
108
+ /** Re-class `base` as a HeartwoodContext with listing marked unavailable. */
109
+ static upgrade(base, reason, message) {
110
+ const hw = Object.setPrototypeOf(base, HeartwoodContext.prototype);
111
+ hw.listingAvailable = false;
112
+ hw.listingUnavailableReason = reason;
113
+ hw.listingUnavailableMessage = message;
114
+ return hw;
30
115
  }
31
- /** Derive a child identity by purpose and index on the Heartwood device. */
116
+ /** Derive a child identity by purpose and index on the Heartwood device.
117
+ * Does not depend on `heartwood_list_identities` -- works even when
118
+ * `listingAvailable` is `false`. */
32
119
  async derive(purpose, index) {
33
- const result = await this.signer.sendRequest('heartwood_derive', [purpose, String(index)]);
120
+ const result = await withApprovalHint(this.signer.sendRequest('heartwood_derive', [purpose, String(index)]), 'heartwood derive');
34
121
  return JSON.parse(result);
35
122
  }
36
- /** Derive a named persona on the Heartwood device. */
123
+ /** Derive a named persona on the Heartwood device.
124
+ * Does not depend on `heartwood_list_identities` -- works even when
125
+ * `listingAvailable` is `false`. */
37
126
  async derivePersona(name, index) {
38
- const result = await this.signer.sendRequest('heartwood_derive_persona', [name, String(index)]);
127
+ const result = await withApprovalHint(this.signer.sendRequest('heartwood_derive_persona', [name, String(index)]), 'heartwood derive persona');
39
128
  return JSON.parse(result);
40
129
  }
41
- /** List all known identities on the Heartwood device. */
130
+ /** List all known identities on the Heartwood device.
131
+ *
132
+ * Throws immediately, without a device round trip, when `probe()` already
133
+ * established that listing isn't available for this pairing -- retrying
134
+ * would just re-hit the same denial or wait out the same timeout. */
42
135
  async listIdentities() {
43
- const result = await this.signer.sendRequest('heartwood_list_identities', []);
136
+ if (!this.listingAvailable) {
137
+ throw new Error(this.listingUnavailableMessage ?? 'Heartwood identity listing is unavailable for this pairing.');
138
+ }
139
+ const result = await withApprovalHint(this.signer.sendRequest('heartwood_list_identities', []), 'heartwood list identities');
44
140
  return JSON.parse(result);
45
141
  }
46
- /** Switch the active identity on the Heartwood device. */
142
+ /** Switch the active identity on the Heartwood device.
143
+ * Does not depend on `heartwood_list_identities` -- works even when
144
+ * `listingAvailable` is `false`. */
47
145
  async switch(purposeOrName, index) {
48
146
  const params = index !== undefined
49
147
  ? [purposeOrName, String(index)]
50
148
  : [purposeOrName];
51
- const raw = await this.signer.sendRequest('heartwood_switch', params);
149
+ const raw = await withApprovalHint(this.signer.sendRequest('heartwood_switch', params), 'heartwood switch');
52
150
  // Parse response — device may return { npub } object or a bare npub string
53
151
  const { decode, npubEncode } = await import('nostr-tools/nip19');
54
152
  let npub;
@@ -80,13 +178,13 @@ export class HeartwoodContext extends BunkerContext {
80
178
  }
81
179
  /** Create a linkage proof on the Heartwood device. */
82
180
  async prove(mode = 'blind') {
83
- const result = await this.signer.sendRequest('heartwood_create_proof', [this.activePublicKeyHex, mode]);
181
+ const result = await withApprovalHint(this.signer.sendRequest('heartwood_create_proof', [this.activePublicKeyHex, mode]), 'heartwood create proof');
84
182
  return JSON.parse(result);
85
183
  }
86
184
  /** Recover identities by scanning derived keys on the Heartwood device. */
87
185
  async recover(lookahead) {
88
186
  const params = lookahead !== undefined ? [String(lookahead)] : [];
89
- const result = await this.signer.sendRequest('heartwood_recover', params);
187
+ const result = await withApprovalHint(this.signer.sendRequest('heartwood_recover', params), 'heartwood recover');
90
188
  return JSON.parse(result);
91
189
  }
92
190
  }
package/dist/index.js CHANGED
@@ -79,8 +79,11 @@ async function establishBunkerInBackground(base) {
79
79
  // Probe for Heartwood extensions and upgrade the prototype in place so
80
80
  // tools that check `ctx instanceof HeartwoodContext` see it.
81
81
  const hw = await HeartwoodContext.probe(base);
82
- if (hw)
82
+ if (hw) {
83
83
  console.error(`Heartwood extensions detected -- ${base.activeNpub}`);
84
+ if (!hw.listingAvailable)
85
+ console.error(hw.listingUnavailableMessage);
86
+ }
84
87
  }
85
88
  catch (e) {
86
89
  console.error('Heartwood probe failed (non-fatal):', e.message);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nostr-bray",
3
- "version": "3.4.1",
3
+ "version": "3.4.3",
4
4
  "mcpName": "dev.forgesworn/bray",
5
5
  "description": "Trust-aware Nostr for AI and humans. Three dimensions of trust -- verification, proximity, and access -- woven into every interaction.",
6
6
  "type": "module",
@@ -178,9 +178,9 @@
178
178
  "devDependencies": {
179
179
  "@types/node": "^25.5.0",
180
180
  "@types/ws": "^8.18.1",
181
- "@vitest/coverage-v8": "^4.1.1",
181
+ "@vitest/coverage-v8": "^5.0.0",
182
182
  "typescript": "^6.0.2",
183
- "vitest": "^4.1.1",
183
+ "vitest": "^5.0.0",
184
184
  "yaml": "^2.9.0"
185
185
  },
186
186
  "publishConfig": {