nostr-bray 2.2.0 → 2.4.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/bunker.js CHANGED
@@ -86,11 +86,33 @@ export function startBunker(opts) {
86
86
  case 'get_public_key':
87
87
  result = ctx.activePublicKeyHex;
88
88
  break;
89
- case 'sign_event': {
90
- const template = JSON.parse(request.params[0]);
89
+ // `sign_event_compact` is the Heartwood dialect, and Bray speaks it so a
90
+ // client does not have to keep two code paths or waste a round trip
91
+ // discovering it is unsupported.
92
+ //
93
+ // Two differences from NIP-46, both about not moving the event around as
94
+ // a string. params[0] may be the event OBJECT rather than a stringified
95
+ // event, which spares a signer an unescape pass costing twice the content
96
+ // in one allocation; and the compact reply returns only what the client
97
+ // cannot work out itself. On a hardware signer those two copies are the
98
+ // difference between a 12 KB and an 18 KB ceiling. Bray has no such
99
+ // limit, but matching the dialect keeps clients simple.
100
+ case 'sign_event':
101
+ case 'sign_event_compact': {
102
+ // Declared string[], but that was always a claim about the client
103
+ // rather than a guarantee, and here it is genuinely either.
104
+ const raw = request.params[0];
105
+ const template = (typeof raw === 'string' ? JSON.parse(raw) : raw);
91
106
  const sign = ctx.getSigningFunction();
92
107
  const signed = await sign(template);
93
- result = JSON.stringify(signed);
108
+ result = request.method === 'sign_event_compact'
109
+ ? JSON.stringify({
110
+ id: signed.id,
111
+ pubkey: signed.pubkey,
112
+ created_at: signed.created_at,
113
+ sig: signed.sig,
114
+ })
115
+ : JSON.stringify(signed);
94
116
  break;
95
117
  }
96
118
  case 'nip04_encrypt': {
@@ -18,11 +18,25 @@ export async function dispatch(cmd, cmdArgs, h, ctx, pool) {
18
18
  case 'persona':
19
19
  out(await ctx.derivePersona(req(1, 'persona <name> [index]'), parseInt(cmdArgs[2] ?? '0', 10)));
20
20
  break;
21
- case 'switch':
22
- await ctx.switch(req(1, 'switch <target> [index]'), cmdArgs[2] ? parseInt(cmdArgs[2], 10) : undefined);
23
- console.error(`Now signing as ${ctx.activeNpub}`);
21
+ case 'switch': {
22
+ const target = req(1, 'switch <target> [index]');
23
+ await ctx.switch(target, cmdArgs[2] ? parseInt(cmdArgs[2], 10) : undefined);
24
+ // Name the derivation path, not just the npub. A bare name means the
25
+ // persona path, so `switch work` and `derive work` are different keys;
26
+ // without the path on screen the difference is invisible until something
27
+ // has been signed by the wrong identity.
28
+ console.error(`Now signing as ${ctx.activeNpub} (${ctx.activePurpose})`);
29
+ if (target !== 'master' && !target.includes(':') && ctx.activePurpose === `persona/${target}`) {
30
+ console.error(`Note: "${target}" resolved to the persona path. The raw purpose ` +
31
+ `"${target}" is a different key — see \`derive ${target}\`.`);
32
+ }
33
+ // The CLI is one process per command, so this applies to the rest of this
34
+ // invocation only. Say so rather than implying a saved default.
35
+ console.error('This switch applies to this command only; use --key or --bunker to sign as');
36
+ console.error('another identity in a later command.');
24
37
  console.log(ctx.activeNpub);
25
38
  break;
39
+ }
26
40
  case 'prove':
27
41
  out(await handleIdentityProve(ctx, { mode: (cmdArgs[1] === 'full' ? 'full' : 'blind') }));
28
42
  break;
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/context.d.ts CHANGED
@@ -13,6 +13,8 @@ export declare class IdentityContext implements ExtendedSigningContext {
13
13
  constructor(secretKey: string, format: 'nsec' | 'hex' | 'mnemonic', opts?: ContextOptions);
14
14
  /** Current active identity's npub (bech32) */
15
15
  get activeNpub(): string;
16
+ /** Derivation path of the current active identity, for reporting to a human */
17
+ get activePurpose(): string;
16
18
  /** Current active identity's hex public key — use this in relay filters */
17
19
  get activePublicKeyHex(): string;
18
20
  /** Derive a child identity by purpose and index */
package/dist/context.js CHANGED
@@ -55,6 +55,10 @@ export class IdentityContext {
55
55
  get activeNpub() {
56
56
  return this.activeEntry.identity.npub;
57
57
  }
58
+ /** Derivation path of the current active identity, for reporting to a human */
59
+ get activePurpose() {
60
+ return this.activeEntry.purpose;
61
+ }
58
62
  /** Current active identity's hex public key — use this in relay filters */
59
63
  get activePublicKeyHex() {
60
64
  return Buffer.from(this.activeEntry.identity.publicKey).toString('hex');
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' });
@@ -8,6 +8,7 @@ export interface BlobDescriptor {
8
8
  type?: string;
9
9
  uploaded?: number;
10
10
  }
11
+ export declare function detectContentType(body: Uint8Array): string | undefined;
11
12
  /** Upload a file to a blossom media server */
12
13
  export declare function handleBlossomUpload(ctx: SigningContext, args: {
13
14
  server: string;
@@ -5,6 +5,36 @@ import { validatePublicUrl } from '../validation.js';
5
5
  // fetch. Keeps an attacker-chosen source from amplifying nostr-bray into a
6
6
  // megabyte-scale Blossom relay.
7
7
  const MAX_UPLOAD = 100 * 1024 * 1024;
8
+ // Blossom servers sniff the body and reject a mismatched Content-Type, so
9
+ // `application/octet-stream` fails for any format they recognise. Detect from
10
+ // the leading bytes rather than the file name: the bytes are what the server
11
+ // checks, and an uploader should not be able to mislabel a blob by renaming it.
12
+ const MAGIC = [
13
+ { type: 'image/png', test: (b) => b[0] === 0x89 && b[1] === 0x50 && b[2] === 0x4e && b[3] === 0x47 },
14
+ { type: 'image/jpeg', test: (b) => b[0] === 0xff && b[1] === 0xd8 && b[2] === 0xff },
15
+ { type: 'image/gif', test: (b) => b[0] === 0x47 && b[1] === 0x49 && b[2] === 0x46 },
16
+ {
17
+ type: 'image/webp',
18
+ test: (b) => b[0] === 0x52 && b[1] === 0x49 && b[2] === 0x46 && b[3] === 0x46 &&
19
+ b[8] === 0x57 && b[9] === 0x45 && b[10] === 0x42 && b[11] === 0x50,
20
+ },
21
+ { type: 'application/pdf', test: (b) => b[0] === 0x25 && b[1] === 0x50 && b[2] === 0x44 && b[3] === 0x46 },
22
+ { type: 'image/svg+xml', test: (b) => Buffer.from(b.subarray(0, 256)).toString('utf8').includes('<svg') },
23
+ ];
24
+ export function detectContentType(body) {
25
+ if (body.byteLength < 4)
26
+ return undefined;
27
+ for (const { type, test } of MAGIC) {
28
+ try {
29
+ if (test(body))
30
+ return type;
31
+ }
32
+ catch {
33
+ // a short or odd buffer simply does not match this signature
34
+ }
35
+ }
36
+ return undefined;
37
+ }
8
38
  /** Upload a file to a blossom media server */
9
39
  export async function handleBlossomUpload(ctx, args) {
10
40
  validatePublicUrl(args.server);
@@ -44,7 +74,7 @@ export async function handleBlossomUpload(ctx, args) {
44
74
  method: 'PUT',
45
75
  headers: {
46
76
  Authorization: authHeader,
47
- 'Content-Type': args.contentType ?? 'application/octet-stream',
77
+ 'Content-Type': args.contentType ?? detectContentType(body) ?? 'application/octet-stream',
48
78
  },
49
79
  body: Buffer.from(body),
50
80
  signal: AbortSignal.timeout(30_000),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nostr-bray",
3
- "version": "2.2.0",
3
+ "version": "2.4.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",