nostr-bray 2.1.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/commands/event.js +70 -21
- package/dist/cli/commands/relay.js +65 -4
- package/dist/cli/dispatch.d.ts +4 -0
- package/dist/cli/dispatch.js +21 -1
- package/dist/cli/index.js +12 -2
- package/dist/event-validation/kind-lookup.d.ts +11 -0
- package/dist/event-validation/kind-lookup.js +41 -0
- package/dist/serve.d.ts +7 -0
- package/dist/serve.js +130 -1
- package/dist/social/handlers.d.ts +2 -0
- package/dist/social/handlers.js +1 -1
- package/package.json +1 -1
|
@@ -1,30 +1,28 @@
|
|
|
1
|
-
import { assertEventSemanticallyValid, handlePublishEvent, handlePublishRaw } from '../../exports.js';
|
|
1
|
+
import { assertEventSemanticallyValid, handlePublishEvent, handlePublishRaw, resolveKind } from '../../exports.js';
|
|
2
|
+
import { nip19 } from 'nostr-tools';
|
|
2
3
|
export async function dispatch(cmd, _cmdArgs, h, ctx, pool) {
|
|
3
|
-
const { flag, flags, hasFlag, out } = h;
|
|
4
|
+
const { flag, flags, hasFlag, flagAny, flagsAny, out } = h;
|
|
4
5
|
switch (cmd) {
|
|
5
6
|
case 'event': {
|
|
6
|
-
const
|
|
7
|
-
if (!
|
|
8
|
-
throw new Error('Usage: event
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
7
|
+
const kindArg = flagAny('kind', 'k');
|
|
8
|
+
if (!kindArg) {
|
|
9
|
+
throw new Error('Usage: event -k <number|name> [-c content] [-t k=v] [-p pubkey] [-e id] [-d ident] [--relay url]');
|
|
10
|
+
}
|
|
11
|
+
// Accepts a number or a registry kind name, e.g. -k "short text note"
|
|
12
|
+
const kind = resolveKind(kindArg);
|
|
13
|
+
const tagValues = collectTags(flagsAny('tag', 't'), {
|
|
14
|
+
p: flagsAny('p'), e: flagsAny('e'), d: flagsAny('d'), h: flagsAny('h'),
|
|
14
15
|
});
|
|
15
|
-
const content =
|
|
16
|
+
const content = flagAny('content', 'c') ?? '';
|
|
16
17
|
const validationMode = parseValidationMode(flag('validation'));
|
|
17
|
-
const relayOverrides =
|
|
18
|
+
const relayOverrides = flagsAny('relay', 'r');
|
|
18
19
|
const pow = flag('pow') ? parseInt(flag('pow'), 10) : undefined;
|
|
19
20
|
const powTimeoutMs = flag('pow-timeout') ? parseInt(flag('pow-timeout'), 10) : undefined;
|
|
21
|
+
const createdAtArg = flagAny('created-at', 'ts', 'time');
|
|
22
|
+
const createdAt = createdAtArg ? parseTimestamp(createdAtArg) : Math.floor(Date.now() / 1000);
|
|
20
23
|
if (hasFlag('no-publish')) {
|
|
21
24
|
const { minePow } = await import('../../event/pow.js');
|
|
22
|
-
let template = {
|
|
23
|
-
kind,
|
|
24
|
-
created_at: Math.floor(Date.now() / 1000),
|
|
25
|
-
tags: tagValues,
|
|
26
|
-
content,
|
|
27
|
-
};
|
|
25
|
+
let template = { kind, created_at: createdAt, tags: tagValues, content };
|
|
28
26
|
if (pow !== undefined) {
|
|
29
27
|
template = minePow(template, { difficulty: pow, pubkey: ctx.activePublicKeyHex, timeoutMs: powTimeoutMs }).template;
|
|
30
28
|
}
|
|
@@ -32,18 +30,27 @@ export async function dispatch(cmd, _cmdArgs, h, ctx, pool) {
|
|
|
32
30
|
reportWarnings(validation.issues);
|
|
33
31
|
const sign = ctx.getSigningFunction();
|
|
34
32
|
const event = await sign(template);
|
|
35
|
-
|
|
33
|
+
// --envelope emits the relay wire form, ready to paste into a socket
|
|
34
|
+
console.log(hasFlag('envelope')
|
|
35
|
+
? JSON.stringify(['EVENT', event])
|
|
36
|
+
: JSON.stringify(event, null, 2));
|
|
37
|
+
if (hasFlag('nevent'))
|
|
38
|
+
printNevent(event, relayOverrides);
|
|
36
39
|
break;
|
|
37
40
|
}
|
|
38
|
-
|
|
41
|
+
const result = await handlePublishEvent(ctx, pool, {
|
|
39
42
|
kind,
|
|
40
43
|
content,
|
|
41
44
|
tags: tagValues,
|
|
45
|
+
createdAt,
|
|
42
46
|
relays: relayOverrides.length ? relayOverrides : undefined,
|
|
43
47
|
validationMode,
|
|
44
48
|
pow,
|
|
45
49
|
powTimeoutMs,
|
|
46
|
-
})
|
|
50
|
+
});
|
|
51
|
+
out(result);
|
|
52
|
+
if (hasFlag('nevent'))
|
|
53
|
+
printNevent(result.event, relayOverrides);
|
|
47
54
|
break;
|
|
48
55
|
}
|
|
49
56
|
case 'publish-raw': {
|
|
@@ -102,3 +109,45 @@ function reportWarnings(issues) {
|
|
|
102
109
|
console.error(`validation warning [${entry.code}]: ${entry.message}`);
|
|
103
110
|
}
|
|
104
111
|
}
|
|
112
|
+
/**
|
|
113
|
+
* Merge `--tag k=v` entries with the single-letter shortcuts.
|
|
114
|
+
*
|
|
115
|
+
* Shortcuts come after the explicit tags so that `-t p=<x> -p <y>` yields both,
|
|
116
|
+
* in the order written, rather than one silently replacing the other.
|
|
117
|
+
*/
|
|
118
|
+
function collectTags(tagArgs, shortcuts) {
|
|
119
|
+
const tags = tagArgs.map(t => {
|
|
120
|
+
const eq = t.indexOf('=');
|
|
121
|
+
if (eq === -1)
|
|
122
|
+
return [t];
|
|
123
|
+
// A value may itself contain '=', so only split on the first one
|
|
124
|
+
return [t.slice(0, eq), t.slice(eq + 1)];
|
|
125
|
+
});
|
|
126
|
+
for (const [name, values] of Object.entries(shortcuts)) {
|
|
127
|
+
for (const v of values)
|
|
128
|
+
tags.push([name, v]);
|
|
129
|
+
}
|
|
130
|
+
return tags;
|
|
131
|
+
}
|
|
132
|
+
/** Parse a created_at: a unix timestamp, an ISO date, or `now`. */
|
|
133
|
+
function parseTimestamp(value) {
|
|
134
|
+
if (value === 'now')
|
|
135
|
+
return Math.floor(Date.now() / 1000);
|
|
136
|
+
if (/^\d+$/.test(value))
|
|
137
|
+
return Number(value);
|
|
138
|
+
const parsed = Date.parse(value);
|
|
139
|
+
if (Number.isNaN(parsed)) {
|
|
140
|
+
throw new Error(`Could not read "${value}" as a timestamp. Use a unix time, an ISO date, or "now".`);
|
|
141
|
+
}
|
|
142
|
+
return Math.floor(parsed / 1000);
|
|
143
|
+
}
|
|
144
|
+
/** Print the nevent code to stderr, so stdout stays parseable. */
|
|
145
|
+
function printNevent(event, relays) {
|
|
146
|
+
const code = nip19.neventEncode({
|
|
147
|
+
id: event.id,
|
|
148
|
+
author: event.pubkey,
|
|
149
|
+
kind: event.kind,
|
|
150
|
+
...(relays.length ? { relays } : {}),
|
|
151
|
+
});
|
|
152
|
+
console.error(code);
|
|
153
|
+
}
|
|
@@ -29,12 +29,20 @@ export async function dispatch(cmd, cmdArgs, h, ctx, pool, _activeNpub) {
|
|
|
29
29
|
out(await handleRelayInfo(req(1, 'relay-info <wss://url>')));
|
|
30
30
|
break;
|
|
31
31
|
case 'req': {
|
|
32
|
-
// Support JSON filter on stdin when stdin is a pipe
|
|
32
|
+
// Support a JSON filter on stdin when stdin is a pipe. Not being a TTY
|
|
33
|
+
// does not guarantee stdin is readable — under a test runner, or a
|
|
34
|
+
// service manager that hands over a closed descriptor, the read throws
|
|
35
|
+
// EAGAIN and there is no filter to find anyway.
|
|
33
36
|
let stdinFilter;
|
|
34
|
-
|
|
35
|
-
if (!isTTY) {
|
|
37
|
+
if (!process.stdin.isTTY) {
|
|
36
38
|
const { readFileSync } = await import('node:fs');
|
|
37
|
-
|
|
39
|
+
let raw = '';
|
|
40
|
+
try {
|
|
41
|
+
raw = readFileSync(0, 'utf-8').trim();
|
|
42
|
+
}
|
|
43
|
+
catch {
|
|
44
|
+
raw = '';
|
|
45
|
+
}
|
|
38
46
|
if (raw)
|
|
39
47
|
stdinFilter = JSON.parse(raw);
|
|
40
48
|
}
|
|
@@ -60,6 +68,59 @@ export async function dispatch(cmd, cmdArgs, h, ctx, pool, _activeNpub) {
|
|
|
60
68
|
const paginate = hasFlag('paginate');
|
|
61
69
|
const paginateIntervalMs = flag('paginate-interval') ? parseInt(flag('paginate-interval'), 10) : undefined;
|
|
62
70
|
const maxPages = flag('max-pages') ? parseInt(flag('max-pages'), 10) : undefined;
|
|
71
|
+
// NIP-77 paths run before the normal REQ: both reconcile IDs with one
|
|
72
|
+
// relay rather than transferring events, which is the whole point.
|
|
73
|
+
if (hasFlag('ids-only') || hasFlag('only-missing')) {
|
|
74
|
+
const target = relayOverrides[0] ?? pool.getRelays(ctx.activeNpub).read[0];
|
|
75
|
+
if (!target)
|
|
76
|
+
throw new Error('--ids-only and --only-missing need a relay: pass --relay <url>');
|
|
77
|
+
const negFilter = {
|
|
78
|
+
...(kindsRaw ? { kinds: kindsRaw.split(',').map(Number) } : {}),
|
|
79
|
+
...(authorsRaw ? { authors: authorsRaw.split(',') } : {}),
|
|
80
|
+
...(since !== undefined ? { since } : {}),
|
|
81
|
+
...(until !== undefined ? { until } : {}),
|
|
82
|
+
};
|
|
83
|
+
// Reconcile against what we already hold: nothing for --ids-only, the
|
|
84
|
+
// supplied jsonl for --only-missing.
|
|
85
|
+
let local = [];
|
|
86
|
+
if (hasFlag('only-missing')) {
|
|
87
|
+
const { readFileSync } = await import('node:fs');
|
|
88
|
+
const { validateInputPath } = await import('../../validation.js');
|
|
89
|
+
const path = flag('only-missing');
|
|
90
|
+
if (!path)
|
|
91
|
+
throw new Error('Usage: req --only-missing <events.jsonl> [--relay url]');
|
|
92
|
+
local = readFileSync(validateInputPath(path), 'utf8')
|
|
93
|
+
.split('\n')
|
|
94
|
+
.filter(l => l.trim())
|
|
95
|
+
.map(l => {
|
|
96
|
+
const e = JSON.parse(l);
|
|
97
|
+
return { id: e.id, createdAt: e.created_at };
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
const recon = await pool.reconcileDirect(target, local, negFilter, {
|
|
101
|
+
timeoutMs: flag('timeout') ? parseInt(flag('timeout'), 10) : undefined,
|
|
102
|
+
maxIds: limit,
|
|
103
|
+
});
|
|
104
|
+
if (recon.truncated) {
|
|
105
|
+
console.error(`note: result truncated at ${recon.remoteOnlyIds.length} ids; raise --limit to see more`);
|
|
106
|
+
}
|
|
107
|
+
if (hasFlag('ids-only')) {
|
|
108
|
+
for (const id of recon.remoteOnlyIds)
|
|
109
|
+
console.log(id);
|
|
110
|
+
break;
|
|
111
|
+
}
|
|
112
|
+
// --only-missing: fetch just the events we do not already have
|
|
113
|
+
if (recon.remoteOnlyIds.length === 0)
|
|
114
|
+
break;
|
|
115
|
+
const missing = await handleRelayQuery(pool, ctx.activeNpub, {
|
|
116
|
+
ids: recon.remoteOnlyIds,
|
|
117
|
+
relays: [target],
|
|
118
|
+
limit: recon.remoteOnlyIds.length,
|
|
119
|
+
});
|
|
120
|
+
for (const ev of missing)
|
|
121
|
+
console.log(JSON.stringify(ev));
|
|
122
|
+
break;
|
|
123
|
+
}
|
|
63
124
|
const queryArgs = {
|
|
64
125
|
...(stdinFilter ?? {
|
|
65
126
|
...(kindsRaw ? { kinds: kindsRaw.split(',').map(Number) } : {}),
|
package/dist/cli/dispatch.d.ts
CHANGED
|
@@ -8,6 +8,10 @@ export interface Helpers {
|
|
|
8
8
|
flag(name: string, fallback?: string): string | undefined;
|
|
9
9
|
flags(name: string): string[];
|
|
10
10
|
hasFlag(name: string): boolean;
|
|
11
|
+
/** First value of whichever alias is present. Single-character names take one dash. */
|
|
12
|
+
flagAny(...names: string[]): string | undefined;
|
|
13
|
+
/** Every value across all the given aliases, in command-line order. */
|
|
14
|
+
flagsAny(...names: string[]): string[];
|
|
11
15
|
out(data: unknown, humanFormatter?: (d: any) => string): void;
|
|
12
16
|
}
|
|
13
17
|
export type OutputMode = 'json' | 'human' | 'jsonl' | 'csv' | 'tsv';
|
package/dist/cli/dispatch.js
CHANGED
|
@@ -123,6 +123,26 @@ export function makeHelpers(cmdArgs, outputMode) {
|
|
|
123
123
|
}
|
|
124
124
|
return result;
|
|
125
125
|
}
|
|
126
|
+
// `-k` and `--kind` should behave identically, so aliases are resolved by
|
|
127
|
+
// dash count rather than requiring every callsite to spell out both forms.
|
|
128
|
+
const asSwitch = (name) => (name.length === 1 ? `-${name}` : `--${name}`);
|
|
129
|
+
function flagAny(...names) {
|
|
130
|
+
const wanted = names.map(asSwitch);
|
|
131
|
+
for (let i = 0; i < cmdArgs.length - 1; i++) {
|
|
132
|
+
if (wanted.includes(cmdArgs[i]))
|
|
133
|
+
return cmdArgs[i + 1];
|
|
134
|
+
}
|
|
135
|
+
return undefined;
|
|
136
|
+
}
|
|
137
|
+
function flagsAny(...names) {
|
|
138
|
+
const wanted = names.map(asSwitch);
|
|
139
|
+
const result = [];
|
|
140
|
+
for (let i = 0; i < cmdArgs.length - 1; i++) {
|
|
141
|
+
if (wanted.includes(cmdArgs[i]))
|
|
142
|
+
result.push(cmdArgs[i + 1]);
|
|
143
|
+
}
|
|
144
|
+
return result;
|
|
145
|
+
}
|
|
126
146
|
function out(data, humanFormatter) {
|
|
127
147
|
switch (outputMode) {
|
|
128
148
|
case 'jsonl':
|
|
@@ -152,7 +172,7 @@ export function makeHelpers(cmdArgs, outputMode) {
|
|
|
152
172
|
}
|
|
153
173
|
}
|
|
154
174
|
}
|
|
155
|
-
return { req, flag, flags, hasFlag, out };
|
|
175
|
+
return { req, flag, flags, hasFlag, flagAny, flagsAny, out };
|
|
156
176
|
}
|
|
157
177
|
/** Parse a shell line into args, respecting quotes */
|
|
158
178
|
export function parseShellLine(line) {
|
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');
|
|
@@ -203,7 +204,16 @@ Safety:
|
|
|
203
204
|
|
|
204
205
|
Utility:
|
|
205
206
|
req [--kinds N,N] [--authors hex,hex] [--since ts] [--limit N] [--relay url] [--min-trust level] Query events
|
|
206
|
-
|
|
207
|
+
[--paginate] Walk 'until' backwards until --limit is met
|
|
208
|
+
(--max-pages N caps round-trips, default 20;
|
|
209
|
+
--paginate-interval ms waits between pages)
|
|
210
|
+
[--ids-only] Fetch only event IDs, via NIP-77 negentropy
|
|
211
|
+
[--only-missing <events.jsonl>] Fetch only events absent from that file (NIP-77)
|
|
212
|
+
event -k <number|name> [-c content] [-t k=v] [-p pk] [-e id] [-d ident] [-h group]
|
|
213
|
+
[--relay url] [--pow N] [--created-at ts|ISO|now] [--envelope] [--nevent]
|
|
214
|
+
[--validation strict-known|off] [--no-publish]
|
|
215
|
+
Validate, sign and publish an arbitrary event.
|
|
216
|
+
-k takes a kind number or a registry name, e.g. -k "short text note"
|
|
207
217
|
validate-event [event-json|--file path] [--validation strict-known|off] Semantic protocol validation (no signature check)
|
|
208
218
|
publish-raw [--file path] [--report] [--timeout ms] [--quorum n] [--validation strict-known|off] Validate, sign+broadcast event
|
|
209
219
|
subscribe [--kinds N,N] [--authors hex] [--relay url] Live-tail events to stdout (JSONL) until SIGINT
|
|
@@ -236,7 +246,7 @@ MuSig2 (BIP-327 multi-signature):
|
|
|
236
246
|
|
|
237
247
|
Modes:
|
|
238
248
|
(no command) Start MCP server (stdio)
|
|
239
|
-
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
|
|
240
250
|
(--auth requires NIP-42; --eager-auth also challenges on connect)
|
|
241
251
|
bunker connect <bunker://…> Save remote bunker URI for future commands
|
|
242
252
|
bunker authorize <hex-pubkey> Pre-authorise an app pubkey on the local bunker
|
|
@@ -46,3 +46,14 @@ export declare function lookupKind(kind: number): KindInfo;
|
|
|
46
46
|
* dozens of kinds whose descriptions merely contain "note".
|
|
47
47
|
*/
|
|
48
48
|
export declare function searchKinds(query: string, limit?: number): KindInfo[];
|
|
49
|
+
/**
|
|
50
|
+
* Resolve a kind given either a number or a description.
|
|
51
|
+
*
|
|
52
|
+
* `-k 1` and `-k "short text note"` should both work, which is what nak does
|
|
53
|
+
* and what makes the CLI usable without memorising kind numbers. An ambiguous
|
|
54
|
+
* name is an error rather than a guess: silently picking one of several
|
|
55
|
+
* matching kinds would publish the wrong thing.
|
|
56
|
+
*
|
|
57
|
+
* @throws if the name matches no kind, or more than one.
|
|
58
|
+
*/
|
|
59
|
+
export declare function resolveKind(input: string): number;
|
|
@@ -84,3 +84,44 @@ export function searchKinds(query, limit = 20) {
|
|
|
84
84
|
scored.sort((a, b) => a.score - b.score || a.kind - b.kind);
|
|
85
85
|
return scored.slice(0, limit).map(s => lookupKind(s.kind));
|
|
86
86
|
}
|
|
87
|
+
/**
|
|
88
|
+
* Resolve a kind given either a number or a description.
|
|
89
|
+
*
|
|
90
|
+
* `-k 1` and `-k "short text note"` should both work, which is what nak does
|
|
91
|
+
* and what makes the CLI usable without memorising kind numbers. An ambiguous
|
|
92
|
+
* name is an error rather than a guess: silently picking one of several
|
|
93
|
+
* matching kinds would publish the wrong thing.
|
|
94
|
+
*
|
|
95
|
+
* @throws if the name matches no kind, or more than one.
|
|
96
|
+
*/
|
|
97
|
+
export function resolveKind(input) {
|
|
98
|
+
const trimmed = input.trim();
|
|
99
|
+
if (/^\d+$/.test(trimmed))
|
|
100
|
+
return Number(trimmed);
|
|
101
|
+
const q = trimmed.toLowerCase();
|
|
102
|
+
const exact = [];
|
|
103
|
+
const partial = [];
|
|
104
|
+
for (const [k, schema] of Object.entries(PINNED_KIND_REGISTRY.kinds)) {
|
|
105
|
+
const desc = (schema.description ?? '').toLowerCase();
|
|
106
|
+
if (!desc)
|
|
107
|
+
continue;
|
|
108
|
+
if (desc === q)
|
|
109
|
+
exact.push(Number(k));
|
|
110
|
+
else if (desc.includes(q))
|
|
111
|
+
partial.push(Number(k));
|
|
112
|
+
}
|
|
113
|
+
if (exact.length === 1)
|
|
114
|
+
return exact[0];
|
|
115
|
+
if (exact.length > 1) {
|
|
116
|
+
throw new Error(`Kind name "${input}" matches several kinds: ${exact.join(', ')}. Use the number.`);
|
|
117
|
+
}
|
|
118
|
+
if (partial.length === 1)
|
|
119
|
+
return partial[0];
|
|
120
|
+
if (partial.length > 1) {
|
|
121
|
+
const shown = partial.slice(0, 8)
|
|
122
|
+
.map(k => `${k} (${PINNED_KIND_REGISTRY.kinds[String(k)].description})`)
|
|
123
|
+
.join(', ');
|
|
124
|
+
throw new Error(`Kind name "${input}" is ambiguous — matches ${partial.length}: ${shown}${partial.length > 8 ? ', …' : ''}. Use the number.`);
|
|
125
|
+
}
|
|
126
|
+
throw new Error(`No kind matches "${input}". Try: nostr-bray kind "${input}"`);
|
|
127
|
+
}
|
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' });
|
|
@@ -274,6 +274,8 @@ export declare function handlePublishEvent(ctx: SigningContext, pool: RelayPool,
|
|
|
274
274
|
pow?: number;
|
|
275
275
|
/** Wall-clock budget for mining, in milliseconds. */
|
|
276
276
|
powTimeoutMs?: number;
|
|
277
|
+
/** Unix timestamp for created_at. Defaults to now. */
|
|
278
|
+
createdAt?: number;
|
|
277
279
|
}): Promise<ValidatedPostResult & {
|
|
278
280
|
pow?: Omit<MinePowResult, 'template'>;
|
|
279
281
|
}>;
|
package/dist/social/handlers.js
CHANGED
|
@@ -480,7 +480,7 @@ export async function handleContactsUnfollow(ctx, pool, args) {
|
|
|
480
480
|
export async function handlePublishEvent(ctx, pool, args) {
|
|
481
481
|
let template = {
|
|
482
482
|
kind: args.kind,
|
|
483
|
-
created_at: Math.floor(Date.now() / 1000),
|
|
483
|
+
created_at: args.createdAt ?? Math.floor(Date.now() / 1000),
|
|
484
484
|
tags: args.tags ?? [],
|
|
485
485
|
content: args.content,
|
|
486
486
|
};
|
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",
|