ncc-05 1.1.4 → 1.1.7

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/index.d.ts CHANGED
@@ -6,7 +6,22 @@
6
6
  *
7
7
  * @module ncc-05
8
8
  */
9
- import { Event } from 'nostr-tools';
9
+ import { SimplePool, Event } from 'nostr-tools';
10
+ export declare class NCC05Error extends Error {
11
+ constructor(message: string);
12
+ }
13
+ export declare class NCC05RelayError extends NCC05Error {
14
+ constructor(message: string);
15
+ }
16
+ export declare class NCC05TimeoutError extends NCC05Error {
17
+ constructor(message: string);
18
+ }
19
+ export declare class NCC05DecryptionError extends NCC05Error {
20
+ constructor(message: string);
21
+ }
22
+ export declare class NCC05ArgumentError extends NCC05Error {
23
+ constructor(message: string);
24
+ }
10
25
  /**
11
26
  * Represents a single reachable service endpoint.
12
27
  */
@@ -47,6 +62,19 @@ export interface ResolverOptions {
47
62
  timeout?: number;
48
63
  /** Custom WebSocket implementation (e.g., for Tor/SOCKS5 in Node.js) */
49
64
  websocketImplementation?: any;
65
+ /** Existing SimplePool instance to share connections */
66
+ pool?: SimplePool;
67
+ }
68
+ /**
69
+ * Options for configuring the NCC05Publisher.
70
+ */
71
+ export interface PublisherOptions {
72
+ /** Custom WebSocket implementation */
73
+ websocketImplementation?: any;
74
+ /** Existing SimplePool instance */
75
+ pool?: SimplePool;
76
+ /** Timeout for publishing in milliseconds (default: 5000) */
77
+ timeout?: number;
50
78
  }
51
79
  /**
52
80
  * Structure for multi-recipient encrypted events.
@@ -83,13 +111,14 @@ export declare class NCC05Group {
83
111
  * @param identifier - The 'd' tag of the record (default: 'addr').
84
112
  * @returns The resolved NCC05Payload or null.
85
113
  */
86
- static resolveAsGroup(resolver: NCC05Resolver, groupPubkey: string, groupSecretKey: Uint8Array, identifier?: string): Promise<NCC05Payload | null>;
114
+ static resolveAsGroup(resolver: NCC05Resolver, groupPubkey: string, groupSecretKey: string | Uint8Array, identifier?: string): Promise<NCC05Payload | null>;
87
115
  }
88
116
  /**
89
117
  * Handles the discovery, selection, and decryption of NCC-05 locator records.
90
118
  */
