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
@@ -5,50 +5,148 @@
5
5
  * extension methods for identity derivation, switching, proofs, and recovery.
6
6
  * HeartwoodContext auto-detects these extensions by probing the remote signer.
7
7
  */
8
- import { BunkerContext } from './bunker-context.js';
8
+ import { BunkerContext, withTimeout, TimeoutError, REQUEST_TIMEOUT_MS } from './bunker-context.js';
9
+ /** Matches an error whose text indicates the remote signer does not know the method at all. */
10
+ function isMethodUnknownError(message) {
11
+ return /unsupported method|method not found|unknown method|no such method|not implemented/i.test(message);
12
+ }
13
+ /** Matches an error whose text indicates the request was understood but refused by policy. */
14
+ function isUnauthorisedError(message) {
15
+ return /unauthori[sz]ed|denied|not permitted|forbidden/i.test(message);
16
+ }
17
+ /** How long a Heartwood request runs before we hint that it's probably
18
+ * waiting on a device button press. Well under REQUEST_TIMEOUT_MS so the
19
+ * hint has a chance to show before the request either completes or times out. */
20
+ const APPROVAL_HINT_MS = 3_000;
21
+ /**
22
+ * Wrap a Heartwood NIP-46 request with the standard 60s timeout, and log a
23
+ * one-off hint to stderr if it's still pending after {@link APPROVAL_HINT_MS}.
24
+ * bray is an MCP server over stdio, so this MUST go to stderr (console.error,
25
+ * same channel every other log line in this codebase uses) and never stdout,
26
+ * which carries the JSON-RPC transport.
27
+ */
28
+ function withApprovalHint(promise, label) {
29
+ const hint = setTimeout(() => {
30
+ console.error(`Waiting for approval on your Heartwood (${label})...`);
31
+ }, APPROVAL_HINT_MS);
32
+ return withTimeout(promise, REQUEST_TIMEOUT_MS, label).finally(() => clearTimeout(hint));
33
+ }
34
+ /** Extract a human-readable message from whatever `sendRequest` rejected with.
35
+ * NIP-46 error responses reject with a bare string (the signer's `error`
36
+ * field); our own code (timeouts, network failures) rejects with an `Error`. */
37
+ function errorMessage(e) {
38
+ if (typeof e === 'string')
39
+ return e;
40
+ if (e instanceof Error)
41
+ return e.message;
42
+ return String(e);
43
+ }
9
44
  export class HeartwoodContext extends BunkerContext {
45
+ /** Whether `listIdentities()` (and thus features that depend on the
46
+ * device's identity list) is expected to work for this pairing. `true`
47
+ * unless `probe()` could not confirm listing works. */
48
+ listingAvailable = true;
49
+ /** Set by `probe()` alongside `listingAvailable = false`; explains why. */
50
+ listingUnavailableReason;
51
+ /** Human-readable explanation of `listingUnavailableReason`, suitable for
52
+ * logging or surfacing to a caller. Undefined when listing is available. */
53
+ listingUnavailableMessage;
10
54
  /**
11
55
  * Probe a connected BunkerContext for Heartwood extensions.
12
- * Returns a HeartwoodContext if extensions are available, null otherwise.
13
56
  *
14
- * The probe sends a single heartwood_list_identities request. If the remote
15
- * signer responds with a result (not an error), the extensions are available.
57
+ * Returns `null` when the remote signer does not speak the Heartwood
58
+ * dialect at all (method unknown/unsupported, or a response that doesn't
59
+ * look like an identity list) -- the base context stays a plain bunker,
60
+ * silently, as before.
61
+ *
62
+ * Returns a `HeartwoodContext` in every other case where the device IS a
63
+ * Heartwood but identity listing could not be confirmed:
64
+ * - the request was refused by policy (`unauthorised`/denied -- e.g. a
65
+ * strict slot that doesn't list `heartwood_list_identities`), or
66
+ * - the request timed out, most likely because it's awaiting a button
67
+ * press on the device right now.
68
+ *
69
+ * In both of those cases `listingAvailable` is set to `false` and
70
+ * `listingUnavailableReason` / `listingUnavailableMessage` explain why.
71
+ * Callers that only check "is this a Heartwood" (`hw !== null` /
72
+ * `instanceof HeartwoodContext`) see no change in behaviour.
16
73
  */
17
74
  static async probe(base) {
75
+ // Cast to access protected signer — safe because HeartwoodContext extends BunkerContext
76
+ const signer = base.signer;
77
+ let result;
18
78
  try {
19
- // Cast to access protected signer — safe because HeartwoodContext extends BunkerContext
20
- const signer = base.signer;
21
- const result = await signer.sendRequest('heartwood_list_identities', []);
22
- if (!isHeartwoodIdentitiesResponse(result))
23
- return null;
24
- // Re-class the base context as HeartwoodContext
25
- return Object.setPrototypeOf(base, HeartwoodContext.prototype);
79
+ result = await withTimeout(signer.sendRequest('heartwood_list_identities', []), REQUEST_TIMEOUT_MS, 'heartwood probe (heartwood_list_identities)');
26
80
  }
27
- catch {
81
+ catch (e) {
82
+ const message = errorMessage(e);
83
+ if (e instanceof TimeoutError) {
84
+ return HeartwoodContext.upgrade(base, 'pending-approval', 'Heartwood is awaiting approval on the device for identity listing ' +
85
+ '(heartwood_list_identities timed out) -- approve the pending request on the device.');
86
+ }
87
+ if (isUnauthorisedError(message)) {
88
+ return HeartwoodContext.upgrade(base, 'denied', 'Heartwood denied identity listing for this pairing; allow heartwood_list_identities for this app in Sapwood.');
89
+ }
90
+ if (isMethodUnknownError(message))
91
+ return null;
92
+ // Anything else is genuinely ambiguous -- keep the previous, conservative
93
+ // behaviour of treating it as "not a Heartwood" rather than guessing.
28
94
  return null;
29
95
  }
96
+ if (!isHeartwoodIdentitiesResponse(result))
97
+ return null;
98
+ // Re-class the base context as HeartwoodContext. Object.setPrototypeOf
99
+ // only swaps the prototype -- it does NOT re-run class field
100
+ // initialisers on an object that was constructed as a plain
101
+ // BunkerContext, so `listingAvailable` must be set explicitly here.
102
+ const hw = Object.setPrototypeOf(base, HeartwoodContext.prototype);
103
+ hw.listingAvailable = true;
104
+ hw.listingUnavailableReason = undefined;
105
+ hw.listingUnavailableMessage = undefined;
106
+ return hw;
107
+ }
108
+ /** Re-class `base` as a HeartwoodContext with listing marked unavailable. */
109
+ static upgrade(base, reason, message) {
110
+ const hw = Object.setPrototypeOf(base, HeartwoodContext.prototype);
111
+ hw.listingAvailable = false;
112
+ hw.listingUnavailableReason = reason;
113
+ hw.listingUnavailableMessage = message;
114
+ return hw;
30
115
  }
31
- /** Derive a child identity by purpose and index on the Heartwood device. */
116
+ /** Derive a child identity by purpose and index on the Heartwood device.
117
+ * Does not depend on `heartwood_list_identities` -- works even when
118
+ * `listingAvailable` is `false`. */
32
119
  async derive(purpose, index) {
33
- const result = await this.signer.sendRequest('heartwood_derive', [purpose, String(index)]);
120
+ const result = await withApprovalHint(this.signer.sendRequest('heartwood_derive', [purpose, String(index)]), 'heartwood derive');
34
121
  return JSON.parse(result);
35
122
  }
36
- /** Derive a named persona on the Heartwood device. */
123
+ /** Derive a named persona on the Heartwood device.
124
+ * Does not depend on `heartwood_list_identities` -- works even when
125
+ * `listingAvailable` is `false`. */
37
126
  async derivePersona(name, index) {
38
- const result = await this.signer.sendRequest('heartwood_derive_persona', [name, String(index)]);
127
+ const result = await withApprovalHint(this.signer.sendRequest('heartwood_derive_persona', [name, String(index)]), 'heartwood derive persona');
39
128
  return JSON.parse(result);
40
129
  }
41
- /** List all known identities on the Heartwood device. */
130
+ /** List all known identities on the Heartwood device.
131
+ *
132
+ * Throws immediately, without a device round trip, when `probe()` already
133
+ * established that listing isn't available for this pairing -- retrying
134
+ * would just re-hit the same denial or wait out the same timeout. */
42
135
  async listIdentities() {
43
- const result = await this.signer.sendRequest('heartwood_list_identities', []);
136
+ if (!this.listingAvailable) {
137
+ throw new Error(this.listingUnavailableMessage ?? 'Heartwood identity listing is unavailable for this pairing.');
138
+ }
139
+ const result = await withApprovalHint(this.signer.sendRequest('heartwood_list_identities', []), 'heartwood list identities');
44
140
  return JSON.parse(result);
45
141
  }
46
- /** Switch the active identity on the Heartwood device. */
142
+ /** Switch the active identity on the Heartwood device.
143
+ * Does not depend on `heartwood_list_identities` -- works even when
144
+ * `listingAvailable` is `false`. */
47
145
  async switch(purposeOrName, index) {
48
146
  const params = index !== undefined
49
147
  ? [purposeOrName, String(index)]
50
148
  : [purposeOrName];
51
- const raw = await this.signer.sendRequest('heartwood_switch', params);
149
+ const raw = await withApprovalHint(this.signer.sendRequest('heartwood_switch', params), 'heartwood switch');
52
150
  // Parse response — device may return { npub } object or a bare npub string
53
151
  const { decode, npubEncode } = await import('nostr-tools/nip19');
54
152
  let npub;
@@ -80,13 +178,13 @@ export class HeartwoodContext extends BunkerContext {
80
178
  }
81
179
  /** Create a linkage proof on the Heartwood device. */
82
180
  async prove(mode = 'blind') {
83
- const result = await this.signer.sendRequest('heartwood_create_proof', [this.activePublicKeyHex, mode]);
181
+ const result = await withApprovalHint(this.signer.sendRequest('heartwood_create_proof', [this.activePublicKeyHex, mode]), 'heartwood create proof');
84
182
  return JSON.parse(result);
85
183
  }
86
184
  /** Recover identities by scanning derived keys on the Heartwood device. */
87
185
  async recover(lookahead) {
88
186
  const params = lookahead !== undefined ? [String(lookahead)] : [];
89
- const result = await this.signer.sendRequest('heartwood_recover', params);
187
+ const result = await withApprovalHint(this.signer.sendRequest('heartwood_recover', params), 'heartwood recover');
90
188
  return JSON.parse(result);
91
189
  }
92
190
  }
@@ -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
@@ -79,8 +63,11 @@ async function establishBunkerInBackground(base) {
79
63
  // Probe for Heartwood extensions and upgrade the prototype in place so
80
64
  // tools that check `ctx instanceof HeartwoodContext` see it.
81
65
  const hw = await HeartwoodContext.probe(base);
82
- if (hw)
66
+ if (hw) {
83
67
  console.error(`Heartwood extensions detected -- ${base.activeNpub}`);
68
+ if (!hw.listingAvailable)
69
+ console.error(hw.listingUnavailableMessage);
70
+ }
84
71
  }
85
72
  catch (e) {
86
73
  console.error('Heartwood probe failed (non-fatal):', e.message);
@@ -132,58 +119,24 @@ config.nwcUri = undefined;
132
119
  config.bunkerUri = undefined;
133
120
  // NIP-65 relay list is loaded via loadIdentityRelays(): immediately for a local
134
121
  // key, or after the bunker pubkey resolves (see establishBunkerInBackground).
135
- const server = new McpServer({ name: 'nostr-bray', version: '0.1.0' }, {
122
+ const server = new McpServer({ name: 'nostr-bray', version: BRAY_VERSION }, {
136
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.',
137
124
  });
138
125
  // Promoted tools are registered directly with the server (always visible to Claude).
139
126
  // Everything else goes to the catalog, discoverable via search-actions + execute-action.
140
- const PROMOTED = new Set([
141
- 'whoami', 'social-post', 'social-reply', 'social-feed',
142
- 'dm-send', 'dm-read', 'zap-send', 'zap-balance',
143
- 'identity-switch', 'relay-query',
144
- 'signet-badge', 'trust-score', 'vault-read',
145
- 'dispatch-send', 'dispatch-check', 'dispatch-reply',
146
- 'dispatch-ack', 'dispatch-status', 'dispatch-cancel',
147
- 'dispatch-refuse', 'dispatch-failure', 'dispatch-query',
148
- 'article-publish', 'article-read', 'article-list',
149
- 'search-notes', 'search-profiles', 'hashtag-feed',
150
- 'social-profile-get', 'dm-conversation', 'verify-person',
151
- 'dispatch-propose', 'dispatch-capability-publish', 'dispatch-capability-discover', 'dispatch-capability-read',
152
- 'badge-create', 'badge-award', 'badge-accept', 'badge-list',
153
- 'community-create', 'community-feed', 'community-post', 'community-approve', 'community-list',
154
- 'calendar-create', 'calendar-read', 'calendar-rsvp',
155
- 'listing-create', 'listing-read', 'listing-search', 'listing-close',
156
- ]);
157
127
  const catalog = new ActionCatalog();
158
- const proxy = createCatalogProxy(server, catalog, PROMOTED);
159
- // Register all tools — the proxy routes promoted to server, rest to catalog
160
- registerIdentityTools(proxy, deps);
161
- registerSocialTools(proxy, deps);
162
- registerTrustTools(proxy, deps);
163
- registerRelayTools(proxy, deps);
164
- registerRelayIntelligenceTools(proxy, deps);
165
- registerZapTools(proxy, deps);
166
- registerWalletServiceTools(proxy, deps);
167
- registerSafetyTools(proxy, deps);
168
- registerUtilTools(proxy, deps);
169
- registerWorkflowTools(proxy, {
170
- ctx: deps.ctx,
171
- pool: deps.pool,
172
- 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, {
173
132
  veilCacheTtl: config.veilCacheTtl,
174
133
  veilCacheMax: config.veilCacheMax,
134
+ ...(config.dispatchIdentities ? { dispatchIdentitiesPath: config.dispatchIdentities } : {}),
135
+ walletService: config.walletService,
175
136
  });
176
- registerMarketplaceTools(proxy, deps);
177
- registerPrivacyTools(proxy, deps);
178
- registerModerationTools(proxy, deps);
179
- registerSignetTools(proxy, deps);
180
- registerVaultTools(proxy, deps);
181
- registerDispatchTools(proxy, { ...deps, dispatchIdentitiesPath: config.dispatchIdentities });
182
- registerHandlerTools(proxy, deps);
183
- registerSyncTools(proxy, deps);
184
137
  // Add search-actions and execute-action meta-tools to the real server
185
138
  catalog.registerMetaTools(server);
186
- 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)`);
187
140
  if (config.transport === 'stdio') {
188
141
  const { StdioServerTransport } = await import('@modelcontextprotocol/sdk/server/stdio.js');
189
142
  await server.connect(new StdioServerTransport());
@@ -194,8 +147,8 @@ else {
194
147
  const { StreamableHTTPServerTransport } = await import('@modelcontextprotocol/sdk/server/streamableHttp.js');
195
148
  const { isInitializeRequest } = await import('@modelcontextprotocol/sdk/types.js');
196
149
  const { randomUUID, timingSafeEqual } = await import('node:crypto');
197
- const token = process.env.BRAY_HTTP_TOKEN ?? randomUUID();
198
- console.error(`nostr-bray HTTP auth token: ${token}`);
150
+ const { resolveHttpToken } = await import('./http-token.js');
151
+ const token = resolveHttpToken();
199
152
  const expectedAuth = Buffer.from(`Bearer ${token}`);
200
153
  // Sliding window rate limiter (per-IP).
201
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,
@@ -2,8 +2,10 @@ import { z } from 'zod';
2
2
  import { randomUUID } from 'node:crypto';
3
3
  import { hexId, relayUrl } from '../validation.js';
4
4
  import { resolveRecipient } from '../resolve.js';
5
- import { handleMarketplaceDiscover, handleMarketplaceInspect, handleMarketplaceSearch, handleMarketplaceReputation, handleMarketplaceCompare, handleMarketplaceProbe, handleMarketplaceCall, handleMarketplaceAnnounce, handleMarketplaceUpdate, handleMarketplaceRetire, storeCredential, clearCredentials, extractBolt11AmountSats, } from './handlers.js';
5
+ import { handleMarketplaceDiscover, handleMarketplaceInspect, handleMarketplaceSearch, handleMarketplaceReputation, handleMarketplaceCompare, handleMarketplaceProbe, handleMarketplaceCall, handleMarketplaceAnnounce, handleMarketplaceUpdate, handleMarketplaceRetire, storeCredential, credentialOrigin, clearCredentials, extractBolt11AmountSats, } from './handlers.js';
6
6
  import { handleZapSend, handleZapDecode } from '../zap/handlers.js';
7
+ import { defaultPaymentGuard } from '../zap/payment-guard.js';
8
+ import { askHumanToApprovePayment } from '../zap/confirm.js';
7
9
  import { handleListingCreate, handleListingRead, handleListingSearch, handleListingClose, } from './listings.js';
8
10
  import { toolResponse } from '../tool-response.js';
9
11
  import * as fmt from '../format.js';
@@ -137,15 +139,20 @@ export function registerMarketplaceTools(server, deps) {
137
139
  });
138
140
  server.registerTool('marketplace-pay', {
139
141
  description: 'Pay an L402 invoice via NWC and store credentials for authenticated API calls. ' +
140
- 'SPENDS REAL SATS. Decodes the invoice first — set confirm: true to execute payment. ' +
142
+ 'SPENDS REAL SATS. Decodes the invoice first; set confirm: true to pay. When the MCP client supports ' +
143
+ 'elicitation the human is asked to approve the payment as well, and bray\'s spending caps always apply. ' +
141
144
  'Returns an opaque credential ID for use with marketplace-call.',
142
145
  inputSchema: {
146
+ url: z.string().url().describe('The endpoint URL whose 402 challenge this is. The credential is only ever sent back to this origin'),
143
147
  macaroon: z.string().describe('Base64-encoded macaroon from L402 challenge'),
144
148
  invoice: z.string().describe('Bolt11 Lightning invoice from L402 challenge'),
145
149
  confirm: z.boolean().default(false).describe('Set true to execute payment (preview by default)'),
146
150
  },
147
151
  annotations: { readOnlyHint: false, destructiveHint: true },
148
- }, async ({ macaroon, invoice, confirm }) => {
152
+ }, async ({ url, macaroon, invoice, confirm }) => {
153
+ // Bound before anything is paid: a credential with nowhere valid to go
154
+ // is money spent for nothing.
155
+ const origin = credentialOrigin(url);
149
156
  const decoded = handleZapDecode(invoice);
150
157
  if (decoded.expiry === undefined)
151
158
  throw new Error('Invalid BOLT-11 invoice');
@@ -165,32 +172,57 @@ export function registerMarketplaceTools(server, deps) {
165
172
  }],
166
173
  };
167
174
  }
175
+ const guard = defaultPaymentGuard();
176
+ guard.check(decoded.paymentHash, decoded.amountMsats);
177
+ const approval = await askHumanToApprovePayment(server, {
178
+ amountMsats: decoded.amountMsats,
179
+ purpose: 'buy access to a paid API (L402)',
180
+ ...(decoded.description ? { description: decoded.description } : {}),
181
+ paymentHash: decoded.paymentHash,
182
+ });
183
+ if (approval === 'declined') {
184
+ return {
185
+ content: [{
186
+ type: 'text',
187
+ text: JSON.stringify({
188
+ paid: false,
189
+ declined: true,
190
+ amountMsats: decoded.amountMsats,
191
+ message: 'The payment was not approved, so nothing was sent.',
192
+ }, null, 2),
193
+ }],
194
+ };
195
+ }
168
196
  // Pay via NWC (same as zap-send) — resolve per-identity wallet
169
197
  const { resolveNwcUri } = await import('../zap/handlers.js');
170
198
  const payResult = await handleZapSend(deps.ctx, deps.pool, {
171
199
  invoice,
172
200
  nwcUri: resolveNwcUri(deps.ctx, deps.walletsFile, deps.nwcUri),
201
+ guard,
202
+ ...(deps.nwcTransport ? { transport: deps.nwcTransport } : {}),
173
203
  });
174
204
  // Keep the bearer credential in process and return only an opaque handle.
175
205
  const credentialId = randomUUID();
176
- storeCredential(credentialId, macaroon, payResult.preimage);
206
+ storeCredential(credentialId, macaroon, payResult.preimage, origin);
177
207
  return {
178
208
  content: [{
179
209
  type: 'text',
180
210
  text: JSON.stringify({
181
211
  paid: true,
182
212
  verified: payResult.verified,
213
+ humanApproved: approval === 'approved',
183
214
  credentialId,
215
+ origin,
184
216
  costSats,
185
217
  amountMsats: decoded.amountMsats,
186
- note: 'Use this credentialId with marketplace-call to make authenticated requests.',
218
+ note: `Use this credentialId with marketplace-call to make authenticated requests to ${origin}. It is refused for any other origin.`,
187
219
  }, null, 2),
188
220
  }],
189
221
  };
190
222
  });
191
223
  server.registerTool('marketplace-call', {
192
224
  description: 'Make an authenticated API call using L402 credentials obtained from marketplace-pay. ' +
193
- 'The credential ID maps to a stored macaroon + preimage — never exposed directly.',
225
+ 'The credential ID maps to a stored macaroon + preimage, never exposed directly, and is only sent to the origin it was paid for.',
194
226
  inputSchema: {
195
227
  url: z.string().url().describe('HTTP(S) endpoint URL'),
196
228
  method: z.enum(['GET', 'POST', 'PUT', 'DELETE', 'PATCH']).default('GET').describe('HTTP method'),
package/dist/sdk.js CHANGED
@@ -28,6 +28,7 @@ import { handleTrustSpokenChallenge, handleTrustSpokenVerify } from './trust/spo
28
28
  import { handleRelayInfo, handleRelayList, handleRelaySet, handleRelayQuery } from './relay/handlers.js';
29
29
  import { handleSyncPlan, handleSyncPull, handleSyncPush } from './sync/handlers.js';
30
30
  import { handleZapSend, handleZapBalance, handleZapMakeInvoice, handleZapLookupInvoice, handleZapListTransactions, handleZapReceipts, handleZapDecode, resolveNwcUri } from './zap/handlers.js';
31
+ import { defaultPaymentGuard } from './zap/payment-guard.js';
31
32
  import { handleDuressConfigure, handleDuressActivate } from './safety/handlers.js';
32
33
  import { handlePublishRaw } from './event/handlers.js';
33
34
  import { handleValidateEvent } from './event-validation/validator.js';
@@ -241,7 +242,9 @@ class BrayClientImpl {
241
242
  }
242
243
  // ── Zap ─────────────────────────────────────────────────────────────────────
243
244
  #nwc() { return resolveNwcUri(this.#ctx, this.#walletsFile, this.#nwcUri); }
244
- async zapSend(bolt11) { return handleZapSend(this.#ctx, this.#pool, { invoice: bolt11, nwcUri: this.#nwc() }); }
245
+ async zapSend(bolt11) {
246
+ return handleZapSend(this.#ctx, this.#pool, { invoice: bolt11, nwcUri: this.#nwc(), guard: defaultPaymentGuard() });
247
+ }
245
248
  async zapBalance() { return handleZapBalance(this.#ctx, this.#pool, { nwcUri: this.#nwc() }); }
246
249
  async zapInvoice(amountMsats, description) {
247
250
  return handleZapMakeInvoice(this.#ctx, this.#pool, { amountMsats, description, nwcUri: this.#nwc() });
@@ -0,0 +1,16 @@
1
+ import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
2
+ import type { ToolDeps } from './identity/tools.js';
3
+ export interface ToolGroupOptions {
4
+ veilCacheTtl: number;
5
+ veilCacheMax: number;
6
+ /** Dispatch tools are registered only when an identities file is configured. */
7
+ dispatchIdentitiesPath?: string;
8
+ /** wallet-grant, wallet-refill and wallet-serve are registered only when true. */
9
+ walletService?: boolean;
10
+ }
11
+ /**
12
+ * Register every tool group, in one place. The MCP server, the tool-schema
13
+ * export and the tool-count test all go through this, so the number of
14
+ * tools the docs quote is the number the server registers.
15
+ */
16
+ export declare function registerAllTools(server: McpServer, deps: ToolDeps, options: ToolGroupOptions): void;
@@ -0,0 +1,52 @@
1
+ import { registerIdentityTools } from './identity/tools.js';
2
+ import { registerSocialTools } from './social/tools.js';
3
+ import { registerTrustTools } from './trust/tools.js';
4
+ import { registerRelayTools } from './relay/tools.js';
5
+ import { registerRelayIntelligenceTools } from './relay/intelligence-tools.js';
6
+ import { registerZapTools } from './zap/tools.js';
7
+ import { registerWalletServiceTools } from './wallet-service/tools.js';
8
+ import { registerSafetyTools } from './safety/tools.js';
9
+ import { registerUtilTools } from './util/tools.js';
10
+ import { registerWorkflowTools } from './workflow/tools.js';
11
+ import { registerMarketplaceTools } from './marketplace/tools.js';
12
+ import { registerPrivacyTools } from './privacy/tools.js';
13
+ import { registerModerationTools } from './moderation/tools.js';
14
+ import { registerSignetTools } from './signet/tools.js';
15
+ import { registerVaultTools } from './vault/tools.js';
16
+ import { registerDispatchTools } from './dispatch/tools.js';
17
+ import { registerHandlerTools } from './handler/tools.js';
18
+ import { registerSyncTools } from './sync/tools.js';
19
+ /**
20
+ * Register every tool group, in one place. The MCP server, the tool-schema
21
+ * export and the tool-count test all go through this, so the number of
22
+ * tools the docs quote is the number the server registers.
23
+ */
24
+ export function registerAllTools(server, deps, options) {
25
+ registerIdentityTools(server, deps);
26
+ registerSocialTools(server, deps);
27
+ registerTrustTools(server, deps);
28
+ registerRelayTools(server, deps);
29
+ registerRelayIntelligenceTools(server, deps);
30
+ registerZapTools(server, deps);
31
+ registerWalletServiceTools(server, deps, { issuing: options.walletService === true });
32
+ registerSafetyTools(server, deps);
33
+ registerUtilTools(server, deps);
34
+ registerWorkflowTools(server, {
35
+ ctx: deps.ctx,
36
+ pool: deps.pool,
37
+ nip65: deps.nip65,
38
+ veilCacheTtl: options.veilCacheTtl,
39
+ veilCacheMax: options.veilCacheMax,
40
+ });
41
+ registerMarketplaceTools(server, deps);
42
+ registerPrivacyTools(server, deps);
43
+ registerModerationTools(server, deps);
44
+ registerSignetTools(server, deps);
45
+ registerVaultTools(server, deps);
46
+ registerDispatchTools(server, {
47
+ ...deps,
48
+ ...(options.dispatchIdentitiesPath ? { dispatchIdentitiesPath: options.dispatchIdentitiesPath } : {}),
49
+ });
50
+ registerHandlerTools(server, deps);
51
+ registerSyncTools(server, deps);
52
+ }
package/dist/types.d.ts CHANGED
@@ -45,6 +45,8 @@ export interface BrayConfig {
45
45
  readonly bunkerUri?: string;
46
46
  readonly nwcUri?: string;
47
47
  readonly walletsFile: string;
48
+ /** Whether the spending-authority wallet-service tools are registered (BRAY_WALLET_SERVICE=1). */
49
+ readonly walletService?: boolean;
48
50
  readonly torProxy?: string;
49
51
  readonly allowClearnetWithTor: boolean;
50
52
  readonly allowPrivateRelays: boolean;
@@ -0,0 +1 @@
1
+ export declare const BRAY_VERSION: string;
@@ -0,0 +1,6 @@
1
+ import { createRequire } from 'node:module';
2
+ // Read from package.json at runtime so the version the MCP server reports
3
+ // cannot drift from the one that was published. From both src/ (tests) and
4
+ // dist/ (the build), package.json is one directory up.
5
+ const require = createRequire(import.meta.url);
6
+ export const BRAY_VERSION = require('../package.json').version;