nostr-bray 1.12.0 → 1.13.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
@@ -4,6 +4,7 @@
4
4
 
5
5
  [![CI](https://github.com/forgesworn/bray/actions/workflows/ci.yml/badge.svg)](https://github.com/forgesworn/bray/actions/workflows/ci.yml)
6
6
  [![npm](https://img.shields.io/npm/v/nostr-bray)](https://www.npmjs.com/package/nostr-bray)
7
+ [![coverage](https://img.shields.io/badge/coverage-96%25-brightgreen)](./package.json)
7
8
  [![licence](https://img.shields.io/npm/l/nostr-bray)](./LICENSE)
8
9
  [![TypeScript](https://img.shields.io/badge/TypeScript-ESM-blue)](./tsconfig.json)
9
10
 
package/dist/cli.js CHANGED
@@ -24,10 +24,24 @@ import * as fmt from './format.js';
24
24
  const args = process.argv.slice(2);
25
25
  const command = args[0];
26
26
  // No command = start MCP server
27
- if (!command || command === 'serve') {
27
+ if (!command) {
28
28
  await import('./index.js');
29
29
  process.exit(0);
30
30
  }
31
+ // Serve = in-memory test relay
32
+ if (command === 'serve' && !args.includes('--help')) {
33
+ const { startRelay } = await import('./serve.js');
34
+ const hostname = args.includes('--hostname') ? args[args.indexOf('--hostname') + 1] : 'localhost';
35
+ const port = args.includes('--port') ? parseInt(args[args.indexOf('--port') + 1], 10) : 10547;
36
+ const eventsFile = args.includes('--events') ? args[args.indexOf('--events') + 1] : undefined;
37
+ const relay = startRelay({ hostname, port, eventsFile, quiet: args.includes('--quiet') });
38
+ console.error(`nostr-bray test relay running at ${relay.url}`);
39
+ console.error('Press Ctrl+C to stop');
40
+ process.on('SIGINT', () => { relay.close(); process.exit(0); });
41
+ process.on('SIGTERM', () => { relay.close(); process.exit(0); });
42
+ // Keep process alive
43
+ await new Promise(() => { });
44
+ }
31
45
  // Per-command help: `nostr-bray post --help`
32
46
  if (args.includes('--help') && command && command !== 'help' && command !== '--help' && command !== '-h') {
33
47
  const help = getCommandHelp(command);
@@ -130,6 +144,7 @@ Utility:
130
144
 
131
145
  Modes:
132
146
  (no command) Start MCP server (stdio)
147
+ serve [--port N] [--events file] Start in-memory test relay
133
148
  shell Interactive REPL (persistent relay connection)
134
149
 
135
150
  Environment:
package/dist/help.js CHANGED
@@ -84,6 +84,7 @@ export const COMMAND_HELP = {
84
84
  count: { usage: 'count [--kinds N,N] [--authors X,X] [--since N]', description: 'Count events matching a filter on relays.', examples: ['nostr-bray count --kinds 1', 'nostr-bray count --kinds 1,7 --authors abc123...'] },
85
85
  fetch: { usage: 'fetch <nip19>', description: 'Fetch events by nip19 code (note, nevent, nprofile, npub, naddr). Resolves the entity and queries relays.', examples: ['nostr-bray fetch note1...', 'nostr-bray fetch nevent1...', 'nostr-bray fetch npub1...'] },
86
86
  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'] },
87
+ 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'] },
87
88
  };
88
89
  /** Get formatted help for a single command */
89
90
  export function getCommandHelp(cmd) {
@@ -0,0 +1,16 @@
1
+ /**
2
+ * In-memory Nostr relay for testing purposes.
3
+ *
4
+ * Implements NIP-01 (events, subscriptions, EOSE) and NIP-11 (relay info).
5
+ * No persistence — events live in memory until the process exits.
6
+ */
7
+ export interface ServeOptions {
8
+ hostname?: string;
9
+ port?: number;
10
+ eventsFile?: string;
11
+ quiet?: boolean;
12
+ }
13
+ export declare function startRelay(opts?: ServeOptions): {
14
+ url: string;
15
+ close: () => void;
16
+ };
package/dist/serve.js ADDED
@@ -0,0 +1,175 @@
1
+ /**
2
+ * In-memory Nostr relay for testing purposes.
3
+ *
4
+ * Implements NIP-01 (events, subscriptions, EOSE) and NIP-11 (relay info).
5
+ * No persistence — events live in memory until the process exits.
6
+ */
7
+ import { WebSocketServer, WebSocket } from 'ws';
8
+ import { createServer } from 'node:http';
9
+ import { readFileSync } from 'node:fs';
10
+ import { verifyEvent } from 'nostr-tools/pure';
11
+ /** Check if an event matches a single filter */
12
+ function matchFilter(filter, event) {
13
+ if (filter.ids && !filter.ids.includes(event.id))
14
+ return false;
15
+ if (filter.kinds && !filter.kinds.includes(event.kind))
16
+ return false;
17
+ if (filter.authors && !filter.authors.includes(event.pubkey))
18
+ return false;
19
+ if (filter.since && event.created_at < filter.since)
20
+ return false;
21
+ if (filter.until && event.created_at > filter.until)
22
+ return false;
23
+ // Tag filters (#e, #p, #t, #d, etc.)
24
+ for (const key of Object.keys(filter)) {
25
+ if (key.startsWith('#')) {
26
+ const vals = filter[key];
27
+ if (!vals || !Array.isArray(vals))
28
+ continue;
29
+ const tagName = key.slice(1);
30
+ const eventTagValues = event.tags.filter(t => t[0] === tagName).map(t => t[1]);
31
+ if (!vals.some(v => eventTagValues.includes(v)))
32
+ return false;
33
+ }
34
+ }
35
+ return true;
36
+ }
37
+ /** Check if an event matches any filter in a list */
38
+ function matchFilters(filters, event) {
39
+ return filters.some(f => matchFilter(f, event));
40
+ }
41
+ export function startRelay(opts = {}) {
42
+ const hostname = opts.hostname ?? 'localhost';
43
+ const port = opts.port ?? 10547;
44
+ const quiet = opts.quiet ?? false;
45
+ const log = quiet ? () => { } : (...args) => console.error('[relay]', ...args);
46
+ const events = new Map();
47
+ const subscriptions = new Map();
48
+ // Pre-load events from JSONL file
49
+ if (opts.eventsFile) {
50
+ const lines = readFileSync(opts.eventsFile, 'utf-8').split('\n').filter(Boolean);
51
+ for (const line of lines) {
52
+ try {
53
+ const event = JSON.parse(line);
54
+ events.set(event.id, event);
55
+ }
56
+ catch { /* skip malformed lines */ }
57
+ }
58
+ log(`Loaded ${events.size} events from ${opts.eventsFile}`);
59
+ }
60
+ const httpServer = createServer((req, res) => {
61
+ // NIP-11 relay info document
62
+ if (req.headers.accept?.includes('application/nostr+json')) {
63
+ res.writeHead(200, { 'Content-Type': 'application/nostr+json' });
64
+ res.end(JSON.stringify({
65
+ name: 'nostr-bray test relay',
66
+ description: 'In-memory relay for testing',
67
+ supported_nips: [1, 11],
68
+ software: 'nostr-bray',
69
+ version: '0.1.0',
70
+ }));
71
+ return;
72
+ }
73
+ res.writeHead(200, { 'Content-Type': 'text/plain' });
74
+ res.end('nostr-bray test relay — connect via WebSocket');
75
+ });
76
+ const wss = new WebSocketServer({ server: httpServer });
77
+ wss.on('connection', (ws) => {
78
+ const clientSubs = new Set();
79
+ log('Client connected');
80
+ ws.on('message', (raw) => {
81
+ let msg;
82
+ try {
83
+ msg = JSON.parse(raw.toString());
84
+ }
85
+ catch {
86
+ ws.send(JSON.stringify(['NOTICE', 'Invalid JSON']));
87
+ return;
88
+ }
89
+ if (!Array.isArray(msg) || msg.length < 2) {
90
+ ws.send(JSON.stringify(['NOTICE', 'Invalid message format']));
91
+ return;
92
+ }
93
+ const type = msg[0];
94
+ if (type === 'EVENT') {
95
+ const event = msg[1];
96
+ if (!event?.id || !event?.sig || !event?.pubkey) {
97
+ ws.send(JSON.stringify(['OK', event?.id ?? '', false, 'invalid: missing fields']));
98
+ return;
99
+ }
100
+ if (!verifyEvent(event)) {
101
+ ws.send(JSON.stringify(['OK', event.id, false, 'invalid: signature verification failed']));
102
+ return;
103
+ }
104
+ // Store event
105
+ events.set(event.id, event);
106
+ ws.send(JSON.stringify(['OK', event.id, true, '']));
107
+ log(`Stored event ${event.id.slice(0, 8)}... kind:${event.kind}`);
108
+ // Notify matching subscriptions
109
+ for (const sub of subscriptions.values()) {
110
+ if (matchFilters(sub.filters, event) && sub.ws.readyState === WebSocket.OPEN) {
111
+ sub.ws.send(JSON.stringify(['EVENT', sub.id, event]));
112
+ }
113
+ }
114
+ }
115
+ else if (type === 'REQ') {
116
+ const subId = msg[1];
117
+ const filters = msg.slice(2);
118
+ // Register subscription
119
+ const sub = { id: subId, filters, ws };
120
+ const key = `${subId}-${Date.now()}`;
121
+ subscriptions.set(key, sub);
122
+ clientSubs.add(key);
123
+ // Send matching stored events
124
+ let count = 0;
125
+ const limit = filters.reduce((min, f) => Math.min(min, f.limit ?? Infinity), Infinity);
126
+ const matching = [...events.values()]
127
+ .filter(e => matchFilters(filters, e))
128
+ .sort((a, b) => b.created_at - a.created_at)
129
+ .slice(0, limit === Infinity ? undefined : limit);
130
+ for (const event of matching) {
131
+ ws.send(JSON.stringify(['EVENT', subId, event]));
132
+ count++;
133
+ }
134
+ // EOSE
135
+ ws.send(JSON.stringify(['EOSE', subId]));
136
+ log(`REQ ${subId}: ${count} events, ${filters.length} filter(s)`);
137
+ }
138
+ else if (type === 'CLOSE') {
139
+ const subId = msg[1];
140
+ for (const key of clientSubs) {
141
+ if (subscriptions.get(key)?.id === subId) {
142
+ subscriptions.delete(key);
143
+ clientSubs.delete(key);
144
+ }
145
+ }
146
+ ws.send(JSON.stringify(['CLOSED', subId, '']));
147
+ }
148
+ else if (type === 'COUNT') {
149
+ const subId = msg[1];
150
+ const filters = msg.slice(2);
151
+ const count = [...events.values()].filter(e => matchFilters(filters, e)).length;
152
+ ws.send(JSON.stringify(['COUNT', subId, { count }]));
153
+ }
154
+ else {
155
+ ws.send(JSON.stringify(['NOTICE', `Unknown message type: ${type}`]));
156
+ }
157
+ });
158
+ ws.on('close', () => {
159
+ for (const key of clientSubs) {
160
+ subscriptions.delete(key);
161
+ }
162
+ log('Client disconnected');
163
+ });
164
+ });
165
+ httpServer.listen(port, hostname, () => {
166
+ log(`Listening on ws://${hostname}:${port}`);
167
+ });
168
+ return {
169
+ url: `ws://${hostname}:${port}`,
170
+ close: () => {
171
+ wss.close();
172
+ httpServer.close();
173
+ },
174
+ };
175
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nostr-bray",
3
- "version": "1.12.0",
3
+ "version": "1.13.0",
4
4
  "description": "MCP server giving AI agents sovereign Nostr identities — key derivation, personas, attestations, duress detection, zaps",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -17,6 +17,7 @@
17
17
  "scripts": {
18
18
  "build": "tsc",
19
19
  "test": "vitest run",
20
+ "test:coverage": "vitest run --coverage",
20
21
  "test:watch": "vitest",
21
22
  "lint": "tsc --noEmit",
22
23
  "start": "node dist/index.js"
@@ -89,6 +90,7 @@
89
90
  "@semantic-release/git": "^10.0.1",
90
91
  "@types/node": "^25.5.0",
91
92
  "@types/ws": "^8.18.1",
93
+ "@vitest/coverage-v8": "^4.1.1",
92
94
  "semantic-release": "^25.0.3",
93
95
  "typescript": "^6.0.2",
94
96
  "vitest": "^4.1.1"