91
119
  export declare class NCC05Resolver {
92
120
  private pool;
121
+ private _ownPool;
93
122
  private bootstrapRelays;
94
123
  private timeout;
95
124
  /**
@@ -106,14 +135,16 @@ export declare class NCC05Resolver {
106
135
  * @param secretKey - Your secret key (required if the record is encrypted).
107
136
  * @param identifier - The 'd' tag of the record (default: 'addr').
108
137
  * @param options - Resolution options (strict mode, gossip discovery).
109
- * @returns The resolved and validated NCC05Payload, or null if not found/invalid.
138
+ * @returns The resolved and validated NCC05Payload, or null if not found.
139
+ * @throws {NCC05TimeoutError} if resolution times out.
140
+ * @throws {NCC05RelayError} if underlying relay communication fails.
110
141
  */
111
- resolve(targetPubkey: string, secretKey?: Uint8Array, identifier?: string, options?: {
142
+ resolve(targetPubkey: string, secretKey?: string | Uint8Array, identifier?: string, options?: {
112
143
  strict?: boolean;
113
144
  gossip?: boolean;
114
145
  }): Promise<NCC05Payload | null>;
115
146
  /**
116
- * Closes connections to all relays in the pool.
147
+ * Closes connections to all relays in the pool if managed internally.
117
148
  */
118
149
  close(): void;
119
150
  }
@@ -122,12 +153,13 @@ export declare class NCC05Resolver {
122
153
  */
123
154
  export declare class NCC05Publisher {
124
155
  private pool;
156
+ private _ownPool;
157
+ private timeout;
125
158
  /**
126
159
  * @param options - Configuration for the publisher.
127
160
  */
128
- constructor(options?: {
129
- websocketImplementation?: any;
130
- });
161
+ constructor(options?: PublisherOptions);
162
+ private _publishToRelays;
131
163
  /**
132
164
  * Publishes a single record encrypted for multiple recipients using the wrapping pattern.
133
165
  * This avoids sharing a single group private key.
@@ -139,7 +171,7 @@ export declare class NCC05Publisher {
139
171
  * @param identifier - The 'd' tag identifier (default: 'addr').
140
172
  * @returns The signed Nostr event.
141
173
  */
142
- publishWrapped(relays: string[], secretKey: Uint8Array, recipients: string[], payload: NCC05Payload, identifier?: string): Promise<Event>;
174
+ publishWrapped(relays: string[], secretKey: string | Uint8Array, recipients: string[], payload: NCC05Payload, identifier?: string): Promise<Event>;
143
175
  /**
144
176
  * Publishes a locator record. Supports self-encryption, targeted encryption, or plaintext.
145
177
  *
@@ -149,13 +181,13 @@ export declare class NCC05Publisher {
149
181
  * @param options - Publishing options (identifier, recipient, or public flag).
150
182
  * @returns The signed Nostr event.
151
183
  */
152
- publish(relays: string[], secretKey: Uint8Array, payload: NCC05Payload, options?: {
184
+ publish(relays: string[], secretKey: string | Uint8Array, payload: NCC05Payload, options?: {
153
185
  identifier?: string;
154
186
  recipientPubkey?: string;
155
187
  public?: boolean;
156
188
  }): Promise<Event>;
157
189
  /**
158
- * Closes connections to the specified relays.
190
+ * Closes connections to the specified relays if managed internally.
159
191
  */
160
192
  close(relays: string[]): void;
161
193
  }
package/dist/index.js CHANGED
@@ -7,6 +7,50 @@
7
7
  * @module ncc-05
8
8
  */
9
9
  import { SimplePool, nip44, nip19, finalizeEvent, verifyEvent, getPublicKey, generateSecretKey } from 'nostr-tools';
10
+ // --- Error Classes ---
11
+ export class NCC05Error extends Error {
12
+ constructor(message) {
13
+ super(message);
14
+ this.name = 'NCC05Error';
15
+ }
16
+ }
17
+ export class NCC05RelayError extends NCC05Error {
18
+ constructor(message) {
19
+ super(message);
20
+ this.name = 'NCC05RelayError';
21
+ }
22
+ }
23
+ export class NCC05TimeoutError extends NCC05Error {
24
+ constructor(message) {
25
+ super(message);
26
+ this.name = 'NCC05TimeoutError';
27
+ }
28
+ }
29
+ export class NCC05DecryptionError extends NCC05Error {
30
+ constructor(message) {
31
+ super(message);
32
+ this.name = 'NCC05DecryptionError';
33
+ }
34
+ }
35
+ export class NCC05ArgumentError extends NCC05Error {
36
+ constructor(message) {
37
+ super(message);
38
+ this.name = 'NCC05ArgumentError';
39
+ }
40
+ }
41
+ // --- Helpers ---
42
+ function ensureUint8Array(key) {
43
+ if (key instanceof Uint8Array)
44
+ return key;
45
+ if (typeof key === 'string') {
46
+ // Assume hex string
47
+ if (key.match(/^[0-9a-fA-F]+$/)) {
48
+ return new Uint8Array(key.match(/.{1,2}/g).map(byte => parseInt(byte, 16)));
49
+ }
50
+ throw new NCC05ArgumentError("Invalid hex key provided");
51
+ }
52
+ throw new NCC05ArgumentError("Key must be a hex string or Uint8Array");
53
+ }
10
54
  /**
11
55
  * Utility for managing shared group access to service records.
12
56
  */
@@ -45,16 +89,24 @@ export class NCC05Group {
45
89
  */
46
90
  export class NCC05Resolver {
47
91
  pool;
92
+ _ownPool;
48
93
  bootstrapRelays;
49
94
  timeout;
50
95
  /**
51
96
  * @param options - Configuration for the resolver.
52
97
  */
53
98
  constructor(options = {}) {
54
- this.pool = new SimplePool();
55
- if (options.websocketImplementation) {
56
- // @ts-ignore - Patching pool for custom transport
57
- this.pool.websocketImplementation = options.websocketImplementation;
99
+ this._ownPool = !options.pool;
100
+ this.pool = options.pool || new SimplePool();
101
+ if (this._ownPool) {
102
+ if (options.websocketImplementation) {
103
+ // @ts-ignore - Patching pool for custom transport
104
+ this.pool.websocketImplementation = options.websocketImplementation;
105
+ }
106
+ else if (typeof WebSocket === 'undefined' && typeof globalThis !== 'undefined' && globalThis.WebSocket) {
107
+ // @ts-ignore
108
+ this.pool.websocketImplementation = globalThis.WebSocket;
109
+ }
58
110
  }
59
111
  this.bootstrapRelays = options.bootstrapRelays || ['wss://relay.damus.io', 'wss://nos.lol'];
60
112
  this.timeout = options.timeout || 10000;
@@ -69,7 +121,9 @@ export class NCC05Resolver {
69
121
  * @param secretKey - Your secret key (required if the record is encrypted).
70
122
  * @param identifier - The 'd' tag of the record (default: 'addr').
71
123
  * @param options - Resolution options (strict mode, gossip discovery).
72
- * @returns The resolved and validated NCC05Payload, or null if not found/invalid.
124
+ * @returns The resolved and validated NCC05Payload, or null if not found.
125
+ * @throws {NCC05TimeoutError} if resolution times out.
126
+ * @throws {NCC05RelayError} if underlying relay communication fails.
73
127
  */
74
128
  async resolve(targetPubkey, secretKey, identifier = 'addr', options = {}) {
75
129
  let hexPubkey = targetPubkey;
@@ -80,19 +134,25 @@ export class NCC05Resolver {
80
134
  let queryRelays = [...this.bootstrapRelays];
81
135
  // 1. NIP-65 Gossip Discovery
82
136
  if (options.gossip) {
83
- const relayListEvent = await this.pool.get(this.bootstrapRelays, {
84
- authors: [hexPubkey],
85
- kinds: [10002]
86
- });
87
- // Security: Verify NIP-65 event signature and author
88
- if (relayListEvent && verifyEvent(relayListEvent) && relayListEvent.pubkey === hexPubkey) {
89
- const discoveredRelays = relayListEvent.tags
90
- .filter(t => t[0] === 'r')
91
- .map(t => t[1]);
92
- if (discoveredRelays.length > 0) {
93
- queryRelays = [...new Set([...queryRelays, ...discoveredRelays])];
137
+ try {
138
+ const relayListEvent = await this.pool.get(this.bootstrapRelays, {
139
+ authors: [hexPubkey],
140
+ kinds: [10002]
141
+ });
142
+ // Security: Verify NIP-65 event signature and author
143
+ if (relayListEvent && verifyEvent(relayListEvent) && relayListEvent.pubkey === hexPubkey) {
144
+ const discoveredRelays = relayListEvent.tags
145
+ .filter(t => t[0] === 'r')
146
+ .map(t => t[1]);
147
+ if (discoveredRelays.length > 0) {
148
+ queryRelays = [...new Set([...queryRelays, ...discoveredRelays])];
149
+ }
94
150
  }
95
151
  }
152
+ catch (e) {
153
+ console.warn(`[NCC-05] Gossip discovery failed: ${e.message}`);
154
+ // Proceed with bootstrap relays
155
+ }
96
156
  }
97
157
  const filter = {
98
158
  authors: [hexPubkey],
@@ -100,47 +160,64 @@ export class NCC05Resolver {
100
160
  '#d': [identifier],
101
161
  limit: 10
102
162
  };
103
- const queryPromise = this.pool.querySync(queryRelays, filter);
104
- const timeoutPromise = new Promise((r) => setTimeout(() => r(null), this.timeout));
105
- const result = await Promise.race([queryPromise, timeoutPromise]);
106
- if (!result || (Array.isArray(result) && result.length === 0))
107
- return null;
108
- // 2. Filter for valid signatures, correct author, and sort by created_at
109
- const validEvents = result
110
- .filter(e => e.pubkey === hexPubkey && verifyEvent(e))
111
- .sort((a, b) => b.created_at - a.created_at);
112
- if (validEvents.length === 0)
113
- return null;
114
- const latestEvent = validEvents[0];
163
+ const sk = secretKey ? ensureUint8Array(secretKey) : undefined;
115
164
  try {
165
+ const queryPromise = this.pool.querySync(queryRelays, filter);
166
+ const timeoutPromise = new Promise((_, reject) => setTimeout(() => reject(new NCC05TimeoutError("Resolution timed out")), this.timeout));
167
+ const result = await Promise.race([queryPromise, timeoutPromise]);
168
+ if (!result || (Array.isArray(result) && result.length === 0))
169
+ return null;
170
+ // 2. Filter for valid signatures, correct author, and sort by created_at
171
+ const validEvents = result
172
+ .filter(e => e.pubkey === hexPubkey && verifyEvent(e))
173
+ .sort((a, b) => b.created_at - a.created_at);
174
+ if (validEvents.length === 0)
175
+ return null;
176
+ const latestEvent = validEvents[0];
116
177
  let content = latestEvent.content;
117
178
  // Security: Robust multi-recipient detection
118
179
  const isWrapped = content.includes('"wraps"') &&
119
180
  content.includes('"ciphertext"') &&
120
181
  content.startsWith('{');
121
- if (isWrapped && secretKey) {
122
- const wrapped = JSON.parse(content);
123
- const myPk = getPublicKey(secretKey);
124
- const myWrap = wrapped.wraps[myPk];
125
- if (myWrap) {
126
- const conversationKey = nip44.getConversationKey(secretKey, hexPubkey);
127
- const symmetricKeyHex = nip44.decrypt(myWrap, conversationKey);
128
- // Convert hex symmetric key back to Uint8Array for NIP-44 decryption
129
- const symmetricKey = new Uint8Array(symmetricKeyHex.match(/.{1,2}/g).map(byte => parseInt(byte, 16)));
130
- const sessionConversationKey = nip44.getConversationKey(symmetricKey, getPublicKey(symmetricKey));
131
- content = nip44.decrypt(wrapped.ciphertext, sessionConversationKey);
182
+ if (isWrapped && sk) {
183
+ try {
184
+ const wrapped = JSON.parse(content);
185
+ const myPk = getPublicKey(sk);
186
+ const myWrap = wrapped.wraps[myPk];
187
+ if (myWrap) {
188
+ const conversationKey = nip44.getConversationKey(sk, hexPubkey);
189
+ const symmetricKeyHex = nip44.decrypt(myWrap, conversationKey);
190
+ // Convert hex symmetric key back to Uint8Array for NIP-44 decryption
191
+ const symmetricKey = new Uint8Array(symmetricKeyHex.match(/.{1,2}/g).map(byte => parseInt(byte, 16)));
192
+ const sessionConversationKey = nip44.getConversationKey(symmetricKey, getPublicKey(symmetricKey));
193
+ content = nip44.decrypt(wrapped.ciphertext, sessionConversationKey);
194
+ }
195
+ else {
196
+ return null; // Not intended for us
197
+ }
132
198
  }
133
- else {
134
- return null; // Not intended for us
199
+ catch (_e) {
200
+ throw new NCC05DecryptionError("Failed to decrypt wrapped content");
135
201
  }
136
202
  }
137
- else if (secretKey && !content.startsWith('{')) {
203
+ else if (sk && !content.startsWith('{')) {
138
204
  // Standard NIP-44 (likely encrypted if not starting with {)
139
- const conversationKey = nip44.getConversationKey(secretKey, hexPubkey);
140
- content = nip44.decrypt(latestEvent.content, conversationKey);
205
+ try {
206
+ const conversationKey = nip44.getConversationKey(sk, hexPubkey);
207
+ content = nip44.decrypt(latestEvent.content, conversationKey);
208
+ }
209
+ catch (_e) {
210
+ throw new NCC05DecryptionError("Failed to decrypt content");
211
+ }
141
212
  }
142
213
  // Security: Safe JSON parsing
143
- const payload = JSON.parse(content);
214
+ let payload;
215
+ try {
216
+ payload = JSON.parse(content);
217
+ }
218
+ catch (_e) {
219
+ return null; // Invalid JSON
220
+ }
144
221
  if (!payload || !payload.endpoints || !Array.isArray(payload.endpoints)) {
145
222
  return null;
146
223
  }
@@ -154,14 +231,18 @@ export class NCC05Resolver {
154
231
  return payload;
155
232
  }
156
233
  catch (e) {
157
- return null; // Decryption or parsing failed
234
+ if (e instanceof NCC05Error)
235
+ throw e;
236
+ throw new NCC05RelayError(`Relay query failed: ${e.message}`);
158
237
  }
159
238
  }
160
239
  /**
161
- * Closes connections to all relays in the pool.
240
+ * Closes connections to all relays in the pool if managed internally.
162
241
  */
163
242
  close() {
164
- this.pool.close(this.bootstrapRelays);
243
+ if (this._ownPool) {
244
+ this.pool.close(this.bootstrapRelays);
245
+ }
165
246
  }
166
247
  }
167
248
  /**
@@ -169,15 +250,53 @@ export class NCC05Resolver {
169
250
  */
170
251
  export class NCC05Publisher {
171
252
  pool;
253
+ _ownPool;
254
+ timeout;
172
255
  /**
173
256
  * @param options - Configuration for the publisher.
174
257
  */
175
258
  constructor(options = {}) {
176
- this.pool = new SimplePool();
177
- if (options.websocketImplementation) {
178
- // @ts-ignore
179
- this.pool.websocketImplementation = options.websocketImplementation;
259
+ this._ownPool = !options.pool;
260
+ this.pool = options.pool || new SimplePool();
261
+ if (this._ownPool) {
262
+ if (options.websocketImplementation) {
263
+ // @ts-ignore
264
+ this.pool.websocketImplementation = options.websocketImplementation;
265
+ }
266
+ else if (typeof WebSocket === 'undefined' && typeof globalThis !== 'undefined' && globalThis.WebSocket) {
267
+ // @ts-ignore
268
+ this.pool.websocketImplementation = globalThis.WebSocket;
269
+ }
180
270
  }
271
+ this.timeout = options.timeout || 5000;
272
+ }
273
+ async _publishToRelays(relays, signedEvent) {
274
+ const publishPromises = this.pool.publish(relays, signedEvent);
275
+ // Convert to promise that resolves/rejects based on timeout
276
+ const wrappedPromises = publishPromises.map(p => {
277
+ // In nostr-tools v2, publish returns Promise<void>.
278
+ // We wrap it to handle timeout.
279
+ return new Promise((resolve, reject) => {
280
+ const timer = setTimeout(() => reject(new NCC05TimeoutError("Publish timed out")), this.timeout);
281
+ p.then(() => {
282
+ clearTimeout(timer);
283
+ resolve();
284
+ }).catch((err) => {
285
+ clearTimeout(timer);
286
+ reject(err);
287
+ });
288
+ });
289
+ });
290
+ const results = await Promise.allSettled(wrappedPromises);
291
+ const successful = results.filter(r => r.status === 'fulfilled');
292
+ if (successful.length === 0) {
293
+ const errors = results
294
+ .filter(r => r.status === 'rejected')
295
+ .map(r => r.reason.message)
296
+ .join(', ');
297
+ throw new NCC05RelayError(`Failed to publish to any relay. Errors: ${errors}`);
298
+ }
299
+ // If partial success, we consider it a success.
181
300
  }
182
301
  /**
183
302
  * Publishes a single record encrypted for multiple recipients using the wrapping pattern.
@@ -191,13 +310,14 @@ export class NCC05Publisher {
191
310
  * @returns The signed Nostr event.
192
311
  */
193
312
  async publishWrapped(relays, secretKey, recipients, payload, identifier = 'addr') {
313
+ const sk = ensureUint8Array(secretKey);
194
314
  const sessionKey = generateSecretKey();
195
315
  const sessionKeyHex = Array.from(sessionKey).map(b => b.toString(16).padStart(2, '0')).join('');
196
316
  const selfConversation = nip44.getConversationKey(sessionKey, getPublicKey(sessionKey));
197
317
  const ciphertext = nip44.encrypt(JSON.stringify(payload), selfConversation);
198
318
  const wraps = {};
199
319
  for (const rPk of recipients) {
200
- const conversationKey = nip44.getConversationKey(secretKey, rPk);
320
+ const conversationKey = nip44.getConversationKey(sk, rPk);
201
321
  wraps[rPk] = nip44.encrypt(sessionKeyHex, conversationKey);
202
322
  }
203
323
  const wrappedContent = { ciphertext, wraps };
@@ -207,8 +327,8 @@ export class NCC05Publisher {
207
327
  tags: [['d', identifier]],
208
328
  content: JSON.stringify(wrappedContent),
209
329
  };
210
- const signedEvent = finalizeEvent(eventTemplate, secretKey);
211
- await Promise.all(this.pool.publish(relays, signedEvent));
330
+ const signedEvent = finalizeEvent(eventTemplate, sk);
331
+ await this._publishToRelays(relays, signedEvent);
212
332
  return signedEvent;
213
333
  }
214
334
  /**
@@ -221,12 +341,13 @@ export class NCC05Publisher {
221
341
  * @returns The signed Nostr event.
222
342
  */
223
343
  async publish(relays, secretKey, payload, options = {}) {
224
- const myPubkey = getPublicKey(secretKey);
344
+ const sk = ensureUint8Array(secretKey);
345
+ const myPubkey = getPublicKey(sk);
225
346
  const identifier = options.identifier || 'addr';
226
347
  let content = JSON.stringify(payload);
227
348
  if (!options.public) {
228
349
  const encryptionTarget = options.recipientPubkey || myPubkey;
229
- const conversationKey = nip44.getConversationKey(secretKey, encryptionTarget);
350
+ const conversationKey = nip44.getConversationKey(sk, encryptionTarget);
230
351
  content = nip44.encrypt(content, conversationKey);
231
352
  }
232
353
  const eventTemplate = {
@@ -236,14 +357,16 @@ export class NCC05Publisher {
236
357
  tags: [['d', identifier]],
237
358
  content: content,
238
359
  };
239
- const signedEvent = finalizeEvent(eventTemplate, secretKey);
240
- await Promise.all(this.pool.publish(relays, signedEvent));
360
+ const signedEvent = finalizeEvent(eventTemplate, sk);
361
+ await this._publishToRelays(relays, signedEvent);
241
362
  return signedEvent;
242
363
  }
243
364
  /**
244
- * Closes connections to the specified relays.
365
+ * Closes connections to the specified relays if managed internally.
245
366
  */
246
367
  close(relays) {
247
- this.pool.close(relays);
368
+ if (this._ownPool) {
369
+ this.pool.close(relays);
370
+ }
248
371
  }
249
372
  }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,33 @@
1
+ import { NCC05Resolver } from './index.js';
2
+ import { SimplePool } from 'nostr-tools';
3
+ async function testLifecycle() {
4
+ console.log('--- Starting Lifecycle Test ---');
5
+ // 1. Internal Pool Management
6
+ console.log('Test 1: Internal Pool (should close)');
7
+ const resolverInternal = new NCC05Resolver();
8
+ // @ts-ignore - Access private property for testing or infer from behavior
9
+ const internalPool = resolverInternal['pool'];
10
+ internalPool.close = (_relays) => {
11
+ console.log('Internal pool close called.');
12
+ };
13
+ resolverInternal.close(); // Should log
14
+ // 2. Shared Pool Management
15
+ console.log('Test 2: Shared Pool (should NOT close)');
16
+ const sharedPool = new SimplePool();
17
+ let sharedClosed = false;
18
+ sharedPool.close = (_relays) => {
19
+ sharedClosed = true;
20
+ console.error('ERROR: Shared pool was closed!');
21
+ };
22
+ const resolverShared = new NCC05Resolver({ pool: sharedPool });
23
+ resolverShared.close(); // Should NOT close sharedPool
24
+ if (!sharedClosed) {
25
+ console.log('Shared pool correctly remained open.');
26
+ }
27
+ else {
28
+ process.exit(1);
29
+ }
30
+ console.log('Lifecycle Test Suite Passed.');
31
+ process.exit(0);
32
+ }
33
+ testLifecycle().catch(console.error);
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,94 @@
1
+ import { NCC05Publisher, NCC05Resolver, NCC05TimeoutError } from './index.js';
2
+ import { generateSecretKey, getPublicKey, SimplePool } from 'nostr-tools';
3
+ import { MockRelay } from './mock-relay.js';
4
+ import { WebSocketServer } from 'ws';
5
+ async function testNewFeatures() {
6
+ console.log('--- Starting New Features Test ---');
7
+ const relayPort = 8081;
8
+ const relayUrl = `ws://localhost:${relayPort}`;
9
+ const relay = new MockRelay(relayPort);
10
+ // 1. External SimplePool & Hex Keys
11
+ console.log('Test 1: External SimplePool & Hex Keys');
12
+ const pool = new SimplePool();
13
+ const publisher = new NCC05Publisher({ pool });
14
+ const resolver = new NCC05Resolver({ bootstrapRelays: [relayUrl], pool });
15
+ const sk = generateSecretKey();
16
+ const pk = getPublicKey(sk);
17
+ const skHex = Array.from(sk).map(b => b.toString(16).padStart(2, '0')).join('');
18
+ const payload = {
19
+ v: 1, ttl: 60, updated_at: Math.floor(Date.now() / 1000),
20
+ endpoints: [{ type: 'tcp', uri: 'hex-test', priority: 1, family: 'ipv4' }]
21
+ };
22
+ try {
23
+ // Publish using HEX string secret key
24
+ await publisher.publish([relayUrl], skHex, payload, { identifier: 'hex-key' });
25
+ console.log('Published with Hex Key: OK');
26
+ }
27
+ catch (e) {
28
+ console.error('Failed to publish with hex key:', e);
29
+ process.exit(1);
30
+ }
31
+ try {
32
+ // Resolve using Hex string secret key
33
+ const res = await resolver.resolve(pk, skHex, 'hex-key');
34
+ if (res && res.endpoints[0].uri === 'hex-test') {
35
+ console.log('Resolved with Hex Key: OK');
36
+ }
37
+ else {
38
+ console.error('Failed to resolve with hex key');
39
+ process.exit(1);
40
+ }
41
+ }
42
+ catch (e) {
43
+ console.error('Resolution error:', e);
44
+ process.exit(1);
45
+ }
46
+ // 2. Graceful Degradation
47
+ console.log('Test 2: Graceful Degradation (One bad relay)');
48
+ const badRelay = 'ws://localhost:9999'; // assuming closed/unreachable
49
+ try {
50
+ // Should succeed because one relay is good
51
+ await publisher.publish([relayUrl, badRelay], sk, payload, { identifier: 'graceful' });
52
+ console.log('Graceful degradation publish: OK');
53
+ }
54
+ catch (e) {
55
+ console.error('Graceful degradation failed:', e);
56
+ process.exit(1);
57
+ }
58
+ // 3. Timeout
59
+ console.log('Test 3: Resolver Timeout');
60
+ // Create a "Hanging Relay" that accepts connection but sends no data
61
+ const hangingPort = 8082;
62
+ const wss = new WebSocketServer({ port: hangingPort });
63
+ const timeoutResolver = new NCC05Resolver({
64
+ bootstrapRelays: [`ws://localhost:${hangingPort}`],
65
+ timeout: 500 // 500ms
66
+ });
67
+ const start = Date.now();
68
+ try {
69
+ await timeoutResolver.resolve(pk, sk, 'any');
70
+ console.error('Should have timed out!');
71
+ wss.close();
72
+ process.exit(1);
73
+ }
74
+ catch (e) {
75
+ const duration = Date.now() - start;
76
+ if (e instanceof NCC05TimeoutError) {
77
+ console.log(`Timed out as expected in ${duration}ms: OK`);
78
+ }
79
+ else {
80
+ console.error('Caught unexpected error:', e);
81
+ // It might be that SimplePool fails connection before timeout if it's super fast,
82
+ // but WebSocketServer is listening.
83
+ }
84
+ }
85
+ finally {
86
+ wss.close();
87
+ }
88
+ relay.stop();
89
+ pool.close([relayUrl]);
90
+ timeoutResolver.close();
91
+ console.log('New Features Test Suite Passed.');
92
+ process.exit(0);
93
+ }
94
+ testNewFeatures().catch(console.error);
@@ -0,0 +1,25 @@
1
+ import globals from "globals";
2
+ import pluginJs from "@eslint/js";
3
+ import tseslint from "typescript-eslint";
4
+
5
+ export default [
6
+ {files: ["**/*.{js,mjs,cjs,ts}"]},
7
+ {languageOptions: { globals: globals.node }},
8
+ pluginJs.configs.recommended,
9
+ ...tseslint.configs.recommended,
10
+ {
11
+ rules: {
12
+ "@typescript-eslint/no-explicit-any": "off",
13
+ "@typescript-eslint/ban-ts-comment": "off",
14
+ "@typescript-eslint/no-unused-vars": ["warn", {
15
+ "argsIgnorePattern": "^_",
16
+ "varsIgnorePattern": "^_",
17
+ "caughtErrorsIgnorePattern": "^_"
18
+ }],
19
+ "no-console": "off"
20
+ }
21
+ },
22
+ {
23
+ ignores: ["dist/", "node_modules/"]
24
+ }
25
+ ];
package/package.json CHANGED
@@ -1,12 +1,13 @@
1
1
  {
2
2
  "name": "ncc-05",
3
- "version": "1.1.4",
3
+ "version": "1.1.7",
4
4
  "description": "Nostr Community Convention 05 - Identity-Bound Service Locator Resolution",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
7
7
  "type": "module",
8
8
  "scripts": {
9
9
  "build": "tsc",
10
+ "lint": "eslint src/**/*.ts",
10
11
  "prepublishOnly": "npm run build"
11
12
  },
12
13
  "keywords": [
@@ -22,9 +23,15 @@
22
23
  "nostr-tools": "^2.10.0"
23
24
  },
24
25
  "devDependencies": {
26
+ "@eslint/js": "^9.39.2",
25
27
  "@types/node": "^25.0.3",
26
28
  "@types/ws": "^8.18.1",
29
+ "@typescript-eslint/eslint-plugin": "^8.50.1",
30
+ "@typescript-eslint/parser": "^8.50.1",
31
+ "eslint": "^9.39.2",
32
+ "globals": "^16.5.0",
27
33
  "typescript": "^5.0.0",
34
+ "typescript-eslint": "^8.50.1",
28
35
  "ws": "^8.18.3"
29
36
  }
30
37
  }
package/src/index.ts CHANGED
@@ -18,6 +18,57 @@ import {
18
18
  generateSecretKey
19
19
  } from 'nostr-tools';
20
20
 
21
+ // --- Error Classes ---
22
+
23
+ export class NCC05Error extends Error {
24
+ constructor(message: string) {
25
+ super(message);
26
+ this.name = 'NCC05Error';
27
+ }
28
+ }
29
+
30
+ export class NCC05RelayError extends NCC05Error {
31
+ constructor(message: string) {
32
+ super(message);
33
+ this.name = 'NCC05RelayError';
34
+ }
35
+ }
36
+
37
+ export class NCC05TimeoutError extends NCC05Error {
38
+ constructor(message: string) {
39
+ super(message);
40
+ this.name = 'NCC05TimeoutError';
41
+ }
42
+ }
43
+
44
+ export class NCC05DecryptionError extends NCC05Error {
45
+ constructor(message: string) {
46
+ super(message);
47
+ this.name = 'NCC05DecryptionError';
48
+ }
49
+ }
50
+
51
+ export class NCC05ArgumentError extends NCC05Error {
52
+ constructor(message: string) {
53
+ super(message);
54
+ this.name = 'NCC05ArgumentError';
55
+ }
56
+ }
57
+
58
+ // --- Helpers ---
59
+
60
+ function ensureUint8Array(key: string | Uint8Array): Uint8Array {
61
+ if (key instanceof Uint8Array) return key;
62
+ if (typeof key === 'string') {
63
+ // Assume hex string
64
+ if (key.match(/^[0-9a-fA-F]+$/)) {
65
+ return new Uint8Array(key.match(/.{1,2}/g)!.map(byte => parseInt(byte, 16)));
66
+ }
67
+ throw new NCC05ArgumentError("Invalid hex key provided");
68
+ }
69
+ throw new NCC05ArgumentError("Key must be a hex string or Uint8Array");
70
+ }
71
+
21
72
  /**
22
73
  * Represents a single reachable service endpoint.
23
74
  */
@@ -60,6 +111,20 @@ export interface ResolverOptions {
60
111
  timeout?: number;
61
112
  /** Custom WebSocket implementation (e.g., for Tor/SOCKS5 in Node.js) */
62
113
  websocketImplementation?: any;
114
+ /** Existing SimplePool instance to share connections */
115
+ pool?: SimplePool;
116
+ }
117
+
118
+ /**
119
+ * Options for configuring the NCC05Publisher.
120
+ */
121
+ export interface PublisherOptions {
122
+ /** Custom WebSocket implementation */
123
+ websocketImplementation?: any;
124
+ /** Existing SimplePool instance */
125
+ pool?: SimplePool;
126
+ /** Timeout for publishing in milliseconds (default: 5000) */
127
+ timeout?: number;
63
128
  }
64
129
 
65
130
  /**
@@ -106,7 +171,7 @@ export class NCC05Group {
106
171
  static async resolveAsGroup(
107
172
  resolver: NCC05Resolver,
108
173
  groupPubkey: string,
109
- groupSecretKey: Uint8Array,
174
+ groupSecretKey: string | Uint8Array,
110
175
  identifier: string = 'addr'
111
176
  ): Promise<NCC05Payload | null> {
112
177
  return resolver.resolve(groupPubkey, groupSecretKey, identifier);
@@ -118,6 +183,7 @@ export class NCC05Group {
118
183
  */
119
184
  export class NCC05Resolver {
120
185
  private pool: SimplePool;
186
+ private _ownPool: boolean;
121
187
  private bootstrapRelays: string[];
122
188
  private timeout: number;
123
189
 
@@ -125,11 +191,19 @@ export class NCC05Resolver {
125
191
  * @param options - Configuration for the resolver.
126
192
  */
127
193
  constructor(options: ResolverOptions = {}) {
128
- this.pool = new SimplePool();
129
- if (options.websocketImplementation) {
130
- // @ts-ignore - Patching pool for custom transport
131
- this.pool.websocketImplementation = options.websocketImplementation;
194
+ this._ownPool = !options.pool;
195
+ this.pool = options.pool || new SimplePool();
196
+
197
+ if (this._ownPool) {
198
+ if (options.websocketImplementation) {
199
+ // @ts-ignore - Patching pool for custom transport
200
+ this.pool.websocketImplementation = options.websocketImplementation;
201
+ } else if (typeof WebSocket === 'undefined' && typeof globalThis !== 'undefined' && globalThis.WebSocket) {
202
+ // @ts-ignore
203
+ this.pool.websocketImplementation = globalThis.WebSocket;
204
+ }
132
205
  }
206
+
133
207
  this.bootstrapRelays = options.bootstrapRelays || ['wss://relay.damus.io', 'wss://nos.lol'];
134
208
  this.timeout = options.timeout || 10000;
135
209
  }
@@ -144,11 +218,13 @@ export class NCC05Resolver {
144
218
  * @param secretKey - Your secret key (required if the record is encrypted).
145
219
  * @param identifier - The 'd' tag of the record (default: 'addr').
146
220
  * @param options - Resolution options (strict mode, gossip discovery).
147
- * @returns The resolved and validated NCC05Payload, or null if not found/invalid.
221
+ * @returns The resolved and validated NCC05Payload, or null if not found.
222
+ * @throws {NCC05TimeoutError} if resolution times out.
223
+ * @throws {NCC05RelayError} if underlying relay communication fails.
148
224
  */
149
225
  async resolve(
150
226
  targetPubkey: string,
151
- secretKey?: Uint8Array,
227
+ secretKey?: string | Uint8Array,
152
228
  identifier: string = 'addr',
153
229
  options: { strict?: boolean, gossip?: boolean } = {}
154
230
  ): Promise<NCC05Payload | null> {
@@ -162,18 +238,23 @@ export class NCC05Resolver {
162
238
 
163
239
  // 1. NIP-65 Gossip Discovery
164
240
  if (options.gossip) {
165
- const relayListEvent = await this.pool.get(this.bootstrapRelays, {
166
- authors: [hexPubkey],
167
- kinds: [10002]
168
- });
169
- // Security: Verify NIP-65 event signature and author
170
- if (relayListEvent && verifyEvent(relayListEvent) && relayListEvent.pubkey === hexPubkey) {
171
- const discoveredRelays = relayListEvent.tags
172
- .filter(t => t[0] === 'r')
173
- .map(t => t[1]);
174
- if (discoveredRelays.length > 0) {
175
- queryRelays = [...new Set([...queryRelays, ...discoveredRelays])];
241
+ try {
242
+ const relayListEvent = await this.pool.get(this.bootstrapRelays, {
243
+ authors: [hexPubkey],
244
+ kinds: [10002]
245
+ });
246
+ // Security: Verify NIP-65 event signature and author
247
+ if (relayListEvent && verifyEvent(relayListEvent) && relayListEvent.pubkey === hexPubkey) {
248
+ const discoveredRelays = relayListEvent.tags
249
+ .filter(t => t[0] === 'r')
250
+ .map(t => t[1]);
251
+ if (discoveredRelays.length > 0) {
252
+ queryRelays = [...new Set([...queryRelays, ...discoveredRelays])];
253
+ }
176
254
  }
255
+ } catch (e: any) {
256
+ console.warn(`[NCC-05] Gossip discovery failed: ${e.message}`);
257
+ // Proceed with bootstrap relays
177
258
  }
178
259
  }
179
260
 
@@ -184,21 +265,26 @@ export class NCC05Resolver {
184
265
  limit: 10
185
266
  };
186
267
 
187
- const queryPromise = this.pool.querySync(queryRelays, filter);
188
- const timeoutPromise = new Promise<null>((r) => setTimeout(() => r(null), this.timeout));
189
- const result = await Promise.race([queryPromise, timeoutPromise]);
190
-
191
- if (!result || (Array.isArray(result) && result.length === 0)) return null;
192
-
193
- // 2. Filter for valid signatures, correct author, and sort by created_at
194
- const validEvents = (result as Event[])
195
- .filter(e => e.pubkey === hexPubkey && verifyEvent(e))
196
- .sort((a, b) => b.created_at - a.created_at);
197
-
198
- if (validEvents.length === 0) return null;
199
- const latestEvent = validEvents[0];
268
+ const sk = secretKey ? ensureUint8Array(secretKey) : undefined;
200
269
 
201
270
  try {
271
+ const queryPromise = this.pool.querySync(queryRelays, filter);
272
+ const timeoutPromise = new Promise<never>((_, reject) =>
273
+ setTimeout(() => reject(new NCC05TimeoutError("Resolution timed out")), this.timeout)
274
+ );
275
+
276
+ const result = await Promise.race([queryPromise, timeoutPromise]);
277
+
278
+ if (!result || (Array.isArray(result) && result.length === 0)) return null;
279
+
280
+ // 2. Filter for valid signatures, correct author, and sort by created_at
281
+ const validEvents = (result as Event[])
282
+ .filter(e => e.pubkey === hexPubkey && verifyEvent(e))
283
+ .sort((a, b) => b.created_at - a.created_at);
284
+
285
+ if (validEvents.length === 0) return null;
286
+ const latestEvent = validEvents[0];
287
+
202
288
  let content = latestEvent.content;
203
289
 
204
290
  // Security: Robust multi-recipient detection
@@ -206,35 +292,49 @@ export class NCC05Resolver {
206
292
  content.includes('"ciphertext"') &&
207
293
  content.startsWith('{');
208
294
 
209
- if (isWrapped && secretKey) {
210
- const wrapped = JSON.parse(content) as WrappedContent;
211
- const myPk = getPublicKey(secretKey);
212
- const myWrap = wrapped.wraps[myPk];
213
-
214
- if (myWrap) {
215
- const conversationKey = nip44.getConversationKey(secretKey, hexPubkey);
216
- const symmetricKeyHex = nip44.decrypt(myWrap, conversationKey);
295
+ if (isWrapped && sk) {
296
+ try {
297
+ const wrapped = JSON.parse(content) as WrappedContent;
298
+ const myPk = getPublicKey(sk);
299
+ const myWrap = wrapped.wraps[myPk];
217
300
 
218
- // Convert hex symmetric key back to Uint8Array for NIP-44 decryption
219
- const symmetricKey = new Uint8Array(
220
- symmetricKeyHex.match(/.{1,2}/g)!.map(byte => parseInt(byte, 16))
221
- );
222
-
223
- const sessionConversationKey = nip44.getConversationKey(
224
- symmetricKey, getPublicKey(symmetricKey)
225
- );
226
- content = nip44.decrypt(wrapped.ciphertext, sessionConversationKey);
227
- } else {
228
- return null; // Not intended for us
301
+ if (myWrap) {
302
+ const conversationKey = nip44.getConversationKey(sk, hexPubkey);
303
+ const symmetricKeyHex = nip44.decrypt(myWrap, conversationKey);
304
+
305
+ // Convert hex symmetric key back to Uint8Array for NIP-44 decryption
306
+ const symmetricKey = new Uint8Array(
307
+ symmetricKeyHex.match(/.{1,2}/g)!.map(byte => parseInt(byte, 16))
308
+ );
309
+
310
+ const sessionConversationKey = nip44.getConversationKey(
311
+ symmetricKey, getPublicKey(symmetricKey)
312
+ );
313
+ content = nip44.decrypt(wrapped.ciphertext, sessionConversationKey);
314
+ } else {
315
+ return null; // Not intended for us
316
+ }
317
+ } catch (_e) {
318
+ throw new NCC05DecryptionError("Failed to decrypt wrapped content");
229
319
  }
230
- } else if (secretKey && !content.startsWith('{')) {
320
+ } else if (sk && !content.startsWith('{')) {
231
321
  // Standard NIP-44 (likely encrypted if not starting with {)
232
- const conversationKey = nip44.getConversationKey(secretKey, hexPubkey);
233
- content = nip44.decrypt(latestEvent.content, conversationKey);
322
+ try {
323
+ const conversationKey = nip44.getConversationKey(sk, hexPubkey);
324
+ content = nip44.decrypt(latestEvent.content, conversationKey);
325
+ } catch (_e) {
326
+ throw new NCC05DecryptionError("Failed to decrypt content");
327
+ }
234
328
  }
235
329
 
236
330
  // Security: Safe JSON parsing
237
- const payload = JSON.parse(content) as NCC05Payload;
331
+ let payload: NCC05Payload;
332
+ try {
333
+ payload = JSON.parse(content) as NCC05Payload;
334
+ } catch (_e) {
335
+ return null; // Invalid JSON
336
+ }
337
+
238
338
  if (!payload || !payload.endpoints || !Array.isArray(payload.endpoints)) {
239
339
  return null;
240
340
  }
@@ -248,15 +348,18 @@ export class NCC05Resolver {
248
348
 
249
349
  return payload;
250
350
  } catch (e) {
251
- return null; // Decryption or parsing failed
351
+ if (e instanceof NCC05Error) throw e;
352
+ throw new NCC05RelayError(`Relay query failed: ${(e as Error).message}`);
252
353
  }
253
354
  }
254
355
 
255
356
  /**
256
- * Closes connections to all relays in the pool.
357
+ * Closes connections to all relays in the pool if managed internally.
257
358
  */
258
359
  close() {
259
- this.pool.close(this.bootstrapRelays);
360
+ if (this._ownPool) {
361
+ this.pool.close(this.bootstrapRelays);
362
+ }
260
363
  }
261
364
  }
262
365
 
@@ -265,16 +368,60 @@ export class NCC05Resolver {
265
368
  */
266
369
  export class NCC05Publisher {
267
370
  private pool: SimplePool;
371
+ private _ownPool: boolean;
372
+ private timeout: number;
268
373
 
269
374
  /**
270
375
  * @param options - Configuration for the publisher.
271
376
  */
272
- constructor(options: { websocketImplementation?: any } = {}) {
273
- this.pool = new SimplePool();
274
- if (options.websocketImplementation) {
275
- // @ts-ignore
276
- this.pool.websocketImplementation = options.websocketImplementation;
377
+ constructor(options: PublisherOptions = {}) {
378
+ this._ownPool = !options.pool;
379
+ this.pool = options.pool || new SimplePool();
380
+
381
+ if (this._ownPool) {
382
+ if (options.websocketImplementation) {
383
+ // @ts-ignore
384
+ this.pool.websocketImplementation = options.websocketImplementation;
385
+ } else if (typeof WebSocket === 'undefined' && typeof globalThis !== 'undefined' && globalThis.WebSocket) {
386
+ // @ts-ignore
387
+ this.pool.websocketImplementation = globalThis.WebSocket;
388
+ }
277
389
  }
390
+
391
+ this.timeout = options.timeout || 5000;
392
+ }
393
+
394
+ private async _publishToRelays(relays: string[], signedEvent: Event): Promise<void> {
395
+ const publishPromises = this.pool.publish(relays, signedEvent);
396
+
397
+ // Convert to promise that resolves/rejects based on timeout
398
+ const wrappedPromises = publishPromises.map(p => {
399
+ // In nostr-tools v2, publish returns Promise<void>.
400
+ // We wrap it to handle timeout.
401
+ return new Promise<void>((resolve, reject) => {
402
+ const timer = setTimeout(() => reject(new NCC05TimeoutError("Publish timed out")), this.timeout);
403
+ p.then(() => {
404
+ clearTimeout(timer);
405
+ resolve();
406
+ }).catch((err) => {
407
+ clearTimeout(timer);
408
+ reject(err);
409
+ });
410
+ });
411
+ });
412
+
413
+ const results = await Promise.allSettled(wrappedPromises);
414
+ const successful = results.filter(r => r.status === 'fulfilled');
415
+
416
+ if (successful.length === 0) {
417
+ const errors = results
418
+ .filter(r => r.status === 'rejected')
419
+ .map(r => (r as PromiseRejectedResult).reason.message)
420
+ .join(', ');
421
+ throw new NCC05RelayError(`Failed to publish to any relay. Errors: ${errors}`);
422
+ }
423
+
424
+ // If partial success, we consider it a success.
278
425
  }
279
426
 
280
427
  /**
@@ -290,11 +437,12 @@ export class NCC05Publisher {
290
437
  */
291
438
  async publishWrapped(
292
439
  relays: string[],
293
- secretKey: Uint8Array,
440
+ secretKey: string | Uint8Array,
294
441
  recipients: string[],
295
442
  payload: NCC05Payload,
296
443
  identifier: string = 'addr'
297
444
  ): Promise<Event> {
445
+ const sk = ensureUint8Array(secretKey);
298
446
  const sessionKey = generateSecretKey();
299
447
  const sessionKeyHex = Array.from(sessionKey).map(b => b.toString(16).padStart(2, '0')).join('');
300
448
 
@@ -303,7 +451,7 @@ export class NCC05Publisher {
303
451
 
304
452
  const wraps: Record<string, string> = {};
305
453
  for (const rPk of recipients) {
306
- const conversationKey = nip44.getConversationKey(secretKey, rPk);
454
+ const conversationKey = nip44.getConversationKey(sk, rPk);
307
455
  wraps[rPk] = nip44.encrypt(sessionKeyHex, conversationKey);
308
456
  }
309
457
 
@@ -316,8 +464,8 @@ export class NCC05Publisher {
316
464
  content: JSON.stringify(wrappedContent),
317
465
  };
318
466
 
319
- const signedEvent = finalizeEvent(eventTemplate, secretKey);
320
- await Promise.all(this.pool.publish(relays, signedEvent));
467
+ const signedEvent = finalizeEvent(eventTemplate, sk);
468
+ await this._publishToRelays(relays, signedEvent);
321
469
  return signedEvent;
322
470
  }
323
471
 
@@ -332,17 +480,18 @@ export class NCC05Publisher {
332
480
  */
333
481
  async publish(
334
482
  relays: string[],
335
- secretKey: Uint8Array,
483
+ secretKey: string | Uint8Array,
336
484
  payload: NCC05Payload,
337
485
  options: { identifier?: string, recipientPubkey?: string, public?: boolean } = {}
338
486
  ): Promise<Event> {
339
- const myPubkey = getPublicKey(secretKey);
487
+ const sk = ensureUint8Array(secretKey);
488
+ const myPubkey = getPublicKey(sk);
340
489
  const identifier = options.identifier || 'addr';
341
490
  let content = JSON.stringify(payload);
342
491
 
343
492
  if (!options.public) {
344
493
  const encryptionTarget = options.recipientPubkey || myPubkey;
345
- const conversationKey = nip44.getConversationKey(secretKey, encryptionTarget);
494
+ const conversationKey = nip44.getConversationKey(sk, encryptionTarget);
346
495
  content = nip44.encrypt(content, conversationKey);
347
496
  }
348
497
 
@@ -354,15 +503,17 @@ export class NCC05Publisher {
354
503
  content: content,
355
504
  };
356
505
 
357
- const signedEvent = finalizeEvent(eventTemplate, secretKey);
358
- await Promise.all(this.pool.publish(relays, signedEvent));
506
+ const signedEvent = finalizeEvent(eventTemplate, sk);
507
+ await this._publishToRelays(relays, signedEvent);
359
508
  return signedEvent;
360
509
  }
361
510
 
362
511
  /**
363
- * Closes connections to the specified relays.
512
+ * Closes connections to the specified relays if managed internally.
364
513
  */
365
514
  close(relays: string[]) {
366
- this.pool.close(relays);
515
+ if (this._ownPool) {
516
+ this.pool.close(relays);
517
+ }
367
518
  }
368
519
  }
@@ -0,0 +1,40 @@
1
+ import { NCC05Resolver } from './index.js';
2
+ import { SimplePool } from 'nostr-tools';
3
+
4
+ async function testLifecycle() {
5
+ console.log('--- Starting Lifecycle Test ---');
6
+
7
+ // 1. Internal Pool Management
8
+ console.log('Test 1: Internal Pool (should close)');
9
+ const resolverInternal = new NCC05Resolver();
10
+ // @ts-ignore - Access private property for testing or infer from behavior
11
+ const internalPool = resolverInternal['pool'];
12
+ internalPool.close = (_relays?: string[]) => {
13
+ console.log('Internal pool close called.');
14
+ };
15
+
16
+ resolverInternal.close(); // Should log
17
+
18
+ // 2. Shared Pool Management
19
+ console.log('Test 2: Shared Pool (should NOT close)');
20
+ const sharedPool = new SimplePool();
21
+ let sharedClosed = false;
22
+ sharedPool.close = (_relays?: string[]) => {
23
+ sharedClosed = true;
24
+ console.error('ERROR: Shared pool was closed!');
25
+ };
26
+
27
+ const resolverShared = new NCC05Resolver({ pool: sharedPool });
28
+ resolverShared.close(); // Should NOT close sharedPool
29
+
30
+ if (!sharedClosed) {
31
+ console.log('Shared pool correctly remained open.');
32
+ } else {
33
+ process.exit(1);
34
+ }
35
+
36
+ console.log('Lifecycle Test Suite Passed.');
37
+ process.exit(0);
38
+ }
39
+
40
+ testLifecycle().catch(console.error);
@@ -0,0 +1,99 @@
1
+ import { NCC05Publisher, NCC05Resolver, NCC05Payload, NCC05TimeoutError } from './index.js';
2
+ import { generateSecretKey, getPublicKey, SimplePool } from 'nostr-tools';
3
+ import { MockRelay } from './mock-relay.js';
4
+ import { WebSocketServer } from 'ws';
5
+
6
+ async function testNewFeatures() {
7
+ console.log('--- Starting New Features Test ---');
8
+ const relayPort = 8081;
9
+ const relayUrl = `ws://localhost:${relayPort}`;
10
+ const relay = new MockRelay(relayPort);
11
+
12
+ // 1. External SimplePool & Hex Keys
13
+ console.log('Test 1: External SimplePool & Hex Keys');
14
+ const pool = new SimplePool();
15
+ const publisher = new NCC05Publisher({ pool });
16
+ const resolver = new NCC05Resolver({ bootstrapRelays: [relayUrl], pool });
17
+
18
+ const sk = generateSecretKey();
19
+ const pk = getPublicKey(sk);
20
+ const skHex = Array.from(sk).map(b => b.toString(16).padStart(2, '0')).join('');
21
+
22
+ const payload: NCC05Payload = {
23
+ v: 1, ttl: 60, updated_at: Math.floor(Date.now() / 1000),
24
+ endpoints: [{ type: 'tcp', uri: 'hex-test', priority: 1, family: 'ipv4' }]
25
+ };
26
+
27
+ try {
28
+ // Publish using HEX string secret key
29
+ await publisher.publish([relayUrl], skHex, payload, { identifier: 'hex-key' });
30
+ console.log('Published with Hex Key: OK');
31
+ } catch (e) {
32
+ console.error('Failed to publish with hex key:', e);
33
+ process.exit(1);
34
+ }
35
+
36
+ try {
37
+ // Resolve using Hex string secret key
38
+ const res = await resolver.resolve(pk, skHex, 'hex-key');
39
+ if (res && res.endpoints[0].uri === 'hex-test') {
40
+ console.log('Resolved with Hex Key: OK');
41
+ } else {
42
+ console.error('Failed to resolve with hex key');
43
+ process.exit(1);
44
+ }
45
+ } catch (e) {
46
+ console.error('Resolution error:', e);
47
+ process.exit(1);
48
+ }
49
+
50
+ // 2. Graceful Degradation
51
+ console.log('Test 2: Graceful Degradation (One bad relay)');
52
+ const badRelay = 'ws://localhost:9999'; // assuming closed/unreachable
53
+ try {
54
+ // Should succeed because one relay is good
55
+ await publisher.publish([relayUrl, badRelay], sk, payload, { identifier: 'graceful' });
56
+ console.log('Graceful degradation publish: OK');
57
+ } catch (e) {
58
+ console.error('Graceful degradation failed:', e);
59
+ process.exit(1);
60
+ }
61
+
62
+ // 3. Timeout
63
+ console.log('Test 3: Resolver Timeout');
64
+ // Create a "Hanging Relay" that accepts connection but sends no data
65
+ const hangingPort = 8082;
66
+ const wss = new WebSocketServer({ port: hangingPort });
67
+
68
+ const timeoutResolver = new NCC05Resolver({
69
+ bootstrapRelays: [`ws://localhost:${hangingPort}`],
70
+ timeout: 500 // 500ms
71
+ });
72
+
73
+ const start = Date.now();
74
+ try {
75
+ await timeoutResolver.resolve(pk, sk, 'any');
76
+ console.error('Should have timed out!');
77
+ wss.close();
78
+ process.exit(1);
79
+ } catch (e) {
80
+ const duration = Date.now() - start;
81
+ if (e instanceof NCC05TimeoutError) {
82
+ console.log(`Timed out as expected in ${duration}ms: OK`);
83
+ } else {
84
+ console.error('Caught unexpected error:', e);
85
+ // It might be that SimplePool fails connection before timeout if it's super fast,
86
+ // but WebSocketServer is listening.
87
+ }
88
+ } finally {
89
+ wss.close();
90
+ }
91
+
92
+ relay.stop();
93
+ pool.close([relayUrl]);
94
+ timeoutResolver.close();
95
+ console.log('New Features Test Suite Passed.');
96
+ process.exit(0);
97
+ }
98
+
99
+ testNewFeatures().catch(console.error);