nostr-bray 3.4.3 → 3.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. package/README.md +77 -10
  2. package/dist/catalog.d.ts +11 -1
  3. package/dist/catalog.js +28 -1
  4. package/dist/cli/commands/wallet.js +2 -1
  5. package/dist/cli/commands/zap.js +2 -2
  6. package/dist/config.d.ts +2 -0
  7. package/dist/config.js +9 -0
  8. package/dist/dispatch/tools.js +1 -1
  9. package/dist/file-lock.d.ts +2 -0
  10. package/dist/file-lock.js +71 -0
  11. package/dist/http-token.d.ts +8 -0
  12. package/dist/http-token.js +18 -0
  13. package/dist/identity/tools.d.ts +3 -0
  14. package/dist/index.js +13 -63
  15. package/dist/marketplace/handlers.d.ts +8 -3
  16. package/dist/marketplace/handlers.js +21 -4
  17. package/dist/marketplace/tools.js +38 -6
  18. package/dist/sdk.js +4 -1
  19. package/dist/tool-groups.d.ts +16 -0
  20. package/dist/tool-groups.js +52 -0
  21. package/dist/types.d.ts +2 -0
  22. package/dist/version.d.ts +1 -0
  23. package/dist/version.js +6 -0
  24. package/dist/wallet-service/grants.d.ts +24 -1
  25. package/dist/wallet-service/grants.js +172 -20
  26. package/dist/wallet-service/service.d.ts +9 -3
  27. package/dist/wallet-service/service.js +136 -36
  28. package/dist/wallet-service/tools.d.ts +10 -1
  29. package/dist/wallet-service/tools.js +101 -111
  30. package/dist/wallet-service/upstream.d.ts +2 -0
  31. package/dist/wallet-service/upstream.js +41 -5
  32. package/dist/zap/confirm.d.ts +16 -0
  33. package/dist/zap/confirm.js +58 -0
  34. package/dist/zap/handlers.d.ts +10 -1
  35. package/dist/zap/handlers.js +56 -6
  36. package/dist/zap/index.d.ts +1 -0
  37. package/dist/zap/index.js +1 -0
  38. package/dist/zap/payment-guard.d.ts +59 -0
  39. package/dist/zap/payment-guard.js +213 -0
  40. package/dist/zap/tools.js +29 -2
  41. package/llms-full.txt +13 -9
  42. package/llms.txt +4 -4
  43. package/package.json +16 -2
@@ -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;
@@ -1,8 +1,31 @@
1
- import type { Grant } from './service.js';
1
+ import { type Grant, type GrantStore } from './service.js';
2
2
  export interface GrantsFile {
3
3
  version: 1;
4
4
  grants: Record<string, Grant[]>;
5
5
  }
6
6
  export declare function defaultGrantsFile(): string;
7
7
  export declare function loadGrants(path: string, pubkeyHex: string): Grant[];
8
+ /**
9
+ * Change one identity's grants as they stand on disk now, under a lock.
10
+ * If `mutate` throws, nothing is written.
11
+ */
12
+ export declare function updateGrants<T>(path: string, pubkeyHex: string, mutate: (grants: Grant[]) => T): T;
13
+ /** Replace one identity's grants wholesale. Prefer `updateGrants`. */
8
14
  export declare function saveGrants(path: string, pubkeyHex: string, grants: Grant[]): void;
