nostr-bray 3.4.2 → 3.5.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 (48) hide show
  1. package/README.md +77 -10
  2. package/dist/bunker-context.d.ts +18 -0
  3. package/dist/bunker-context.js +20 -5
  4. package/dist/catalog.d.ts +11 -1
  5. package/dist/catalog.js +28 -1
  6. package/dist/cli/commands/wallet.js +2 -1
  7. package/dist/cli/commands/zap.js +2 -2
  8. package/dist/cli/index.js +2 -0
  9. package/dist/config.d.ts +2 -0
  10. package/dist/config.js +9 -0
  11. package/dist/dispatch/tools.js +1 -1
  12. package/dist/file-lock.d.ts +2 -0
  13. package/dist/file-lock.js +71 -0
  14. package/dist/heartwood-context.d.ts +53 -7
  15. package/dist/heartwood-context.js +120 -22
  16. package/dist/http-token.d.ts +8 -0
  17. package/dist/http-token.js +18 -0
  18. package/dist/identity/tools.d.ts +3 -0
  19. package/dist/index.js +17 -64
  20. package/dist/marketplace/handlers.d.ts +8 -3
  21. package/dist/marketplace/handlers.js +21 -4
  22. package/dist/marketplace/tools.js +38 -6
  23. package/dist/sdk.js +4 -1
  24. package/dist/tool-groups.d.ts +16 -0
  25. package/dist/tool-groups.js +52 -0
  26. package/dist/types.d.ts +2 -0
  27. package/dist/version.d.ts +1 -0
  28. package/dist/version.js +6 -0
  29. package/dist/wallet-service/grants.d.ts +24 -1
  30. package/dist/wallet-service/grants.js +172 -20
  31. package/dist/wallet-service/service.d.ts +9 -3
  32. package/dist/wallet-service/service.js +136 -36
  33. package/dist/wallet-service/tools.d.ts +10 -1
  34. package/dist/wallet-service/tools.js +101 -111
  35. package/dist/wallet-service/upstream.d.ts +2 -0
  36. package/dist/wallet-service/upstream.js +41 -5
  37. package/dist/zap/confirm.d.ts +16 -0
  38. package/dist/zap/confirm.js +58 -0
  39. package/dist/zap/handlers.d.ts +10 -1
  40. package/dist/zap/handlers.js +56 -6
  41. package/dist/zap/index.d.ts +1 -0
  42. package/dist/zap/index.js +1 -0
  43. package/dist/zap/payment-guard.d.ts +59 -0
  44. package/dist/zap/payment-guard.js +213 -0
  45. package/dist/zap/tools.js +29 -2
  46. package/llms-full.txt +13 -9
  47. package/llms.txt +4 -4
  48. package/package.json +18 -4
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # nostr-bray
2
2
 
3
- **Trust-aware Nostr MCP for AI and humans.** 258 tools across 29 groups. Model-agnostic. Works with Claude, ChatGPT, Gemini, Cursor, Windsurf, or any MCP client.
3
+ **Trust-aware Nostr MCP for AI and humans.** 260 tools across 29 groups. Model-agnostic. Works with Claude, ChatGPT, Gemini, Cursor, Windsurf, or any MCP client.
4
4
 
