nostr-bray 3.7.1 → 3.9.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/dist/bunker.d.ts CHANGED
@@ -19,9 +19,27 @@ export interface BunkerOptions {
19
19
  heartwoodExtensions?: boolean;
20
20
  stateDir?: string;
21
21
  torProxy?: string;
22
+ /**
23
+ * Approve any client that sends `connect`, with no secret: the behaviour
24
+ * before connect secrets. Ignored when `authorizedKeys` is set.
25
+ */
26
+ open?: boolean;
27
+ /** Called with the new link each time a client uses up the connect secret. */
28
+ onUrlChange?: (url: string) => void;
22
29
  }
30
+ /**
31
+ * Remove a client pubkey from every bunker key's approvals in
32
+ * approved-clients.json. A running daemon notices on the client's next
33
+ * request. Returns how many bunker keys had approved it.
34
+ */
35
+ export declare function revokeClient(clientPk: string, stateDir?: string): number;
23
36
  export interface BunkerInstance {
24
- url: string;
37
+ /** bunker:// URI for clients. Carries the current connect secret, so it changes once used. */
38
+ readonly url: string;
39
+ /** Client pubkeys approved for this bunker key (approved-clients.json). A revoked key leaves this list at that client's next request. */
40
+ approvedClients: readonly string[];
41
+ /** Whether a new client needs the connect secret (false with authorizedKeys or open). */
42
+ requiresSecret: boolean;
25
43
  pubkey: string;
26
44
  npub: string;
27
45
  close: () => void;
package/dist/bunker.js CHANGED
@@ -9,12 +9,33 @@
9
9
  * - Bunker responds with kind 24133 encrypted response
10
10
  * - All communication NIP-44 encrypted between client and bunker
11
11
  */
12
+ import { randomBytes, timingSafeEqual } from 'node:crypto';
12
13
  import { getConversationKey, encrypt, decrypt } from 'nostr-tools/nip44';
13
14
  import { finalizeEvent, getPublicKey, generateSecretKey } from 'nostr-tools/pure';
14
15
  import { npubEncode } from 'nostr-tools/nip19';
15
16
  import { SimplePool } from 'nostr-tools/pool';
16
17
  import { webSocketFor } from './ws-safe.js';
17
- import { readStateFile, writeStateFile } from './state.js';
18
+ import { readStateFile, readStateFileStrict, writeStateFile } from './state.js';
19
+ /**
20
+ * Remove a client pubkey from every bunker key's approvals in
21
+ * approved-clients.json. A running daemon notices on the client's next
22
+ * request. Returns how many bunker keys had approved it.
23
+ */
24
+ export function revokeClient(clientPk, stateDir) {
25
+ const file = readStateFileStrict('approved-clients.json', stateDir);
26
+ if (file === null)
27
+ throw new Error('approved-clients.json in the bray state directory is unreadable; fix it or move it aside, then retry');
28
+ let removed = 0;
29
+ for (const [bunker, list] of Object.entries(file)) {
30
+ if (!Array.isArray(list) || !list.includes(clientPk))
31
+ continue;
32
+ file[bunker] = list.filter(pk => pk !== clientPk);
33
+ removed++;
34
+ }
35
+ if (removed > 0)
36
+ writeStateFile('approved-clients.json', file, stateDir);
37
+ return removed;
38
+ }
18
39
  export function startBunker(opts) {
19
40
  const { ctx, relays, quiet } = opts;
20
41
  const log = quiet ? () => { } : (...args) => console.error('[bunker]', ...args);
@@ -33,6 +54,61 @@ export function startBunker(opts) {
33
54
  ...cliAuthorizedKeys,
34
55
  ...bunkerApprovals,
35
56
  ]);
57
+ // Who may become a client. With --authorized-keys, only those keys (as
58
+ // before). Otherwise a new client needs the connect secret from the link,
59
+ // unless the daemon runs open. Clients approved earlier never need it.
60
+ const allowList = cliAuthorizedKeys.size > 0;
61
+ const requireSecret = !allowList && opts.open !== true;
62
+ // Open: any client is served and nothing is saved, so a stranger who found
63
+ // the link is not approved for a later daemon run without --open.
64
+ const openMode = !allowList && !requireSecret;
65
+ // The unused secret is kept per bunker key, so a link handed out before a
66
+ // restart still works. Only for a persistent bunker key: an ephemeral one
67
+ // gets a new link every run anyway.
68
+ const SECRETS_FILE = 'bunker-secrets.json';
69
+ const persistSecret = requireSecret && opts.bunkerKeyHex !== undefined;
70
+ const newSecret = () => {
71
+ const secret = randomBytes(16).toString('hex');
72
+ if (persistSecret) {
73
+ const current = readStateFile(SECRETS_FILE, opts.stateDir);
74
+ current[bunkerPk] = secret;
75
+ writeStateFile(SECRETS_FILE, current, opts.stateDir);
76
+ }
77
+ return secret;
78
+ };
79
+ const savedSecret = persistSecret
80
+ ? readStateFile(SECRETS_FILE, opts.stateDir)[bunkerPk]
81
+ : undefined;
82
+ let connectSecret = requireSecret
83
+ ? (typeof savedSecret === 'string' && /^[0-9a-f]{32}$/.test(savedSecret) ? savedSecret : newSecret())
84
+ : undefined;
85
+ function secretMatches(given) {
86
+ if (connectSecret === undefined || typeof given !== 'string')
87
+ return false;
88
+ const a = Buffer.from(given);
89
+ const b = Buffer.from(connectSecret);
90
+ return a.length === b.length && timingSafeEqual(a, b);
91
+ }
92
+ function approve(clientPk) {
93
+ if (openMode)
94
+ return;
95
+ authorizedKeys.add(clientPk);
96
+ if (bunkerApprovals.includes(clientPk))
97
+ return;
98
+ bunkerApprovals.push(clientPk);
99
+ // Add just this key to the file, so a key added meanwhile by `bunker
100
+ // authorize` stays and a key removed by `bunker revoke` stays removed.
101
+ // An unreadable file is left alone rather than replaced by this one key.
102
+ const current = readStateFileStrict(APPROVALS_FILE, opts.stateDir);
103
+ if (current === null) {
104
+ log(`approved-clients.json is unreadable, so this approval is not saved; once the file is fixed this client needs a fresh link: ${clientPk.slice(0, 12)}...`);
105
+ return;
106
+ }
107
+ const onDisk = Array.isArray(current[bunkerPk]) ? current[bunkerPk] : [];
108
+ current[bunkerPk] = [...new Set([...onDisk, clientPk])];
109
+ writeStateFile(APPROVALS_FILE, current, opts.stateDir);
110
+ log(`Approved and persisted client: ${clientPk.slice(0, 12)}...`);
111
+ }
36
112
  // Sign in (NIP-42) with the bunker key when a relay asks, so a private
37
113
  // relay lets the bunker read requests addressed to it.
38
114
  const pool = new SimplePool({ websocketImplementation: webSocketFor(opts.torProxy) });
@@ -48,10 +124,37 @@ export function startBunker(opts) {
48
124
  }
49
125
  },
