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 +2 -1
- package/dist/serve.d.ts +7 -0
- package/dist/serve.js +130 -1
- package/package.json +1 -1
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.
|
|
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",
|