nostr-bray 1.40.1 → 1.42.2

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.
Files changed (44) hide show
  1. package/dist/admin/handlers.d.ts +14 -1
  2. package/dist/admin/handlers.js +47 -3
  3. package/dist/bunker-context.d.ts +29 -2
  4. package/dist/bunker-context.js +68 -19
  5. package/dist/cli/commands/admin.d.ts +4 -0
  6. package/dist/cli/commands/admin.js +24 -13
  7. package/dist/cli/commands/event.js +11 -1
  8. package/dist/cli/commands/relay.js +18 -9
  9. package/dist/cli/commands/util.js +14 -1
  10. package/dist/cli/dispatch.d.ts +2 -0
  11. package/dist/cli/dispatch.js +21 -4
  12. package/dist/cli/index.js +45 -14
  13. package/dist/config.d.ts +12 -0
  14. package/dist/config.js +23 -0
  15. package/dist/dispatch/tools.js +2 -2
  16. package/dist/event/handlers.d.ts +7 -0
  17. package/dist/event/handlers.js +20 -2
  18. package/dist/event/index.d.ts +1 -0
  19. package/dist/event/index.js +1 -0
  20. package/dist/event/pow.d.ts +50 -0
  21. package/dist/event/pow.js +85 -0
  22. package/dist/event-validation/index.d.ts +1 -0
  23. package/dist/event-validation/index.js +1 -0
  24. package/dist/event-validation/kind-lookup.d.ts +48 -0
  25. package/dist/event-validation/kind-lookup.js +86 -0
  26. package/dist/format.d.ts +18 -0
  27. package/dist/format.js +40 -0
  28. package/dist/index.js +53 -18
  29. package/dist/relay/handlers.d.ts +28 -0
  30. package/dist/relay/handlers.js +63 -2
  31. package/dist/relay/tools.js +14 -1
  32. package/dist/relay-pool.d.ts +55 -11
  33. package/dist/relay-pool.js +93 -8
  34. package/dist/serve.d.ts +2 -0
  35. package/dist/serve.js +20 -3
  36. package/dist/social/handlers.d.ts +10 -2
  37. package/dist/social/handlers.js +18 -3
  38. package/dist/social/tools.js +10 -2
  39. package/dist/types.d.ts +2 -0
  40. package/dist/util/tools.js +22 -0
  41. package/dist/widgets/dm-thread.html +11 -11
  42. package/dist/widgets/identity-picker.html +11 -11
  43. package/dist/widgets/social-feed.html +11 -11
  44. package/package.json +1 -1
@@ -7,7 +7,20 @@
7
7
  * Reference: https://github.com/nostr-protocol/nips/blob/master/86.md
8
8
  */
9
9
  import type { SigningContext } from '../signing-context.js';
