nostr-bray 1.26.1 → 1.27.1

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.
@@ -20,6 +20,8 @@ export interface BunkerConfig {
20
20
  }
21
21
  /** Parse a bunker:// URI */
22
22
  export declare function parseBunkerUri(uri: string): BunkerConfig;
23
+ /** Resolve the client secret key: URI secret > cached > generate & persist. */
24
+ export declare function resolveClientKey(config: BunkerConfig, stateDir?: string): Uint8Array;
23
25
  export declare class BunkerContext implements SigningContext {
24
26
  protected signer: BunkerSigner;
25
27
  protected pool: SimplePool;
@@ -14,6 +14,7 @@ import { useWebSocketImplementation, SimplePool } from 'nostr-tools/pool';
14
14
  import { generateSecretKey } from 'nostr-tools/pure';
15
15
  import { npubEncode } from 'nostr-tools/nip19';
16
16
  import WebSocket from 'ws';
17
+ import { readStateFile, writeStateFile } from './state.js';
17
18
  useWebSocketImplementation(WebSocket);
18
19
  /** Parse a bunker:// URI */
19
20
  export function parseBunkerUri(uri) {
@@ -27,6 +28,21 @@ export function parseBunkerUri(uri) {
27
28
  }
28
29
  return { pubkey, relays, secret };
29
30
  }
31
+ const CLIENT_KEYS_FILE = 'client-keys.json';
32
+ /** Resolve the client secret key: URI secret > cached > generate & persist. */
33
+ export function resolveClientKey(config, stateDir) {
34
+ if (config.secret) {
35
+ return Buffer.from(config.secret, 'hex');
36
+ }
37
+ const cache = readStateFile(CLIENT_KEYS_FILE, stateDir);
38
+ if (cache[config.pubkey]) {
39
+ return Buffer.from(cache[config.pubkey], 'hex');
40
+ }
41
+ const sk = generateSecretKey();
42
+ cache[config.pubkey] = Buffer.from(sk).toString('hex');
43
+ writeStateFile(CLIENT_KEYS_FILE, cache, stateDir);
44
+ return sk;
45
+ }
30
46
  export class BunkerContext {
31
47
  signer;
32
48
  pool;
@@ -40,9 +56,7 @@ export class BunkerContext {
40
56
  /** Connect to a remote bunker. Blocks until the connection is established. */
41
57
  static async connect(uri, timeoutMs = 15_000) {
42
58
  const config = parseBunkerUri(uri);
43
- const clientSk = config.secret
44
- ? Buffer.from(config.secret, 'hex')
45
- : generateSecretKey();
59
+ const clientSk = resolveClientKey(config);
46
60
  const pool = new SimplePool();
47
61
  const signer = BunkerSigner.fromBunker(clientSk, { pubkey: config.pubkey, relays: config.relays, secret: null }, { pool });
48
62
  // Connect and verify
package/dist/bunker.d.ts CHANGED
@@ -17,6 +17,7 @@ export interface BunkerOptions {
17
17
  bunkerKeyHex?: string;
18
18
  quiet?: boolean;
19
19
  heartwoodExtensions?: boolean;
20
+ stateDir?: string;
20
21
  }
21
22
  export interface BunkerInstance {
22
23
  url: string;
package/dist/bunker.js CHANGED
@@ -14,17 +14,26 @@ import { finalizeEvent, getPublicKey, generateSecretKey } from 'nostr-tools/pure
14
14
  import { npubEncode } from 'nostr-tools/nip19';
15
15
  import { useWebSocketImplementation, SimplePool } from 'nostr-tools/pool';
16
16
  import WebSocket from 'ws';
17
+ import { readStateFile, writeStateFile } from './state.js';
17
18
  useWebSocketImplementation(WebSocket);
18
19
  export function startBunker(opts) {
19
20
  const { ctx, relays, quiet } = opts;
20
- const authorizedKeys = new Set(opts.authorizedKeys ?? []);
21
21
  const log = quiet ? () => { } : (...args) => console.error('[bunker]', ...args);
22
- // Use persistent key if provided, otherwise generate ephemeral
22
+ // Bunker keypair — must be computed first so we can load persisted approvals
23
23
  const bunkerSk = opts.bunkerKeyHex
24
24
  ? Buffer.from(opts.bunkerKeyHex, 'hex')
25
25
  : generateSecretKey();
26
26
  const bunkerPk = getPublicKey(bunkerSk);
27
27
  const bunkerNpub = npubEncode(bunkerPk);
28
+ // Authorised clients: CLI flag controls the gate, persisted approvals supplement
29
+ const APPROVALS_FILE = 'approved-clients.json';
30
+ const persisted = readStateFile(APPROVALS_FILE, opts.stateDir);
31
+ const bunkerApprovals = persisted[bunkerPk] ?? [];
32
+ const cliAuthorizedKeys = new Set(opts.authorizedKeys ?? []);
33
+ const authorizedKeys = new Set([
34
+ ...cliAuthorizedKeys,
35
+ ...bunkerApprovals,
36
+ ]);
28
37
  const pool = new SimplePool();
29
38
  // Subscribe to kind 24133 events addressed to us
30
39
  const sub = pool.subscribeMany(relays, { kinds: [24133], '#p': [bunkerPk] }, {
@@ -39,8 +48,8 @@ export function startBunker(opts) {
39
48
  });
40
49
  async function handleRequest(event) {
41
50
  const clientPk = event.pubkey;
42
- // Check authorization
43
- if (authorizedKeys.size > 0 && !authorizedKeys.has(clientPk)) {
51
+ // Check authorization — gate only activates when CLI --authorized-keys were set
52
+ if (cliAuthorizedKeys.size > 0 && !authorizedKeys.has(clientPk)) {
44
53
  log(`Rejected request from unauthorized key: ${clientPk.slice(0, 12)}...`);
45
54
  return;
46
55
  }
@@ -60,6 +69,15 @@ export function startBunker(opts) {
60
69
  let error;
61
70
  switch (request.method) {
62
71
  case 'connect':
72
+ // Persist newly-approved client
73
+ if (!bunkerApprovals.includes(clientPk)) {
74
+ bunkerApprovals.push(clientPk);
75
+ authorizedKeys.add(clientPk);
76
+ const current = readStateFile(APPROVALS_FILE, opts.stateDir);
77
+ current[bunkerPk] = bunkerApprovals;
78
+ writeStateFile(APPROVALS_FILE, current, opts.stateDir);
79
+ log(`Approved and persisted client: ${clientPk.slice(0, 12)}...`);
80
+ }
63
81
  result = 'ack';
64
82
  break;
65
83
  case 'ping':
@@ -188,8 +206,8 @@ export function startBunker(opts) {
188
206
  log(`Bunker started: ${bunkerNpub}`);
189
207
  log(`URI: ${bunkerUrl}`);
190
208
  log(`Signing as: ${ctx.activeNpub}`);
191
- if (authorizedKeys.size > 0) {
192
- log(`Authorized keys: ${authorizedKeys.size}`);
209
+ if (cliAuthorizedKeys.size > 0) {
210
+ log(`Authorized keys: ${authorizedKeys.size} (${cliAuthorizedKeys.size} CLI + ${bunkerApprovals.length} persisted)`);
193
211
  }
194
212
  else {
195
213
  log('WARNING: No authorized keys set — accepting requests from anyone');
package/dist/catalog.js CHANGED
@@ -108,11 +108,17 @@ export class ActionCatalog {
108
108
  description: 'Execute an action found via search-actions. Pass the exact action name and its parameters.',
109
109
  inputSchema: {
110
110
  action: z.string().describe('Action name from search-actions results'),
111
- params: z.record(z.string(), z.unknown()).default({}).describe('Parameters for the action (see search-actions results for expected params)'),
111
+ params: z.record(z.string(), z.unknown()).optional().describe('Parameters for the action (see search-actions results for expected params). Alias: parameters.'),
112
+ // Alias for clients that naturally reach for "parameters" rather than "params".
113
+ // Without this, MCP SDK strips unknown keys from the validated args and the
114
+ // inner action handler receives an empty object, causing spurious
115
+ // "expected string, received undefined" validation failures.
116
+ parameters: z.record(z.string(), z.unknown()).optional().describe('Alias for params. Either field is accepted.'),
112
117
  },
113
118
  annotations: { openWorldHint: true },
114
- }, async ({ action, params }) => {
115
- return catalog.execute(action, params);
119
+ }, async ({ action, params, parameters }) => {
120
+ const resolved = (params ?? parameters ?? {});
121
+ return catalog.execute(action, resolved);
116
122
  });
117
123
  }
118
124
  }
@@ -37,6 +37,7 @@ export declare function handleRelayAdd(ctx: SigningContext, pool: RelayPool, arg
37
37
  reconfigured: boolean;
38
38
  };
39
39
  export interface RelayQueryArgs {
40
+ ids?: string[];
40
41
  kinds?: number[];
41
42
  authors?: string[];
42
43
  tags?: Record<string, string[]>;
@@ -81,6 +81,8 @@ export function handleRelayAdd(ctx, pool, args) {
81
81
  /** Query events from relays by arbitrary filter. Uses explicit relays if provided, otherwise the active identity's read relays. */
82
82
  export async function handleRelayQuery(pool, npub, args) {
83
83
  const filter = {};
84
+ if (args.ids?.length)
85
+ filter.ids = args.ids;
84
86
  if (args.kinds?.length)
85
87
  filter.kinds = args.kinds;
86
88
  if (args.authors?.length)
@@ -50,21 +50,49 @@ export function registerRelayTools(server, deps) {
50
50
  };
51
51
  });
52
52
  server.registerTool('relay-query', {
53
- description: 'Query events from Nostr relays by kind, author, tags, or time range. Useful for discovering events, scanning for specific kinds, or investigating unknown event schemas. Uses explicit relays if provided, otherwise the active identity\'s read relays.',
53
+ description: 'Query events from Nostr relays by kind, author, ids, tags, or time range. Useful for discovering events, scanning for specific kinds, or investigating unknown event schemas. Uses explicit relays if provided, otherwise the active identity\'s read relays. Filter fields may be supplied either as top-level arguments or wrapped in a single "filter" object (both shapes are accepted).',
54
54
  inputSchema: {
55
+ ids: z.array(z.string()).optional().describe('Event IDs (hex) to fetch directly'),
55
56
  kinds: z.array(z.number().int()).optional().describe('Event kinds to filter by (e.g. [30301, 31000])'),
56
57
  authors: z.array(z.string()).optional().describe('Hex pubkeys of event authors'),
57
58
  tags: z.record(z.string(), z.array(z.string())).optional().describe('Tag filters as key-value pairs (e.g. {"#p": ["hex..."], "#d": ["prefix"]})'),
58
- since: z.number().int().optional().describe('Unix timestamp — only events created after this time'),
59
- until: z.number().int().optional().describe('Unix timestamp — only events created before this time'),
60
- limit: z.number().int().min(1).max(500).default(50).describe('Maximum number of events to return (default 50, max 500)'),
59
+ since: z.number().int().optional().describe('Unix timestamp, only events created after this time'),
60
+ until: z.number().int().optional().describe('Unix timestamp, only events created before this time'),
61
+ limit: z.number().int().min(1).max(500).optional().describe('Maximum number of events to return (default 50, max 500)'),
61
62
  relays: z.array(relayUrl).optional().describe('Explicit relay URLs to query (overrides identity relay set)'),
62
63
  search: z.string().optional().describe('Full-text search query (NIP-50). Only works on relays that support NIP-50; others will ignore it.'),
64
+ // Alias: some MCP clients naturally wrap the whole filter in a single "filter" object.
65
+ // Without this alias the MCP SDK silently strips the unknown key, leaving the handler
66
+ // with an empty argument set and returning a broad firehose of events.
67
+ filter: z.object({
68
+ ids: z.array(z.string()).optional(),
69
+ kinds: z.array(z.number().int()).optional(),
70
+ authors: z.array(z.string()).optional(),
71
+ tags: z.record(z.string(), z.array(z.string())).optional(),
72
+ since: z.number().int().optional(),
73
+ until: z.number().int().optional(),
74
+ limit: z.number().int().min(1).max(500).optional(),
75
+ search: z.string().optional(),
76
+ }).optional().describe('Alias: all filter fields wrapped in a single object. Merged with any top-level fields; top-level values win on conflict.'),
63
77
  },
64
78
  annotations: { readOnlyHint: true },
65
- }, async ({ kinds, authors, tags, since, until, limit, relays, search }) => {
79
+ }, async (args) => {
80
+ // Merge nested "filter" alias with top-level fields. Top-level values win on conflict so
81
+ // the canonical form stays authoritative, but a caller that only supplies "filter" still
82
+ // gets everything through. Without this merge the MCP SDK strips the unknown top-level
83
+ // "filter" key and the handler receives an empty object, yielding a firehose response.
84
+ const f = args.filter ?? {};
85
+ const mergedLimit = args.limit ?? f.limit ?? 50;
66
86
  const events = await handleRelayQuery(deps.pool, deps.ctx.activeNpub, {
67
- kinds, authors, tags, since, until, limit, relays, search,
87
+ ids: args.ids ?? f.ids,
88
+ kinds: args.kinds ?? f.kinds,
89
+ authors: args.authors ?? f.authors,
90
+ tags: args.tags ?? f.tags,
91
+ since: args.since ?? f.since,
92
+ until: args.until ?? f.until,
93
+ limit: mergedLimit,
94
+ relays: args.relays,
95
+ search: args.search ?? f.search,
68
96
  });
69
97
  const summary = events.map(e => ({
70
98
  id: e.id,
@@ -0,0 +1,4 @@
1
+ /** Read and parse a JSON state file. Returns `{}` if missing or corrupt. */
2
+ export declare function readStateFile<T = Record<string, unknown>>(name: string, stateDir?: string): T;
3
+ /** Write a JSON state file with 0600 permissions. Creates directory if needed. */
4
+ export declare function writeStateFile(name: string, data: unknown, stateDir?: string): void;
package/dist/state.js ADDED
@@ -0,0 +1,23 @@
1
+ import { readFileSync, writeFileSync, mkdirSync, chmodSync, existsSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+ import { homedir } from 'node:os';
4
+ const DEFAULT_STATE_DIR = join(process.env.XDG_CONFIG_HOME ?? join(homedir(), '.config'), 'bray');
5
+ /** Read and parse a JSON state file. Returns `{}` if missing or corrupt. */
6
+ export function readStateFile(name, stateDir = DEFAULT_STATE_DIR) {
7
+ const path = join(stateDir, name);
8
+ try {
9
+ return JSON.parse(readFileSync(path, 'utf-8'));
10
+ }
11
+ catch {
12
+ return {};
13
+ }
14
+ }
15
+ /** Write a JSON state file with 0600 permissions. Creates directory if needed. */
16
+ export function writeStateFile(name, data, stateDir = DEFAULT_STATE_DIR) {
17
+ if (!existsSync(stateDir)) {
18
+ mkdirSync(stateDir, { recursive: true });
19
+ }
20
+ const path = join(stateDir, name);
21
+ writeFileSync(path, JSON.stringify(data, null, 2), { mode: 0o600 });
22
+ chmodSync(path, 0o600);
23
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nostr-bray",
3
- "version": "1.26.1",
3
+ "version": "1.27.1",
4
4
  "description": "Trust-aware Nostr for AI and humans. Three dimensions of trust -- verification, proximity, and access -- woven into every interaction.",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",