5
5
  [![npm](https://img.shields.io/npm/v/nostr-bray)](https://www.npmjs.com/package/nostr-bray)
6
6
  [![CI](https://github.com/forgesworn/bray/actions/workflows/ci.yml/badge.svg)](https://github.com/forgesworn/bray/actions/workflows/ci.yml)
@@ -166,12 +166,13 @@ nostr-bray implements or integrates the following NIPs:
166
166
  | **NIP-44** | Encrypted payloads v2 |
167
167
  | **NIP-45** | Event counts |
168
168
  | **NIP-46** | Nostr Connect (bunker) |
169
+ | **NIP-47** | Nostr Wallet Connect: pay BOLT-11 invoices, balance, invoices, and scoped spending connections over one wallet. NIP-44 v2 only |
169
170
  | **NIP-49** | Private key encryption (ncryptsec) |
170
171
  | **NIP-50** | Search |
171
172
  | **NIP-51** | Lists (mute, pin, follow sets, bookmarks) |
172
173
  | **NIP-52** | Calendar events |
173
174
  | **NIP-54** | Wiki pages |
174
- | **NIP-57** | Lightning zaps |
175
+ | **NIP-57** | Zap receipts (kind 9735) are read. bray pays BOLT-11 invoices over NIP-47 but does not build kind 9734 zap requests or resolve LNURL, so it cannot yet zap a profile or note by itself |
175
176
  | **NIP-58** | Badges |
176
177
  | **NIP-65** | Relay list metadata |
177
178
  | **NIP-72** | Communities |
@@ -214,6 +215,10 @@ Search order: `BRAY_CONFIG` env var > `$XDG_CONFIG_HOME/bray/config.json` > `~/.
214
215
  | `NOSTR_NCRYPTSEC` | NIP-49 encrypted key |
215
216
  | `NOSTR_NCRYPTSEC_PASSWORD` | Password for ncryptsec |
216
217
  | `NWC_URI_FILE` | Path to a private `0600` file containing the NWC bearer URI |
218
+ | `BRAY_MAX_PAYMENT_MSAT` | bray's ceiling on any one payment, in msat (default `5000000`, 5,000 sats) |
219
+ | `BRAY_MAX_DAILY_MSAT` | bray's ceiling on spending in any rolling 24 hours, in msat (default `20000000`, 20,000 sats) |
220
+ | `BRAY_WALLET_SERVICE` | Set `1` to register `wallet-grant`, `wallet-refill` and `wallet-serve` |
221
+ | `BRAY_HOME` | Directory for bray's state files (default `$XDG_CONFIG_HOME/bray`, else `~/.config/bray`) |
217
222
  | `NOSTR_RELAYS` | Comma-separated relay URLs |
218
223
  | `NOSTR_FORBID_PUBKEY` | Comma-separated npubs or hex this process must never sign as. See below. |
219
224
  | `TOR_PROXY` | SOCKS5h proxy for Tor |
@@ -224,7 +229,8 @@ Search order: `BRAY_CONFIG` env var > `$XDG_CONFIG_HOME/bray/config.json` > `~/.
224
229
  All secret env vars are deleted from `process.env` before parsing can fail.
225
230
  Raw `NWC_URI` is refused; use `NWC_URI_FILE` or `wallet connect <nwc-file>` so
226
231
  the bearer credential never appears in a process environment, command argument
227
- or MCP tool argument.
232
+ or MCP tool argument. The wallet must speak NIP-44 v2 encryption for NWC;
233
+ wallets that only offer NIP-04 are refused.
228
234
 
229
235
  ### Keys this process must never be
230
236
 
@@ -252,13 +258,45 @@ everything.
252
258
  The active npub is printed on stderr on every start, so the answer to "which
253
259
  key is this?" does not depend on asking the agent.
254
260
 
261
+ ## Spending limits
262
+
263
+ **The hard limit is the wallet's own.** Point `NWC_URI_FILE` (or
264
+ `wallet connect <file>`) at a connection your wallet issued with a spending
265
+ budget. Whatever bray does, and whatever a model asks of it, the wallet will
266
+ not pay past that budget. Everything below is bray policing itself, which is
267
+ worth having but is not the same thing.
268
+
269
+ On top of that, every payment bray makes (`zap-send`, `marketplace-pay`, the
270
+ CLI's `zap-send` and `wallet pay`, the SDK, and payments made through the
271
+ scoped connections below) is checked before anything is sent:
272
+
273
+ - **Per payment:** at most `BRAY_MAX_PAYMENT_MSAT` (default 5,000 sats).
274
+ - **Per day:** at most `BRAY_MAX_DAILY_MSAT` in any rolling 24 hours (default
275
+ 20,000 sats), counting a 1% (minimum 1 sat) fee reserve until the real fee
276
+ is known. Attempts are written to `payment-ledger.json` in the state
277
+ directory before the wallet is asked, so a restart does not hand the
278
+ allowance back.
279
+ - **Once per invoice:** an invoice is never paid twice, and an attempt whose
280
+ outcome is unknown is only retried after a lookup shows it failed.
281
+
282
+ `confirm: true` is set by the model, so it is not a human approval. When your
283
+ MCP client supports elicitation, `zap-send` and `marketplace-pay` also ask
284
+ you directly and pay only on an explicit yes; clients without elicitation
285
+ fall back to `confirm`, with the caps still applying. `zap-send` is not in
286
+ the default tool list either: it is reached through `search-actions`.
287
+
255
288
  ## Handing out a wallet connection
256
289
 
257
290
  `zap-send` spends through the NWC URI you configured. That URI is an
258
291
  unbounded capability over the wallet behind it: every method it supports, no
259
292
  ceiling, until it is rotated. Handing one to an agent hands over everything.
260
293
 
261
- `wallet-grant` issues connections over the same wallet that are narrower.
294
+ `wallet-grant` issues scoped spending connections over that one wallet: one
295
+ wallet, one balance, and several narrower ways in. They are not separate
296
+ wallets. The tools that mint or widen spending authority (`wallet-grant`,
297
+ `wallet-refill`, `wallet-serve`) are registered only with
298
+ `BRAY_WALLET_SERVICE=1`; `wallet-grants` and `wallet-revoke` are always
299
+ available.
262
300
 
263
301
  ```
264
302
  wallet-grant { name: "research-agent", methods: ["get_info","pay_invoice"],
@@ -269,11 +307,20 @@ wallet-refill { nameOrId: "research-agent", budgetMsat: 50000 }
269
307
  wallet-serve { action: "start" }
270
308
  ```
271
309
 
310
+ `wallet-grant` writes the new connection's `nostr+walletconnect://` URI to a
311
+ `0600` file under `wallet-connections/` beside the grants file and returns
312
+ the path; the URI itself never appears in a tool result.
313
+
272
314
  What a connection may do is an allowlist, and the default grants no spending
273
- and does not disclose the balance: `get_info`, `make_invoice`,
274
- `lookup_invoice`. Both of the others are opt-in per connection, and **a
275
- connection that can spend must carry a budget** - there is no unlimited grant
276
- to issue by accident.
315
+ and does not disclose anything: `get_info` and `make_invoice`. The others are
316
+ opt-in per connection, and **a connection that can spend must carry a
317
+ budget** - there is no unlimited grant to issue by accident. A connection
318
+ sees only itself:
319
+
320
+ - `get_balance` reports the connection's remaining budget, never the
321
+ wallet's balance.
322
+ - `lookup_invoice` answers only for invoices that connection issued or paid;
323
+ anything else is reported as not found.
277
324
 
278
325
  The rules exist because a payment cannot be recalled:
279
326
 
@@ -286,8 +333,13 @@ The rules exist because a payment cannot be recalled:
286
333
  `expiration` tag claims.
287
334
  - Each connection has **its own service key**: two grants share nothing a
288
335
  relay can correlate, and revoking one is deleting a key.
289
- - The budget is charged what the **invoice** says, decoded here. A budget
290
- checked against a figure the payer supplied is not a budget.
336
+ - The budget is charged what the **invoice** says, decoded here, plus the
337
+ routing fee: a fee reserve is held until the wallet reports the real fee.
338
+ Invoices that state no amount are refused. A budget checked against a
339
+ figure the payer supplied is not a budget.
340
+ - A definite refusal from the wallet (insufficient balance, say) gives the
341
+ budget back, and the connection is not told why the wallet refused.
342
+ - bray's own per-payment and daily caps apply to these payments as well.
291
343
  - A claimed payment whose preimage does not settle the invoice is refused
292
344
  rather than passed on. Repeating an unproven claim would make this service
293
345
  the one telling the lie.
@@ -298,6 +350,12 @@ as long as this process is alive. That is worth telling whoever you hand a URI
298
350
  to: a connection that works while an MCP session is open is a different
299
351
  promise from one that works overnight.
300
352
 
353
+ The file is the record, shared by every bray process on the machine. Only one
354
+ process may serve the grants at a time, the serving process re-reads a grant
355
+ before every request, and every change is a locked update of the file, so a
356
+ revocation or refill made from any process applies to the next request and
357
+ cannot be written away by another.
358
+
301
359
  ## CLI
302
360
 
303
361
  ```bash
@@ -335,6 +393,15 @@ See [llms.txt](./llms.txt) for a concise summary optimised for AI context window
335
393
  | [dominion](https://github.com/forgesworn/dominion) | Epoch-based encrypted access control |
336
394
  | [nostr-veil](https://github.com/forgesworn/nostr-veil) | Privacy-preserving Web of Trust |
337
395
 
396
+ ## Support
397
+
398
+ For issues and feature requests, see [GitHub Issues](https://github.com/forgesworn/bray/issues).
399
+
400
+ If bray is useful to you, a tip is always welcome:
401
+
402
+ - Lightning: `profusemeat89@walletofsatoshi.com`
403
+ - Nostr zaps: `npub1mgvlrnf5hm9yf0n5mf9nqmvarhvxkc6remu5ec3vf8r0txqkuk7su0e7q2`
404
+
338
405
  ## Licence
339
406
 
340
407
  MIT
@@ -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/catalog.d.ts CHANGED
@@ -5,6 +5,14 @@ type ToolHandler = (args: any) => Promise<{
5
5
  text: string;
6
6
  }>;
7
7
  }>;
8
+ /**
9
+ * Tools registered directly on the MCP server, always in the client's tool
10
+ * list. Everything else is reached through search-actions + execute-action.
11
+ *
12
+ * Nothing that spends is promoted: a payment tool in the default list
13
+ * invites a model to reach for it. zap-send stays one search away.
14
+ */
15
+ export declare const PROMOTED_TOOLS: ReadonlySet<string>;
8
16
  /**
9
17
  * Holds non-promoted tool definitions and exposes them via search-actions
10
18
  * and execute-action meta-tools. This keeps the primary tool list lean
@@ -12,6 +20,8 @@ type ToolHandler = (args: any) => Promise<{
12
20
  */
13
21
  export declare class ActionCatalog {
14
22
  private readonly entries;
23
+ /** Promoted tools actually registered on the server, as counted by the proxy. */
24
+ promotedCount: number;
15
25
  add(name: string, definition: any, handler: ToolHandler): void;
16
26
  get size(): number;
17
27
  search(intent: string): Array<{
@@ -31,7 +41,7 @@ export declare class ActionCatalog {
31
41
  * Create a proxy around McpServer that intercepts registerTool calls.
32
42
  * Promoted tools go to the real server; everything else goes to the catalog.
33
43
  */
34
- export declare function createCatalogProxy(server: McpServer, catalog: ActionCatalog, promoted: Set<string>): McpServer;
44
+ export declare function createCatalogProxy(server: McpServer, catalog: ActionCatalog, promoted: ReadonlySet<string>): McpServer;
35
45
  /**
36
46
  * Coerce execute-action's `params`/`parameters` argument into a plain object.
37
47
  *
package/dist/catalog.js CHANGED
@@ -1,4 +1,28 @@
1
1
  import { z } from 'zod';
2
+ /**
3
+ * Tools registered directly on the MCP server, always in the client's tool
4
+ * list. Everything else is reached through search-actions + execute-action.
5
+ *
6
+ * Nothing that spends is promoted: a payment tool in the default list
7
+ * invites a model to reach for it. zap-send stays one search away.
8
+ */
9
+ export const PROMOTED_TOOLS = new Set([
10
+ 'whoami', 'social-post', 'social-reply', 'social-feed',
11
+ 'dm-send', 'dm-read', 'zap-balance',
12
+ 'identity-switch', 'relay-query',
13
+ 'signet-badge', 'trust-score', 'vault-read',
14
+ 'dispatch-send', 'dispatch-check', 'dispatch-reply',
15
+ 'dispatch-ack', 'dispatch-status', 'dispatch-cancel',
16
+ 'dispatch-refuse', 'dispatch-failure', 'dispatch-query',
17
+ 'article-publish', 'article-read', 'article-list',
18
+ 'search-notes', 'search-profiles', 'hashtag-feed',
19
+ 'social-profile-get', 'dm-conversation', 'verify-person',
20
+ 'dispatch-propose', 'dispatch-capability-publish', 'dispatch-capability-discover', 'dispatch-capability-read',
21
+ 'badge-create', 'badge-award', 'badge-accept', 'badge-list',
22
+ 'community-create', 'community-feed', 'community-post', 'community-approve', 'community-list',
23
+ 'calendar-create', 'calendar-read', 'calendar-rsvp',
24
+ 'listing-create', 'listing-read', 'listing-search', 'listing-close',
25
+ ]);
2
26
  /**
3
27
  * Holds non-promoted tool definitions and exposes them via search-actions
4
28
  * and execute-action meta-tools. This keeps the primary tool list lean
@@ -6,6 +30,8 @@ import { z } from 'zod';
6
30
  */
7
31
  export class ActionCatalog {
8
32
  entries = new Map();
33
+ /** Promoted tools actually registered on the server, as counted by the proxy. */
34
+ promotedCount = 0;
9
35
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
10
36
  add(name, definition, handler) {
11
37
  this.entries.set(name, {
@@ -88,7 +114,7 @@ export class ActionCatalog {
88
114
  description: `Search ${catalog.size} additional actions by describing what you want to do. ` +
89
115
  'Returns matching actions with names, descriptions, and parameter schemas. ' +
90
116
  'Use this when the promoted tools (whoami, social-post, social-reply, social-feed, ' +
91
- 'dm-send, dm-read, zap-send, zap-balance, identity-switch, relay-query) ' +
117
+ 'dm-send, dm-read, zap-balance, identity-switch, relay-query) ' +
92
118
  'do not cover what you need.',
93
119
  inputSchema: {
94
120
  intent: z.string().describe('Describe what you want to do (e.g. "upload a file", "verify an attestation", ' +
@@ -171,6 +197,7 @@ export function createCatalogProxy(server, catalog, promoted) {
171
197
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
172
198
  return (name, definition, handler) => {
173
199
  if (promoted.has(name)) {
200
+ catalog.promotedCount++;
174
201
  return target.registerTool(name, definition, handler);
175
202
  }
176
203
  catalog.add(name, definition, handler);
@@ -1,4 +1,4 @@
1
- import { handleZapSend, handleZapBalance, handleZapListTransactions, resolveNwcUri, loadWallets, saveWallets, parseNwcUri, normaliseNwcUriFile, } from '../../exports.js';
1
+ import { handleZapSend, handleZapBalance, handleZapListTransactions, resolveNwcUri, loadWallets, saveWallets, parseNwcUri, normaliseNwcUriFile, defaultPaymentGuard, } from '../../exports.js';
2
2
  /**
3
3
  * Dispatch `wallet *` subcommands (NIP-47 Nostr Wallet Connect).
4
4
  *
@@ -50,6 +50,7 @@ export async function dispatch(cmd, _cmdArgs, h, ctx, pool, extras) {
50
50
  const result = await handleZapSend(ctx, pool, {
51
51
  invoice: req(1, 'wallet pay <bolt11>'),
52
52
  nwcUri: resolveNwcUri(ctx, walletsFile, globalNwcUri),
53
+ guard: defaultPaymentGuard(),
53
54
  });
54
55
  // A preimage can be an L402 bearer credential when the challenge macaroon
55
56
  // is known. Report verified payment without printing the preimage.
@@ -1,11 +1,11 @@
1
- import { handleZapSend, handleZapBalance, handleZapMakeInvoice, handleZapLookupInvoice, handleZapListTransactions, handleZapReceipts, handleZapDecode, resolveNwcUri } from '../../exports.js';
1
+ import { handleZapSend, handleZapBalance, handleZapMakeInvoice, handleZapLookupInvoice, handleZapListTransactions, handleZapReceipts, handleZapDecode, resolveNwcUri, defaultPaymentGuard } from '../../exports.js';
2
2
  import * as fmt from '../../format.js';
3
3
  export async function dispatch(cmd, cmdArgs, h, ctx, pool, extras) {
4
4
  const { req, flag, out } = h;
5
5
  const { globalNwcUri, walletsFile } = extras;
6
6
  switch (cmd) {
7
7
  case 'zap-send': {
8
- const result = await handleZapSend(ctx, pool, { invoice: req(1, 'zap-send <bolt11>'), nwcUri: resolveNwcUri(ctx, walletsFile, globalNwcUri) });
8
+ const result = await handleZapSend(ctx, pool, { invoice: req(1, 'zap-send <bolt11>'), nwcUri: resolveNwcUri(ctx, walletsFile, globalNwcUri), guard: defaultPaymentGuard() });
9
9
  out({ paid: true, verified: result.verified, paymentHash: result.paymentHash, feesPaidMsats: result.fees_paid });
10
10
  break;
11
11
  }
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 });
package/dist/config.d.ts CHANGED
@@ -38,6 +38,8 @@ interface ConfigFile {
38
38
  dispatchIdentities?: string;
39
39
  /** Public keys this process must never sign as. See ContextOptions. */
40
40
  forbidPubkeys?: string[];
41
+ /** Register the tools that mint spending connections (wallet-grant, wallet-refill, wallet-serve). */
42
+ walletService?: boolean;
41
43
  }
42
44
  /**
43
45
  * Load config from a JSON file. Searches in order:
package/dist/config.js CHANGED
@@ -2,6 +2,7 @@ import { readFileSync, existsSync } from 'node:fs';
2
2
  import { join } from 'node:path';
3
3
  import { homedir } from 'node:os';
4
4
  import { readNwcUriFile } from './zap/nwc-file.js';
5
+ import { paymentLimitsFromEnv } from './zap/payment-guard.js';
5
6
  const NSEC_RE = /^nsec1[a-z0-9]{58}$/;
6
7
  const HEX_RE = /^[0-9a-f]{64}$/;
7
8
  const NCRYPTSEC_RE = /^ncryptsec1[a-z0-9]+$/;
@@ -225,6 +226,8 @@ export async function loadConfig() {
225
226
  if (nwcFilePath) {
226
227
  nwcUri = readNwcUriFile(nwcFilePath);
227
228
  }
229
+ // A mistyped spending ceiling is a startup error, not a silent default.
230
+ paymentLimitsFromEnv();
228
231
  // --- Relays ---
229
232
  const relays = process.env.NOSTR_RELAYS
230
233
  ? parseRelays(process.env.NOSTR_RELAYS)
@@ -291,6 +294,11 @@ export async function loadConfig() {
291
294
  ]
292
295
  .map((s) => s.trim())
293
296
  .filter(Boolean);
297
+ // --- Wallet service ---
298
+ // Off unless asked for: wallet-grant, wallet-refill and wallet-serve hand
299
+ // out spending authority over the operator's wallet, which is not
300
+ // something a model should find in its tool list by default.
301
+ const walletService = process.env.BRAY_WALLET_SERVICE === '1' || file.walletService === true;
294
302
  // --- Wallets file ---
295
303
  const walletsFile = process.env.BRAY_WALLETS_FILE
296
304
  ?? file.walletsFile
@@ -318,5 +326,6 @@ export async function loadConfig() {
318
326
  bindAddress,
319
327
  dispatchIdentities,
320
328
  forbidPubkeys,
329
+ walletService,
321
330
  };
322
331
  }
@@ -262,7 +262,7 @@ export function registerDispatchTools(server, deps) {
262
262
  server.registerTool('dispatch-capability-publish', {
263
263
  description: 'Publish your agent\'s dispatch capabilities as a NIP-89 capability card (kind 31990). Lets other agents discover you on Nostr without needing a pre-shared identities file. The card advertises what task types you support, which repos you can work on, and your availability.',
264
264
  inputSchema: {
265
- name: z.string().describe('Agent name (e.g. "prometheus", "forge-worker")'),
265
+ name: z.string().describe('Agent name (e.g. "research-agent", "forge-worker")'),
266
266
  description: z.string().describe('What this agent does (e.g. "Full-stack TypeScript agent for TROTT ecosystem")'),
267
267
  task_types: z.array(z.string()).describe('Supported task types: "think", "build", or custom types'),
268
268
  repos: z.array(z.string()).optional().describe('Repository names this agent can work on (e.g. ["toll-booth", "nsec-tree"])'),
@@ -0,0 +1,2 @@
1
+ /** Run `fn` while holding an exclusive lock at `${path}.lock`. */
2
+ export declare function withFileLock<T>(path: string, fn: () => T): T;
@@ -0,0 +1,71 @@
1
+ import { closeSync, openSync, readFileSync, statSync, unlinkSync, writeSync } from 'node:fs';
2
+ // A short-lived advisory lock between bray processes that share one state
3
+ // file. Held only for a read-modify-write, never across a network call, so
4
+ // waiting for it is a matter of milliseconds.
5
+ //
6
+ // The lock is a file created with O_EXCL. A holder that died leaves it
7
+ // behind; it is taken over once its process is gone or it is older than
8
+ // anything a read-modify-write could plausibly take.
9
+ const STALE_MS = 10_000;
10
+ const WAIT_MS = 5_000;
11
+ const SLEEP = new Int32Array(new SharedArrayBuffer(4));
12
+ function holderIsGone(lockPath) {
13
+ try {
14
+ const age = Date.now() - statSync(lockPath).mtimeMs;
15
+ if (age > STALE_MS)
16
+ return true;
17
+ const pid = Number.parseInt(readFileSync(lockPath, 'utf8'), 10);
18
+ if (!Number.isSafeInteger(pid) || pid <= 0)
19
+ return false;
20
+ if (pid === process.pid)
21
+ return false;
22
+ try {
23
+ process.kill(pid, 0);
24
+ return false;
25
+ }
26
+ catch (err) {
27
+ return err.code === 'ESRCH';
28
+ }
29
+ }
30
+ catch {
31
+ // vanished between the failed create and this look: try again
32
+ return false;
33
+ }
34
+ }
35
+ /** Run `fn` while holding an exclusive lock at `${path}.lock`. */
36
+ export function withFileLock(path, fn) {
37
+ const lockPath = `${path}.lock`;
38
+ const deadline = Date.now() + WAIT_MS;
39
+ let descriptor;
40
+ while (descriptor === undefined) {
41
+ try {
42
+ descriptor = openSync(lockPath, 'wx', 0o600);
43
+ }
44
+ catch (err) {
45
+ if (err.code !== 'EEXIST')
46
+ throw err;
47
+ if (holderIsGone(lockPath)) {
48
+ try {
49
+ unlinkSync(lockPath);
50
+ }
51
+ catch { /* another waiter took it over first */ }
52
+ continue;
53
+ }
54
+ if (Date.now() > deadline) {
55
+ throw new Error(`Timed out waiting for ${lockPath}; another bray process is holding it.`);
56
+ }
57
+ Atomics.wait(SLEEP, 0, 0, 10);
58
+ }
59
+ }
60
+ try {
61
+ writeSync(descriptor, String(process.pid));
62
+ return fn();
63
+ }
64
+ finally {
65
+ closeSync(descriptor);
66
+ try {
67
+ unlinkSync(lockPath);
68
+ }
69
+ catch { /* already gone */ }
70
+ }
71
+ }
@@ -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>;