10
- export type AdminMethod = 'allowpubkey' | 'banpubkey' | 'listallowedpubkeys' | 'listbannedpubkeys' | 'allowkind' | 'bankind' | 'listallowedkinds' | 'listbannedkinds' | 'blockip' | 'unblockip' | 'listblockedips';
10
+ /** Method names defined by NIP-86, plus widely-implemented extensions. */
11
+ export type AdminMethod = 'supportedmethods' | 'allowpubkey' | 'unallowpubkey' | 'banpubkey' | 'unbanpubkey' | 'listallowedpubkeys' | 'listbannedpubkeys' | 'allowkind' | 'disallowkind' | 'listallowedkinds' | 'allowevent' | 'banevent' | 'listbannedevents' | 'listeventsneedingmoderation' | 'changerelayname' | 'changerelaydescription' | 'changerelayicon' | 'createrole' | 'editrole' | 'deleterole' | 'assignrole' | 'unassignrole' | 'grantadmin' | 'revokeadmin' | 'blockip' | 'unblockip' | 'listblockedips' | 'listdisallowedkinds';
12
+ /**
13
+ * Legacy bray subcommand names retained as aliases.
14
+ *
15
+ * `bankind` and `listbannedkinds` were never NIP-86 method names, so requests
16
+ * using them were rejected by spec-compliant relays. They are still accepted as
17
+ * input and rewritten to the correct wire method.
18
+ */
19
+ export declare const ADMIN_METHOD_ALIASES: Record<string, AdminMethod>;
20
+ /** Resolve a user-supplied method name to its NIP-86 wire name. */
21
+ export declare function resolveAdminMethod(method: string): AdminMethod;
22
+ /** Coerce the parameters a given method expects as numbers, leaving the rest alone. */
23
+ export declare function coerceAdminParams(method: AdminMethod, params: Array<string | number>): Array<string | number>;
11
24
  export interface AdminCallOptions {
12
25
  /** Relay HTTP URL (e.g. https://relay.example.com) */
13
26
  relay: string;
@@ -6,6 +6,48 @@
6
6
  *
7
7
  * Reference: https://github.com/nostr-protocol/nips/blob/master/86.md
8
8
  */
9
+ /**
10
+ * Legacy bray subcommand names retained as aliases.
11
+ *
12
+ * `bankind` and `listbannedkinds` were never NIP-86 method names, so requests
13
+ * using them were rejected by spec-compliant relays. They are still accepted as
14
+ * input and rewritten to the correct wire method.
15
+ */
16
+ export const ADMIN_METHOD_ALIASES = {
17
+ bankind: 'disallowkind',
18
+ listbannedkinds: 'listdisallowedkinds',
19
+ };
20
+ /**
21
+ * Positions (zero-indexed) whose parameter is a number rather than a string.
22
+ *
23
+ * Everything else is passed through verbatim. Blanket numeric coercion is
24
+ * unsafe here because an all-digit 64-character pubkey would be mangled by
25
+ * `Number()`.
26
+ */
27
+ const NUMERIC_PARAM_POSITIONS = {
28
+ allowkind: [0],
29
+ disallowkind: [0],
30
+ createrole: [4],
31
+ editrole: [4],
32
+ };
33
+ /** Resolve a user-supplied method name to its NIP-86 wire name. */
34
+ export function resolveAdminMethod(method) {
35
+ return ADMIN_METHOD_ALIASES[method] ?? method;
36
+ }
37
+ /** Coerce the parameters a given method expects as numbers, leaving the rest alone. */
38
+ export function coerceAdminParams(method, params) {
39
+ const numeric = NUMERIC_PARAM_POSITIONS[method];
40
+ if (!numeric)
41
+ return params;
42
+ return params.map((p, i) => {
43
+ if (!numeric.includes(i) || typeof p === 'number')
44
+ return p;
45
+ const n = Number(p);
46
+ if (!Number.isFinite(n))
47
+ throw new Error(`${method}: parameter ${i + 1} must be a number, got "${p}"`);
48
+ return n;
49
+ });
50
+ }
9
51
  // ─── Internal helpers ─────────────────────────────────────────────────────────
10
52
  /**
11
53
  * Build a NIP-98 HTTP Auth header value for the given URL and HTTP method.
@@ -40,9 +82,11 @@ async function buildNip98Header(ctx, url, httpMethod, payloadHash) {
40
82
  export async function handleAdminCall(ctx, opts) {
41
83
  // NIP-86 always POSTs to the relay root URL
42
84
  const url = opts.relay.replace(/\/$/, '');
85
+ // Rewrite legacy aliases so SDK callers get the spec method name on the wire too
86
+ const method = resolveAdminMethod(opts.method);
43
87
  const body = JSON.stringify({
44
- method: opts.method,
45
- params: opts.params ?? [],
88
+ method,
89
+ params: coerceAdminParams(method, opts.params ?? []),
46
90
  });
47
91
  // Hash the request body for the NIP-98 payload tag (SHA-256 hex)
48
92
  const { createHash } = await import('node:crypto');
@@ -65,7 +109,7 @@ export async function handleAdminCall(ctx, opts) {
65
109
  throw new Error(`Relay error: ${json.error}`);
66
110
  return {
67
111
  relay: opts.relay,
68
- method: opts.method,
112
+ method,
69
113
  result: json.result,
70
114
  };
71
115
  }
@@ -16,6 +16,13 @@ import { BunkerSigner } from 'nostr-tools/nip46';
16
16
  import { SimplePool } from 'nostr-tools/pool';
17
17
  import type { PublicIdentity, SignFn } from './types.js';
18
18
  import type { SigningContext } from './signing-context.js';
19
+ /**
20
+ * Reject if `promise` does not settle within `ms`. Converts a silent NIP-46
21
+ * hang into a catchable error so callers can fail fast or retry with backoff
22
+ * instead of wedging -- which, at startup under an MCP health check, becomes a
23
+ * respawn loop that floods the signer.
24
+ */
25
+ export declare function withTimeout<T>(promise: Promise<T>, ms: number, label: string): Promise<T>;
19
26
  export interface BunkerConfig {
20
27
  pubkey: string;
21
28
  /**
@@ -73,9 +80,29 @@ export declare class BunkerContext implements SigningContext {
73
80
  protected pool: SimplePool;
74
81
  protected pubkeyHex: string | undefined;
75
82
  private clientSk;
76
- protected constructor(signer: BunkerSigner, pool: SimplePool, clientSk: Uint8Array);
83
+ private config;
84
+ protected constructor(signer: BunkerSigner, pool: SimplePool, clientSk: Uint8Array, config: ReturnType<typeof parseBunkerUri>);
85
+ /**
86
+ * Construct a BunkerContext WITHOUT performing the network handshake.
87
+ * Lets a caller bring its process up immediately and connect in the
88
+ * background (see {@link establish}), so a slow or unreachable signer
89
+ * cannot wedge startup. Under an MCP stdio health check that respawns
90
+ * "failed to connect" servers, a wedged startup turns a flaky signer into
91
+ * a respawn loop that floods the bunker with `connect` requests.
92
+ */
93
+ static create(uri: string, stateDir?: string): BunkerContext;
94
+ /**
95
+ * Perform the NIP-46 `connect` handshake, bounded by `timeoutMs`.
96
+ *
97
+ * nostr-tools' BunkerSigner has no per-request timeout, so a signer that is
98
+ * offline or whose relays are unreachable makes `signer.connect()` hang
99
+ * forever. We race it against a timer and reject on expiry so the caller can
100
+ * retry with backoff. Ping is redundant (a successful connect proves the
101
+ * signer is alive) and getPublicKey is deferred to first access.
102
+ */
103
+ establish(timeoutMs?: number): Promise<void>;
77
104
  /** Connect to a remote bunker. Blocks until the connection is established. */
78
- static connect(uri: string, _timeoutMs?: number, stateDir?: string): Promise<BunkerContext>;
105
+ static connect(uri: string, timeoutMs?: number, stateDir?: string): Promise<BunkerContext>;
79
106
  /**
80
107
  * Resolve the signing identity pubkey from the remote bunker.
81
108
  * Called lazily on first access so startup stays fast (no extra
@@ -19,6 +19,25 @@ import { npubEncode } from 'nostr-tools/nip19';
19
19
  import WebSocket from 'ws';
20
20
  import { readStateFile, writeStateFile } from './state.js';
21
21
  useWebSocketImplementation(WebSocket);
22
+ /** Bound the NIP-46 connect handshake. nostr-tools' BunkerSigner has no
23
+ * per-request timeout, so an offline signer or unreachable relays make the
24
+ * connect hang forever. */
25
+ const CONNECT_TIMEOUT_MS = 15_000;
26
+ /** Bound individual signer round-trips (sign_event, get_public_key, nip44). */
27
+ const REQUEST_TIMEOUT_MS = 30_000;
28
+ /**
29
+ * Reject if `promise` does not settle within `ms`. Converts a silent NIP-46
30
+ * hang into a catchable error so callers can fail fast or retry with backoff
31
+ * instead of wedging -- which, at startup under an MCP health check, becomes a
32
+ * respawn loop that floods the signer.
33
+ */
34
+ export function withTimeout(promise, ms, label) {
35
+ let timer;
36
+ const timeout = new Promise((_resolve, reject) => {
37
+ timer = setTimeout(() => reject(new Error(`${label} timed out after ${ms}ms`)), ms);
38
+ });
39
+ return Promise.race([promise, timeout]).finally(() => clearTimeout(timer));
40
+ }
22
41
  /** Parse a bunker:// URI */
23
42
  export function parseBunkerUri(uri) {
24
43
  // bunker://<pubkey>?relay=<url>&relay=<url>...&secret=<hex>
@@ -74,13 +93,22 @@ export class BunkerContext {
74
93
  pool;
75
94
  pubkeyHex;
76
95
  clientSk;
77
- constructor(signer, pool, clientSk) {
96
+ config;
97
+ constructor(signer, pool, clientSk, config) {
78
98
  this.signer = signer;
79
99
  this.pool = pool;
80
100
  this.clientSk = clientSk;
101
+ this.config = config;
81
102
  }
82
- /** Connect to a remote bunker. Blocks until the connection is established. */
83
- static async connect(uri, _timeoutMs = 15_000, stateDir) {
103
+ /**
104
+ * Construct a BunkerContext WITHOUT performing the network handshake.
105
+ * Lets a caller bring its process up immediately and connect in the
106
+ * background (see {@link establish}), so a slow or unreachable signer
107
+ * cannot wedge startup. Under an MCP stdio health check that respawns
108
+ * "failed to connect" servers, a wedged startup turns a flaky signer into
109
+ * a respawn loop that floods the bunker with `connect` requests.
110
+ */
111
+ static create(uri, stateDir) {
84
112
  const config = parseBunkerUri(uri);
85
113
  const clientSk = resolveClientKey(config, stateDir);
86
114
  const pool = new SimplePool();
@@ -93,18 +121,39 @@ export class BunkerContext {
93
121
  // request will require a manual approval on the signer side.
94
122
  secret: config.connectSecret ?? null,
95
123
  }, { pool });
96
- // Only the `connect` handshake is needed at startup. Ping is
97
- // redundant (if connect succeeded the signer is alive), and
98
- // getPublicKey is deferred to first access so startup stays fast.
99
- // Sent via sendRequest (not signer.connect()) so params[3] can carry
100
- // the client-name metadata that signer approval screens display.
101
- // nostr-tools' connect() is literally sendRequest('connect', [pubkey,
102
- // secret]) — semantics are otherwise identical.
103
- await signer.sendRequest('connect', buildConnectParams(config));
104
- const ctx = new BunkerContext(signer, pool, clientSk);
105
- // Do NOT use config.pubkey here — that is the bunker's transport
106
- // key from the URI, not the signing identity. The actual identity
107
- // pubkey is resolved lazily via getPublicKey() on first access.
124
+ // Do NOT use config.pubkey as the identity -- it is the bunker's
125
+ // transport key from the URI, not the signing identity. The actual
126
+ // identity pubkey is resolved lazily via resolvePublicKey().
127
+ // The parsed config is retained so establish() can send the connect
128
+ // handshake with its client-name metadata.
129
+ return new BunkerContext(signer, pool, clientSk, config);
130
+ }
131
+ /**
132
+ * Perform the NIP-46 `connect` handshake, bounded by `timeoutMs`.
133
+ *
134
+ * nostr-tools' BunkerSigner has no per-request timeout, so a signer that is
135
+ * offline or whose relays are unreachable makes `signer.connect()` hang
136
+ * forever. We race it against a timer and reject on expiry so the caller can
137
+ * retry with backoff. Ping is redundant (a successful connect proves the
138
+ * signer is alive) and getPublicKey is deferred to first access.
139
+ */
140
+ async establish(timeoutMs = CONNECT_TIMEOUT_MS) {
141
+ // Sent via sendRequest (not signer.connect()) so params[3] can carry the
142
+ // client-name metadata that signer approval screens display. nostr-tools'
143
+ // connect() is literally sendRequest('connect', [pubkey, secret]);
144
+ // semantics are otherwise identical.
145
+ await withTimeout(this.signer.sendRequest('connect', buildConnectParams(this.config)), timeoutMs, 'bunker connect');
146
+ }
147
+ /** Connect to a remote bunker. Blocks until the connection is established. */
148
+ static async connect(uri, timeoutMs = CONNECT_TIMEOUT_MS, stateDir) {
149
+ const ctx = BunkerContext.create(uri, stateDir);
150
+ try {
151
+ await ctx.establish(timeoutMs);
152
+ }
153
+ catch (e) {
154
+ ctx.destroy();
155
+ throw e;
156
+ }
108
157
  return ctx;
109
158
  }
110
159
  /**
@@ -114,7 +163,7 @@ export class BunkerContext {
114
163
  */
115
164
  async resolvePublicKey() {
116
165
  if (!this.pubkeyHex) {
117
- this.pubkeyHex = await this.signer.getPublicKey();
166
+ this.pubkeyHex = await withTimeout(this.signer.getPublicKey(), REQUEST_TIMEOUT_MS, 'bunker get_public_key');
118
167
  }
119
168
  return this.pubkeyHex;
120
169
  }
@@ -135,7 +184,7 @@ export class BunkerContext {
135
184
  /** Sign an event via the remote bunker */
136
185
  getSigningFunction() {
137
186
  return async (template) => {
138
- return this.signer.signEvent(template);
187
+ return withTimeout(this.signer.signEvent(template), REQUEST_TIMEOUT_MS, 'bunker sign_event');
139
188
  };
140
189
  }
141
190
  /** List identities — bunker mode only has one (the remote key) */
@@ -144,11 +193,11 @@ export class BunkerContext {
144
193
  }
145
194
  /** NIP-44 encrypt via the remote bunker */
146
195
  async nip44Encrypt(recipientPubkey, plaintext) {
147
- return this.signer.nip44Encrypt(recipientPubkey, plaintext);
196
+ return withTimeout(this.signer.nip44Encrypt(recipientPubkey, plaintext), REQUEST_TIMEOUT_MS, 'bunker nip44_encrypt');
148
197
  }
149
198
  /** NIP-44 decrypt via the remote bunker */
150
199
  async nip44Decrypt(senderPubkey, ciphertext) {
151
- return this.signer.nip44Decrypt(senderPubkey, ciphertext);
200
+ return withTimeout(this.signer.nip44Decrypt(senderPubkey, ciphertext), REQUEST_TIMEOUT_MS, 'bunker nip44_decrypt');
152
201
  }
153
202
  /** Clean up */
154
203
  destroy() {
@@ -1,2 +1,6 @@
1
+ import type { AdminMethod } from '../../exports.js';
1
2
  import type { Helpers } from '../dispatch.js';
3
+ export declare const ADMIN_METHODS: Set<AdminMethod>;
4
+ /** Legacy subcommand spellings still accepted on the CLI. */
5
+ export declare const ADMIN_ALIASES: string[];
2
6
  export declare function dispatch(cmd: string, cmdArgs: string[], h: Helpers, ctx: any): Promise<void>;
@@ -1,26 +1,37 @@
1
- import { handleAdminCall } from '../../exports.js';
2
- const ADMIN_METHODS = new Set([
3
- 'allowpubkey', 'banpubkey', 'listallowedpubkeys', 'listbannedpubkeys',
4
- 'allowkind', 'bankind', 'listallowedkinds', 'listbannedkinds',
1
+ import { handleAdminCall, coerceAdminParams, resolveAdminMethod, ADMIN_METHOD_ALIASES } from '../../exports.js';
2
+ export const ADMIN_METHODS = new Set([
3
+ 'supportedmethods',
4
+ 'allowpubkey', 'unallowpubkey', 'banpubkey', 'unbanpubkey',
5
+ 'listallowedpubkeys', 'listbannedpubkeys',
6
+ 'allowkind', 'disallowkind', 'listallowedkinds', 'listdisallowedkinds',
7
+ 'allowevent', 'banevent', 'listbannedevents', 'listeventsneedingmoderation',
8
+ 'changerelayname', 'changerelaydescription', 'changerelayicon',
9
+ 'createrole', 'editrole', 'deleterole', 'assignrole', 'unassignrole',
10
+ 'grantadmin', 'revokeadmin',
5
11
  'blockip', 'unblockip', 'listblockedips',
6
12
  ]);
13
+ /** Legacy subcommand spellings still accepted on the CLI. */
14
+ export const ADMIN_ALIASES = Object.keys(ADMIN_METHOD_ALIASES);
7
15
  export async function dispatch(cmd, cmdArgs, h, ctx) {
8
16
  // cmd is always 'admin'; subcommand is cmdArgs[1] (already sliced by normaliser)
9
17
  // After COMPOUND_COMMANDS normalisation: cmd = 'admin-<subcommand>'
10
18
  const { req, out } = h;
11
- // Extract the method from the compound command name
12
- const method = cmd.replace(/^admin-/, '');
13
- if (!ADMIN_METHODS.has(method)) {
14
- throw new Error(`Unknown admin subcommand: ${method}. Valid: ${[...ADMIN_METHODS].join(', ')}`);
19
+ // Extract the method from the compound command name, mapping legacy spellings.
20
+ // A bare `admin` means the subcommand was not recognised during normalisation.
21
+ const requested = cmd === 'admin' ? (cmdArgs[1] ?? '') : cmd.replace(/^admin-/, '');
22
+ const method = resolveAdminMethod(requested);
23
+ if (!requested || !ADMIN_METHODS.has(method)) {
24
+ throw new Error(`${requested ? `Unknown admin subcommand: ${requested}.` : 'admin needs a subcommand.'}\n` +
25
+ `Usage: admin <subcommand> <relay-url> [param...]\n` +
26
+ `Valid: ${[...ADMIN_METHODS].join(', ')}`);
27
+ }
28
+ if (requested !== method) {
29
+ console.error(`note: "admin ${requested}" is not a NIP-86 method name; sending "${method}" instead`);
15
30
  }
16
31
  const relay = req(1, `admin ${method} <relay-url> [param...]`);
17
32
  // Remaining positional args after relay URL are method params
18
33
  const params = cmdArgs.slice(2).filter(a => !a.startsWith('--'));
19
- // allowkind / bankind take integer params
20
- const coercedParams = params.map(p => {
21
- const n = Number(p);
22
- return isNaN(n) ? p : n;
23
- });
34
+ const coercedParams = coerceAdminParams(method, params);
24
35
  out(await handleAdminCall(ctx, {
25
36
  relay,
26
37
  method,
@@ -15,13 +15,19 @@ export async function dispatch(cmd, _cmdArgs, h, ctx, pool) {
15
15
  const content = flag('content') ?? '';
16
16
  const validationMode = parseValidationMode(flag('validation'));
17
17
  const relayOverrides = flags('relay');
18
+ const pow = flag('pow') ? parseInt(flag('pow'), 10) : undefined;
19
+ const powTimeoutMs = flag('pow-timeout') ? parseInt(flag('pow-timeout'), 10) : undefined;
18
20
  if (hasFlag('no-publish')) {
19
- const template = {
21
+ const { minePow } = await import('../../event/pow.js');
22
+ let template = {
20
23
  kind,
21
24
  created_at: Math.floor(Date.now() / 1000),
22
25
  tags: tagValues,
23
26
  content,
24
27
  };
28
+ if (pow !== undefined) {
29
+ template = minePow(template, { difficulty: pow, pubkey: ctx.activePublicKeyHex, timeoutMs: powTimeoutMs }).template;
30
+ }
25
31
  const validation = assertEventSemanticallyValid(template, validationMode);
26
32
  reportWarnings(validation.issues);
27
33
  const sign = ctx.getSigningFunction();
@@ -35,6 +41,8 @@ export async function dispatch(cmd, _cmdArgs, h, ctx, pool) {
35
41
  tags: tagValues,
36
42
  relays: relayOverrides.length ? relayOverrides : undefined,
37
43
  validationMode,
44
+ pow,
45
+ powTimeoutMs,
38
46
  }));
39
47
  break;
40
48
  }
@@ -56,6 +64,8 @@ export async function dispatch(cmd, _cmdArgs, h, ctx, pool) {
56
64
  timeoutMs,
57
65
  quorum,
58
66
  validationMode,
67
+ pow: flag('pow') ? parseInt(flag('pow'), 10) : undefined,
68
+ powTimeoutMs: flag('pow-timeout') ? parseInt(flag('pow-timeout'), 10) : undefined,
59
69
  });
60
70
  if (report) {
61
71
  const { accepted, rejected, errors } = result.publish;
@@ -57,15 +57,24 @@ export async function dispatch(cmd, cmdArgs, h, ctx, pool, _activeNpub) {
57
57
  tags[k].push(v);
58
58
  }
59
59
  }
60
- const queryArgs = stdinFilter ?? {
61
- ...(kindsRaw ? { kinds: kindsRaw.split(',').map(Number) } : {}),
62
- ...(authorsRaw ? { authors: authorsRaw.split(',') } : {}),
63
- ...(since !== undefined ? { since } : {}),
64
- ...(until !== undefined ? { until } : {}),
65
- ...(limit !== undefined ? { limit } : {}),
66
- ...(search ? { search } : {}),
67
- ...(Object.keys(tags).length ? { tags } : {}),
68
- ...(relayOverrides.length ? { relays: relayOverrides } : {}),
60
+ const paginate = hasFlag('paginate');
61
+ const paginateIntervalMs = flag('paginate-interval') ? parseInt(flag('paginate-interval'), 10) : undefined;
62
+ const maxPages = flag('max-pages') ? parseInt(flag('max-pages'), 10) : undefined;
63
+ const queryArgs = {
64
+ ...(stdinFilter ?? {
65
+ ...(kindsRaw ? { kinds: kindsRaw.split(',').map(Number) } : {}),
66
+ ...(authorsRaw ? { authors: authorsRaw.split(',') } : {}),
67
+ ...(since !== undefined ? { since } : {}),
68
+ ...(until !== undefined ? { until } : {}),
69
+ ...(limit !== undefined ? { limit } : {}),
70
+ ...(search ? { search } : {}),
71
+ ...(Object.keys(tags).length ? { tags } : {}),
72
+ ...(relayOverrides.length ? { relays: relayOverrides } : {}),
73
+ }),
74
+ // Pagination is a transport concern, so it applies to a stdin filter too
75
+ ...(paginate ? { paginate } : {}),
76
+ ...(paginateIntervalMs !== undefined ? { paginateIntervalMs } : {}),
77
+ ...(maxPages !== undefined ? { maxPages } : {}),
69
78
  };
70
79
  let events = await handleRelayQuery(pool, ctx.activeNpub, queryArgs);
71
80
  const minTrust = flag('min-trust');
@@ -1,4 +1,4 @@
1
- import { handleDecode, handleEncodeNpub, handleEncodeNote, handleEncodeNprofile, handleEncodeNevent, handleVerify, handleEncrypt, handleDecrypt, handleCount, handleFetch, handleKeyPublic, handleEncodeNsec, handleFilter, handleNipList, handleNipShow, handleKeyEncrypt, handleKeyDecrypt, handleValidateEvent, } 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, } 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 'kind': {
67
+ const arg = req(1, 'kind <number|search text>');
68
+ if (/^\d+$/.test(arg)) {
69
+ out(lookupKind(Number(arg)), fmt.formatKindInfo);
70
+ }
71
+ else {
72
+ const results = searchKinds(cmdArgs.slice(1).filter(a => !a.startsWith('--')).join(' '));
73
+ if (results.length === 0)
74
+ throw new Error(`No kind matches "${arg}"`);
75
+ out(results, rs => rs.map(fmt.formatKindInfo).join('\n\n'));
76
+ }
77
+ break;
78
+ }
66
79
  case 'encrypt': {
67
80
  const skHex = Buffer.from(ctx.activePrivateKey).toString('hex');
68
81
  console.log(handleEncrypt(skHex, req(1, 'encrypt <pubkey-hex> "plaintext"'), req(2, 'encrypt <pubkey-hex> "plaintext"')));
@@ -1,4 +1,6 @@
1
1
  /** Shared dispatch helpers shared across all CLI command modules. */
2
+ export declare const ADMIN_SUBCOMMANDS: readonly ["supportedmethods", "allowpubkey", "unallowpubkey", "banpubkey", "unbanpubkey", "listallowedpubkeys", "listbannedpubkeys", "allowkind", "disallowkind", "listallowedkinds", "listdisallowedkinds", "allowevent", "banevent", "listbannedevents", "listeventsneedingmoderation", "changerelayname", "changerelaydescription", "changerelayicon", "createrole", "editrole", "deleterole", "assignrole", "unassignrole", "grantadmin", "revokeadmin", "blockip", "unblockip", "listblockedips", "bankind", "listbannedkinds"];
3
+ export declare const ADMIN_COMMANDS: string[];
2
4
  export declare const COMPOUND_COMMANDS: Set<string>;
3
5
  export declare const OFFLINE_COMMANDS: Set<string>;
4
6
  export interface Helpers {
@@ -1,4 +1,21 @@
1
1
  /** Shared dispatch helpers shared across all CLI command modules. */
2
+ // NIP-86 relay management subcommands, plus the two legacy bray spellings
3
+ // (`bankind`, `listbannedkinds`) kept as aliases. Declared once and reused for
4
+ // compound-command normalisation, routing, and shell completion.
5
+ export const ADMIN_SUBCOMMANDS = [
6
+ 'supportedmethods',
7
+ 'allowpubkey', 'unallowpubkey', 'banpubkey', 'unbanpubkey',
8
+ 'listallowedpubkeys', 'listbannedpubkeys',
9
+ 'allowkind', 'disallowkind', 'listallowedkinds', 'listdisallowedkinds',
10
+ 'allowevent', 'banevent', 'listbannedevents', 'listeventsneedingmoderation',
11
+ 'changerelayname', 'changerelaydescription', 'changerelayicon',
12
+ 'createrole', 'editrole', 'deleterole', 'assignrole', 'unassignrole',
13
+ 'grantadmin', 'revokeadmin',
14
+ 'blockip', 'unblockip', 'listblockedips',
15
+ // legacy aliases
16
+ 'bankind', 'listbannedkinds',
17
+ ];
18
+ export const ADMIN_COMMANDS = ADMIN_SUBCOMMANDS.map(s => `admin-${s}`);
2
19
  // Known two-word commands: `noun subverb` → normalised to `noun-subverb` internally.
3
20
  // Enumerated explicitly to avoid swallowing positional args (e.g. `profile <pubkey>`).
4
21
  export const COMPOUND_COMMANDS = new Set([
@@ -16,9 +33,7 @@ export const COMPOUND_COMMANDS = new Set([
16
33
  // sync
17
34
  'sync-plan', 'sync-pull', 'sync-push',
18
35
  // admin (NIP-86 relay management)
19
- 'admin-allowpubkey', 'admin-banpubkey', 'admin-listallowedpubkeys', 'admin-listbannedpubkeys',
20
- 'admin-allowkind', 'admin-bankind', 'admin-listallowedkinds', 'admin-listbannedkinds',
21
- 'admin-blockip', 'admin-unblockip', 'admin-listblockedips',
36
+ ...ADMIN_COMMANDS,
22
37
  // wallet (NIP-47 Nostr Wallet Connect)
23
38
  'wallet-connect', 'wallet-disconnect', 'wallet-status', 'wallet-pay', 'wallet-balance', 'wallet-history',
24
39
  ]);
@@ -29,10 +44,12 @@ export const OFFLINE_COMMANDS = new Set([
29
44
  'ring-verify', 'zap-decode', 'safety-configure', 'safety-activate',
30
45
  'decode', 'encode-npub', 'encode-note', 'encode-nprofile', 'encode-nevent', 'encode-nsec',
31
46
  'key-public', 'key-encrypt', 'key-decrypt', 'filter', 'verify', 'encrypt', 'decrypt',
32
- 'validate-event',
47
+ 'validate-event', 'kind',
33
48
  'musig2-key', 'musig2-nonce', 'musig2-partial-sign', 'musig2-aggregate',
34
49
  // wallet config subcommands don't touch the relay
35
50
  'wallet-connect', 'wallet-disconnect', 'wallet-status',
51
+ // NIP-86 admin calls are plain HTTP to the target relay; the pool is not used
52
+ ...ADMIN_COMMANDS,
36
53
  ]);
37
54
  export function resolveOutputMode(cmdArgs, envDefault) {
38
55
  if (cmdArgs.includes('--jsonl'))
package/dist/cli/index.js CHANGED
@@ -4,7 +4,7 @@ import { IdentityContext } from '../context.js';
4
4
  import { RelayPool } from '../relay-pool.js';
5
5
  import { Nip65Manager } from '../nip65.js';
6
6
  import { getCommandHelp } from '../help.js';
7
- import { COMPOUND_COMMANDS, OFFLINE_COMMANDS, makeHelpers, resolveOutputMode, parseShellLine } from './dispatch.js';
7
+ import { COMPOUND_COMMANDS, OFFLINE_COMMANDS, ADMIN_COMMANDS, makeHelpers, resolveOutputMode, parseShellLine } from './dispatch.js';
8
8
  import * as identity from './commands/identity.js';
9
9
  import * as social from './commands/social.js';
10
10
  import * as trust from './commands/trust.js';
@@ -28,6 +28,18 @@ for (const flag of ['--bunker', '--key']) {
28
28
  args.splice(i, 2);
29
29
  }
30
30
  }
31
+ // Boolean global flags, recognised only in the leading position (`--auth req ...`)
32
+ // so they can be stripped before command detection. Anything after the command
33
+ // belongs to that command: `relay curl <url> --auth` means NIP-98, not NIP-42.
34
+ let authFlagPresent = false;
35
+ let eagerAuthFlagPresent = false;
36
+ while (args[0] === '--auth' || args[0] === '--eager-auth') {
37
+ if (args[0] === '--eager-auth')
38
+ eagerAuthFlagPresent = true;
39
+ else
40
+ authFlagPresent = true;
41
+ args.shift();
42
+ }
31
43
  const command = args[0];
32
44
  // No command = start MCP server.
33
45
  if (!command) {
@@ -136,10 +148,23 @@ Sync (NIP-77 reconciliation, then explicit REQ/EVENT transfer):
136
148
  Options: --protocol auto|nip77|req-fallback --timeout ms --max-remote N
137
149
 
138
150
  Admin (NIP-86 relay management):
139
- admin allowpubkey|banpubkey <relay-url> <pubkey-hex>
151
+ admin supportedmethods <relay-url> List methods the relay implements
152
+ admin allowpubkey|unallowpubkey <relay-url> <pubkey> [reason]
153
+ admin banpubkey|unbanpubkey <relay-url> <pubkey> [reason]
140
154
  admin listallowedpubkeys|listbannedpubkeys <relay-url>
141
- admin allowkind|bankind <relay-url> <kind> (list: listallowedkinds|listbannedkinds)
142
- admin blockip|unblockip <relay-url> <ip> (list: listblockedips)
155
+ admin allowkind|disallowkind <relay-url> <kind>
156
+ admin listallowedkinds|listdisallowedkinds <relay-url>
157
+ admin allowevent|banevent <relay-url> <event-id> [reason]
158
+ admin listbannedevents|listeventsneedingmoderation <relay-url>
159
+ admin changerelayname|changerelaydescription|changerelayicon <relay-url> <value>
160
+ admin createrole|editrole <relay-url> <id> <label> <description> <colour> <order>
161
+ admin deleterole <relay-url> <id>
162
+ admin assignrole|unassignrole <relay-url> <pubkey> <role-id>
163
+ admin grantadmin|revokeadmin <relay-url> <pubkey>
164
+ admin blockip|unblockip <relay-url> <ip> [reason]
165
+ admin listblockedips <relay-url>
166
+ (bankind and listbannedkinds are accepted as aliases for disallowkind and
167
+ listdisallowedkinds; they were never NIP-86 method names)
143
168
 
144
169
  Wallet (NIP-47 Nostr Wallet Connect):
145
170
  wallet connect <nwc-url> Store NWC URI for the active identity
@@ -180,6 +205,7 @@ Utility:
180
205
  filter <event-json> <filter-json> Test if event matches filter
181
206
  nips List all official NIPs
182
207
  nip <number> Show a specific NIP
208
+ kind <number|text> Look up an event kind: required tags, value shapes
183
209
  verify <event-json> Verify event hash and signature
184
210
  encrypt <pubkey-hex> "plaintext" NIP-44 encrypt for a recipient
185
211
  decrypt <pubkey-hex> <ciphertext> NIP-44 decrypt from a sender
@@ -207,6 +233,7 @@ Environment:
207
233
  NOSTR_SECRET_KEY_FILE Path to secret key file
208
234
  BUNKER_URI / BUNKER_URI_FILE bunker:// URI (use INSTEAD of secret key)
209
235
  NOSTR_RELAYS Comma-separated relay URLs
236
+ NOSTR_AUTH NIP-42 policy: off (default), on-demand, eager
210
237
  NWC_URI / NWC_URI_FILE Nostr Wallet Connect URI
211
238
  TOR_PROXY SOCKS5h proxy URL
212
239
  NOSTR_BRAY_OUTPUT Default output: "human" (default) or "json"
@@ -223,6 +250,8 @@ Quick examples:
223
250
  Flags:
224
251
  --bunker <uri> Use bunker:// URI (overrides env/config)
225
252
  --key <nsec|hex|mnemonic> Use this secret key (overrides env/config)
253
+ --auth Answer NIP-42 AUTH after an auth-required rejection
254
+ --eager-auth Answer the NIP-42 AUTH challenge as soon as it arrives
226
255
  --json Output raw JSON (for piping/scripts)
227
256
  --human Force human-readable output
228
257
  --jsonl | --csv | --tsv Structured list output (line-, comma- or tab-delimited)
@@ -304,11 +333,16 @@ if (command === 'validate-event') {
304
333
  const config = await loadConfig();
305
334
  const { configureHttpClient } = await import('../http-client.js');
306
335
  configureHttpClient({ torProxy: config.torProxy });
336
+ // `--auth` / `--eager-auth` override the NOSTR_AUTH env default for one run.
337
+ const authMode = eagerAuthFlagPresent
338
+ ? 'eager'
339
+ : authFlagPresent ? 'on-demand' : config.authMode;
307
340
  const pool = new RelayPool({
308
341
  torProxy: config.torProxy,
309
342
  allowClearnet: config.allowClearnetWithTor || !config.torProxy,
310
343
  defaultRelays: config.relays,
311
344
  allowPrivateRelays: config.allowPrivateRelays,
345
+ authMode,
312
346
  });
313
347
  const nip65 = new Nip65Manager(pool, config.relays);
314
348
  let ctx;
@@ -325,6 +359,8 @@ if (config.bunkerUri) {
325
359
  else {
326
360
  ctx = new IdentityContext(config.secretKey, config.secretFormat);
327
361
  }
362
+ // NIP-42: the pool answers AUTH challenges with the active identity's key.
363
+ pool.setAuthSigner(async (evt) => ctx.getSigningFunction()(evt));
328
364
  const globalNwcUri = config.nwcUri;
329
365
  const walletsFile = config.walletsFile;
330
366
  config.secretKey = '';
@@ -362,15 +398,12 @@ const SAFETY_CMDS = new Set(['safety-configure', 'safety-activate']);
362
398
  const EVENT_CMDS = new Set(['event', 'publish-raw']);
363
399
  const UTIL_CMDS = new Set([
364
400
  'decode', 'encode-npub', 'encode-note', 'encode-nprofile', 'encode-nevent', 'encode-nsec',
365
- 'key-public', 'key-encrypt', 'key-decrypt', 'filter', 'nips', 'nip', 'verify', 'validate-event', 'encrypt', 'decrypt', 'count', 'fetch',
401
+ 'key-public', 'key-encrypt', 'key-decrypt', 'filter', 'nips', 'nip', 'verify', 'validate-event', 'kind', 'encrypt', 'decrypt', 'count', 'fetch',
366
402
  ]);
367
403
  const MUSIG2_CMDS = new Set(['musig2-key', 'musig2-nonce', 'musig2-partial-sign', 'musig2-aggregate']);
368
404
  const SYNC_CMDS = new Set(['sync-plan', 'sync-pull', 'sync-push']);
369
- const ADMIN_CMDS = new Set([
370
- 'admin-allowpubkey', 'admin-banpubkey', 'admin-listallowedpubkeys', 'admin-listbannedpubkeys',
371
- 'admin-allowkind', 'admin-bankind', 'admin-listallowedkinds', 'admin-listbannedkinds',
372
- 'admin-blockip', 'admin-unblockip', 'admin-listblockedips',
373
- ]);
405
+ // Bare `admin` is routed too so an unrecognised subcommand gets a useful error
406
+ const ADMIN_CMDS = new Set(['admin', ...ADMIN_COMMANDS]);
374
407
  const WALLET_CMDS = new Set([
375
408
  'wallet-connect', 'wallet-disconnect', 'wallet-status', 'wallet-pay', 'wallet-balance', 'wallet-history',
376
409
  ]);
@@ -429,12 +462,10 @@ const ALL_COMMANDS = [
429
462
  'group-forum-topics', 'group-forum-topic-create', 'group-forum-comments', 'group-forum-comment',
430
463
  'event', 'publish-raw',
431
464
  'decode', 'encode-npub', 'encode-note', 'encode-nprofile', 'encode-nevent', 'encode-nsec',
432
- 'key-public', 'key-encrypt', 'key-decrypt', 'filter', 'nips', 'nip', 'verify', 'validate-event', 'encrypt', 'decrypt', 'count', 'fetch',
465
+ 'key-public', 'key-encrypt', 'key-decrypt', 'filter', 'nips', 'nip', 'verify', 'validate-event', 'kind', 'encrypt', 'decrypt', 'count', 'fetch',
433
466
  'musig2-key', 'musig2-nonce', 'musig2-partial-sign', 'musig2-aggregate',
434
467
  'sync-plan', 'sync-pull', 'sync-push',
435
- 'admin-allowpubkey', 'admin-banpubkey', 'admin-listallowedpubkeys', 'admin-listbannedpubkeys',
436
- 'admin-allowkind', 'admin-bankind', 'admin-listallowedkinds', 'admin-listbannedkinds',
437
- 'admin-blockip', 'admin-unblockip', 'admin-listblockedips',
468
+ ...ADMIN_COMMANDS,
438
469
  'wallet-connect', 'wallet-disconnect', 'wallet-status', 'wallet-pay', 'wallet-balance', 'wallet-history',
439
470
  'relay-curl',
440
471
  'bunker',