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.
- package/README.md +77 -10
- 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/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/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 +13 -63
- 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 +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
|
|
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;
|
|
@@ -1,8 +1,31 @@
|
|
|
1
|
-
import type
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
38
|
-
|
|
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
|
-
|
|
41
|
-
|
|
42
|
-
file = parsed;
|
|
90
|
+
process.kill(pid, 0);
|
|
91
|
+
return pid;
|
|
43
92
|
}
|
|
44
|
-
catch {
|
|
45
|
-
|
|
46
|
-
// alternative is refusing to ever write again
|
|
93
|
+
catch (err) {
|
|
94
|
+
return err.code === 'EPERM' ? pid : null;
|
|
47
95
|
}
|
|
48
96
|
}
|
|
49
|
-
|
|
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
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
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"
|
|
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
|
|
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
|
-
|
|
92
|
+
store?: GrantStore;
|
|
93
|
+
persist?: () => Promise<void>;
|
|
88
94
|
log?: (message: string) => void;
|
|
89
95
|
now?: () => number;
|
|
90
96
|
}
|