nostr-bray 2.2.0 → 2.3.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/cli/index.js CHANGED
@@ -61,6 +61,7 @@ if (command === 'serve' && !args.includes('--help')) {
61
61
  quiet: args.includes('--quiet'),
62
62
  auth: args.includes('--auth') || args.includes('--eager-auth'),
63
63
  eagerAuth: args.includes('--eager-auth'),
64
+ blossom: args.includes('--blossom'),
64
65
  });
65
66
  console.error(`nostr-bray test relay running at ${relayServer.url}`);
66
67
  console.error('Press Ctrl+C to stop');
@@ -245,7 +246,7 @@ MuSig2 (BIP-327 multi-signature):
245
246
 
246
247
  Modes:
247
248
  (no command) Start MCP server (stdio)
248
- serve [--port N] [--events file] [--auth] [--eager-auth] Start in-memory test relay
249
+ serve [--port N] [--events file] [--auth] [--eager-auth] [--blossom] Start in-memory test relay
249
250
  (--auth requires NIP-42; --eager-auth also challenges on connect)
250
251
  bunker connect <bunker://…> Save remote bunker URI for future commands
251
252
  bunker authorize <hex-pubkey> Pre-authorise an app pubkey on the local bunker
package/dist/serve.d.ts CHANGED
@@ -19,6 +19,13 @@ export interface ServeOptions {
19
19
  auth?: boolean;
20
20
  /** Send the AUTH challenge on connect rather than waiting for a rejection. */
21
21
  eagerAuth?: boolean;
22
+ /**
23
+ * Serve Blossom blob endpoints (BUD-01/02) alongside the relay.
24
+ *
25
+ * bray has ten blossom tools but no way to exercise them without a real
26
+ * blossom server, the same gap `--auth` closed for NIP-42.
27
+ */
28
+ blossom?: boolean;
22
29
  }
