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.
- package/dist/bunker-context.d.ts +2 -0
- package/dist/bunker-context.js +17 -3
- package/dist/bunker.d.ts +1 -0
- package/dist/bunker.js +24 -6
- package/dist/catalog.js +9 -3
- package/dist/relay/handlers.d.ts +1 -0
- package/dist/relay/handlers.js +2 -0
- package/dist/relay/tools.js +34 -6
- package/dist/state.d.ts +4 -0
- package/dist/state.js +23 -0
- package/package.json +1 -1
package/dist/bunker-context.d.ts
CHANGED
|
@@ -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;
|
package/dist/bunker-context.js
CHANGED
|
@@ -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
|
|
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
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
|
-
//
|
|
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 (
|
|
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 (
|
|
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()).
|
|
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
|
-
|
|
119
|
+
}, async ({ action, params, parameters }) => {
|
|
120
|
+
const resolved = (params ?? parameters ?? {});
|
|
121
|
+
return catalog.execute(action, resolved);
|
|
116
122
|
});
|
|
117
123
|
}
|
|
118
124
|
}
|
package/dist/relay/handlers.d.ts
CHANGED
package/dist/relay/handlers.js
CHANGED
|
@@ -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)
|
package/dist/relay/tools.js
CHANGED
|
@@ -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
|
|
59
|
-
until: z.number().int().optional().describe('Unix timestamp
|
|
60
|
-
limit: z.number().int().min(1).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 (
|
|
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
|
-
|
|
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,
|
package/dist/state.d.ts
ADDED
|
@@ -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.
|
|
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",
|