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
|
@@ -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
|
-
*
|
|
15
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
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
|
+
}
|
package/dist/identity/tools.d.ts
CHANGED
|
@@ -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 {
|
|
22
|
-
import {
|
|
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:
|
|
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,
|
|
159
|
-
// Register all tools
|
|
160
|
-
|
|
161
|
-
|
|
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: ${
|
|
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
|
|
198
|
-
|
|
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
|
-
|
|
137
|
-
export declare function getCredential(id: string): {
|
|
136
|
+
interface StoredCredential {
|
|
138
137
|
macaroon: string;
|
|
139
138
|
preimage: string;
|
|
140
|
-
|
|
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
|
-
|
|
316
|
-
|
|
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
|
|
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:
|
|
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
|
|
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) {
|
|
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;
|
package/dist/version.js
ADDED
|
@@ -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;
|