nostr-bray 2.0.0 → 2.2.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/commands/social.js +62 -1
- package/dist/cli/commands/util.js +26 -1
- package/dist/cli/dispatch.d.ts +4 -0
- package/dist/cli/dispatch.js +24 -2
- package/dist/cli/index.js +33 -5
- package/dist/event/gift-wrap.d.ts +72 -0
- package/dist/event/gift-wrap.js +104 -0
- package/dist/event/index.d.ts +1 -0
- package/dist/event/index.js +1 -0
- package/dist/event-validation/kind-lookup.d.ts +11 -0
- package/dist/event-validation/kind-lookup.js +41 -0
- package/dist/serve.d.ts +10 -0
- package/dist/serve.js +59 -0
- package/dist/social/handlers.d.ts +2 -0
- package/dist/social/handlers.js +1 -1
- package/dist/util/tools.js +30 -0
- 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) } : {}),
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { handleSocialPost, handleSocialReply, handleSocialReact, handleSocialDelete, handleSocialRepost, handleSocialProfileGet, handleSocialProfileSet, handleContactsGet, handleContactsFollow, handleContactsUnfollow, handleDmSend, handleDmRead, handleNotifications, handleFeed, handleNipPublish, handleNipRead, handleBlossomUpload, handleBlossomList, handleBlossomDelete, handleGroupInfo, handleGroupChat, handleGroupSend, handleGroupMembers, handleGroupCreate, handleGroupUpdate, handleGroupAddUser, handleGroupRemoveUser, handleGroupAdmins, handleGroupRoles, handleGroupInspect, handleGroupCreateInvite, handleGroupJoin, handleGroupLeave, handleGroupDeleteEvent, handleGroupDelete, handleGroupForumTopics, handleGroupForumTopicCreate, handleGroupForumComments, handleGroupForumComment, } from '../../exports.js';
|
|
1
|
+
import { handleSocialPost, handleSocialReply, handleSocialReact, handleSocialDelete, handleSocialRepost, handleSocialProfileGet, handleSocialProfileSet, handleContactsGet, handleContactsFollow, handleContactsUnfollow, handleDmSend, handleDmRead, handleNotifications, handleFeed, handleNipPublish, handleNipRead, handleBlossomUpload, handleBlossomList, handleBlossomDelete, handleBlossomDownload, handleBlossomCheck, handleBlossomDiscover, handleBlossomVerify, handleBlossomRepair, handleBlossomUsage, handleBlossomMirror, handleBlossomServersGet, handleBlossomServersSet, handleGroupInfo, handleGroupChat, handleGroupSend, handleGroupMembers, handleGroupCreate, handleGroupUpdate, handleGroupAddUser, handleGroupRemoveUser, handleGroupAdmins, handleGroupRoles, handleGroupInspect, handleGroupCreateInvite, handleGroupJoin, handleGroupLeave, handleGroupDeleteEvent, handleGroupDelete, handleGroupForumTopics, handleGroupForumTopicCreate, handleGroupForumComments, handleGroupForumComment, } from '../../exports.js';
|
|
2
2
|
import * as fmt from '../../format.js';
|
|
3
3
|
export async function dispatch(cmd, cmdArgs, h, ctx, pool, config) {
|
|
4
4
|
const { req, flag, flags, hasFlag, out } = h;
|
|
@@ -121,6 +121,67 @@ export async function dispatch(cmd, cmdArgs, h, ctx, pool, config) {
|
|
|
121
121
|
sha256: req(2, 'blossom-delete <server> <sha256>'),
|
|
122
122
|
}));
|
|
123
123
|
break;
|
|
124
|
+
case 'blossom-download': {
|
|
125
|
+
const { writeFileSync } = await import('node:fs');
|
|
126
|
+
const result = await handleBlossomDownload({
|
|
127
|
+
server: req(1, 'blossom-download <server> <sha256> [out-file]'),
|
|
128
|
+
sha256: req(2, 'blossom-download <server> <sha256> [out-file]'),
|
|
129
|
+
});
|
|
130
|
+
const dest = cmdArgs[3];
|
|
131
|
+
if (dest) {
|
|
132
|
+
writeFileSync(dest, result.data);
|
|
133
|
+
out({ sha256: cmdArgs[2], bytes: result.data.length, contentType: result.contentType, path: dest });
|
|
134
|
+
}
|
|
135
|
+
else {
|
|
136
|
+
// No destination: stream the blob to stdout so it can be piped
|
|
137
|
+
process.stdout.write(result.data);
|
|
138
|
+
}
|
|
139
|
+
break;
|
|
140
|
+
}
|
|
141
|
+
case 'blossom-check':
|
|
142
|
+
out(await handleBlossomCheck({
|
|
143
|
+
server: req(1, 'blossom-check <server> <sha256> [--verify]'),
|
|
144
|
+
sha256: req(2, 'blossom-check <server> <sha256> [--verify]'),
|
|
145
|
+
verify: hasFlag('verify'),
|
|
146
|
+
}));
|
|
147
|
+
break;
|
|
148
|
+
case 'blossom-discover':
|
|
149
|
+
out(await handleBlossomDiscover(pool, ctx.activeNpub, {
|
|
150
|
+
pubkeys: flags('pubkey').length ? flags('pubkey') : undefined,
|
|
151
|
+
}));
|
|
152
|
+
break;
|
|
153
|
+
case 'blossom-verify':
|
|
154
|
+
out(await handleBlossomVerify({
|
|
155
|
+
content: req(1, 'blossom-verify <content> [--verify-hash]'),
|
|
156
|
+
verifyHash: hasFlag('verify-hash'),
|
|
157
|
+
}));
|
|
158
|
+
break;
|
|
159
|
+
case 'blossom-repair':
|
|
160
|
+
out(await handleBlossomRepair(ctx, {
|
|
161
|
+
sha256: req(1, 'blossom-repair <sha256> --search <url> [--search <url>...] [--target <url>]'),
|
|
162
|
+
searchServers: flags('search'),
|
|
163
|
+
targetServer: flag('target'),
|
|
164
|
+
}));
|
|
165
|
+
break;
|
|
166
|
+
case 'blossom-usage':
|
|
167
|
+
out(await handleBlossomUsage({
|
|
168
|
+
servers: flags('server'),
|
|
169
|
+
pubkeyHex: req(1, 'blossom-usage <pubkey-hex> --server <url> [--server <url>...]'),
|
|
170
|
+
}));
|
|
171
|
+
break;
|
|
172
|
+
case 'blossom-mirror':
|
|
173
|
+
out(await handleBlossomMirror(ctx, {
|
|
174
|
+
servers: flags('server'),
|
|
175
|
+
sourceUrl: flag('source'),
|
|
176
|
+
filePath: flag('file'),
|
|
177
|
+
}));
|
|
178
|
+
break;
|
|
179
|
+
case 'blossom-servers':
|
|
180
|
+
out(await handleBlossomServersGet(pool, ctx.activeNpub, req(1, 'blossom-servers <pubkey-hex>')));
|
|
181
|
+
break;
|
|
182
|
+
case 'blossom-servers-set':
|
|
183
|
+
out(await handleBlossomServersSet(ctx, pool, { servers: flags('server') }));
|
|
184
|
+
break;
|
|
124
185
|
case 'group-info':
|
|
125
186
|
out(await handleGroupInfo(pool, ctx.activeNpub, {
|
|
126
187
|
relay: req(1, 'group-info <relay-url> <group-id>'),
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { handleDecode, handleEncodeNpub, handleEncodeNote, handleEncodeNprofile, handleEncodeNevent, handleVerify, handleEncrypt, handleDecrypt, handleCount, handleFetch, handleKeyPublic, handleEncodeNsec, handleFilter, handleNipList, handleNipShow, handleKeyEncrypt, handleKeyDecrypt, handleValidateEvent, lookupKind, searchKinds, } from '../../exports.js';
|
|
1
|
+
import { handleDecode, handleEncodeNpub, handleEncodeNote, handleEncodeNprofile, handleEncodeNevent, handleVerify, handleEncrypt, handleDecrypt, handleCount, handleFetch, handleKeyPublic, handleEncodeNsec, handleFilter, handleNipList, handleNipShow, handleKeyEncrypt, handleKeyDecrypt, handleValidateEvent, lookupKind, searchKinds, handleGiftWrap, handleGiftUnwrap, } from '../../exports.js';
|
|
2
2
|
import { validateInputPath } from '../../validation.js';
|
|
3
3
|
import * as fmt from '../../format.js';
|
|
4
4
|
export async function dispatch(cmd, cmdArgs, h, ctx, pool) {
|
|
@@ -63,6 +63,19 @@ export async function dispatch(cmd, cmdArgs, h, ctx, pool) {
|
|
|
63
63
|
out(handleValidateEvent(JSON.parse(raw), mode));
|
|
64
64
|
break;
|
|
65
65
|
}
|
|
66
|
+
case 'gift-wrap': {
|
|
67
|
+
const recipient = req(1, 'gift-wrap <recipient-pubkey-hex> <event-json|-> [--file path]');
|
|
68
|
+
out(await handleGiftWrap(ctx, {
|
|
69
|
+
event: JSON.parse(await readJsonArg(cmdArgs[2], flag('file'))),
|
|
70
|
+
recipientPubkey: recipient,
|
|
71
|
+
}));
|
|
72
|
+
break;
|
|
73
|
+
}
|
|
74
|
+
case 'gift-unwrap':
|
|
75
|
+
out(await handleGiftUnwrap(ctx, {
|
|
76
|
+
event: JSON.parse(await readJsonArg(cmdArgs[1], flag('file'))),
|
|
77
|
+
}));
|
|
78
|
+
break;
|
|
66
79
|
case 'kind': {
|
|
67
80
|
const arg = req(1, 'kind <number|search text>');
|
|
68
81
|
if (/^\d+$/.test(arg)) {
|
|
@@ -107,3 +120,15 @@ export async function dispatch(cmd, cmdArgs, h, ctx, pool) {
|
|
|
107
120
|
throw new Error(`Unknown command: ${cmd}. Run --help for usage.`);
|
|
108
121
|
}
|
|
109
122
|
}
|
|
123
|
+
/**
|
|
124
|
+
* Read a JSON argument from, in order: an explicit --file, a positional
|
|
125
|
+
* argument, or stdin. `-` selects stdin explicitly, matching common CLI usage.
|
|
126
|
+
*/
|
|
127
|
+
async function readJsonArg(positional, filePath) {
|
|
128
|
+
const { readFileSync } = await import('node:fs');
|
|
129
|
+
if (filePath)
|
|
130
|
+
return readFileSync(validateInputPath(filePath), 'utf8');
|
|
131
|
+
if (positional && positional !== '-' && !positional.startsWith('--'))
|
|
132
|
+
return positional;
|
|
133
|
+
return readFileSync(0, 'utf8');
|
|
134
|
+
}
|
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
|
@@ -27,6 +27,8 @@ export const COMPOUND_COMMANDS = new Set([
|
|
|
27
27
|
'trust-read', 'trust-verify', 'trust-revoke', 'trust-request', 'trust-rank',
|
|
28
28
|
'nip-publish', 'nip-read',
|
|
29
29
|
'relay-set', 'relay-add', 'relay-curl',
|
|
30
|
+
// blossom two-word forms
|
|
31
|
+
'blossom-servers-set',
|
|
30
32
|
'outbox-relays', 'outbox-publish',
|
|
31
33
|
'ring-prove', 'ring-verify',
|
|
32
34
|
'musig2-key', 'musig2-nonce', 'musig2-partial-sign', 'musig2-aggregate',
|
|
@@ -44,7 +46,7 @@ export const OFFLINE_COMMANDS = new Set([
|
|
|
44
46
|
'ring-verify', 'zap-decode', 'safety-configure', 'safety-activate',
|
|
45
47
|
'decode', 'encode-npub', 'encode-note', 'encode-nprofile', 'encode-nevent', 'encode-nsec',
|
|
46
48
|
'key-public', 'key-encrypt', 'key-decrypt', 'filter', 'verify', 'encrypt', 'decrypt',
|
|
47
|
-
'validate-event', 'kind',
|
|
49
|
+
'validate-event', 'kind', 'gift-wrap', 'gift-unwrap',
|
|
48
50
|
'musig2-key', 'musig2-nonce', 'musig2-partial-sign', 'musig2-aggregate',
|
|
49
51
|
// wallet config subcommands don't touch the relay
|
|
50
52
|
'wallet-connect', 'wallet-disconnect', 'wallet-status',
|
|
@@ -121,6 +123,26 @@ export function makeHelpers(cmdArgs, outputMode) {
|
|
|
121
123
|
}
|
|
122
124
|
return result;
|
|
123
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
|
+
}
|
|
124
146
|
function out(data, humanFormatter) {
|
|
125
147
|
switch (outputMode) {
|
|
126
148
|
case 'jsonl':
|
|
@@ -150,7 +172,7 @@ export function makeHelpers(cmdArgs, outputMode) {
|
|
|
150
172
|
}
|
|
151
173
|
}
|
|
152
174
|
}
|
|
153
|
-
return { req, flag, flags, hasFlag, out };
|
|
175
|
+
return { req, flag, flags, hasFlag, flagAny, flagsAny, out };
|
|
154
176
|
}
|
|
155
177
|
/** Parse a shell line into args, respecting quotes */
|
|
156
178
|
export function parseShellLine(line) {
|
package/dist/cli/index.js
CHANGED
|
@@ -56,7 +56,12 @@ if (command === 'serve' && !args.includes('--help')) {
|
|
|
56
56
|
const hostname = args.includes('--hostname') ? args[args.indexOf('--hostname') + 1] : 'localhost';
|
|
57
57
|
const port = args.includes('--port') ? parseInt(args[args.indexOf('--port') + 1], 10) : 10547;
|
|
58
58
|
const eventsFile = args.includes('--events') ? args[args.indexOf('--events') + 1] : undefined;
|
|
59
|
-
const relayServer = startRelay({
|
|
59
|
+
const relayServer = startRelay({
|
|
60
|
+
hostname, port, eventsFile,
|
|
61
|
+
quiet: args.includes('--quiet'),
|
|
62
|
+
auth: args.includes('--auth') || args.includes('--eager-auth'),
|
|
63
|
+
eagerAuth: args.includes('--eager-auth'),
|
|
64
|
+
});
|
|
60
65
|
console.error(`nostr-bray test relay running at ${relayServer.url}`);
|
|
61
66
|
console.error('Press Ctrl+C to stop');
|
|
62
67
|
process.on('SIGINT', () => { relayServer.close(); process.exit(0); });
|
|
@@ -102,6 +107,15 @@ Social:
|
|
|
102
107
|
blossom-upload <server> <file> Upload file to blossom media server
|
|
103
108
|
blossom-list <server> <pubkey> List blobs on blossom server
|
|
104
109
|
blossom-delete <server> <sha256> Delete blob from blossom server
|
|
110
|
+
blossom-download <server> <sha256> [file] Download a blob (stdout if no file)
|
|
111
|
+
blossom-check <server> <sha256> [--verify] Check a blob exists
|
|
112
|
+
blossom-discover [--pubkey hex ...] Discover servers from kind 10063 lists
|
|
113
|
+
blossom-verify <content> [--verify-hash] Check media URLs in content resolve
|
|
114
|
+
blossom-repair <sha256> --search <url>... [--target <url>] Re-upload a lost blob
|
|
115
|
+
blossom-usage <pubkey-hex> --server <url>... Storage used per server
|
|
116
|
+
blossom-mirror --server <url>... [--source <url>|--file <path>] Copy a blob to servers
|
|
117
|
+
blossom-servers <pubkey-hex> Read a kind 10063 server list
|
|
118
|
+
blossom-servers-set --server <url>... Publish your kind 10063 server list
|
|
105
119
|
group-info <relay> <group-id> Fetch verified NIP-29 group metadata
|
|
106
120
|
group-chat <relay> <group-id> Fetch relay-scoped group chat
|
|
107
121
|
group-send <relay> <group-id> "message" Send a group message
|
|
@@ -189,7 +203,16 @@ Safety:
|
|
|
189
203
|
|
|
190
204
|
Utility:
|
|
191
205
|
req [--kinds N,N] [--authors hex,hex] [--since ts] [--limit N] [--relay url] [--min-trust level] Query events
|
|
192
|
-
|
|
206
|
+
[--paginate] Walk 'until' backwards until --limit is met
|
|
207
|
+
(--max-pages N caps round-trips, default 20;
|
|
208
|
+
--paginate-interval ms waits between pages)
|
|
209
|
+
[--ids-only] Fetch only event IDs, via NIP-77 negentropy
|
|
210
|
+
[--only-missing <events.jsonl>] Fetch only events absent from that file (NIP-77)
|
|
211
|
+
event -k <number|name> [-c content] [-t k=v] [-p pk] [-e id] [-d ident] [-h group]
|
|
212
|
+
[--relay url] [--pow N] [--created-at ts|ISO|now] [--envelope] [--nevent]
|
|
213
|
+
[--validation strict-known|off] [--no-publish]
|
|
214
|
+
Validate, sign and publish an arbitrary event.
|
|
215
|
+
-k takes a kind number or a registry name, e.g. -k "short text note"
|
|
193
216
|
validate-event [event-json|--file path] [--validation strict-known|off] Semantic protocol validation (no signature check)
|
|
194
217
|
publish-raw [--file path] [--report] [--timeout ms] [--quorum n] [--validation strict-known|off] Validate, sign+broadcast event
|
|
195
218
|
subscribe [--kinds N,N] [--authors hex] [--relay url] Live-tail events to stdout (JSONL) until SIGINT
|
|
@@ -206,6 +229,8 @@ Utility:
|
|
|
206
229
|
nips List all official NIPs
|
|
207
230
|
nip <number> Show a specific NIP
|
|
208
231
|
kind <number|text> Look up an event kind: required tags, value shapes
|
|
232
|
+
gift-wrap <pubkey-hex> <event|-> [--file f] Seal + wrap an event for a recipient (NIP-59)
|
|
233
|
+
gift-unwrap <event-json|-> [--file f] Open a kind 1059 gift wrap addressed to you
|
|
209
234
|
verify <event-json> Verify event hash and signature
|
|
210
235
|
encrypt <pubkey-hex> "plaintext" NIP-44 encrypt for a recipient
|
|
211
236
|
decrypt <pubkey-hex> <ciphertext> NIP-44 decrypt from a sender
|
|
@@ -220,7 +245,8 @@ MuSig2 (BIP-327 multi-signature):
|
|
|
220
245
|
|
|
221
246
|
Modes:
|
|
222
247
|
(no command) Start MCP server (stdio)
|
|
223
|
-
serve [--port N] [--events file]
|
|
248
|
+
serve [--port N] [--events file] [--auth] [--eager-auth] Start in-memory test relay
|
|
249
|
+
(--auth requires NIP-42; --eager-auth also challenges on connect)
|
|
224
250
|
bunker connect <bunker://…> Save remote bunker URI for future commands
|
|
225
251
|
bunker authorize <hex-pubkey> Pre-authorise an app pubkey on the local bunker
|
|
226
252
|
bunker status Show saved bunker connection state
|
|
@@ -382,6 +408,7 @@ const SOCIAL_CMDS = new Set([
|
|
|
382
408
|
'contacts', 'follow', 'unfollow', 'dm', 'dm-read', 'feed', 'notifications',
|
|
383
409
|
'nip-publish', 'nip-read',
|
|
384
410
|
'blossom-upload', 'blossom-list', 'blossom-delete',
|
|
411
|
+
'blossom-download', 'blossom-check', 'blossom-discover', 'blossom-verify', 'blossom-repair', 'blossom-usage', 'blossom-mirror', 'blossom-servers', 'blossom-servers-set',
|
|
385
412
|
'group-info', 'group-chat', 'group-send', 'group-members',
|
|
386
413
|
'group-admins', 'group-roles', 'group-inspect',
|
|
387
414
|
'group-create', 'group-update', 'group-add-user', 'group-remove-user',
|
|
@@ -398,7 +425,7 @@ const SAFETY_CMDS = new Set(['safety-configure', 'safety-activate']);
|
|
|
398
425
|
const EVENT_CMDS = new Set(['event', 'publish-raw']);
|
|
399
426
|
const UTIL_CMDS = new Set([
|
|
400
427
|
'decode', 'encode-npub', 'encode-note', 'encode-nprofile', 'encode-nevent', 'encode-nsec',
|
|
401
|
-
'key-public', 'key-encrypt', 'key-decrypt', 'filter', 'nips', 'nip', 'verify', 'validate-event', 'kind', 'encrypt', 'decrypt', 'count', 'fetch',
|
|
428
|
+
'key-public', 'key-encrypt', 'key-decrypt', 'filter', 'nips', 'nip', 'verify', 'validate-event', 'kind', 'gift-wrap', 'gift-unwrap', 'encrypt', 'decrypt', 'count', 'fetch',
|
|
402
429
|
]);
|
|
403
430
|
const MUSIG2_CMDS = new Set(['musig2-key', 'musig2-nonce', 'musig2-partial-sign', 'musig2-aggregate']);
|
|
404
431
|
const SYNC_CMDS = new Set(['sync-plan', 'sync-pull', 'sync-push']);
|
|
@@ -455,6 +482,7 @@ const ALL_COMMANDS = [
|
|
|
455
482
|
'zap-send', 'zap-balance', 'zap-invoice', 'zap-lookup', 'zap-transactions', 'zap-receipts', 'zap-decode',
|
|
456
483
|
'safety-configure', 'safety-activate',
|
|
457
484
|
'blossom-upload', 'blossom-list', 'blossom-delete',
|
|
485
|
+
'blossom-download', 'blossom-check', 'blossom-discover', 'blossom-verify', 'blossom-repair', 'blossom-usage', 'blossom-mirror', 'blossom-servers', 'blossom-servers-set',
|
|
458
486
|
'group-info', 'group-chat', 'group-send', 'group-members',
|
|
459
487
|
'group-admins', 'group-roles', 'group-inspect',
|
|
460
488
|
'group-create', 'group-update', 'group-add-user', 'group-remove-user',
|
|
@@ -462,7 +490,7 @@ const ALL_COMMANDS = [
|
|
|
462
490
|
'group-forum-topics', 'group-forum-topic-create', 'group-forum-comments', 'group-forum-comment',
|
|
463
491
|
'event', 'publish-raw',
|
|
464
492
|
'decode', 'encode-npub', 'encode-note', 'encode-nprofile', 'encode-nevent', 'encode-nsec',
|
|
465
|
-
'key-public', 'key-encrypt', 'key-decrypt', 'filter', 'nips', 'nip', 'verify', 'validate-event', 'kind', 'encrypt', 'decrypt', 'count', 'fetch',
|
|
493
|
+
'key-public', 'key-encrypt', 'key-decrypt', 'filter', 'nips', 'nip', 'verify', 'validate-event', 'kind', 'gift-wrap', 'gift-unwrap', 'encrypt', 'decrypt', 'count', 'fetch',
|
|
466
494
|
'musig2-key', 'musig2-nonce', 'musig2-partial-sign', 'musig2-aggregate',
|
|
467
495
|
'sync-plan', 'sync-pull', 'sync-push',
|
|
468
496
|
...ADMIN_COMMANDS,
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* NIP-59 gift wrap for arbitrary events.
|
|
3
|
+
*
|
|
4
|
+
* `src/nip17-wrap.ts` wraps DMs specifically: it builds a kind 14 rumor from a
|
|
5
|
+
* message string. This is the general form — seal and wrap any event you like,
|
|
6
|
+
* which is what `nak gift wrap` does and what NIP-59 actually describes.
|
|
7
|
+
*
|
|
8
|
+
* Three layers:
|
|
9
|
+
* rumor — an unsigned event, signed by nobody, identified only by its hash
|
|
10
|
+
* seal — kind 13, the rumor NIP-44 encrypted to the recipient, signed by you
|
|
11
|
+
* wrap — kind 1059, the seal NIP-44 encrypted under a throwaway key
|
|
12
|
+
*
|
|
13
|
+
* Only the wrap is public, and it is signed by an ephemeral key, so nothing
|
|
14
|
+
* on the wire links back to the sender.
|
|
15
|
+
*
|
|
16
|
+
* All crypto goes through the SigningContext rather than a raw private key, so
|
|
17
|
+
* this works unchanged under NIP-46 where the key lives in a remote bunker.
|
|
18
|
+
*/
|
|
19
|
+
import type { Event as NostrEvent } from 'nostr-tools';
|
|
20
|
+
import type { SigningContext } from '../signing-context.js';
|
|
21
|
+
/** An event to be wrapped. Anything already signed has its signature dropped: a rumor is unsigned by definition. */
|
|
22
|
+
export interface RumorInput {
|
|
23
|
+
kind: number;
|
|
24
|
+
content?: string;
|
|
25
|
+
tags?: string[][];
|
|
26
|
+
created_at?: number;
|
|
27
|
+
}
|
|
28
|
+
export interface GiftWrapResult {
|
|
29
|
+
/** The kind 1059 wrap, ready to publish. */
|
|
30
|
+
wrap: NostrEvent;
|
|
31
|
+
/** The rumor's id, which the recipient will see after unwrapping. */
|
|
32
|
+
rumorId: string;
|
|
33
|
+
recipient: string;
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* Seal and wrap an event for one recipient.
|
|
37
|
+
*
|
|
38
|
+
* @throws if the recipient pubkey is not 64-char hex.
|
|
39
|
+
*/
|
|
40
|
+
export declare function handleGiftWrap(ctx: SigningContext, args: {
|
|
41
|
+
event: RumorInput;
|
|
42
|
+
recipientPubkey: string;
|
|
43
|
+
}): Promise<GiftWrapResult>;
|
|
44
|
+
export interface GiftUnwrapResult {
|
|
45
|
+
/** The rumor that was hidden inside. */
|
|
46
|
+
rumor: {
|
|
47
|
+
id?: string;
|
|
48
|
+
pubkey: string;
|
|
49
|
+
kind: number;
|
|
50
|
+
content: string;
|
|
51
|
+
tags: string[][];
|
|
52
|
+
created_at: number;
|
|
53
|
+
};
|
|
54
|
+
/** Pubkey that signed the seal — the real sender. */
|
|
55
|
+
sender: string;
|
|
56
|
+
/**
|
|
57
|
+
* Whether the seal's signer matches the rumor's claimed author.
|
|
58
|
+
*
|
|
59
|
+
* A mismatch means someone sealed an event attributed to a third party.
|
|
60
|
+
* NIP-59 requires the recipient to check this; a rumor is unsigned, so its
|
|
61
|
+
* `pubkey` field is a claim, not proof.
|
|
62
|
+
*/
|
|
63
|
+
senderMatchesRumor: boolean;
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* Unwrap a kind 1059 gift wrap back to its rumor.
|
|
67
|
+
*
|
|
68
|
+
* @throws if the event is not a gift wrap, or either layer fails to decrypt.
|
|
69
|
+
*/
|
|
70
|
+
export declare function handleGiftUnwrap(ctx: SigningContext, args: {
|
|
71
|
+
event: NostrEvent;
|
|
72
|
+
}): Promise<GiftUnwrapResult>;
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* NIP-59 gift wrap for arbitrary events.
|
|
3
|
+
*
|
|
4
|
+
* `src/nip17-wrap.ts` wraps DMs specifically: it builds a kind 14 rumor from a
|
|
5
|
+
* message string. This is the general form — seal and wrap any event you like,
|
|
6
|
+
* which is what `nak gift wrap` does and what NIP-59 actually describes.
|
|
7
|
+
*
|
|
8
|
+
* Three layers:
|
|
9
|
+
* rumor — an unsigned event, signed by nobody, identified only by its hash
|
|
10
|
+
* seal — kind 13, the rumor NIP-44 encrypted to the recipient, signed by you
|
|
11
|
+
* wrap — kind 1059, the seal NIP-44 encrypted under a throwaway key
|
|
12
|
+
*
|
|
13
|
+
* Only the wrap is public, and it is signed by an ephemeral key, so nothing
|
|
14
|
+
* on the wire links back to the sender.
|
|
15
|
+
*
|
|
16
|
+
* All crypto goes through the SigningContext rather than a raw private key, so
|
|
17
|
+
* this works unchanged under NIP-46 where the key lives in a remote bunker.
|
|
18
|
+
*/
|
|
19
|
+
import { finalizeEvent, generateSecretKey, getEventHash } from 'nostr-tools/pure';
|
|
20
|
+
/** Timestamps are randomised up to two days back, per NIP-59, to frustrate correlation. */
|
|
21
|
+
const TWO_DAYS = 2 * 24 * 60 * 60;
|
|
22
|
+
const randomNow = () => Math.round(Date.now() / 1000 - Math.random() * TWO_DAYS);
|
|
23
|
+
const HEX64 = /^[0-9a-f]{64}$/i;
|
|
24
|
+
/**
|
|
25
|
+
* Seal and wrap an event for one recipient.
|
|
26
|
+
*
|
|
27
|
+
* @throws if the recipient pubkey is not 64-char hex.
|
|
28
|
+
*/
|
|
29
|
+
export async function handleGiftWrap(ctx, args) {
|
|
30
|
+
const recipient = args.recipientPubkey.toLowerCase();
|
|
31
|
+
if (!HEX64.test(recipient)) {
|
|
32
|
+
throw new Error('recipientPubkey must be a 64-character hex public key');
|
|
33
|
+
}
|
|
34
|
+
// A rumor carries an id but no signature — that is what makes it deniable.
|
|
35
|
+
const rumor = {
|
|
36
|
+
pubkey: ctx.activePublicKeyHex,
|
|
37
|
+
created_at: args.event.created_at ?? Math.floor(Date.now() / 1000),
|
|
38
|
+
kind: args.event.kind,
|
|
39
|
+
tags: args.event.tags ?? [],
|
|
40
|
+
content: args.event.content ?? '',
|
|
41
|
+
};
|
|
42
|
+
const rumorId = getEventHash(rumor);
|
|
43
|
+
const rumorWithId = { ...rumor, id: rumorId };
|
|
44
|
+
// Seal: rumor encrypted to the recipient, signed by us
|
|
45
|
+
const sealContent = await ctx.nip44Encrypt(recipient, JSON.stringify(rumorWithId));
|
|
46
|
+
const sign = ctx.getSigningFunction();
|
|
47
|
+
const seal = await sign({
|
|
48
|
+
kind: 13,
|
|
49
|
+
content: sealContent,
|
|
50
|
+
created_at: randomNow(),
|
|
51
|
+
tags: [],
|
|
52
|
+
});
|
|
53
|
+
// Wrap: seal encrypted under a throwaway key, so the wrap is unattributable
|
|
54
|
+
const ephemeralSk = generateSecretKey();
|
|
55
|
+
try {
|
|
56
|
+
const { getConversationKey, encrypt } = await import('nostr-tools/nip44');
|
|
57
|
+
const ck = getConversationKey(ephemeralSk, recipient);
|
|
58
|
+
const wrap = finalizeEvent({
|
|
59
|
+
kind: 1059,
|
|
60
|
+
content: encrypt(JSON.stringify(seal), ck),
|
|
61
|
+
created_at: randomNow(),
|
|
62
|
+
tags: [['p', recipient]],
|
|
63
|
+
}, ephemeralSk);
|
|
64
|
+
return { wrap, rumorId, recipient };
|
|
65
|
+
}
|
|
66
|
+
finally {
|
|
67
|
+
ephemeralSk.fill(0);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
* Unwrap a kind 1059 gift wrap back to its rumor.
|
|
72
|
+
*
|
|
73
|
+
* @throws if the event is not a gift wrap, or either layer fails to decrypt.
|
|
74
|
+
*/
|
|
75
|
+
export async function handleGiftUnwrap(ctx, args) {
|
|
76
|
+
const wrap = args.event;
|
|
77
|
+
if (wrap?.kind !== 1059) {
|
|
78
|
+
throw new Error(`Expected a kind 1059 gift wrap, got kind ${wrap?.kind}`);
|
|
79
|
+
}
|
|
80
|
+
// Outer layer: decrypt against the ephemeral pubkey that signed the wrap
|
|
81
|
+
let seal;
|
|
82
|
+
try {
|
|
83
|
+
seal = JSON.parse(await ctx.nip44Decrypt(wrap.pubkey, wrap.content));
|
|
84
|
+
}
|
|
85
|
+
catch (e) {
|
|
86
|
+
throw new Error(`Could not decrypt the gift wrap — it may not be addressed to this identity: ${e.message}`);
|
|
87
|
+
}
|
|
88
|
+
if (seal?.kind !== 13) {
|
|
89
|
+
throw new Error(`Expected a kind 13 seal inside the wrap, got kind ${seal?.kind}`);
|
|
90
|
+
}
|
|
91
|
+
// Inner layer: decrypt against the seal's signer, which is the real sender
|
|
92
|
+
let rumor;
|
|
93
|
+
try {
|
|
94
|
+
rumor = JSON.parse(await ctx.nip44Decrypt(seal.pubkey, seal.content));
|
|
95
|
+
}
|
|
96
|
+
catch (e) {
|
|
97
|
+
throw new Error(`Could not decrypt the seal: ${e.message}`);
|
|
98
|
+
}
|
|
99
|
+
return {
|
|
100
|
+
rumor,
|
|
101
|
+
sender: seal.pubkey,
|
|
102
|
+
senderMatchesRumor: rumor.pubkey === seal.pubkey,
|
|
103
|
+
};
|
|
104
|
+
}
|
package/dist/event/index.d.ts
CHANGED
package/dist/event/index.js
CHANGED
|
@@ -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
|
@@ -9,6 +9,16 @@ export interface ServeOptions {
|
|
|
9
9
|
port?: number;
|
|
10
10
|
eventsFile?: string;
|
|
11
11
|
quiet?: boolean;
|
|
12
|
+
/**
|
|
13
|
+
* Require NIP-42 AUTH before serving EVENT or REQ.
|
|
14
|
+
*
|
|
15
|
+
* Exists so clients can be tested against an auth-gated relay without
|
|
16
|
+
* needing a real one — bray's own NIP-42 support is otherwise only
|
|
17
|
+
* exercisable against third-party infrastructure.
|
|
18
|
+
*/
|
|
19
|
+
auth?: boolean;
|
|
20
|
+
/** Send the AUTH challenge on connect rather than waiting for a rejection. */
|
|
21
|
+
eagerAuth?: boolean;
|
|
12
22
|
}
|
|
13
23
|
export declare function startRelay(opts?: ServeOptions): {
|
|
14
24
|
url: string;
|
package/dist/serve.js
CHANGED
|
@@ -7,6 +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
11
|
import { verifyEvent } from 'nostr-tools/pure';
|
|
11
12
|
let subCounter = 0;
|
|
12
13
|
/** Check if an event matches a single filter */
|
|
@@ -39,10 +40,36 @@ function matchFilter(filter, event) {
|
|
|
39
40
|
function matchFilters(filters, event) {
|
|
40
41
|
return filters.some(f => matchFilter(f, event));
|
|
41
42
|
}
|
|
43
|
+
/**
|
|
44
|
+
* Check a NIP-42 auth event against the challenge this connection issued.
|
|
45
|
+
*
|
|
46
|
+
* Returns a reason string when it should be rejected, or undefined when it is
|
|
47
|
+
* good. Beyond the signature this checks kind, freshness and that the
|
|
48
|
+
* `challenge` tag matches — without the last one an auth event captured from
|
|
49
|
+
* another connection would be replayable here.
|
|
50
|
+
*/
|
|
51
|
+
function checkAuthEvent(event, challenge) {
|
|
52
|
+
if (!event?.id || !event?.sig || !event?.pubkey)
|
|
53
|
+
return 'malformed auth event';
|
|
54
|
+
if (event.kind !== 22242)
|
|
55
|
+
return `expected kind 22242, got ${event.kind}`;
|
|
56
|
+
if (!verifyEvent(event))
|
|
57
|
+
return 'invalid signature';
|
|
58
|
+
const sent = event.tags?.find(t => t[0] === 'challenge')?.[1];
|
|
59
|
+
if (sent !== challenge)
|
|
60
|
+
return 'challenge mismatch';
|
|
61
|
+
// NIP-42 suggests rejecting anything far from the present
|
|
62
|
+
const age = Math.abs(Math.floor(Date.now() / 1000) - event.created_at);
|
|
63
|
+
if (age > 600)
|
|
64
|
+
return 'auth event timestamp too far from now';
|
|
65
|
+
return undefined;
|
|
66
|
+
}
|
|
42
67
|
export function startRelay(opts = {}) {
|
|
43
68
|
const hostname = opts.hostname ?? 'localhost';
|
|
44
69
|
const port = opts.port ?? 10547;
|
|
45
70
|
const quiet = opts.quiet ?? false;
|
|
71
|
+
const requireAuth = opts.auth ?? false;
|
|
72
|
+
const eagerAuth = opts.eagerAuth ?? false;
|
|
46
73
|
const log = quiet ? () => { } : (...args) => console.error('[relay]', ...args);
|
|
47
74
|
// The bundled test relay has no auth and accepts arbitrary signed events.
|
|
48
75
|
// Binding it to anything other than loopback exposes a writable Nostr endpoint
|
|
@@ -86,7 +113,14 @@ export function startRelay(opts = {}) {
|
|
|
86
113
|
const wss = new WebSocketServer({ server: httpServer });
|
|
87
114
|
wss.on('connection', (ws) => {
|
|
88
115
|
const clientSubs = new Set();
|
|
116
|
+
// NIP-42: one challenge per connection; authedPubkey is set once the
|
|
117
|
+
// client returns a valid kind 22242 naming this relay and that challenge.
|
|
118
|
+
const challenge = randomBytes(16).toString('hex');
|
|
119
|
+
let authedPubkey;
|
|
89
120
|
log('Client connected');
|
|
121
|
+
if (requireAuth && eagerAuth) {
|
|
122
|
+
ws.send(JSON.stringify(['AUTH', challenge]));
|
|
123
|
+
}
|
|
90
124
|
ws.on('message', (raw) => {
|
|
91
125
|
let msg;
|
|
92
126
|
try {
|
|
@@ -101,6 +135,31 @@ export function startRelay(opts = {}) {
|
|
|
101
135
|
return;
|
|
102
136
|
}
|
|
103
137
|
const type = msg[0];
|
|
138
|
+
if (type === 'AUTH') {
|
|
139
|
+
const authEvent = msg[1];
|
|
140
|
+
const problem = checkAuthEvent(authEvent, challenge);
|
|
141
|
+
if (problem) {
|
|
142
|
+
ws.send(JSON.stringify(['OK', authEvent?.id ?? '', false, `auth-required: ${problem}`]));
|
|
143
|
+
return;
|
|
144
|
+
}
|
|
145
|
+
authedPubkey = authEvent.pubkey;
|
|
146
|
+
log('Client authenticated as', authedPubkey);
|
|
147
|
+
ws.send(JSON.stringify(['OK', authEvent.id, true, '']));
|
|
148
|
+
return;
|
|
149
|
+
}
|
|
150
|
+
if (requireAuth && !authedPubkey && (type === 'EVENT' || type === 'REQ')) {
|
|
151
|
+
// Send the challenge alongside the rejection so a client that did not
|
|
152
|
+
// ask for one up front can still recover without reconnecting.
|
|
153
|
+
ws.send(JSON.stringify(['AUTH', challenge]));
|
|
154
|
+
if (type === 'EVENT') {
|
|
155
|
+
const e = msg[1];
|
|
156
|
+
ws.send(JSON.stringify(['OK', e?.id ?? '', false, 'auth-required: authentication required']));
|
|
157
|
+
}
|
|
158
|
+
else {
|
|
159
|
+
ws.send(JSON.stringify(['CLOSED', String(msg[1]), 'auth-required: authentication required']));
|
|
160
|
+
}
|
|
161
|
+
return;
|
|
162
|
+
}
|
|
104
163
|
if (type === 'EVENT') {
|
|
105
164
|
const event = msg[1];
|
|
106
165
|
if (!event?.id || !event?.sig || !event?.pubkey) {
|
|
@@ -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/dist/util/tools.js
CHANGED
|
@@ -7,6 +7,7 @@ import { handleDecode, handleEncodeNpub, handleEncodeNote, handleEncodeNprofile,
|
|
|
7
7
|
import { handleValidateEvent } from '../event-validation/validator.js';
|
|
8
8
|
import { eventValidationModeSchema, semanticEventInputSchema } from '../event-validation/tools-schema.js';
|
|
9
9
|
import { lookupKind, searchKinds } from '../event-validation/kind-lookup.js';
|
|
10
|
+
import { handleGiftWrap, handleGiftUnwrap } from '../event/gift-wrap.js';
|
|
10
11
|
export function registerUtilTools(server, deps) {
|
|
11
12
|
server.registerTool('decode', {
|
|
12
13
|
description: 'Decode a nip19 entity (npub, nsec, note, nevent, nprofile, naddr) or nostr: URI to its components.',
|
|
@@ -87,6 +88,35 @@ export function registerUtilTools(server, deps) {
|
|
|
87
88
|
const result = handleValidateEvent(event, mode);
|
|
88
89
|
return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] };
|
|
89
90
|
});
|
|
91
|
+
server.registerTool('gift-wrap', {
|
|
92
|
+
description: 'Seal and gift-wrap any event for one recipient (NIP-59). Returns a kind 1059 wrap signed by a throwaway key, ' +
|
|
93
|
+
'so nothing public links it to you. Use for metadata-private delivery of arbitrary events; DMs already wrap themselves via dm-send.',
|
|
94
|
+
inputSchema: {
|
|
95
|
+
event: z.object({
|
|
96
|
+
kind: z.number().int().min(0).describe('Kind of the event to wrap'),
|
|
97
|
+
content: z.string().optional().describe('Event content'),
|
|
98
|
+
tags: z.array(z.array(z.string())).optional().describe('Event tags'),
|
|
99
|
+
created_at: z.number().int().optional().describe('Unix timestamp (defaults to now)'),
|
|
100
|
+
}).describe('The event to wrap. It is sealed unsigned, as NIP-59 requires.'),
|
|
101
|
+
recipient_pubkey: hexId.describe('Recipient hex public key'),
|
|
102
|
+
},
|
|
103
|
+
annotations: { readOnlyHint: true, openWorldHint: false },
|
|
104
|
+
}, async ({ event, recipient_pubkey }) => {
|
|
105
|
+
const result = await handleGiftWrap(deps.ctx, { event, recipientPubkey: recipient_pubkey });
|
|
106
|
+
return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] };
|
|
107
|
+
});
|
|
108
|
+
server.registerTool('gift-unwrap', {
|
|
109
|
+
description: 'Unwrap a NIP-59 kind 1059 gift wrap addressed to the active identity, returning the hidden rumor and the ' +
|
|
110
|
+
'pubkey that sealed it. Also reports whether the sealer matches the rumor\'s claimed author — a rumor is ' +
|
|
111
|
+
'unsigned, so its pubkey is a claim rather than proof, and a mismatch means someone sealed an event attributed to a third party.',
|
|
112
|
+
inputSchema: {
|
|
113
|
+
event: nostrEventSchema.describe('The kind 1059 gift wrap to open'),
|
|
114
|
+
},
|
|
115
|
+
annotations: { readOnlyHint: true, openWorldHint: false },
|
|
116
|
+
}, async ({ event }) => {
|
|
117
|
+
const result = await handleGiftUnwrap(deps.ctx, { event: event });
|
|
118
|
+
return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] };
|
|
119
|
+
});
|
|
90
120
|
server.registerTool('kind-info', {
|
|
91
121
|
description: 'Look up what a Nostr event kind is for and what shape it takes: description, storage class ' +
|
|
92
122
|
'(regular/replaceable/ephemeral/addressable), required tags, repeatable tags, and the expected value type ' +
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "nostr-bray",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.2.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",
|