23
30
  export declare function startRelay(opts?: ServeOptions): {
24
31
  url: string;
package/dist/serve.js CHANGED
@@ -7,7 +7,7 @@
7
7
  import { WebSocketServer, WebSocket } from 'ws';
8
8
  import { createServer } from 'node:http';
9
9
  import { readFileSync } from 'node:fs';
10
- import { randomBytes } from 'node:crypto';
10
+ import { randomBytes, createHash } from 'node:crypto';
11
11
  import { verifyEvent } from 'nostr-tools/pure';
12
12
  let subCounter = 0;
13
13
  /** Check if an event matches a single filter */
@@ -64,12 +64,137 @@ function checkAuthEvent(event, challenge) {
64
64
  return 'auth event timestamp too far from now';
65
65
  return undefined;
66
66
  }
67
+ const SHA256_PATH = /^\/([0-9a-f]{64})(\.[a-z0-9]+)?$/i;
68
+ const LIST_PATH = /^\/list\/([0-9a-f]{64})$/i;
69
+ /** 10 MiB, matching what the client refuses to send. */
70
+ const MAX_BLOB = 10 * 1024 * 1024;
71
+ /** Does this URL belong to the Blossom surface rather than the relay? */
72
+ function isBlossomRequest(url) {
73
+ const path = url.split('?')[0];
74
+ return path === '/upload' || SHA256_PATH.test(path) || LIST_PATH.test(path);
75
+ }
76
+ /**
77
+ * Check a BUD-01 `Authorization: Nostr <base64 kind 24242>` header.
78
+ *
79
+ * Returns the uploader pubkey, or a reason to reject. The `t` and `x` tags are
80
+ * both checked: without `x` an upload authorisation could be replayed to
81
+ * delete a different blob, which is the whole point of binding it to a hash.
82
+ */
83
+ function checkBlossomAuth(header, action, sha256) {
84
+ if (!header?.startsWith('Nostr '))
85
+ return { error: 'missing Nostr authorization header' };
86
+ let event;
87
+ try {
88
+ event = JSON.parse(Buffer.from(header.slice(6), 'base64').toString('utf8'));
89
+ }
90
+ catch {
91
+ return { error: 'authorization header is not base64-encoded JSON' };
92
+ }
93
+ if (event.kind !== 24242)
94
+ return { error: `expected kind 24242, got ${event.kind}` };
95
+ if (!verifyEvent(event))
96
+ return { error: 'invalid signature' };
97
+ const t = event.tags?.find(tag => tag[0] === 't')?.[1];
98
+ if (t !== action)
99
+ return { error: `authorization is for "${t}", not "${action}"` };
100
+ const expiration = event.tags?.find(tag => tag[0] === 'expiration')?.[1];
101
+ if (expiration && Number(expiration) < Math.floor(Date.now() / 1000)) {
102
+ return { error: 'authorization expired' };
103
+ }
104
+ if (sha256) {
105
+ const x = event.tags?.filter(tag => tag[0] === 'x').map(tag => tag[1]) ?? [];
106
+ if (!x.includes(sha256))
107
+ return { error: 'authorization does not name this blob hash' };
108
+ }
109
+ return { pubkey: event.pubkey };
110
+ }
111
+ function readBody(req) {
112
+ return new Promise((resolve, reject) => {
113
+ const chunks = [];
114
+ let size = 0;
115
+ req.on('data', (c) => {
116
+ size += c.length;
117
+ if (size > MAX_BLOB) {
118
+ reject(new Error(`blob exceeds ${MAX_BLOB} bytes`));
119
+ req.destroy();
120
+ return;
121
+ }
122
+ chunks.push(c);
123
+ });
124
+ req.on('end', () => resolve(Buffer.concat(chunks)));
125
+ req.on('error', reject);
126
+ });
127
+ }
128
+ /** Serve BUD-01/02: PUT /upload, GET|HEAD /<sha256>, GET /list/<pubkey>, DELETE /<sha256>. */
129
+ async function serveBlossom(req, res, blobs, hostname, log) {
130
+ const path = (req.url ?? '/').split('?')[0];
131
+ const port = (req.socket.localPort ?? 0);
132
+ const base = `http://${hostname}:${port}`;
133
+ const json = (code, body) => {
134
+ res.writeHead(code, { 'Content-Type': 'application/json' });
135
+ res.end(JSON.stringify(body));
136
+ };
137
+ try {
138
+ if (path === '/upload' && req.method === 'PUT') {
139
+ const body = await readBody(req);
140
+ const sha256 = createHash('sha256').update(body).digest('hex');
141
+ const auth = checkBlossomAuth(req.headers.authorization, 'upload', sha256);
142
+ if ('error' in auth)
143
+ return json(401, { message: auth.error });
144
+ const type = req.headers['content-type'] ?? 'application/octet-stream';
145
+ const uploaded = Math.floor(Date.now() / 1000);
146
+ blobs.set(sha256, { data: body, type: String(type), uploader: auth.pubkey, uploaded });
147
+ log(`blossom: stored ${sha256.slice(0, 12)}… (${body.length} bytes)`);
148
+ return json(200, { url: `${base}/${sha256}`, sha256, size: body.length, type, uploaded });
149
+ }
150
+ const listMatch = LIST_PATH.exec(path);
151
+ if (listMatch && req.method === 'GET') {
152
+ const pubkey = listMatch[1].toLowerCase();
153
+ const owned = [...blobs.entries()]
154
+ .filter(([, b]) => b.uploader === pubkey)
155
+ .map(([sha256, b]) => ({ url: `${base}/${sha256}`, sha256, size: b.data.length, type: b.type, uploaded: b.uploaded }));
156
+ return json(200, owned);
157
+ }
158
+ const blobMatch = SHA256_PATH.exec(path);
159
+ if (blobMatch) {
160
+ const sha256 = blobMatch[1].toLowerCase();
161
+ const blob = blobs.get(sha256);
162
+ if (req.method === 'GET' || req.method === 'HEAD') {
163
+ if (!blob)
164
+ return json(404, { message: 'blob not found' });
165
+ res.writeHead(200, { 'Content-Type': blob.type, 'Content-Length': String(blob.data.length) });
166
+ res.end(req.method === 'HEAD' ? undefined : blob.data);
167
+ return;
168
+ }
169
+ if (req.method === 'DELETE') {
170
+ const auth = checkBlossomAuth(req.headers.authorization, 'delete', sha256);
171
+ if ('error' in auth)
172
+ return json(401, { message: auth.error });
173
+ if (!blob)
174
+ return json(404, { message: 'blob not found' });
175
+ // Only the uploader may delete, otherwise anyone could clear the store
176
+ if (blob.uploader !== auth.pubkey)
177
+ return json(403, { message: 'not the uploader of this blob' });
178
+ blobs.delete(sha256);
179
+ log(`blossom: deleted ${sha256.slice(0, 12)}…`);
180
+ return json(200, { message: 'deleted' });
181
+ }
182
+ }
183
+ json(405, { message: `method ${req.method} not allowed on ${path}` });
184
+ }
185
+ catch (e) {
186
+ json(400, { message: e.message });
187
+ }
188
+ }
67
189
  export function startRelay(opts = {}) {
68
190
  const hostname = opts.hostname ?? 'localhost';
69
191
  const port = opts.port ?? 10547;
70
192
  const quiet = opts.quiet ?? false;
71
193
  const requireAuth = opts.auth ?? false;
72
194
  const eagerAuth = opts.eagerAuth ?? false;
195
+ const enableBlossom = opts.blossom ?? false;
196
+ /** sha256 -> blob. In memory, like the event store. */
197
+ const blobs = new Map();
73
198
  const log = quiet ? () => { } : (...args) => console.error('[relay]', ...args);
74
199
  // The bundled test relay has no auth and accepts arbitrary signed events.
75
200
  // Binding it to anything other than loopback exposes a writable Nostr endpoint
@@ -95,6 +220,10 @@ export function startRelay(opts = {}) {
95
220
  log(`Loaded ${events.size} events from ${opts.eventsFile}`);
96
221
  }
97
222
  const httpServer = createServer((req, res) => {
223
+ if (enableBlossom && isBlossomRequest(req.url ?? '/')) {
224
+ void serveBlossom(req, res, blobs, hostname, log);
225
+ return;
226
+ }
98
227
  // NIP-11 relay info document
99
228
  if (req.headers.accept?.includes('application/nostr+json')) {
100
229
  res.writeHead(200, { 'Content-Type': 'application/nostr+json' });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nostr-bray",
3
- "version": "2.2.0",
3
+ "version": "2.3.0",
4
4
  "mcpName": "dev.forgesworn/bray",
5
5
  "description": "Trust-aware Nostr for AI and humans. Three dimensions of trust -- verification, proximity, and access -- woven into every interaction.",
6
6
  "type": "module",