nostr-bray 2.3.0 → 3.0.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 CHANGED
@@ -212,13 +212,17 @@ Search order: `BRAY_CONFIG` env var > `$XDG_CONFIG_HOME/bray/config.json` > `~/.
212
212
  | `NOSTR_SECRET_KEY_FILE` | Path to secret key file |
213
213
  | `NOSTR_NCRYPTSEC` | NIP-49 encrypted key |
214
214
  | `NOSTR_NCRYPTSEC_PASSWORD` | Password for ncryptsec |
215
+ | `NWC_URI_FILE` | Path to a private `0600` file containing the NWC bearer URI |
215
216
  | `NOSTR_RELAYS` | Comma-separated relay URLs |
216
217
  | `TOR_PROXY` | SOCKS5h proxy for Tor |
217
218
  | `NIP04_ENABLED` | Set `1` to enable legacy NIP-04 DMs |
218
219
  | `TRANSPORT` | `stdio` (default) or `http` |
219
220
  | `PORT` | HTTP port (default 3000) |
220
221
 
221
- All secret env vars are deleted from `process.env` immediately after parsing.
222
+ All secret env vars are deleted from `process.env` before parsing can fail.
223
+ Raw `NWC_URI` is refused; use `NWC_URI_FILE` or `wallet connect <nwc-file>` so
224
+ the bearer credential never appears in a process environment, command argument
225
+ or MCP tool argument.
222
226
 
223
227
  ## CLI
224
228
 
package/dist/bunker.js CHANGED
@@ -86,11 +86,33 @@ export function startBunker(opts) {
86
86
  case 'get_public_key':
87
87
  result = ctx.activePublicKeyHex;
88
88
  break;
89
- case 'sign_event': {
90
- const template = JSON.parse(request.params[0]);
89
+ // `sign_event_compact` is the Heartwood dialect, and Bray speaks it so a
90
+ // client does not have to keep two code paths or waste a round trip
91
+ // discovering it is unsupported.
92
+ //
93
+ // Two differences from NIP-46, both about not moving the event around as
94
+ // a string. params[0] may be the event OBJECT rather than a stringified
95
+ // event, which spares a signer an unescape pass costing twice the content
96
+ // in one allocation; and the compact reply returns only what the client
97
+ // cannot work out itself. On a hardware signer those two copies are the
98
+ // difference between a 12 KB and an 18 KB ceiling. Bray has no such
99
+ // limit, but matching the dialect keeps clients simple.
100
+ case 'sign_event':
101
+ case 'sign_event_compact': {
102
+ // Declared string[], but that was always a claim about the client
103
+ // rather than a guarantee, and here it is genuinely either.
104
+ const raw = request.params[0];
105
+ const template = (typeof raw === 'string' ? JSON.parse(raw) : raw);
91
106
  const sign = ctx.getSigningFunction();
92
107
  const signed = await sign(template);
93
- result = JSON.stringify(signed);
108
+ result = request.method === 'sign_event_compact'
109
+ ? JSON.stringify({
110
+ id: signed.id,
111
+ pubkey: signed.pubkey,
112
+ created_at: signed.created_at,
113
+ sig: signed.sig,
114
+ })
115
+ : JSON.stringify(signed);
94
116
  break;
95
117
  }
96
118
  case 'nip04_encrypt': {
@@ -18,11 +18,25 @@ export async function dispatch(cmd, cmdArgs, h, ctx, pool) {
18
18
  case 'persona':
19
19
  out(await ctx.derivePersona(req(1, 'persona <name> [index]'), parseInt(cmdArgs[2] ?? '0', 10)));
20
20
  break;
21
- case 'switch':
22
- await ctx.switch(req(1, 'switch <target> [index]'), cmdArgs[2] ? parseInt(cmdArgs[2], 10) : undefined);
23
- console.error(`Now signing as ${ctx.activeNpub}`);
21
+ case 'switch': {
22
+ const target = req(1, 'switch <target> [index]');
23
+ await ctx.switch(target, cmdArgs[2] ? parseInt(cmdArgs[2], 10) : undefined);
24
+ // Name the derivation path, not just the npub. A bare name means the
25
+ // persona path, so `switch work` and `derive work` are different keys;
26
+ // without the path on screen the difference is invisible until something
27
+ // has been signed by the wrong identity.
28
+ console.error(`Now signing as ${ctx.activeNpub} (${ctx.activePurpose})`);
29
+ if (target !== 'master' && !target.includes(':') && ctx.activePurpose === `persona/${target}`) {
30
+ console.error(`Note: "${target}" resolved to the persona path. The raw purpose ` +
31
+ `"${target}" is a different key — see \`derive ${target}\`.`);
32
+ }
33
+ // The CLI is one process per command, so this applies to the rest of this
34
+ // invocation only. Say so rather than implying a saved default.
35
+ console.error('This switch applies to this command only; use --key or --bunker to sign as');
36
+ console.error('another identity in a later command.');
24
37
  console.log(ctx.activeNpub);
