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.
- package/README.md +77 -10
- package/dist/bunker-context.d.ts +18 -0
- package/dist/bunker-context.js +20 -5
- package/dist/catalog.d.ts +11 -1
- package/dist/catalog.js +28 -1
- package/dist/cli/commands/wallet.js +2 -1
- package/dist/cli/commands/zap.js +2 -2
- package/dist/cli/index.js +2 -0
- package/dist/config.d.ts +2 -0
- package/dist/config.js +9 -0
- package/dist/dispatch/tools.js +1 -1
- package/dist/file-lock.d.ts +2 -0
- package/dist/file-lock.js +71 -0
- package/dist/heartwood-context.d.ts +53 -7
- package/dist/heartwood-context.js +120 -22
- package/dist/http-token.d.ts +8 -0
- package/dist/http-token.js +18 -0
- package/dist/identity/tools.d.ts +3 -0
- package/dist/index.js +17 -64
- package/dist/marketplace/handlers.d.ts +8 -3
- package/dist/marketplace/handlers.js +21 -4
- package/dist/marketplace/tools.js +38 -6
- package/dist/sdk.js +4 -1
- package/dist/tool-groups.d.ts +16 -0
- package/dist/tool-groups.js +52 -0
- package/dist/types.d.ts +2 -0
- package/dist/version.d.ts +1 -0
- package/dist/version.js +6 -0
- package/dist/wallet-service/grants.d.ts +24 -1
- package/dist/wallet-service/grants.js +172 -20
- package/dist/wallet-service/service.d.ts +9 -3
- package/dist/wallet-service/service.js +136 -36
- package/dist/wallet-service/tools.d.ts +10 -1
- package/dist/wallet-service/tools.js +101 -111
- package/dist/wallet-service/upstream.d.ts +2 -0
- package/dist/wallet-service/upstream.js +41 -5
- package/dist/zap/confirm.d.ts +16 -0
- package/dist/zap/confirm.js +58 -0
- package/dist/zap/handlers.d.ts +10 -1
- package/dist/zap/handlers.js +56 -6
- package/dist/zap/index.d.ts +1 -0
- package/dist/zap/index.js +1 -0
- package/dist/zap/payment-guard.d.ts +59 -0
- package/dist/zap/payment-guard.js +213 -0
- package/dist/zap/tools.js +29 -2
- package/llms-full.txt +13 -9
- package/llms.txt +4 -4
- 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.**
|
|
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
|
[](https://www.npmjs.com/package/nostr-bray)
|
|
6
6
|
[](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** |
|
|
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
|
|
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
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
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
|
|
290
|
-
|
|
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/bunker-context.d.ts
CHANGED
|
@@ -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
|
package/dist/bunker-context.js
CHANGED
|
@@ -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
|
-
|
|
26
|
-
|
|
27
|
-
const
|
|
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
|
|
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:
|
|
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-
|
|
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.
|
package/dist/cli/commands/zap.js
CHANGED
|
@@ -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
|
}
|
package/dist/dispatch/tools.js
CHANGED
|
@@ -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. "
|
|
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,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
|
-
*
|
|
18
|
-
*
|
|
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
|
-
/**
|
|
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>;
|