15
+ /** Claim the right to serve the grants in `path`, or say who holds it. */
16
+ export declare function acquireServeLock(path: string): void;
17
+ export declare function releaseServeLock(path: string): void;
18
+ /** A `GrantStore` over the grants file, for the process that serves them. */
19
+ export declare function fileGrantStore(path: string, pubkeyHex: string): GrantStore;
20
+ /** Revoke a connection on disk. Every process sees it before its next request. */
21
+ export declare function revokeGrant(path: string, pubkeyHex: string, nameOrId: string, now?: number): Grant;
22
+ /** Reset a spending connection's budget on disk, optionally to a new figure. */
23
+ export declare function refillGrant(path: string, pubkeyHex: string, nameOrId: string, budgetMsat?: number): Grant;
24
+ /**
25
+ * Write a grant's connection URI to its own 0600 file and return the path.
26
+ *
27
+ * The URI is a bearer secret: whoever holds it can do what the grant
28
+ * allows. It goes to a file for the operator to hand over, never into a
29
+ * tool result, where it would sit in a model's context and transcript.
30
+ */
31
+ export declare function writeGrantUriFile(grantsFile: string, grant: Grant): string;
@@ -1,21 +1,30 @@
1
- import { chmodSync, existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
1
+ import { chmodSync, closeSync, existsSync, mkdirSync, openSync, readFileSync, renameSync, unlinkSync, writeFileSync, writeSync } from 'node:fs';
2
2
  import { dirname, join } from 'node:path';
3
- import { homedir } from 'node:os';
4
3
  import { randomUUID } from 'node:crypto';
4
+ import { homedir } from 'node:os';
5
+ import { grantUri } from './service.js';
6
+ import { withFileLock } from '../file-lock.js';
5
7
  // Where the grants live.
6
8
  //
7
9
  // Each one carries two secret keys: the service key it answers under, and
8
10
  // the client key inside the URI that was handed out. Both spend, so the
9
11
  // file is 0600 and written atomically - a half-written grants file after a
10
12
  // crash would either lose a capability or leave one nobody can revoke.
13
+ //
14
+ // The file, not any process's memory, is the record. Several bray
15
+ // processes can share it (an MCP session, a CLI, a second client), so
16
+ // every change is a locked read-modify-write of the current file, and the
17
+ // process that serves grants re-reads a grant before each request. A
18
+ // revocation or refill made anywhere is therefore seen everywhere, and no
19
+ // process can undo one by writing back what it happened to remember.
11
20
  const MAX_FILE_BYTES = 1_048_576;
12
21
  const MAX_GRANTS = 128;
13
22
  export function defaultGrantsFile() {
14
23
  return join(process.env.BRAY_HOME ?? join(homedir(), '.config', 'bray'), 'wallet-grants.json');
15
24
  }
16
- export function loadGrants(path, pubkeyHex) {
25
+ function readFile(path) {
17
26
  if (!existsSync(path))
18
- return [];
27
+ return { version: 1, grants: {} };
19
28
  const raw = readFileSync(path, 'utf8');
20
29
  if (raw.length > MAX_FILE_BYTES)
21
30
  throw new Error('The grants file is implausibly large; refusing to read it.');
@@ -26,31 +35,174 @@ export function loadGrants(path, pubkeyHex) {
26
35
  catch {
27
36
  throw new Error('The grants file is not readable JSON.');
28
37
  }
29
- if (parsed?.version !== 1 || typeof parsed.grants !== 'object')
30
- return [];
31
- const held = parsed.grants[pubkeyHex];
38
+ if (parsed?.version !== 1 || typeof parsed.grants !== 'object' || parsed.grants === null) {
39
+ return { version: 1, grants: {} };
40
+ }
41
+ return parsed;
42
+ }
43
+ function writeFile(path, file) {
44
+ mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
45
+ const temporary = `${path}.${randomUUID()}.tmp`;
46
+ writeFileSync(temporary, JSON.stringify(file, null, 2), { mode: 0o600 });
47
+ chmodSync(temporary, 0o600);
48
+ renameSync(temporary, path);
49
+ chmodSync(path, 0o600);
50
+ }
51
+ export function loadGrants(path, pubkeyHex) {
52
+ const held = readFile(path).grants[pubkeyHex];
32
53
  return Array.isArray(held) ? held : [];
33
54
  }
55
+ /**
56
+ * Change one identity's grants as they stand on disk now, under a lock.
57
+ * If `mutate` throws, nothing is written.
58
+ */
59
+ export function updateGrants(path, pubkeyHex, mutate) {
60
+ return withFileLock(path, () => {
61
+ const file = readFile(path);
62
+ const grants = Array.isArray(file.grants[pubkeyHex]) ? file.grants[pubkeyHex] : [];
63
+ const result = mutate(grants);
64
+ if (grants.length > MAX_GRANTS)
65
+ throw new Error(`No more than ${MAX_GRANTS} connections per identity.`);
66
+ file.grants[pubkeyHex] = grants;
67
+ writeFile(path, file);
68
+ return result;
69
+ });
70
+ }
71
+ /** Replace one identity's grants wholesale. Prefer `updateGrants`. */
34
72
  export function saveGrants(path, pubkeyHex, grants) {
35
73
  if (grants.length > MAX_GRANTS)
36
74
  throw new Error(`No more than ${MAX_GRANTS} connections per identity.`);
37
- let file = { version: 1, grants: {} };
38
- if (existsSync(path)) {
75
+ updateGrants(path, pubkeyHex, (held) => {
76
+ held.splice(0, held.length, ...grants);
77
+ });
78
+ }
79
+ // Only one process answers for the grants at a time. Two serving the same
80
+ // connection would each charge their own copy of its budget and each
81
+ // answer the same request.
82
+ function servingPid(lockPath) {
83
+ try {
84
+ const pid = Number.parseInt(readFileSync(lockPath, 'utf8'), 10);
85
+ if (!Number.isSafeInteger(pid) || pid <= 0)
86
+ return null;
87
+ if (pid === process.pid)
88
+ return pid;
39
89
  try {
40
- const parsed = JSON.parse(readFileSync(path, 'utf8'));
41
- if (parsed?.version === 1 && typeof parsed.grants === 'object')
42
- file = parsed;
90
+ process.kill(pid, 0);
91
+ return pid;
43
92
  }
44
- catch {
45
- // an unreadable file is replaced rather than appended to; the
46
- // alternative is refusing to ever write again
93
+ catch (err) {
94
+ return err.code === 'EPERM' ? pid : null;
47
95
  }
48
96
  }
49
- file.grants[pubkeyHex] = grants;
97
+ catch {
98
+ return null;
99
+ }
100
+ }
101
+ /** Claim the right to serve the grants in `path`, or say who holds it. */
102
+ export function acquireServeLock(path) {
103
+ const lockPath = `${path}.serving`;
50
104
  mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
51
- const temporary = `${path}.${randomUUID()}.tmp`;
52
- writeFileSync(temporary, JSON.stringify(file, null, 2), { mode: 0o600 });
53
- chmodSync(temporary, 0o600);
54
- renameSync(temporary, path);
105
+ for (let attempt = 0; attempt < 3; attempt++) {
106
+ try {
107
+ const descriptor = openSync(lockPath, 'wx', 0o600);
108
+ try {
109
+ writeSync(descriptor, String(process.pid));
110
+ }
111
+ finally {
112
+ closeSync(descriptor);
113
+ }
114
+ return;
115
+ }
116
+ catch (err) {
117
+ if (err.code !== 'EEXIST')
118
+ throw err;
119
+ const holder = servingPid(lockPath);
120
+ if (holder === process.pid)
121
+ return;
122
+ if (holder !== null) {
123
+ throw new Error(`Another bray process (pid ${holder}) is already serving these wallet connections. Stop it first; two processes serving the same connections would each count their own budget.`);
124
+ }
125
+ // The holder is gone: its claim goes with it.
126
+ try {
127
+ unlinkSync(lockPath);
128
+ }
129
+ catch { /* someone else cleared it first */ }
130
+ }
131
+ }
132
+ throw new Error('Could not claim the wallet-service lock.');
133
+ }
134
+ export function releaseServeLock(path) {
135
+ const lockPath = `${path}.serving`;
136
+ if (servingPid(lockPath) === process.pid) {
137
+ try {
138
+ unlinkSync(lockPath);
139
+ }
140
+ catch { /* already gone */ }
141
+ }
142
+ }
143
+ /** A `GrantStore` over the grants file, for the process that serves them. */
144
+ export function fileGrantStore(path, pubkeyHex) {
145
+ return {
146
+ read: (grantId) => loadGrants(path, pubkeyHex).find((grant) => grant.id === grantId),
147
+ update: (grantId, mutate) => updateGrants(path, pubkeyHex, (grants) => {
148
+ const grant = grants.find((held) => held.id === grantId);
149
+ if (!grant)
150
+ return undefined;
151
+ mutate(grant);
152
+ return structuredClone(grant);
153
+ }),
154
+ };
155
+ }
156
+ function find(grants, nameOrId, byPrefix) {
157
+ const wanted = nameOrId.trim().toLowerCase();
158
+ return (grants.find((held) => held.id === wanted) ??
159
+ grants.find((held) => held.name.toLowerCase() === wanted) ??
160
+ (byPrefix ? grants.find((held) => held.id.startsWith(wanted)) : undefined));
161
+ }
162
+ /** Revoke a connection on disk. Every process sees it before its next request. */
163
+ export function revokeGrant(path, pubkeyHex, nameOrId, now = Date.now()) {
164
+ return updateGrants(path, pubkeyHex, (grants) => {
165
+ const grant = find(grants, nameOrId, true);
166
+ if (!grant)
167
+ throw new Error(`No connection here called ${nameOrId}.`);
168
+ grant.revokedAt ??= now;
169
+ return structuredClone(grant);
170
+ });
171
+ }
172
+ /** Reset a spending connection's budget on disk, optionally to a new figure. */
173
+ export function refillGrant(path, pubkeyHex, nameOrId, budgetMsat) {
174
+ return updateGrants(path, pubkeyHex, (grants) => {
175
+ const grant = find(grants, nameOrId, false);
176
+ if (!grant)
177
+ throw new Error(`No connection here called ${nameOrId}.`);
178
+ if (grant.revokedAt)
179
+ throw new Error('That connection is revoked - issue a new one.');
180
+ if (grant.budgetMsat === undefined)
181
+ throw new Error('That connection cannot spend, so it has no budget.');
182
+ if (budgetMsat !== undefined) {
183
+ if (grant.maxPaymentMsat !== undefined && grant.maxPaymentMsat > budgetMsat) {
184
+ throw new Error('The per-payment ceiling is above that budget.');
185
+ }
186
+ grant.budgetMsat = budgetMsat;
187
+ }
188
+ grant.spentMsat = 0;
189
+ return structuredClone(grant);
190
+ });
191
+ }
192
+ /**
193
+ * Write a grant's connection URI to its own 0600 file and return the path.
194
+ *
195
+ * The URI is a bearer secret: whoever holds it can do what the grant
196
+ * allows. It goes to a file for the operator to hand over, never into a
197
+ * tool result, where it would sit in a model's context and transcript.
198
+ */
199
+ export function writeGrantUriFile(grantsFile, grant) {
200
+ const directory = join(dirname(grantsFile), 'wallet-connections');
201
+ mkdirSync(directory, { recursive: true, mode: 0o700 });
202
+ chmodSync(directory, 0o700);
203
+ const slug = grant.name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 40) || 'connection';
204
+ const path = join(directory, `${slug}-${grant.id}.nwc`);
205
+ writeFileSync(path, `${grantUri(grant)}\n`, { mode: 0o600, flag: 'wx' });
55
206
  chmodSync(path, 0o600);
207
+ return path;
56
208
  }
@@ -2,7 +2,7 @@ import type { Event as NostrEvent, Filter } from 'nostr-tools';
2
2
  export declare const NWC_INFO_KIND = 13194;
3
3
  export declare const NWC_REQUEST_KIND = 23194;
4
4
  export declare const NWC_RESPONSE_KIND = 23195;
5
- export declare const DEFAULT_METHODS: readonly ["get_info", "make_invoice", "lookup_invoice"];
5
+ export declare const DEFAULT_METHODS: readonly ["get_info", "make_invoice"];
6
6
  export declare const SUPPORTED_METHODS: readonly ["get_info", "get_balance", "make_invoice", "lookup_invoice", "pay_invoice"];
7
7
  export declare const SPENDING_METHODS: readonly string[];
8
8
  export declare class ServiceError extends Error {
@@ -36,7 +36,7 @@ export interface ServiceWallet {
36
36
  amountMsat: number;
37
37
  }): Promise<{
38
38
  preimage: string;
39
- feesPaidMsat: number;
39
+ feesPaidMsat?: number;
40
40
  }>;
41
41
  lookupInvoice(query: {
42
42
  paymentHash?: string;
@@ -60,6 +60,7 @@ export interface Grant {
60
60
  spentMsat: number;
61
61
  maxPaymentMsat?: number;
62
62
  seen?: string[];
63
+ hashes?: string[];
63
64
  createdAt: number;
64
65
  lastUsedAt?: number;
65
66
  revokedAt?: number;
@@ -81,10 +82,15 @@ export declare function newGrant(input: {
81
82
  maxPaymentMsat?: number;
82
83
  }): Grant;
83
84
  export declare function infoEvent(grant: Grant): NostrEvent;
85
+ export interface GrantStore {
86
+ read(grantId: string): Grant | undefined;
87
+ update(grantId: string, mutate: (grant: Grant) => void): Grant | undefined;
88
+ }
84
89
  export interface ServiceOptions {
85
90
  wallet: ServiceWallet;
86
91
  transport: ServiceTransport;
87
- persist: () => Promise<void>;
92
+ store?: GrantStore;
93
+ persist?: () => Promise<void>;
88
94
  log?: (message: string) => void;
89
95
  now?: () => number;
90
96
  }