nostr-bray 3.4.3 → 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 (43) hide show
  1. package/README.md +77 -10
  2. package/dist/catalog.d.ts +11 -1
  3. package/dist/catalog.js +28 -1
  4. package/dist/cli/commands/wallet.js +2 -1
  5. package/dist/cli/commands/zap.js +2 -2
  6. package/dist/config.d.ts +2 -0
  7. package/dist/config.js +9 -0
  8. package/dist/dispatch/tools.js +1 -1
  9. package/dist/file-lock.d.ts +2 -0
  10. package/dist/file-lock.js +71 -0
  11. package/dist/http-token.d.ts +8 -0
  12. package/dist/http-token.js +18 -0
  13. package/dist/identity/tools.d.ts +3 -0
  14. package/dist/index.js +13 -63
  15. package/dist/marketplace/handlers.d.ts +8 -3
  16. package/dist/marketplace/handlers.js +21 -4
  17. package/dist/marketplace/tools.js +38 -6
  18. package/dist/sdk.js +4 -1
  19. package/dist/tool-groups.d.ts +16 -0
  20. package/dist/tool-groups.js +52 -0
  21. package/dist/types.d.ts +2 -0
  22. package/dist/version.d.ts +1 -0
  23. package/dist/version.js +6 -0
  24. package/dist/wallet-service/grants.d.ts +24 -1
  25. package/dist/wallet-service/grants.js +172 -20
  26. package/dist/wallet-service/service.d.ts +9 -3
  27. package/dist/wallet-service/service.js +136 -36
  28. package/dist/wallet-service/tools.d.ts +10 -1
  29. package/dist/wallet-service/tools.js +101 -111
  30. package/dist/wallet-service/upstream.d.ts +2 -0
  31. package/dist/wallet-service/upstream.js +41 -5
  32. package/dist/zap/confirm.d.ts +16 -0
  33. package/dist/zap/confirm.js +58 -0
  34. package/dist/zap/handlers.d.ts +10 -1
  35. package/dist/zap/handlers.js +56 -6
  36. package/dist/zap/index.d.ts +1 -0
  37. package/dist/zap/index.js +1 -0
  38. package/dist/zap/payment-guard.d.ts +59 -0
  39. package/dist/zap/payment-guard.js +213 -0
  40. package/dist/zap/tools.js +29 -2
  41. package/llms-full.txt +13 -9
  42. package/llms.txt +4 -4
  43. package/package.json +16 -2
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
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/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
+ }
@@ -0,0 +1,8 @@
1
+ /**
2
+ * The bearer token the HTTP transport requires.
3
+ *
4
+ * A token the operator supplied in BRAY_HTTP_TOKEN is never echoed: they
5
+ * already have it, and stderr ends up in logs and process supervisors. A
6
+ * generated one is printed once, because nobody else can know it.
7
+ */
8
+ export declare function resolveHttpToken(env?: NodeJS.ProcessEnv, log?: (message: string) => void): string;
@@ -0,0 +1,18 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ /**
3
+ * The bearer token the HTTP transport requires.
4
+ *
5
+ * A token the operator supplied in BRAY_HTTP_TOKEN is never echoed: they
6
+ * already have it, and stderr ends up in logs and process supervisors. A
7
+ * generated one is printed once, because nobody else can know it.
8
+ */
9
+ export function resolveHttpToken(env = process.env, log = (message) => console.error(message)) {
10
+ const supplied = env.BRAY_HTTP_TOKEN?.trim();
11
+ if (supplied) {
12
+ log('nostr-bray HTTP auth: using the token from BRAY_HTTP_TOKEN');
13
+ return supplied;
14
+ }
15
+ const generated = randomUUID();
16
+ log(`nostr-bray HTTP auth token: ${generated}`);
17
+ return generated;
18
+ }
@@ -3,12 +3,15 @@ import type { SigningContext } from '../signing-context.js';
3
3
  import type { RelayPool } from '../relay-pool.js';