25
38
  break;
39
+ }
26
40
  case 'prove':
27
41
  out(await handleIdentityProve(ctx, { mode: (cmdArgs[1] === 'full' ? 'full' : 'blind') }));
28
42
  break;
@@ -7,8 +7,8 @@ export interface WalletExtras {
7
7
  * Dispatch `wallet *` subcommands (NIP-47 Nostr Wallet Connect).
8
8
  *
9
9
  * Commands:
10
- * wallet connect <nwc-url> Store NWC URI for the active identity
11
- * wallet disconnect Remove the stored NWC URI for the active identity
10
+ * wallet connect <nwc-file> Store an NWC secret-file reference for the active identity
11
+ * wallet disconnect Remove the stored NWC file reference for the active identity
12
12
  * wallet status Show configured wallet pubkey and relay
13
13
  * wallet pay <bolt11> Pay a Lightning invoice via NWC
14
14
  * wallet balance Request wallet balance via NWC
@@ -1,10 +1,10 @@
1
- import { handleZapSend, handleZapBalance, handleZapListTransactions, resolveNwcUri, loadWallets, saveWallets, parseNwcUri, } from '../../exports.js';
1
+ import { handleZapSend, handleZapBalance, handleZapListTransactions, resolveNwcUri, loadWallets, saveWallets, parseNwcUri, normaliseNwcUriFile, } from '../../exports.js';
2
2
  /**
3
3
  * Dispatch `wallet *` subcommands (NIP-47 Nostr Wallet Connect).
4
4
  *
5
5
  * Commands:
6
- * wallet connect <nwc-url> Store NWC URI for the active identity
7
- * wallet disconnect Remove the stored NWC URI for the active identity
6
+ * wallet connect <nwc-file> Store an NWC secret-file reference for the active identity
7
+ * wallet disconnect Remove the stored NWC file reference for the active identity
8
8
  * wallet status Show configured wallet pubkey and relay
9
9
  * wallet pay <bolt11> Pay a Lightning invoice via NWC
10
10
  * wallet balance Request wallet balance via NWC
@@ -15,13 +15,11 @@ export async function dispatch(cmd, _cmdArgs, h, ctx, pool, extras) {
15
15
  const { globalNwcUri, walletsFile } = extras;
16
16
  switch (cmd) {
17
17
  case 'wallet-connect': {
18
- const uri = req(1, 'wallet connect <nwc-url>');
19
- // Validate URI before storing
20
- parseNwcUri(uri);
18
+ const secretFile = normaliseNwcUriFile(req(1, 'wallet connect <nwc-file>'));
21
19
  const pubkey = ctx.activePublicKeyHex;
22
20
  const npub = ctx.activeNpub;
23
21
  const wallets = loadWallets(walletsFile);
24
- wallets[pubkey] = uri;
22
+ wallets[pubkey] = secretFile;
25
23
  saveWallets(walletsFile, wallets);
26
24
  out({ ok: true, identity: npub, message: `Wallet configured for ${npub}` });
27
25
  break;
@@ -40,7 +38,7 @@ export async function dispatch(cmd, _cmdArgs, h, ctx, pool, extras) {
40
38
  const npub = ctx.activeNpub;
41
39
  const uri = resolveNwcUri(ctx, walletsFile, globalNwcUri);
42
40
  if (!uri) {
43
- out({ ok: false, identity: npub, configured: false, message: 'No wallet configured. Use `wallet connect <nwc-url>` or set NWC_URI.' });
41
+ out({ ok: false, identity: npub, configured: false, message: 'No wallet configured. Use `wallet connect <nwc-file>` or set NWC_URI_FILE.' });
44
42
  break;
45
43
  }
46
44
  const conn = parseNwcUri(uri);
@@ -48,12 +46,16 @@ export async function dispatch(cmd, _cmdArgs, h, ctx, pool, extras) {
48
46
  out({ ok: true, identity: npub, configured: true, walletPubkey: conn.pubkey, relay: conn.relay });
49
47
  break;
50
48
  }
51
- case 'wallet-pay':
52
- out(await handleZapSend(ctx, pool, {
49
+ case 'wallet-pay': {
50
+ const result = await handleZapSend(ctx, pool, {
53
51
  invoice: req(1, 'wallet pay <bolt11>'),
54
52
  nwcUri: resolveNwcUri(ctx, walletsFile, globalNwcUri),
55
- }));
53
+ });
54
+ // A preimage can be an L402 bearer credential when the challenge macaroon
55
+ // is known. Report verified payment without printing the preimage.
56
+ out({ paid: true, verified: result.verified, paymentHash: result.paymentHash, feesPaidMsats: result.fees_paid });
56
57
  break;
58
+ }
57
59
  case 'wallet-balance':
58
60
  out(await handleZapBalance(ctx, pool, { nwcUri: resolveNwcUri(ctx, walletsFile, globalNwcUri) }));
59
61
  break;
@@ -4,9 +4,11 @@ export async function dispatch(cmd, cmdArgs, h, ctx, pool, extras) {
4
4
  const { req, flag, out } = h;
5
5
  const { globalNwcUri, walletsFile } = extras;
6
6
  switch (cmd) {
7
- case 'zap-send':
8
- out(await handleZapSend(ctx, pool, { invoice: req(1, 'zap-send <bolt11>'), nwcUri: resolveNwcUri(ctx, walletsFile, globalNwcUri) }));
7
+ case 'zap-send': {
8
+ const result = await handleZapSend(ctx, pool, { invoice: req(1, 'zap-send <bolt11>'), nwcUri: resolveNwcUri(ctx, walletsFile, globalNwcUri) });
9
+ out({ paid: true, verified: result.verified, paymentHash: result.paymentHash, feesPaidMsats: result.fees_paid });
9
10
  break;
11
+ }
10
12
  case 'zap-balance':
11
13
  out(await handleZapBalance(ctx, pool, { nwcUri: resolveNwcUri(ctx, walletsFile, globalNwcUri) }));
12
14
  break;
package/dist/cli/index.js CHANGED
@@ -182,8 +182,8 @@ Admin (NIP-86 relay management):
182
182
  listdisallowedkinds; they were never NIP-86 method names)
183
183
 
184
184
  Wallet (NIP-47 Nostr Wallet Connect):
185
- wallet connect <nwc-url> Store NWC URI for the active identity
186
- wallet disconnect Remove stored NWC URI for the active identity
185
+ wallet connect <nwc-file> Store a private NWC secret-file reference
186
+ wallet disconnect Remove stored NWC file reference
187
187
  wallet status Show configured wallet pubkey and relay
188
188
  wallet pay <bolt11> Pay a Lightning invoice via NWC
189
189
  wallet balance Request wallet balance via NWC
@@ -261,7 +261,7 @@ Environment:
261
261
  BUNKER_URI / BUNKER_URI_FILE bunker:// URI (use INSTEAD of secret key)
262
262
  NOSTR_RELAYS Comma-separated relay URLs
263
263
  NOSTR_AUTH NIP-42 policy: off (default), on-demand, eager
264
- NWC_URI / NWC_URI_FILE Nostr Wallet Connect URI
264
+ NWC_URI_FILE Private 0600 file containing an NWC URI
265
265
  TOR_PROXY SOCKS5h proxy URL
266
266
  NOSTR_BRAY_OUTPUT Default output: "human" (default) or "json"
267
267
 
package/dist/config.js CHANGED
@@ -1,6 +1,7 @@
1
1
  import { readFileSync, existsSync } from 'node:fs';
2
2
  import { join } from 'node:path';
3
3
  import { homedir } from 'node:os';
4
+ import { readNwcUriFile } from './zap/nwc-file.js';
4
5
  const NSEC_RE = /^nsec1[a-z0-9]{58}$/;
5
6
  const HEX_RE = /^[0-9a-f]{64}$/;
6
7
  const NCRYPTSEC_RE = /^ncryptsec1[a-z0-9]+$/;
@@ -85,10 +86,34 @@ export function loadConfigFile() {
85
86
  /** Load configuration from config file, environment variables, and secret files.
86
87
  * Priority: env vars > config file > defaults */
87
88
  export async function loadConfig() {
89
+ // Capture then remove bearer credentials before any parsing or validation can
90
+ // throw. This keeps failure paths from leaving secrets in process.env.
91
+ const secretEnvironment = {
92
+ secretKey: process.env.NOSTR_SECRET_KEY,
93
+ secretKeyFile: process.env.NOSTR_SECRET_KEY_FILE,
94
+ bunkerUri: process.env.BUNKER_URI,
95
+ bunkerUriFile: process.env.BUNKER_URI_FILE,
96
+ ncryptsec: process.env.NOSTR_NCRYPTSEC,
97
+ ncryptsecFile: process.env.NOSTR_NCRYPTSEC_FILE,
98
+ ncryptsecPassword: process.env.NOSTR_NCRYPTSEC_PASSWORD,
99
+ ncryptsecPasswordFile: process.env.NOSTR_NCRYPTSEC_PASSWORD_FILE,
100
+ nwcUri: process.env.NWC_URI,
101
+ nwcUriFile: process.env.NWC_URI_FILE,
102
+ };
103
+ delete process.env.NOSTR_SECRET_KEY;
104
+ delete process.env.NOSTR_SECRET_KEY_FILE;
105
+ delete process.env.NWC_URI;
106
+ delete process.env.NWC_URI_FILE;
107
+ delete process.env.BUNKER_URI;
108
+ delete process.env.BUNKER_URI_FILE;
109
+ delete process.env.NOSTR_NCRYPTSEC;
110
+ delete process.env.NOSTR_NCRYPTSEC_FILE;
111
+ delete process.env.NOSTR_NCRYPTSEC_PASSWORD;
112
+ delete process.env.NOSTR_NCRYPTSEC_PASSWORD_FILE;
88
113
  const file = loadConfigFile();
89
114
  // --- Secret key ---
90
- const keyFilePath = process.env.NOSTR_SECRET_KEY_FILE ?? file.secretKeyFile;
91
- const keyEnvVar = process.env.NOSTR_SECRET_KEY;
115
+ const keyFilePath = secretEnvironment.secretKeyFile ?? file.secretKeyFile;
116
+ const keyEnvVar = secretEnvironment.secretKey;
92
117
  let secretKey;
93
118
  // A local key source is an explicit choice to sign locally. A bunker URI
94
119
  // saved by `bunker connect` is only a fallback and must not override that
@@ -96,15 +121,15 @@ export async function loadConfig() {
96
121
  // it is present. Explicit bunker configuration still takes precedence.
97
122
  const hasLocalKeySource = !!(keyFilePath
98
123
  || keyEnvVar
99
- || process.env.NOSTR_NCRYPTSEC_FILE
100
- || process.env.NOSTR_NCRYPTSEC
124
+ || secretEnvironment.ncryptsecFile
125
+ || secretEnvironment.ncryptsec
101
126
  || file.ncryptsecFile);
102
127
  let bunkerUri;
103
- if (process.env.BUNKER_URI_FILE) {
104
- bunkerUri = readSecretFile(process.env.BUNKER_URI_FILE);
128
+ if (secretEnvironment.bunkerUriFile) {
129
+ bunkerUri = readSecretFile(secretEnvironment.bunkerUriFile);
105
130
  }
106
- else if (process.env.BUNKER_URI) {
107
- bunkerUri = process.env.BUNKER_URI;
131
+ else if (secretEnvironment.bunkerUri) {
132
+ bunkerUri = secretEnvironment.bunkerUri;
108
133
  }
109
134
  else if (file.bunkerUriFile) {
110
135
  bunkerUri = readSecretFile(file.bunkerUriFile);
@@ -118,10 +143,10 @@ export async function loadConfig() {
118
143
  bunkerUri = saved.uri;
119
144
  }
120
145
  // --- NIP-49 ncryptsec (password-encrypted key) ---
121
- const ncryptsec = process.env.NOSTR_NCRYPTSEC_FILE
122
- ? readSecretFile(process.env.NOSTR_NCRYPTSEC_FILE)
123
- : process.env.NOSTR_NCRYPTSEC
124
- ? process.env.NOSTR_NCRYPTSEC
146
+ const ncryptsec = secretEnvironment.ncryptsecFile
147
+ ? readSecretFile(secretEnvironment.ncryptsecFile)
148
+ : secretEnvironment.ncryptsec
149
+ ? secretEnvironment.ncryptsec
125
150
  : file.ncryptsecFile
126
151
  ? readSecretFile(file.ncryptsecFile)
127
152
  : undefined;
@@ -129,11 +154,11 @@ export async function loadConfig() {
129
154
  // Prefer file-based sources so the password can be delivered via systemd
130
155
  // credentials, docker secrets, or similar secure channels and never appears
131
156
  // in the process environment or config JSON.
132
- const ncryptsecPasswordFile = process.env.NOSTR_NCRYPTSEC_PASSWORD_FILE ?? file.ncryptsecPasswordFile;
157
+ const ncryptsecPasswordFile = secretEnvironment.ncryptsecPasswordFile ?? file.ncryptsecPasswordFile;
133
158
  let ncryptsecPasswordBuf = null;
134
159
  let ncryptsecPassword;
135
- if (process.env.NOSTR_NCRYPTSEC_PASSWORD) {
136
- ncryptsecPassword = process.env.NOSTR_NCRYPTSEC_PASSWORD;
160
+ if (secretEnvironment.ncryptsecPassword) {
161
+ ncryptsecPassword = secretEnvironment.ncryptsecPassword;
137
162
  }
138
163
  else if (ncryptsecPasswordFile) {
139
164
  // Read as Buffer so we can zeroise the at-rest byte representation even
@@ -191,13 +216,14 @@ export async function loadConfig() {
191
216
  }
192
217
  const secretFormat = secretKey ? detectKeyFormat(secretKey) : 'nsec';
193
218
  // --- NWC URI ---
194
- const nwcFilePath = process.env.NWC_URI_FILE ?? file.nwcUriFile;
219
+ const rawNwcUri = secretEnvironment.nwcUri;
220
+ const nwcFilePath = secretEnvironment.nwcUriFile ?? file.nwcUriFile;
221
+ if (rawNwcUri) {
222
+ throw new Error('NWC_URI is disabled because bearer wallet credentials must not be stored in environment variables; use NWC_URI_FILE');
223
+ }
195
224
  let nwcUri;
196
225
  if (nwcFilePath) {
197
- nwcUri = readSecretFile(nwcFilePath);
198
- }
199
- else if (process.env.NWC_URI) {
200
- nwcUri = process.env.NWC_URI;
226
+ nwcUri = readNwcUriFile(nwcFilePath);
201
227
  }
202
228
  // --- Relays ---
203
229
  const relays = process.env.NOSTR_RELAYS
@@ -261,17 +287,6 @@ export async function loadConfig() {
261
287
  const walletsFile = process.env.BRAY_WALLETS_FILE
262
288
  ?? file.walletsFile
263
289
  ?? (process.env.HOME ? `${process.env.HOME}/.nostr/bray-wallets.json` : '');
264
- // --- Clean up secrets from process.env ---
265
- delete process.env.NOSTR_SECRET_KEY;
266
- delete process.env.NOSTR_SECRET_KEY_FILE;
267
- delete process.env.NWC_URI;
268
- delete process.env.NWC_URI_FILE;
269
- delete process.env.BUNKER_URI;
270
- delete process.env.BUNKER_URI_FILE;
271
- delete process.env.NOSTR_NCRYPTSEC;
272
- delete process.env.NOSTR_NCRYPTSEC_FILE;
273
- delete process.env.NOSTR_NCRYPTSEC_PASSWORD;
274
- delete process.env.NOSTR_NCRYPTSEC_PASSWORD_FILE;
275
290
  return {
276
291
  secretKey,
277
292
  secretFormat,
package/dist/context.d.ts CHANGED
@@ -13,6 +13,8 @@ export declare class IdentityContext implements ExtendedSigningContext {
13
13
  constructor(secretKey: string, format: 'nsec' | 'hex' | 'mnemonic', opts?: ContextOptions);
14
14
  /** Current active identity's npub (bech32) */
15
15
  get activeNpub(): string;
16
+ /** Derivation path of the current active identity, for reporting to a human */
17
+ get activePurpose(): string;
16
18
  /** Current active identity's hex public key — use this in relay filters */
17
19
  get activePublicKeyHex(): string;
18
20
  /** Derive a child identity by purpose and index */
package/dist/context.js CHANGED
@@ -55,6 +55,10 @@ export class IdentityContext {
55
55
  get activeNpub() {
56
56
  return this.activeEntry.identity.npub;
57
57
  }
58
+ /** Derivation path of the current active identity, for reporting to a human */
59
+ get activePurpose() {
60
+ return this.activeEntry.purpose;
61
+ }
58
62
  /** Current active identity's hex public key — use this in relay filters */
59
63
  get activePublicKeyHex() {
60
64
  return Buffer.from(this.activeEntry.identity.publicKey).toString('hex');
package/dist/help.js CHANGED
@@ -57,14 +57,14 @@ export const COMMAND_HELP = {
57
57
  'relay-add': { usage: 'relay-add <url> [read|write]', description: 'Add a relay to the active identity\'s relay set (in-memory only — does not publish kind 10002).', examples: ['nostr-bray relay-add wss://new-relay.com', 'nostr-bray relay-add wss://read-only.com read'] },
58
58
  'relay-info': { usage: 'relay-info <wss://url>', description: 'Fetch the NIP-11 relay information document (name, description, supported NIPs, limits).', examples: ['nostr-bray relay-info wss://relay.damus.io'] },
59
59
  // Wallet (NIP-47 Nostr Wallet Connect)
60
- 'wallet-connect': { usage: 'wallet connect <nwc-url>', description: 'Store a nostr+walletconnect:// URI for the active identity. Each identity can have its own Lightning wallet.', examples: ['nostr-bray wallet connect "nostr+walletconnect://pubkey?relay=wss%3A%2F%2Frelay.example&secret=deadbeef"'], notes: 'URI is stored per-identity in the wallets file (0600). Use `wallet status` to confirm.' },
61
- 'wallet-disconnect': { usage: 'wallet disconnect', description: 'Remove the stored NWC URI for the active identity. Falls back to NWC_URI env var if set.', examples: ['nostr-bray wallet disconnect'] },
60
+ 'wallet-connect': { usage: 'wallet connect <nwc-file>', description: 'Store a reference to a private local file containing the NWC URI for the active identity.', examples: ['nostr-bray wallet connect /run/secrets/alice-nwc'], notes: 'The file must be a regular 0600 file. The bearer URI is never passed on the command line or stored in the wallet registry.' },
61
+ 'wallet-disconnect': { usage: 'wallet disconnect', description: 'Remove the stored NWC file reference for the active identity. Falls back to NWC_URI_FILE if configured.', examples: ['nostr-bray wallet disconnect'] },
62
62
  'wallet-status': { usage: 'wallet status', description: 'Show the wallet pubkey and relay configured for the active identity. The connection secret is never shown.', examples: ['nostr-bray wallet status --json'] },
63
- 'wallet-pay': { usage: 'wallet pay <bolt11>', description: 'Pay a Lightning invoice via Nostr Wallet Connect (NIP-47). Requires a wallet configured via `wallet connect` or NWC_URI.', examples: ['nostr-bray wallet pay lnbc10u1...'], notes: 'SPENDS REAL SATS. Decodes the invoice and sends a pay_invoice NIP-47 request.' },
63
+ 'wallet-pay': { usage: 'wallet pay <bolt11>', description: 'Pay a Lightning invoice via Nostr Wallet Connect (NIP-47). Requires a wallet configured via `wallet connect` or NWC_URI_FILE.', examples: ['nostr-bray wallet pay lnbc10u1...'], notes: 'SPENDS REAL SATS. Waits for an authenticated wallet result and verifies the returned preimage without printing it.' },
64
64
  'wallet-balance': { usage: 'wallet balance', description: 'Request wallet balance via Nostr Wallet Connect (NIP-47).', examples: ['nostr-bray wallet balance', 'nostr-bray wallet balance --json'] },
65
65
  'wallet-history': { usage: 'wallet history [--limit N]', description: 'List recent Lightning transactions via Nostr Wallet Connect (NIP-47). Default limit: 10.', examples: ['nostr-bray wallet history', 'nostr-bray wallet history --limit 25 --json'] },
66
66
  // Zap
67
- 'zap-send': { usage: 'zap-send <bolt11>', description: 'Pay a Lightning invoice via Nostr Wallet Connect (NIP-47). Requires NWC_URI configured.', examples: ['nostr-bray zap-send lnbc10u1...'] },
67
+ 'zap-send': { usage: 'zap-send <bolt11>', description: 'Pay a Lightning invoice via Nostr Wallet Connect (NIP-47). Requires NWC_URI_FILE or a persona wallet file.', examples: ['nostr-bray zap-send lnbc10u1...'] },
68
68
  'zap-balance': { usage: 'zap-balance', description: 'Request wallet balance via NWC.', examples: ['nostr-bray zap-balance'] },
69
69
  'zap-invoice': { usage: 'zap-invoice <msats> [description]', description: 'Generate a Lightning invoice via NWC to receive payments.', examples: ['nostr-bray zap-invoice 100000 "Coffee payment"'] },
70
70
  'zap-lookup': { usage: 'zap-lookup <payment-hash>', description: 'Look up a Lightning invoice payment status via NWC.', examples: ['nostr-bray zap-lookup abc123...'] },
@@ -1,4 +1,5 @@
1
1
  import { z } from 'zod';
2
+ import { randomUUID } from 'node:crypto';
2
3
  import { hexId, relayUrl } from '../validation.js';
3
4
  import { resolveRecipient } from '../resolve.js';
4
5
  import { handleMarketplaceDiscover, handleMarketplaceInspect, handleMarketplaceSearch, handleMarketplaceReputation, handleMarketplaceCompare, handleMarketplaceProbe, handleMarketplaceCall, handleMarketplaceAnnounce, handleMarketplaceUpdate, handleMarketplaceRetire, storeCredential, clearCredentials, extractBolt11AmountSats, } from './handlers.js';
@@ -146,6 +147,10 @@ export function registerMarketplaceTools(server, deps) {
146
147
  annotations: { readOnlyHint: false, destructiveHint: true },
147
148
  }, async ({ macaroon, invoice, confirm }) => {
148
149
  const decoded = handleZapDecode(invoice);
150
+ if (decoded.expiry === undefined)
151
+ throw new Error('Invalid BOLT-11 invoice');
152
+ if (decoded.amountMsats === undefined)
153
+ throw new Error('Amountless L402 invoices are not supported');
149
154
  const costSats = extractBolt11AmountSats(invoice);
150
155
  if (!confirm) {
151
156
  return {
@@ -166,17 +171,15 @@ export function registerMarketplaceTools(server, deps) {
166
171
  invoice,
167
172
  nwcUri: resolveNwcUri(deps.ctx, deps.walletsFile, deps.nwcUri),
168
173
  });
169
- // Store credentials keyed by event ID (opaque to caller)
170
- const credentialId = payResult.event.id;
171
- // In a real L402 flow the preimage comes from the payment result.
172
- // NWC pay_invoice returns asynchronously — use the payment hash as placeholder
173
- // until the wallet confirms. For now we store with the event id as preimage proxy.
174
- storeCredential(credentialId, macaroon, credentialId);
174
+ // Keep the bearer credential in process and return only an opaque handle.
175
+ const credentialId = randomUUID();
176
+ storeCredential(credentialId, macaroon, payResult.preimage);
175
177
  return {
176
178
  content: [{
177
179
  type: 'text',
178
180
  text: JSON.stringify({
179
181
  paid: true,
182
+ verified: payResult.verified,
180
183
  credentialId,
181
184
  costSats,
182
185
  amountMsats: decoded.amountMsats,
package/dist/sdk.d.ts CHANGED
@@ -363,6 +363,6 @@ export interface BrayClient {
363
363
  export declare function createBray(config: BrayClientConfig): Promise<BrayClient>;
364
364
  /**
365
365
  * Get or create a singleton BrayClient built from environment variables.
366
- * Reads NOSTR_SECRET_KEY / BUNKER_URI / NOSTR_RELAYS / NWC_URI etc.
366
+ * Reads NOSTR_SECRET_KEY / BUNKER_URI / NOSTR_RELAYS / NWC_URI_FILE etc.
367
367
  */
368
368
  export declare function defaultBray(): Promise<BrayClient>;
package/dist/sdk.js CHANGED
@@ -343,7 +343,7 @@ export async function createBray(config) {
343
343
  let _default = null;
344
344
  /**
345
345
  * Get or create a singleton BrayClient built from environment variables.
346
- * Reads NOSTR_SECRET_KEY / BUNKER_URI / NOSTR_RELAYS / NWC_URI etc.
346
+ * Reads NOSTR_SECRET_KEY / BUNKER_URI / NOSTR_RELAYS / NWC_URI_FILE etc.
347
347
  */
348
348
  export function defaultBray() {
349
349
  if (!_default) {
@@ -8,6 +8,7 @@ export interface BlobDescriptor {
8
8
  type?: string;
9
9
  uploaded?: number;
10
10
  }
11
+ export declare function detectContentType(body: Uint8Array): string | undefined;
11
12
  /** Upload a file to a blossom media server */
12
13
  export declare function handleBlossomUpload(ctx: SigningContext, args: {
13
14
  server: string;
@@ -5,6 +5,36 @@ import { validatePublicUrl } from '../validation.js';
5
5
  // fetch. Keeps an attacker-chosen source from amplifying nostr-bray into a
6
6
  // megabyte-scale Blossom relay.
7
7
  const MAX_UPLOAD = 100 * 1024 * 1024;
8
+ // Blossom servers sniff the body and reject a mismatched Content-Type, so
9
+ // `application/octet-stream` fails for any format they recognise. Detect from
10
+ // the leading bytes rather than the file name: the bytes are what the server
11
+ // checks, and an uploader should not be able to mislabel a blob by renaming it.
12
+ const MAGIC = [
13
+ { type: 'image/png', test: (b) => b[0] === 0x89 && b[1] === 0x50 && b[2] === 0x4e && b[3] === 0x47 },
14
+ { type: 'image/jpeg', test: (b) => b[0] === 0xff && b[1] === 0xd8 && b[2] === 0xff },
15
+ { type: 'image/gif', test: (b) => b[0] === 0x47 && b[1] === 0x49 && b[2] === 0x46 },
16
+ {
17
+ type: 'image/webp',
18
+ test: (b) => b[0] === 0x52 && b[1] === 0x49 && b[2] === 0x46 && b[3] === 0x46 &&
19
+ b[8] === 0x57 && b[9] === 0x45 && b[10] === 0x42 && b[11] === 0x50,
20
+ },
21
+ { type: 'application/pdf', test: (b) => b[0] === 0x25 && b[1] === 0x50 && b[2] === 0x44 && b[3] === 0x46 },
22
+ { type: 'image/svg+xml', test: (b) => Buffer.from(b.subarray(0, 256)).toString('utf8').includes('<svg') },
23
+ ];
24
+ export function detectContentType(body) {
25
+ if (body.byteLength < 4)
26
+ return undefined;
27
+ for (const { type, test } of MAGIC) {
28
+ try {
29
+ if (test(body))
30
+ return type;
31
+ }
32
+ catch {
33
+ // a short or odd buffer simply does not match this signature
34
+ }
35
+ }
36
+ return undefined;
37
+ }
8
38
  /** Upload a file to a blossom media server */
9
39
  export async function handleBlossomUpload(ctx, args) {
10
40
  validatePublicUrl(args.server);
@@ -44,7 +74,7 @@ export async function handleBlossomUpload(ctx, args) {
44
74
  method: 'PUT',
45
75
  headers: {
46
76
  Authorization: authHeader,
47
- 'Content-Type': args.contentType ?? 'application/octet-stream',
77
+ 'Content-Type': args.contentType ?? detectContentType(body) ?? 'application/octet-stream',
48
78
  },
49
79
  body: Buffer.from(body),
50
80
  signal: AbortSignal.timeout(30_000),