50
126
  });
127
+ // `bunker revoke` edits the approvals file while the daemon runs, so a
128
+ // client approved in memory is checked against the file before each request.
129
+ function stillApproved(clientPk) {
130
+ if (cliAuthorizedKeys.has(clientPk))
131
+ return true;
132
+ const current = readStateFileStrict(APPROVALS_FILE, opts.stateDir);
133
+ if (current === null) {
134
+ // Fail closed, but keep the in-memory approval for when the file is fixed.
135
+ log(`approved-clients.json is unreadable, so refused: ${clientPk.slice(0, 12)}...`);
136
+ return false;
137
+ }
138
+ const onDisk = Array.isArray(current[bunkerPk]) ? current[bunkerPk] : [];
139
+ if (onDisk.includes(clientPk))
140
+ return true;
141
+ authorizedKeys.delete(clientPk);
142
+ const i = bunkerApprovals.indexOf(clientPk);
143
+ if (i >= 0)
144
+ bunkerApprovals.splice(i, 1);
145
+ log(`Client no longer in approved-clients.json, so refused: ${clientPk.slice(0, 12)}...`);
146
+ return false;
147
+ }
51
148
  async function handleRequest(event) {
52
149
  const clientPk = event.pubkey;
53
- // Check authorization — gate only activates when CLI --authorized-keys were set
54
- if (cliAuthorizedKeys.size > 0 && !authorizedKeys.has(clientPk)) {
150
+ if (!openMode && authorizedKeys.has(clientPk) && !stillApproved(clientPk)) {
151
+ // A revoked client has left authorizedKeys and meets the gates below as
152
+ // a stranger. One still there could not be checked (unreadable file).
153
+ if (authorizedKeys.has(clientPk))
154
+ return;
155
+ }
156
+ // With --authorized-keys, drop anyone else unanswered, as before.
157
+ if (allowList && !authorizedKeys.has(clientPk)) {
55
158
  log(`Rejected request from unauthorized key: ${clientPk.slice(0, 12)}...`);
56
159
  return;
57
160
  }
@@ -67,19 +170,30 @@ export function startBunker(opts) {
67
170
  return;
68
171
  }
69
172
  log(`Request ${request.id}: ${request.method}`);
173
+ // A client not yet approved gets in only with the connect secret, which
174
+ // then works once: the daemon moves to a new one and reports the new link.
175
+ // Check, rotate and approve must stay free of `await`: two clients racing
176
+ // with the same secret then cannot both get in.
177
+ if (requireSecret && !authorizedKeys.has(clientPk)) {
178
+ if (request.method !== 'connect') {
179
+ log(`Ignored ${request.method} from unapproved client: ${clientPk.slice(0, 12)}...`);
180
+ return;
181
+ }
182
+ if (!secretMatches(request.params?.[1])) {
183
+ log(`Refused connect without the current secret: ${clientPk.slice(0, 12)}...`);
184
+ await respond(clientPk, conversationKey, request.id, '', 'connect refused: use the current bunker:// link from the daemon, which carries a one-time secret');
185
+ return;
186
+ }
187
+ connectSecret = newSecret();
188
+ approve(clientPk);
189
+ log('Connect secret used; moved to a new one');
190
+ opts.onUrlChange?.(buildUrl());
191
+ }
70
192
  let result;
71
193
  let error;
72
194
  switch (request.method) {
73
195
  case 'connect':
74
- // Persist newly-approved client
75
- if (!bunkerApprovals.includes(clientPk)) {
76
- bunkerApprovals.push(clientPk);
77
- authorizedKeys.add(clientPk);
78
- const current = readStateFile(APPROVALS_FILE, opts.stateDir);
79
- current[bunkerPk] = bunkerApprovals;
80
- writeStateFile(APPROVALS_FILE, current, opts.stateDir);
81
- log(`Approved and persisted client: ${clientPk.slice(0, 12)}...`);
82
- }
196
+ approve(clientPk);
83
197
  result = 'ack';
84
198
  break;
85
199
  case 'ping':
@@ -210,10 +324,12 @@ export function startBunker(opts) {
210
324
  result = '';
211
325
  error = `unsupported method: ${request.method}`;
212
326
  }
213
- // Encrypt and send response
327
+ await respond(clientPk, conversationKey, request.id, result, error);
328
+ }
329
+ async function respond(clientPk, conversationKey, id, result, error) {
214
330
  const response = error
215
- ? JSON.stringify({ id: request.id, result: '', error })
216
- : JSON.stringify({ id: request.id, result });
331
+ ? JSON.stringify({ id, result: '', error })
332
+ : JSON.stringify({ id, result });
217
333
  const encrypted = encrypt(response, conversationKey);
218
334
  const responseEvent = finalizeEvent({
219
335
  kind: 24133,
@@ -222,22 +338,30 @@ export function startBunker(opts) {
222
338
  content: encrypted,
223
339
  }, bunkerSk);
224
340
  await Promise.any(pool.publish(relays, responseEvent));
225
- log(`Response ${request.id}: ${error ?? 'ok'}`);
341
+ log(`Response ${id}: ${error ?? 'ok'}`);
226
342
  }
227
343
  // Build bunker:// URI
228
344
  const relayParams = relays.map(r => `relay=${encodeURIComponent(r)}`).join('&');
229
- const bunkerUrl = `bunker://${bunkerPk}?${relayParams}`;
345
+ const buildUrl = () => connectSecret === undefined
346
+ ? `bunker://${bunkerPk}?${relayParams}`
347
+ : `bunker://${bunkerPk}?${relayParams}&secret=${connectSecret}`;
230
348
  log(`Bunker started: ${bunkerNpub}`);
231
- log(`URI: ${bunkerUrl}`);
349
+ // The secret stays out of the log; the caller shows the link (bunker.url).
350
+ log(`URI: bunker://${bunkerPk}?${relayParams}${connectSecret === undefined ? '' : ' (plus a connect secret)'}`);
232
351
  log(`Signing as: ${ctx.activeNpub}`);
233
- if (cliAuthorizedKeys.size > 0) {
352
+ if (allowList) {
234
353
  log(`Authorized keys: ${authorizedKeys.size} (${cliAuthorizedKeys.size} CLI + ${bunkerApprovals.length} persisted)`);
235
354
  }
355
+ else if (requireSecret) {
356
+ log(`New clients need the secret in this link; it works once. ${bunkerApprovals.length} client(s) approved earlier.`);
357
+ }
236
358
  else {
237
- log('WARNING: No authorized keys set — accepting requests from anyone');
359
+ log('WARNING: running open (--open): any client that connects is approved');
238
360
  }
239
361
  return {
240
- url: bunkerUrl,
362
+ get url() { return buildUrl(); },
363
+ get approvedClients() { return [...bunkerApprovals]; },
364
+ requiresSecret: requireSecret,
241
365
  pubkey: bunkerPk,
242
366
  npub: bunkerNpub,
243
367
  close: () => {
@@ -1,6 +1,6 @@
1
1
  /**
2
2
  * Bunker command module.
3
- * bunker sign, bunker connect, bunker pair, bunker authorize, bunker status, and bunker daemon
3
+ * bunker sign, bunker connect, bunker pair, bunker authorize, bunker revoke, bunker status, and bunker daemon
4
4
  * are all dispatched BEFORE ctx/pool setup in index.ts, so this module does its
5
5
  * own config/context construction.
6
6
  */
@@ -1,6 +1,6 @@
1
1
  /**
2
2
  * Bunker command module.
3
- * bunker sign, bunker connect, bunker pair, bunker authorize, bunker status, and bunker daemon
3
+ * bunker sign, bunker connect, bunker pair, bunker authorize, bunker revoke, bunker status, and bunker daemon
4
4
  * are all dispatched BEFORE ctx/pool setup in index.ts, so this module does its
5
5
  * own config/context construction.
6
6
  */
@@ -237,6 +237,53 @@ export async function dispatch(args) {
237
237
  console.log(JSON.stringify({ authorised: normalised, total: entries.length }, null, 2));
238
238
  process.exit(0);
239
239
  }
240
+ // ── bunker revoke <app-pubkey|npub> ─────────────────────────────────────────
241
+ // Withdraw a client's approval, the reverse of `bunker authorize` and of a
242
+ // client connecting with the secret. Removes it for every bunker key; a
243
+ // running daemon refuses the client from its next request.
244
+ if (args[1] === 'revoke') {
245
+ const usage = 'usage: bray bunker revoke <64-char-hex-pubkey|npub>';
246
+ const given = args[2];
247
+ if (!given || args.length > 3) {
248
+ console.error(usage);
249
+ process.exit(1);
250
+ }
251
+ let clientPk;
252
+ if (/^[0-9a-f]{64}$/i.test(given)) {
253
+ clientPk = given.toLowerCase();
254
+ }
255
+ else {
256
+ const { decode } = await import('nostr-tools/nip19');
257
+ try {
258
+ const decoded = decode(given);
259
+ if (decoded.type === 'npub')
260
+ clientPk = decoded.data;
261
+ }
262
+ catch {
263
+ clientPk = undefined;
264
+ }
265
+ }
266
+ if (!clientPk) {
267
+ console.error(usage);
268
+ process.exit(1);
269
+ }
270
+ const { revokeClient } = await import('../../bunker.js');
271
+ let removed;
272
+ try {
273
+ removed = revokeClient(clientPk);
274
+ }
275
+ catch (err) {
276
+ console.error(`bunker revoke: ${err.message}`);
277
+ process.exit(1);
278
+ }
279
+ if (removed === 0) {
280
+ console.error(`bunker revoke: ${clientPk} is not an approved client (keys passed with --authorized-keys are set when the daemon starts; restart it without them)`);
281
+ process.exit(1);
282
+ }
283
+ console.error('A running bunker daemon refuses this client from its next request. A daemon started with --open serves any client, revoked or not; one started with --authorized-keys keeps the keys it was given.');
284
+ console.log(JSON.stringify({ revoked: clientPk, bunkers: removed }, null, 2));
285
+ process.exit(0);
286
+ }
240
287
  // ── bunker status ───────────────────────────────────────────────────────────
241
288
  // Report the saved bunker URI and its parsed details. Does NOT attempt a
242
289
  // live connection (use `bunker connect` to verify reachability).
@@ -339,9 +386,32 @@ export async function dispatch(args) {
339
386
  bunkerKeyHex,
340
387
  quiet: args.includes('--quiet'),
341
388
  torProxy: config.torProxy,
389
+ open: args.includes('--open'),
390
+ onUrlChange: url => {
391
+ console.error('A new client connected, so that secret is used up. Give the next new client this link:');
392
+ console.error(url);
393
+ },
342
394
  });
395
+ if (authorizedKeys && args.includes('--open')) {
396
+ console.error('--open is ignored with --authorized-keys: only the listed clients are accepted.');
397
+ }
343
398
  console.error(`nostr-bray bunker running`);
344
399
  console.error(`URI: ${bunker.url}`);
400
+ if (authorizedKeys) {
401
+ console.error('Only the --authorized-keys clients (and clients approved earlier) are accepted.');
402
+ }
403
+ else if (!bunker.requiresSecret) {
404
+ console.error('Running open: any client that connects is approved. Anyone with the link can sign as you.');
405
+ }
406
+ else {
407
+ console.error('The link carries a one-time secret for one new client. Treat it like a password.');
408
+ console.error('It survives restarts until used; each new client needs the next link the daemon prints.');
409
+ if (bunker.approvedClients.length > 0) {
410
+ console.error(`Clients approved earlier (they need no secret; remove any you do not recognise with "bray bunker revoke <pubkey>"):`);
411
+ for (const pk of bunker.approvedClients)
412
+ console.error(` ${pk}`);
413
+ }
414
+ }
345
415
  console.error(`Signing as: ${bCtx.activeNpub}`);
346
416
  console.error('Press Ctrl+C to stop');
347
417
  process.on('SIGINT', () => { bunker.close(); bCtx.destroy(); process.exit(0); });
package/dist/cli/index.js CHANGED
@@ -251,9 +251,10 @@ Modes:
251
251
  bunker connect <bunker://…> Save remote bunker URI for future commands
252
252
  bunker pair [--relay <url>] [--expect <npub>] Pair with a signer app over nostrconnect:// and save it
253
253
  bunker authorize <hex-pubkey> Pre-authorise an app pubkey on the local bunker
254
+ bunker revoke <hex-pubkey|npub> Withdraw an app's approval, even from a running daemon
254
255
  bunker status Show saved bunker connection state
255
256
  bunker sign <file|-> One-shot: sign an event template via the bunker (or the saved signer)
256
- bunker daemon [--profile N] [--persona N] [--authorized-keys pk,pk] Start NIP-46 remote signer daemon
257
+ bunker daemon [--profile N] [--persona N] [--authorized-keys pk,pk] [--open] Start NIP-46 remote signer daemon
257
258
  shell Interactive REPL (persistent relay connection)
258
259
 
259
260
  Environment:
package/dist/help.js CHANGED
@@ -93,7 +93,7 @@ export const COMMAND_HELP = {
93
93
  shell: { usage: 'shell', description: 'Start an interactive REPL with a persistent relay connection. Supports tab autocomplete. Type "help" for commands, "exit" to quit.', examples: ['nostr-bray shell'] },
94
94
  'key-encrypt': { usage: 'key-encrypt <nsec-or-hex> <password>', description: 'Encrypt a secret key with a password using NIP-49. Returns an ncryptsec string safe for storage. Also returns the derived pubkey for verification.', examples: ['nostr-bray key-encrypt nsec1... mypassword'], notes: 'WARNING: secret key is passed as a CLI argument. Use only on trusted systems.' },
95
95
  'key-decrypt': { usage: 'key-decrypt <ncryptsec> <password>', description: 'Decrypt an ncryptsec (NIP-49) with a password. Returns the pubkey for verification — never the raw secret key.', examples: ['nostr-bray key-decrypt ncryptsec1... mypassword'] },
96
- bunker: { usage: 'bunker [--profile <name>] [--persona <name>] [--bunker-key-file <f>] [--authorized-keys pk1,pk2] [--quiet]', description: 'Start a NIP-46 remote signer daemon. Holds your secret key and responds to signing requests from authorised clients over Nostr relays. Clients never see your nsec. --profile <name> loads the master from an nsec-tree-cli profile (~/.nsec-tree/profiles/<name>.json); --persona <name> signs as a derived sub-identity (the same key as nsec-tree-cli "export nsec <name>"). The bunker:// stays stable across restarts — a per-identity connection key is persisted automatically.', examples: ['nostr-bray bunker', 'nostr-bray bunker --profile master --persona magazine', 'nostr-bray bunker --authorized-keys abc123...,def456...', 'nostr-bray bunker --quiet'], notes: 'Without --authorized-keys, accepts requests from anyone. Set authorized keys for production use.' },
96
+ bunker: { usage: 'bunker [--profile <name>] [--persona <name>] [--bunker-key-file <f>] [--authorized-keys pk1,pk2] [--open] [--quiet]', description: 'Start a NIP-46 remote signer daemon. Holds your secret key and responds to signing requests from authorised clients over Nostr relays. Clients never see your nsec. --profile <name> loads the master from an nsec-tree-cli profile (~/.nsec-tree/profiles/<name>.json); --persona <name> signs as a derived sub-identity (the same key as nsec-tree-cli "export nsec <name>"). The bunker:// key stays stable across restarts, since a per-identity connection key is persisted automatically. The printed link carries a one-time connect secret: a new client must connect with it, after which the daemon prints the next link, so one link cannot be shared between two apps. An unused secret survives restarts. Clients approved earlier reconnect without it and are listed at start; `bunker revoke <pubkey>` withdraws one, and a running daemon refuses it from its next request (except with --open, which serves any client).', examples: ['nostr-bray bunker', 'nostr-bray bunker --profile master --persona magazine', 'nostr-bray bunker --authorized-keys abc123...,def456...', 'nostr-bray bunker --quiet'], notes: 'With --authorized-keys, only those clients are accepted and no secret is used. --open serves any client that connects, with no secret; anyone who learns the link can then sign as you. Clients that connect while it runs open are not saved, so a later daemon without --open still asks them for the secret.' },
97
97
  serve: { usage: 'serve [--port N] [--hostname H] [--events file.jsonl] [--quiet]', description: 'Start an in-memory Nostr relay for testing. Implements NIP-01 (events, subscriptions, EOSE) and NIP-11 (relay info). Events live in memory only — no persistence. Optionally pre-load events from a JSONL file.', examples: ['nostr-bray serve', 'nostr-bray serve --port 7777', 'nostr-bray serve --events test-data.jsonl', 'nostr-bray serve --port 7777 --quiet'] },
98
98
  };
99
99
  /** Get formatted help for a single command */
package/dist/state.d.ts CHANGED
@@ -1,3 +1,9 @@
1
+ /**
2
+ * Read a JSON state file that holds an object. Returns `{}` if the file is
3
+ * missing, and null if it exists but cannot be read or is not a JSON object,
4
+ * so a caller can refuse to overwrite it.
5
+ */
6
+ export declare function readStateFileStrict<T extends object = Record<string, unknown>>(name: string, stateDir?: string): T | null;
1
7
  /** Read and parse a JSON state file. Returns `{}` if missing or corrupt. */
2
8
  export declare function readStateFile<T = Record<string, unknown>>(name: string, stateDir?: string): T;
3
9
  /**
package/dist/state.js CHANGED
@@ -3,6 +3,23 @@ import { join } from 'node:path';
3
3
  import { homedir } from 'node:os';
4
4
  import { randomBytes } from 'node:crypto';
5
5
  const DEFAULT_STATE_DIR = join(process.env.XDG_CONFIG_HOME ?? join(homedir(), '.config'), 'bray');
6
+ /**
7
+ * Read a JSON state file that holds an object. Returns `{}` if the file is
8
+ * missing, and null if it exists but cannot be read or is not a JSON object,
9
+ * so a caller can refuse to overwrite it.
10
+ */
11
+ export function readStateFileStrict(name, stateDir = DEFAULT_STATE_DIR) {
12
+ const path = join(stateDir, name);
13
+ if (!existsSync(path))
14
+ return {};
15
+ try {
16
+ const parsed = JSON.parse(readFileSync(path, 'utf-8'));
17
+ return typeof parsed === 'object' && parsed !== null && !Array.isArray(parsed) ? parsed : null;
18
+ }
19
+ catch {
20
+ return null;
21
+ }
22
+ }
6
23
  /** Read and parse a JSON state file. Returns `{}` if missing or corrupt. */
7
24
  export function readStateFile(name, stateDir = DEFAULT_STATE_DIR) {
8
25
  const path = join(stateDir, name);
package/llms-full.txt CHANGED
@@ -39,6 +39,7 @@ Single-process MCP server (stdio or HTTP transport) with 261 tools in 29 groups.
39
39
  *Authentication tiers (use exactly one):
40
40
  1. **Bunker** (NIP-46) -- most secure, keys never leave the signer
41
41
  For a signer app (Amber, nsec.app), `npx nostr-bray bunker pair --relay <wss://…>` prints a nostrconnect:// URI to paste or scan into the app. Once the app answers, bray saves the signer (a secret-free bunker:// URI marked paired, plus the client key the app approved) in its state directory, and the MCP server and CLI sign through it with no BUNKER_URI. Options: `--relay` (repeatable; defaults to NOSTR_RELAYS, then the config file relays), `--timeout <seconds>` (default 300), `--expect <npub>` (refuse any other account), `--yes` (skip the y/N prompt). In a terminal bray shows the account the signer offers and asks y/N before saving; outside one, `--expect` or `--yes` is required. The URI is a one-time credential: whoever answers it first becomes the signer. With TOR_PROXY set, pairing goes through the proxy and only .onion relays are accepted unless ALLOW_CLEARNET_WITH_TOR=1. `bunker status` reports `paired`; if the app forgets bray, start-up fails with a hint to run `bunker pair` again. An explicit BUNKER_URI or key takes precedence; `bunker connect` replaces the saved signer. A paired signer is checked with get_public_key on start, not sent connect again.
42
+ bray can also be the signer: `npx nostr-bray bunker` runs a NIP-46 daemon holding your key and prints a bunker:// link with a one-time connect secret. A new client must connect with that link; the daemon then prints the next link, so one link serves one app. An unused secret survives restarts. Clients approved earlier reconnect without the secret and are listed at start; `npx nostr-bray bunker revoke <pubkey|npub>` withdraws one, and a running daemon refuses it from its next request. `--authorized-keys pk1,pk2` accepts only those clients; `--open` serves any client that connects, so anyone who learns the link can sign as you; clients served while open are not saved as approved.
42
43
  2. **ncryptsec** (NIP-49) -- password-encrypted key, decrypted at startup
43
44
  3. **File** -- secret read from disk, deleted from process.env after parsing
44
45
  4. **Env var** -- quickest start, lowest security
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nostr-bray",
3
- "version": "3.7.1",
3
+ "version": "3.9.0",
4
4
  "mcpName": "dev.forgesworn/bray",
5
5
  "description": "Trust-aware Nostr MCP server, CLI and TypeScript SDK for AI agents and humans. Three dimensions of trust -- verification, proximity, and access -- woven into every interaction.",
6
6
  "type": "module",
@@ -180,7 +180,7 @@
180
180
  "farrier-kit": "1.1.3",
181
181
  "nostr-attestations": "^3.0.1",
182
182
  "nostr-tools": "2.24.1",
183
- "nostr-veil": "^0.30.0",
183
+ "nostr-veil": "^0.31.1",
184
184
  "nsec-tree": "^1.4.2",
185
185
  "signet-protocol": "^1.1.0",
186
186
  "socks-proxy-agent": "^10.0.0",