4
4
  import type { Nip65Manager } from '../nip65.js';
5
5
  import type { TrustContext } from '../trust-context.js';
6
+ import type { NwcTransport } from '@forgesworn/nwc-kit';
6
7
  export interface ToolDeps {
7
8
  ctx: SigningContext;
8
9
  pool: RelayPool;
9
10
  nip65: Nip65Manager;
10
11
  nwcUri?: string;
11
12
  walletsFile: string;
13
+ /** NWC relay transport override. Tests only; production uses the default relays. */
14
+ nwcTransport?: NwcTransport;
12
15
  nip04Enabled?: boolean;
13
16
  veilCacheTtl?: number;
14
17
  veilCacheMax?: number;
package/dist/index.js CHANGED
@@ -4,27 +4,11 @@ import { loadConfig } from './config.js';
4
4
  import { IdentityContext } from './context.js';
5
5
  import { RelayPool } from './relay-pool.js';
6
6
  import { Nip65Manager } from './nip65.js';
7
- import { registerIdentityTools } from './identity/tools.js';
8
- import { registerSocialTools } from './social/tools.js';
9
- import { registerTrustTools } from './trust/tools.js';
10
- import { registerRelayTools } from './relay/tools.js';
11
- import { registerRelayIntelligenceTools } from './relay/intelligence-tools.js';
12
- import { registerZapTools } from './zap/tools.js';
13
- import { registerWalletServiceTools } from './wallet-service/tools.js';
14
- import { registerSafetyTools } from './safety/tools.js';
15
- import { registerUtilTools } from './util/tools.js';
16
- import { registerWorkflowTools } from './workflow/tools.js';
17
- import { registerMarketplaceTools } from './marketplace/tools.js';
18
- import { registerPrivacyTools } from './privacy/tools.js';
19
- import { registerModerationTools } from './moderation/tools.js';
20
7
  import { TrustContext } from './trust-context.js';
21
- import { registerSignetTools } from './signet/tools.js';
22
- import { registerVaultTools } from './vault/tools.js';
23
- import { registerDispatchTools } from './dispatch/tools.js';
24
- import { registerHandlerTools } from './handler/tools.js';
25
- import { registerSyncTools } from './sync/tools.js';
26
- import { ActionCatalog, createCatalogProxy } from './catalog.js';
8
+ import { ActionCatalog, createCatalogProxy, PROMOTED_TOOLS } from './catalog.js';
9
+ import { registerAllTools } from './tool-groups.js';
27
10
  import { configureHttpClient } from './http-client.js';
11
+ import { BRAY_VERSION } from './version.js';
28
12
  const config = await loadConfig();
29
13
  // Route every fetch() in this process through the SOCKS proxy when Tor is
30
14
  // configured, before any HTTP-using code runs. Structural protection
@@ -135,58 +119,24 @@ config.nwcUri = undefined;
135
119
  config.bunkerUri = undefined;
136
120
  // NIP-65 relay list is loaded via loadIdentityRelays(): immediately for a local
137
121
  // key, or after the bunker pubkey resolves (see establishBunkerInBackground).
138
- const server = new McpServer({ name: 'nostr-bray', version: '0.1.0' }, {
122
+ const server = new McpServer({ name: 'nostr-bray', version: BRAY_VERSION }, {
139
123
  instructions: 'Always check whoami before posting or signing. Use signet-badge to check trust before interacting with unfamiliar pubkeys. Use trust-score for the full three-dimensional view (verification + proximity + access). Use social-feed or social-notifications to get event IDs and author pubkeys before calling social-reply or social-react. DMs default to NIP-17 gift wrap (most private); only use NIP-04 if the recipient requires it. Respect vault tiers -- do not share decrypted content outside its intended audience. For less common actions, use search-actions to discover them, then execute-action to run them.',
140
124
  });
141
125
  // Promoted tools are registered directly with the server (always visible to Claude).
142
126
  // Everything else goes to the catalog, discoverable via search-actions + execute-action.
143
- const PROMOTED = new Set([
144
- 'whoami', 'social-post', 'social-reply', 'social-feed',
145
- 'dm-send', 'dm-read', 'zap-send', 'zap-balance',
146
- 'identity-switch', 'relay-query',
147
- 'signet-badge', 'trust-score', 'vault-read',
148
- 'dispatch-send', 'dispatch-check', 'dispatch-reply',
149
- 'dispatch-ack', 'dispatch-status', 'dispatch-cancel',
150
- 'dispatch-refuse', 'dispatch-failure', 'dispatch-query',
151
- 'article-publish', 'article-read', 'article-list',
152
- 'search-notes', 'search-profiles', 'hashtag-feed',
153
- 'social-profile-get', 'dm-conversation', 'verify-person',
154
- 'dispatch-propose', 'dispatch-capability-publish', 'dispatch-capability-discover', 'dispatch-capability-read',
155
- 'badge-create', 'badge-award', 'badge-accept', 'badge-list',
156
- 'community-create', 'community-feed', 'community-post', 'community-approve', 'community-list',
157
- 'calendar-create', 'calendar-read', 'calendar-rsvp',
158
- 'listing-create', 'listing-read', 'listing-search', 'listing-close',
159
- ]);
160
127
  const catalog = new ActionCatalog();
161
- const proxy = createCatalogProxy(server, catalog, PROMOTED);
162
- // Register all tools — the proxy routes promoted to server, rest to catalog
163
- registerIdentityTools(proxy, deps);
164
- registerSocialTools(proxy, deps);
165
- registerTrustTools(proxy, deps);
166
- registerRelayTools(proxy, deps);
167
- registerRelayIntelligenceTools(proxy, deps);
168
- registerZapTools(proxy, deps);
169
- registerWalletServiceTools(proxy, deps);
170
- registerSafetyTools(proxy, deps);
171
- registerUtilTools(proxy, deps);
172
- registerWorkflowTools(proxy, {
173
- ctx: deps.ctx,
174
- pool: deps.pool,
175
- nip65: deps.nip65,
128
+ const proxy = createCatalogProxy(server, catalog, PROMOTED_TOOLS);
129
+ // Register all tools. The proxy routes promoted ones to the server and the
130
+ // rest to the catalog.
131
+ registerAllTools(proxy, deps, {
176
132
  veilCacheTtl: config.veilCacheTtl,
177
133
  veilCacheMax: config.veilCacheMax,
134
+ ...(config.dispatchIdentities ? { dispatchIdentitiesPath: config.dispatchIdentities } : {}),
135
+ walletService: config.walletService,
178
136
  });
179
- registerMarketplaceTools(proxy, deps);
180
- registerPrivacyTools(proxy, deps);
181
- registerModerationTools(proxy, deps);
182
- registerSignetTools(proxy, deps);
183
- registerVaultTools(proxy, deps);
184
- registerDispatchTools(proxy, { ...deps, dispatchIdentitiesPath: config.dispatchIdentities });
185
- registerHandlerTools(proxy, deps);
186
- registerSyncTools(proxy, deps);
187
137
  // Add search-actions and execute-action meta-tools to the real server
188
138
  catalog.registerMetaTools(server);
189
- console.error(`nostr-bray: ${PROMOTED.size} promoted tools + ${catalog.size} cataloged (${PROMOTED.size + catalog.size + 2} total)`);
139
+ console.error(`nostr-bray: ${catalog.promotedCount} promoted tools + ${catalog.size} cataloged (${catalog.promotedCount + catalog.size + 2} total)`);
190
140
  if (config.transport === 'stdio') {
191
141
  const { StdioServerTransport } = await import('@modelcontextprotocol/sdk/server/stdio.js');
192
142
  await server.connect(new StdioServerTransport());
@@ -197,8 +147,8 @@ else {
197
147
  const { StreamableHTTPServerTransport } = await import('@modelcontextprotocol/sdk/server/streamableHttp.js');
198
148
  const { isInitializeRequest } = await import('@modelcontextprotocol/sdk/types.js');
199
149
  const { randomUUID, timingSafeEqual } = await import('node:crypto');
200
- const token = process.env.BRAY_HTTP_TOKEN ?? randomUUID();
201
- console.error(`nostr-bray HTTP auth token: ${token}`);
150
+ const { resolveHttpToken } = await import('./http-token.js');
151
+ const token = resolveHttpToken();
202
152
  const expectedAuth = Buffer.from(`Bearer ${token}`);
203
153
  // Sliding window rate limiter (per-IP).
204
154
  // Cap the map size so a caller rotating source IPs cannot grow it without
@@ -133,11 +133,15 @@ export interface PayResult {
133
133
  credentialId: string;
134
134
  costSats?: number;
135
135
  }
136
- export declare function storeCredential(id: string, macaroon: string, preimage: string): void;
137
- export declare function getCredential(id: string): {
136
+ interface StoredCredential {
138
137
  macaroon: string;
139
138
  preimage: string;
140
- } | undefined;
139
+ origin: string;
140
+ }
141
+ /** The origin a credential is bound to, normalised as `URL.origin`. */
142
+ export declare function credentialOrigin(url: string): string;
143
+ export declare function storeCredential(id: string, macaroon: string, preimage: string, url: string): void;
144
+ export declare function getCredential(id: string): StoredCredential | undefined;
141
145
  export declare function clearCredentials(): void;
142
146
  /** Build the L402 Authorization header from stored credentials. */
143
147
  export declare function buildL402AuthHeader(credentialId: string): string | null;
@@ -194,3 +198,4 @@ export declare function handleMarketplaceRetire(ctx: SigningContext, pool: Relay
194
198
  event: NostrEvent;
195
199
  publish: PublishResult;
196
200
  }>;
201
+ export {};
@@ -310,10 +310,23 @@ export function extractBolt11AmountSats(bolt11) {
310
310
  }
311
311
  return undefined;
312
312
  }
313
- // In-memory credential store for L402 tokens (per-session, not persisted)
314
313
  const credentialStore = new Map();
315
- export function storeCredential(id, macaroon, preimage) {
316
- credentialStore.set(id, { macaroon, preimage });
314
+ /** The origin a credential is bound to, normalised as `URL.origin`. */
315
+ export function credentialOrigin(url) {
316
+ let parsed;
317
+ try {
318
+ parsed = new URL(url);
319
+ }
320
+ catch {
321
+ throw new Error('The URL the L402 challenge came from is not a valid URL.');
322
+ }
323
+ if (parsed.protocol !== 'https:' && parsed.protocol !== 'http:') {
324
+ throw new Error('L402 credentials can only be bound to an http(s) origin.');
325
+ }
326
+ return parsed.origin;
327
+ }
328
+ export function storeCredential(id, macaroon, preimage, url) {
329
+ credentialStore.set(id, { macaroon, preimage, origin: credentialOrigin(url) });
317
330
  }
318
331
  export function getCredential(id) {
319
332
  return credentialStore.get(id);
@@ -331,10 +344,14 @@ export function buildL402AuthHeader(credentialId) {
331
344
  /** Make an authenticated API call using L402 credentials. */
332
345
  export async function handleMarketplaceCall(args) {
333
346
  validatePublicUrl(args.url);
347
+ const credential = credentialStore.get(args.credentialId);
334
348
  const authHeader = buildL402AuthHeader(args.credentialId);
335
- if (!authHeader) {
349
+ if (!credential || !authHeader) {
336
350
  throw new Error(`No credentials found for ID "${args.credentialId}". Use marketplace-pay first.`);
337
351
  }
352
+ if (credentialOrigin(args.url) !== credential.origin) {
353
+ throw new Error(`That credential was paid for ${credential.origin} and is only sent there; refusing to send it to ${credentialOrigin(args.url)}.`);
354
+ }
338
355
  const headers = {
339
356
  ...args.headers,
340
357
  Authorization: authHeader,