ncc-05 1.1.4 → 1.1.5
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 +39 -9
- package/dist/index.js +175 -55
- package/dist/test-new.d.ts +1 -0
- package/dist/test-new.js +94 -0
- package/package.json +1 -1
- package/src/index.ts +215 -67
- package/src/test-new.ts +99 -0
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,7 +111,7 @@ 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.
|
|
@@ -106,9 +134,11 @@ export declare class NCC05Resolver {
|
|
|
106
134
|
* @param secretKey - Your secret key (required if the record is encrypted).
|
|
107
135
|
* @param identifier - The 'd' tag of the record (default: 'addr').
|
|
108
136
|
* @param options - Resolution options (strict mode, gossip discovery).
|
|
109
|
-
* @returns The resolved and validated NCC05Payload, or null if not found
|
|
137
|
+
* @returns The resolved and validated NCC05Payload, or null if not found.
|
|
138
|
+
* @throws {NCC05TimeoutError} if resolution times out.
|
|
139
|
+
* @throws {NCC05RelayError} if underlying relay communication fails.
|
|
110
140
|
*/
|
|
111
|
-
resolve(targetPubkey: string, secretKey?: Uint8Array, identifier?: string, options?: {
|
|
141
|
+
resolve(targetPubkey: string, secretKey?: string | Uint8Array, identifier?: string, options?: {
|
|
112
142
|
strict?: boolean;
|
|
113
143
|
gossip?: boolean;
|
|
114
144
|
}): Promise<NCC05Payload | null>;
|
|
@@ -122,12 +152,12 @@ export declare class NCC05Resolver {
|
|
|
122
152
|
*/
|
|
123
153
|
export declare class NCC05Publisher {
|
|
124
154
|
private pool;
|
|
155
|
+
private timeout;
|
|
125
156
|
/**
|
|
126
157
|
* @param options - Configuration for the publisher.
|
|
127
158
|
*/
|
|
128
|
-
constructor(options?:
|
|
129
|
-
|
|
130
|
-
});
|
|
159
|
+
constructor(options?: PublisherOptions);
|
|
160
|
+
private _publishToRelays;
|
|
131
161
|
/**
|
|
132
162
|
* Publishes a single record encrypted for multiple recipients using the wrapping pattern.
|
|
133
163
|
* This avoids sharing a single group private key.
|
|
@@ -139,7 +169,7 @@ export declare class NCC05Publisher {
|
|
|
139
169
|
* @param identifier - The 'd' tag identifier (default: 'addr').
|
|
140
170
|
* @returns The signed Nostr event.
|
|
141
171
|
*/
|
|
142
|
-
publishWrapped(relays: string[], secretKey: Uint8Array, recipients: string[], payload: NCC05Payload, identifier?: string): Promise<Event>;
|
|
172
|
+
publishWrapped(relays: string[], secretKey: string | Uint8Array, recipients: string[], payload: NCC05Payload, identifier?: string): Promise<Event>;
|
|
143
173
|
/**
|
|
144
174
|
* Publishes a locator record. Supports self-encryption, targeted encryption, or plaintext.
|
|
145
175
|
*
|
|
@@ -149,7 +179,7 @@ export declare class NCC05Publisher {
|
|
|
149
179
|
* @param options - Publishing options (identifier, recipient, or public flag).
|
|
150
180
|
* @returns The signed Nostr event.
|
|
151
181
|
*/
|
|
152
|
-
publish(relays: string[], secretKey: Uint8Array, payload: NCC05Payload, options?: {
|
|
182
|
+
publish(relays: string[], secretKey: string | Uint8Array, payload: NCC05Payload, options?: {
|
|
153
183
|
identifier?: string;
|
|
154
184
|
recipientPubkey?: string;
|
|
155
185
|
public?: boolean;
|
package/dist/index.js
CHANGED
|
@@ -7,6 +7,56 @@
|
|
|
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
|
+
}
|
|
54
|
+
function getHexPubkey(key) {
|
|
55
|
+
if (key instanceof Uint8Array) {
|
|
56
|
+
return Array.from(key).map(b => b.toString(16).padStart(2, '0')).join('');
|
|
57
|
+
}
|
|
58
|
+
return key;
|
|
59
|
+
}
|
|
10
60
|
/**
|
|
11
61
|
* Utility for managing shared group access to service records.
|
|
12
62
|
*/
|
|
@@ -51,10 +101,17 @@ export class NCC05Resolver {
|
|
|
51
101
|
* @param options - Configuration for the resolver.
|
|
52
102
|
*/
|
|
53
103
|
constructor(options = {}) {
|
|
54
|
-
this.pool = new SimplePool();
|
|
55
|
-
if (options.
|
|
56
|
-
|
|
57
|
-
|
|
104
|
+
this.pool = options.pool || new SimplePool();
|
|
105
|
+
if (!options.pool) {
|
|
106
|
+
if (options.websocketImplementation) {
|
|
107
|
+
// @ts-ignore - Patching pool for custom transport
|
|
108
|
+
this.pool.websocketImplementation = options.websocketImplementation;
|
|
109
|
+
}
|
|
110
|
+
else if (typeof globalThis !== 'undefined' && !globalThis.WebSocket) {
|
|
111
|
+
// In Node.js environment without global WebSocket, this might fail later.
|
|
112
|
+
// We leave it to the user or nostr-tools to handle, but this logic
|
|
113
|
+
// allows 'websocketImplementation' to be explicitly checked.
|
|
114
|
+
}
|
|
58
115
|
}
|
|
59
116
|
this.bootstrapRelays = options.bootstrapRelays || ['wss://relay.damus.io', 'wss://nos.lol'];
|
|
60
117
|
this.timeout = options.timeout || 10000;
|
|
@@ -69,7 +126,9 @@ export class NCC05Resolver {
|
|
|
69
126
|
* @param secretKey - Your secret key (required if the record is encrypted).
|
|
70
127
|
* @param identifier - The 'd' tag of the record (default: 'addr').
|
|
71
128
|
* @param options - Resolution options (strict mode, gossip discovery).
|
|
72
|
-
* @returns The resolved and validated NCC05Payload, or null if not found
|
|
129
|
+
* @returns The resolved and validated NCC05Payload, or null if not found.
|
|
130
|
+
* @throws {NCC05TimeoutError} if resolution times out.
|
|
131
|
+
* @throws {NCC05RelayError} if underlying relay communication fails.
|
|
73
132
|
*/
|
|
74
133
|
async resolve(targetPubkey, secretKey, identifier = 'addr', options = {}) {
|
|
75
134
|
let hexPubkey = targetPubkey;
|
|
@@ -80,19 +139,25 @@ export class NCC05Resolver {
|
|
|
80
139
|
let queryRelays = [...this.bootstrapRelays];
|
|
81
140
|
// 1. NIP-65 Gossip Discovery
|
|
82
141
|
if (options.gossip) {
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
142
|
+
try {
|
|
143
|
+
const relayListEvent = await this.pool.get(this.bootstrapRelays, {
|
|
144
|
+
authors: [hexPubkey],
|
|
145
|
+
kinds: [10002]
|
|
146
|
+
});
|
|
147
|
+
// Security: Verify NIP-65 event signature and author
|
|
148
|
+
if (relayListEvent && verifyEvent(relayListEvent) && relayListEvent.pubkey === hexPubkey) {
|
|
149
|
+
const discoveredRelays = relayListEvent.tags
|
|
150
|
+
.filter(t => t[0] === 'r')
|
|
151
|
+
.map(t => t[1]);
|
|
152
|
+
if (discoveredRelays.length > 0) {
|
|
153
|
+
queryRelays = [...new Set([...queryRelays, ...discoveredRelays])];
|
|
154
|
+
}
|
|
94
155
|
}
|
|
95
156
|
}
|
|
157
|
+
catch (e) {
|
|
158
|
+
console.warn(`[NCC-05] Gossip discovery failed: ${e.message}`);
|
|
159
|
+
// Proceed with bootstrap relays
|
|
160
|
+
}
|
|
96
161
|
}
|
|
97
162
|
const filter = {
|
|
98
163
|
authors: [hexPubkey],
|
|
@@ -100,47 +165,64 @@ export class NCC05Resolver {
|
|
|
100
165
|
'#d': [identifier],
|
|
101
166
|
limit: 10
|
|
102
167
|
};
|
|
103
|
-
const
|
|
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];
|
|
168
|
+
const sk = secretKey ? ensureUint8Array(secretKey) : undefined;
|
|
115
169
|
try {
|
|
170
|
+
const queryPromise = this.pool.querySync(queryRelays, filter);
|
|
171
|
+
const timeoutPromise = new Promise((_, reject) => setTimeout(() => reject(new NCC05TimeoutError("Resolution timed out")), this.timeout));
|
|
172
|
+
const result = await Promise.race([queryPromise, timeoutPromise]);
|
|
173
|
+
if (!result || (Array.isArray(result) && result.length === 0))
|
|
174
|
+
return null;
|
|
175
|
+
// 2. Filter for valid signatures, correct author, and sort by created_at
|
|
176
|
+
const validEvents = result
|
|
177
|
+
.filter(e => e.pubkey === hexPubkey && verifyEvent(e))
|
|
178
|
+
.sort((a, b) => b.created_at - a.created_at);
|
|
179
|
+
if (validEvents.length === 0)
|
|
180
|
+
return null;
|
|
181
|
+
const latestEvent = validEvents[0];
|
|
116
182
|
let content = latestEvent.content;
|
|
117
183
|
// Security: Robust multi-recipient detection
|
|
118
184
|
const isWrapped = content.includes('"wraps"') &&
|
|
119
185
|
content.includes('"ciphertext"') &&
|
|
120
186
|
content.startsWith('{');
|
|
121
|
-
if (isWrapped &&
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
187
|
+
if (isWrapped && sk) {
|
|
188
|
+
try {
|
|
189
|
+
const wrapped = JSON.parse(content);
|
|
190
|
+
const myPk = getPublicKey(sk);
|
|
191
|
+
const myWrap = wrapped.wraps[myPk];
|
|
192
|
+
if (myWrap) {
|
|
193
|
+
const conversationKey = nip44.getConversationKey(sk, hexPubkey);
|
|
194
|
+
const symmetricKeyHex = nip44.decrypt(myWrap, conversationKey);
|
|
195
|
+
// Convert hex symmetric key back to Uint8Array for NIP-44 decryption
|
|
196
|
+
const symmetricKey = new Uint8Array(symmetricKeyHex.match(/.{1,2}/g).map(byte => parseInt(byte, 16)));
|
|
197
|
+
const sessionConversationKey = nip44.getConversationKey(symmetricKey, getPublicKey(symmetricKey));
|
|
198
|
+
content = nip44.decrypt(wrapped.ciphertext, sessionConversationKey);
|
|
199
|
+
}
|
|
200
|
+
else {
|
|
201
|
+
return null; // Not intended for us
|
|
202
|
+
}
|
|
132
203
|
}
|
|
133
|
-
|
|
134
|
-
|
|
204
|
+
catch (e) {
|
|
205
|
+
throw new NCC05DecryptionError("Failed to decrypt wrapped content");
|
|
135
206
|
}
|
|
136
207
|
}
|
|
137
|
-
else if (
|
|
208
|
+
else if (sk && !content.startsWith('{')) {
|
|
138
209
|
// Standard NIP-44 (likely encrypted if not starting with {)
|
|
139
|
-
|
|
140
|
-
|
|
210
|
+
try {
|
|
211
|
+
const conversationKey = nip44.getConversationKey(sk, hexPubkey);
|
|
212
|
+
content = nip44.decrypt(latestEvent.content, conversationKey);
|
|
213
|
+
}
|
|
214
|
+
catch (e) {
|
|
215
|
+
throw new NCC05DecryptionError("Failed to decrypt content");
|
|
216
|
+
}
|
|
141
217
|
}
|
|
142
218
|
// Security: Safe JSON parsing
|
|
143
|
-
|
|
219
|
+
let payload;
|
|
220
|
+
try {
|
|
221
|
+
payload = JSON.parse(content);
|
|
222
|
+
}
|
|
223
|
+
catch (e) {
|
|
224
|
+
return null; // Invalid JSON
|
|
225
|
+
}
|
|
144
226
|
if (!payload || !payload.endpoints || !Array.isArray(payload.endpoints)) {
|
|
145
227
|
return null;
|
|
146
228
|
}
|
|
@@ -154,13 +236,19 @@ export class NCC05Resolver {
|
|
|
154
236
|
return payload;
|
|
155
237
|
}
|
|
156
238
|
catch (e) {
|
|
157
|
-
|
|
239
|
+
if (e instanceof NCC05Error)
|
|
240
|
+
throw e;
|
|
241
|
+
throw new NCC05RelayError(`Relay query failed: ${e.message}`);
|
|
158
242
|
}
|
|
159
243
|
}
|
|
160
244
|
/**
|
|
161
245
|
* Closes connections to all relays in the pool.
|
|
162
246
|
*/
|
|
163
247
|
close() {
|
|
248
|
+
// If we didn't create the pool, we probably shouldn't close it?
|
|
249
|
+
// But the previous implementation did.
|
|
250
|
+
// We will only close bootstrap relays to be safe if sharing pool.
|
|
251
|
+
// Actually, pool.close() takes args.
|
|
164
252
|
this.pool.close(this.bootstrapRelays);
|
|
165
253
|
}
|
|
166
254
|
}
|
|
@@ -169,15 +257,45 @@ export class NCC05Resolver {
|
|
|
169
257
|
*/
|
|
170
258
|
export class NCC05Publisher {
|
|
171
259
|
pool;
|
|
260
|
+
timeout;
|
|
172
261
|
/**
|
|
173
262
|
* @param options - Configuration for the publisher.
|
|
174
263
|
*/
|
|
175
264
|
constructor(options = {}) {
|
|
176
|
-
this.pool = new SimplePool();
|
|
177
|
-
if (options.websocketImplementation) {
|
|
265
|
+
this.pool = options.pool || new SimplePool();
|
|
266
|
+
if (!options.pool && options.websocketImplementation) {
|
|
178
267
|
// @ts-ignore
|
|
179
268
|
this.pool.websocketImplementation = options.websocketImplementation;
|
|
180
269
|
}
|
|
270
|
+
this.timeout = options.timeout || 5000;
|
|
271
|
+
}
|
|
272
|
+
async _publishToRelays(relays, signedEvent) {
|
|
273
|
+
const publishPromises = this.pool.publish(relays, signedEvent);
|
|
274
|
+
// Convert to promise that resolves/rejects based on timeout
|
|
275
|
+
const wrappedPromises = publishPromises.map(p => {
|
|
276
|
+
// In nostr-tools v2, publish returns Promise<void>.
|
|
277
|
+
// We wrap it to handle timeout.
|
|
278
|
+
return new Promise((resolve, reject) => {
|
|
279
|
+
const timer = setTimeout(() => reject(new NCC05TimeoutError("Publish timed out")), this.timeout);
|
|
280
|
+
p.then(() => {
|
|
281
|
+
clearTimeout(timer);
|
|
282
|
+
resolve();
|
|
283
|
+
}).catch((err) => {
|
|
284
|
+
clearTimeout(timer);
|
|
285
|
+
reject(err);
|
|
286
|
+
});
|
|
287
|
+
});
|
|
288
|
+
});
|
|
289
|
+
const results = await Promise.allSettled(wrappedPromises);
|
|
290
|
+
const successful = results.filter(r => r.status === 'fulfilled');
|
|
291
|
+
if (successful.length === 0) {
|
|
292
|
+
const errors = results
|
|
293
|
+
.filter(r => r.status === 'rejected')
|
|
294
|
+
.map(r => r.reason.message)
|
|
295
|
+
.join(', ');
|
|
296
|
+
throw new NCC05RelayError(`Failed to publish to any relay. Errors: ${errors}`);
|
|
297
|
+
}
|
|
298
|
+
// If partial success, we consider it a success.
|
|
181
299
|
}
|
|
182
300
|
/**
|
|
183
301
|
* Publishes a single record encrypted for multiple recipients using the wrapping pattern.
|
|
@@ -191,13 +309,14 @@ export class NCC05Publisher {
|
|
|
191
309
|
* @returns The signed Nostr event.
|
|
192
310
|
*/
|
|
193
311
|
async publishWrapped(relays, secretKey, recipients, payload, identifier = 'addr') {
|
|
312
|
+
const sk = ensureUint8Array(secretKey);
|
|
194
313
|
const sessionKey = generateSecretKey();
|
|
195
314
|
const sessionKeyHex = Array.from(sessionKey).map(b => b.toString(16).padStart(2, '0')).join('');
|
|
196
315
|
const selfConversation = nip44.getConversationKey(sessionKey, getPublicKey(sessionKey));
|
|
197
316
|
const ciphertext = nip44.encrypt(JSON.stringify(payload), selfConversation);
|
|
198
317
|
const wraps = {};
|
|
199
318
|
for (const rPk of recipients) {
|
|
200
|
-
const conversationKey = nip44.getConversationKey(
|
|
319
|
+
const conversationKey = nip44.getConversationKey(sk, rPk);
|
|
201
320
|
wraps[rPk] = nip44.encrypt(sessionKeyHex, conversationKey);
|
|
202
321
|
}
|
|
203
322
|
const wrappedContent = { ciphertext, wraps };
|
|
@@ -207,8 +326,8 @@ export class NCC05Publisher {
|
|
|
207
326
|
tags: [['d', identifier]],
|
|
208
327
|
content: JSON.stringify(wrappedContent),
|
|
209
328
|
};
|
|
210
|
-
const signedEvent = finalizeEvent(eventTemplate,
|
|
211
|
-
await
|
|
329
|
+
const signedEvent = finalizeEvent(eventTemplate, sk);
|
|
330
|
+
await this._publishToRelays(relays, signedEvent);
|
|
212
331
|
return signedEvent;
|
|
213
332
|
}
|
|
214
333
|
/**
|
|
@@ -221,12 +340,13 @@ export class NCC05Publisher {
|
|
|
221
340
|
* @returns The signed Nostr event.
|
|
222
341
|
*/
|
|
223
342
|
async publish(relays, secretKey, payload, options = {}) {
|
|
224
|
-
const
|
|
343
|
+
const sk = ensureUint8Array(secretKey);
|
|
344
|
+
const myPubkey = getPublicKey(sk);
|
|
225
345
|
const identifier = options.identifier || 'addr';
|
|
226
346
|
let content = JSON.stringify(payload);
|
|
227
347
|
if (!options.public) {
|
|
228
348
|
const encryptionTarget = options.recipientPubkey || myPubkey;
|
|
229
|
-
const conversationKey = nip44.getConversationKey(
|
|
349
|
+
const conversationKey = nip44.getConversationKey(sk, encryptionTarget);
|
|
230
350
|
content = nip44.encrypt(content, conversationKey);
|
|
231
351
|
}
|
|
232
352
|
const eventTemplate = {
|
|
@@ -236,8 +356,8 @@ export class NCC05Publisher {
|
|
|
236
356
|
tags: [['d', identifier]],
|
|
237
357
|
content: content,
|
|
238
358
|
};
|
|
239
|
-
const signedEvent = finalizeEvent(eventTemplate,
|
|
240
|
-
await
|
|
359
|
+
const signedEvent = finalizeEvent(eventTemplate, sk);
|
|
360
|
+
await this._publishToRelays(relays, signedEvent);
|
|
241
361
|
return signedEvent;
|
|
242
362
|
}
|
|
243
363
|
/**
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
package/dist/test-new.js
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import { NCC05Publisher, NCC05Resolver } 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.name === '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);
|
package/package.json
CHANGED
package/src/index.ts
CHANGED
|
@@ -18,6 +18,64 @@ 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
|
+
|
|
72
|
+
function getHexPubkey(key: string | Uint8Array): string {
|
|
73
|
+
if (key instanceof Uint8Array) {
|
|
74
|
+
return Array.from(key).map(b => b.toString(16).padStart(2, '0')).join('');
|
|
75
|
+
}
|
|
76
|
+
return key;
|
|
77
|
+
}
|
|
78
|
+
|
|
21
79
|
/**
|
|
22
80
|
* Represents a single reachable service endpoint.
|
|
23
81
|
*/
|
|
@@ -60,6 +118,20 @@ export interface ResolverOptions {
|
|
|
60
118
|
timeout?: number;
|
|
61
119
|
/** Custom WebSocket implementation (e.g., for Tor/SOCKS5 in Node.js) */
|
|
62
120
|
websocketImplementation?: any;
|
|
121
|
+
/** Existing SimplePool instance to share connections */
|
|
122
|
+
pool?: SimplePool;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Options for configuring the NCC05Publisher.
|
|
127
|
+
*/
|
|
128
|
+
export interface PublisherOptions {
|
|
129
|
+
/** Custom WebSocket implementation */
|
|
130
|
+
websocketImplementation?: any;
|
|
131
|
+
/** Existing SimplePool instance */
|
|
132
|
+
pool?: SimplePool;
|
|
133
|
+
/** Timeout for publishing in milliseconds (default: 5000) */
|
|
134
|
+
timeout?: number;
|
|
63
135
|
}
|
|
64
136
|
|
|
65
137
|
/**
|
|
@@ -106,7 +178,7 @@ export class NCC05Group {
|
|
|
106
178
|
static async resolveAsGroup(
|
|
107
179
|
resolver: NCC05Resolver,
|
|
108
180
|
groupPubkey: string,
|
|
109
|
-
groupSecretKey: Uint8Array,
|
|
181
|
+
groupSecretKey: string | Uint8Array,
|
|
110
182
|
identifier: string = 'addr'
|
|
111
183
|
): Promise<NCC05Payload | null> {
|
|
112
184
|
return resolver.resolve(groupPubkey, groupSecretKey, identifier);
|
|
@@ -125,11 +197,19 @@ export class NCC05Resolver {
|
|
|
125
197
|
* @param options - Configuration for the resolver.
|
|
126
198
|
*/
|
|
127
199
|
constructor(options: ResolverOptions = {}) {
|
|
128
|
-
this.pool = new SimplePool();
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
200
|
+
this.pool = options.pool || new SimplePool();
|
|
201
|
+
|
|
202
|
+
if (!options.pool) {
|
|
203
|
+
if (options.websocketImplementation) {
|
|
204
|
+
// @ts-ignore - Patching pool for custom transport
|
|
205
|
+
this.pool.websocketImplementation = options.websocketImplementation;
|
|
206
|
+
} else if (typeof globalThis !== 'undefined' && !globalThis.WebSocket) {
|
|
207
|
+
// In Node.js environment without global WebSocket, this might fail later.
|
|
208
|
+
// We leave it to the user or nostr-tools to handle, but this logic
|
|
209
|
+
// allows 'websocketImplementation' to be explicitly checked.
|
|
210
|
+
}
|
|
132
211
|
}
|
|
212
|
+
|
|
133
213
|
this.bootstrapRelays = options.bootstrapRelays || ['wss://relay.damus.io', 'wss://nos.lol'];
|
|
134
214
|
this.timeout = options.timeout || 10000;
|
|
135
215
|
}
|
|
@@ -144,11 +224,13 @@ export class NCC05Resolver {
|
|
|
144
224
|
* @param secretKey - Your secret key (required if the record is encrypted).
|
|
145
225
|
* @param identifier - The 'd' tag of the record (default: 'addr').
|
|
146
226
|
* @param options - Resolution options (strict mode, gossip discovery).
|
|
147
|
-
* @returns The resolved and validated NCC05Payload, or null if not found
|
|
227
|
+
* @returns The resolved and validated NCC05Payload, or null if not found.
|
|
228
|
+
* @throws {NCC05TimeoutError} if resolution times out.
|
|
229
|
+
* @throws {NCC05RelayError} if underlying relay communication fails.
|
|
148
230
|
*/
|
|
149
231
|
async resolve(
|
|
150
232
|
targetPubkey: string,
|
|
151
|
-
secretKey?: Uint8Array,
|
|
233
|
+
secretKey?: string | Uint8Array,
|
|
152
234
|
identifier: string = 'addr',
|
|
153
235
|
options: { strict?: boolean, gossip?: boolean } = {}
|
|
154
236
|
): Promise<NCC05Payload | null> {
|
|
@@ -162,18 +244,23 @@ export class NCC05Resolver {
|
|
|
162
244
|
|
|
163
245
|
// 1. NIP-65 Gossip Discovery
|
|
164
246
|
if (options.gossip) {
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
247
|
+
try {
|
|
248
|
+
const relayListEvent = await this.pool.get(this.bootstrapRelays, {
|
|
249
|
+
authors: [hexPubkey],
|
|
250
|
+
kinds: [10002]
|
|
251
|
+
});
|
|
252
|
+
// Security: Verify NIP-65 event signature and author
|
|
253
|
+
if (relayListEvent && verifyEvent(relayListEvent) && relayListEvent.pubkey === hexPubkey) {
|
|
254
|
+
const discoveredRelays = relayListEvent.tags
|
|
255
|
+
.filter(t => t[0] === 'r')
|
|
256
|
+
.map(t => t[1]);
|
|
257
|
+
if (discoveredRelays.length > 0) {
|
|
258
|
+
queryRelays = [...new Set([...queryRelays, ...discoveredRelays])];
|
|
259
|
+
}
|
|
176
260
|
}
|
|
261
|
+
} catch (e: any) {
|
|
262
|
+
console.warn(`[NCC-05] Gossip discovery failed: ${e.message}`);
|
|
263
|
+
// Proceed with bootstrap relays
|
|
177
264
|
}
|
|
178
265
|
}
|
|
179
266
|
|
|
@@ -184,21 +271,26 @@ export class NCC05Resolver {
|
|
|
184
271
|
limit: 10
|
|
185
272
|
};
|
|
186
273
|
|
|
187
|
-
const
|
|
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];
|
|
274
|
+
const sk = secretKey ? ensureUint8Array(secretKey) : undefined;
|
|
200
275
|
|
|
201
276
|
try {
|
|
277
|
+
const queryPromise = this.pool.querySync(queryRelays, filter);
|
|
278
|
+
const timeoutPromise = new Promise<never>((_, reject) =>
|
|
279
|
+
setTimeout(() => reject(new NCC05TimeoutError("Resolution timed out")), this.timeout)
|
|
280
|
+
);
|
|
281
|
+
|
|
282
|
+
const result = await Promise.race([queryPromise, timeoutPromise]);
|
|
283
|
+
|
|
284
|
+
if (!result || (Array.isArray(result) && result.length === 0)) return null;
|
|
285
|
+
|
|
286
|
+
// 2. Filter for valid signatures, correct author, and sort by created_at
|
|
287
|
+
const validEvents = (result as Event[])
|
|
288
|
+
.filter(e => e.pubkey === hexPubkey && verifyEvent(e))
|
|
289
|
+
.sort((a, b) => b.created_at - a.created_at);
|
|
290
|
+
|
|
291
|
+
if (validEvents.length === 0) return null;
|
|
292
|
+
const latestEvent = validEvents[0];
|
|
293
|
+
|
|
202
294
|
let content = latestEvent.content;
|
|
203
295
|
|
|
204
296
|
// Security: Robust multi-recipient detection
|
|
@@ -206,35 +298,49 @@ export class NCC05Resolver {
|
|
|
206
298
|
content.includes('"ciphertext"') &&
|
|
207
299
|
content.startsWith('{');
|
|
208
300
|
|
|
209
|
-
if (isWrapped &&
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
if (myWrap) {
|
|
215
|
-
const conversationKey = nip44.getConversationKey(secretKey, hexPubkey);
|
|
216
|
-
const symmetricKeyHex = nip44.decrypt(myWrap, conversationKey);
|
|
301
|
+
if (isWrapped && sk) {
|
|
302
|
+
try {
|
|
303
|
+
const wrapped = JSON.parse(content) as WrappedContent;
|
|
304
|
+
const myPk = getPublicKey(sk);
|
|
305
|
+
const myWrap = wrapped.wraps[myPk];
|
|
217
306
|
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
symmetricKeyHex
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
307
|
+
if (myWrap) {
|
|
308
|
+
const conversationKey = nip44.getConversationKey(sk, hexPubkey);
|
|
309
|
+
const symmetricKeyHex = nip44.decrypt(myWrap, conversationKey);
|
|
310
|
+
|
|
311
|
+
// Convert hex symmetric key back to Uint8Array for NIP-44 decryption
|
|
312
|
+
const symmetricKey = new Uint8Array(
|
|
313
|
+
symmetricKeyHex.match(/.{1,2}/g)!.map(byte => parseInt(byte, 16))
|
|
314
|
+
);
|
|
315
|
+
|
|
316
|
+
const sessionConversationKey = nip44.getConversationKey(
|
|
317
|
+
symmetricKey, getPublicKey(symmetricKey)
|
|
318
|
+
);
|
|
319
|
+
content = nip44.decrypt(wrapped.ciphertext, sessionConversationKey);
|
|
320
|
+
} else {
|
|
321
|
+
return null; // Not intended for us
|
|
322
|
+
}
|
|
323
|
+
} catch (e) {
|
|
324
|
+
throw new NCC05DecryptionError("Failed to decrypt wrapped content");
|
|
229
325
|
}
|
|
230
|
-
} else if (
|
|
326
|
+
} else if (sk && !content.startsWith('{')) {
|
|
231
327
|
// Standard NIP-44 (likely encrypted if not starting with {)
|
|
232
|
-
|
|
233
|
-
|
|
328
|
+
try {
|
|
329
|
+
const conversationKey = nip44.getConversationKey(sk, hexPubkey);
|
|
330
|
+
content = nip44.decrypt(latestEvent.content, conversationKey);
|
|
331
|
+
} catch (e) {
|
|
332
|
+
throw new NCC05DecryptionError("Failed to decrypt content");
|
|
333
|
+
}
|
|
234
334
|
}
|
|
235
335
|
|
|
236
336
|
// Security: Safe JSON parsing
|
|
237
|
-
|
|
337
|
+
let payload: NCC05Payload;
|
|
338
|
+
try {
|
|
339
|
+
payload = JSON.parse(content) as NCC05Payload;
|
|
340
|
+
} catch (e) {
|
|
341
|
+
return null; // Invalid JSON
|
|
342
|
+
}
|
|
343
|
+
|
|
238
344
|
if (!payload || !payload.endpoints || !Array.isArray(payload.endpoints)) {
|
|
239
345
|
return null;
|
|
240
346
|
}
|
|
@@ -248,7 +354,8 @@ export class NCC05Resolver {
|
|
|
248
354
|
|
|
249
355
|
return payload;
|
|
250
356
|
} catch (e) {
|
|
251
|
-
|
|
357
|
+
if (e instanceof NCC05Error) throw e;
|
|
358
|
+
throw new NCC05RelayError(`Relay query failed: ${(e as Error).message}`);
|
|
252
359
|
}
|
|
253
360
|
}
|
|
254
361
|
|
|
@@ -256,6 +363,10 @@ export class NCC05Resolver {
|
|
|
256
363
|
* Closes connections to all relays in the pool.
|
|
257
364
|
*/
|
|
258
365
|
close() {
|
|
366
|
+
// If we didn't create the pool, we probably shouldn't close it?
|
|
367
|
+
// But the previous implementation did.
|
|
368
|
+
// We will only close bootstrap relays to be safe if sharing pool.
|
|
369
|
+
// Actually, pool.close() takes args.
|
|
259
370
|
this.pool.close(this.bootstrapRelays);
|
|
260
371
|
}
|
|
261
372
|
}
|
|
@@ -265,16 +376,51 @@ export class NCC05Resolver {
|
|
|
265
376
|
*/
|
|
266
377
|
export class NCC05Publisher {
|
|
267
378
|
private pool: SimplePool;
|
|
379
|
+
private timeout: number;
|
|
268
380
|
|
|
269
381
|
/**
|
|
270
382
|
* @param options - Configuration for the publisher.
|
|
271
383
|
*/
|
|
272
|
-
constructor(options:
|
|
273
|
-
this.pool = new SimplePool();
|
|
274
|
-
if (options.websocketImplementation) {
|
|
384
|
+
constructor(options: PublisherOptions = {}) {
|
|
385
|
+
this.pool = options.pool || new SimplePool();
|
|
386
|
+
if (!options.pool && options.websocketImplementation) {
|
|
275
387
|
// @ts-ignore
|
|
276
388
|
this.pool.websocketImplementation = options.websocketImplementation;
|
|
277
389
|
}
|
|
390
|
+
this.timeout = options.timeout || 5000;
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
private async _publishToRelays(relays: string[], signedEvent: Event): Promise<void> {
|
|
394
|
+
const publishPromises = this.pool.publish(relays, signedEvent);
|
|
395
|
+
|
|
396
|
+
// Convert to promise that resolves/rejects based on timeout
|
|
397
|
+
const wrappedPromises = publishPromises.map(p => {
|
|
398
|
+
// In nostr-tools v2, publish returns Promise<void>.
|
|
399
|
+
// We wrap it to handle timeout.
|
|
400
|
+
return new Promise<void>((resolve, reject) => {
|
|
401
|
+
const timer = setTimeout(() => reject(new NCC05TimeoutError("Publish timed out")), this.timeout);
|
|
402
|
+
p.then(() => {
|
|
403
|
+
clearTimeout(timer);
|
|
404
|
+
resolve();
|
|
405
|
+
}).catch((err) => {
|
|
406
|
+
clearTimeout(timer);
|
|
407
|
+
reject(err);
|
|
408
|
+
});
|
|
409
|
+
});
|
|
410
|
+
});
|
|
411
|
+
|
|
412
|
+
const results = await Promise.allSettled(wrappedPromises);
|
|
413
|
+
const successful = results.filter(r => r.status === 'fulfilled');
|
|
414
|
+
|
|
415
|
+
if (successful.length === 0) {
|
|
416
|
+
const errors = results
|
|
417
|
+
.filter(r => r.status === 'rejected')
|
|
418
|
+
.map(r => (r as PromiseRejectedResult).reason.message)
|
|
419
|
+
.join(', ');
|
|
420
|
+
throw new NCC05RelayError(`Failed to publish to any relay. Errors: ${errors}`);
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
// If partial success, we consider it a success.
|
|
278
424
|
}
|
|
279
425
|
|
|
280
426
|
/**
|
|
@@ -290,11 +436,12 @@ export class NCC05Publisher {
|
|
|
290
436
|
*/
|
|
291
437
|
async publishWrapped(
|
|
292
438
|
relays: string[],
|
|
293
|
-
secretKey: Uint8Array,
|
|
439
|
+
secretKey: string | Uint8Array,
|
|
294
440
|
recipients: string[],
|
|
295
441
|
payload: NCC05Payload,
|
|
296
442
|
identifier: string = 'addr'
|
|
297
443
|
): Promise<Event> {
|
|
444
|
+
const sk = ensureUint8Array(secretKey);
|
|
298
445
|
const sessionKey = generateSecretKey();
|
|
299
446
|
const sessionKeyHex = Array.from(sessionKey).map(b => b.toString(16).padStart(2, '0')).join('');
|
|
300
447
|
|
|
@@ -303,7 +450,7 @@ export class NCC05Publisher {
|
|
|
303
450
|
|
|
304
451
|
const wraps: Record<string, string> = {};
|
|
305
452
|
for (const rPk of recipients) {
|
|
306
|
-
const conversationKey = nip44.getConversationKey(
|
|
453
|
+
const conversationKey = nip44.getConversationKey(sk, rPk);
|
|
307
454
|
wraps[rPk] = nip44.encrypt(sessionKeyHex, conversationKey);
|
|
308
455
|
}
|
|
309
456
|
|
|
@@ -316,8 +463,8 @@ export class NCC05Publisher {
|
|
|
316
463
|
content: JSON.stringify(wrappedContent),
|
|
317
464
|
};
|
|
318
465
|
|
|
319
|
-
const signedEvent = finalizeEvent(eventTemplate,
|
|
320
|
-
await
|
|
466
|
+
const signedEvent = finalizeEvent(eventTemplate, sk);
|
|
467
|
+
await this._publishToRelays(relays, signedEvent);
|
|
321
468
|
return signedEvent;
|
|
322
469
|
}
|
|
323
470
|
|
|
@@ -332,17 +479,18 @@ export class NCC05Publisher {
|
|
|
332
479
|
*/
|
|
333
480
|
async publish(
|
|
334
481
|
relays: string[],
|
|
335
|
-
secretKey: Uint8Array,
|
|
482
|
+
secretKey: string | Uint8Array,
|
|
336
483
|
payload: NCC05Payload,
|
|
337
484
|
options: { identifier?: string, recipientPubkey?: string, public?: boolean } = {}
|
|
338
485
|
): Promise<Event> {
|
|
339
|
-
const
|
|
486
|
+
const sk = ensureUint8Array(secretKey);
|
|
487
|
+
const myPubkey = getPublicKey(sk);
|
|
340
488
|
const identifier = options.identifier || 'addr';
|
|
341
489
|
let content = JSON.stringify(payload);
|
|
342
490
|
|
|
343
491
|
if (!options.public) {
|
|
344
492
|
const encryptionTarget = options.recipientPubkey || myPubkey;
|
|
345
|
-
const conversationKey = nip44.getConversationKey(
|
|
493
|
+
const conversationKey = nip44.getConversationKey(sk, encryptionTarget);
|
|
346
494
|
content = nip44.encrypt(content, conversationKey);
|
|
347
495
|
}
|
|
348
496
|
|
|
@@ -354,8 +502,8 @@ export class NCC05Publisher {
|
|
|
354
502
|
content: content,
|
|
355
503
|
};
|
|
356
504
|
|
|
357
|
-
const signedEvent = finalizeEvent(eventTemplate,
|
|
358
|
-
await
|
|
505
|
+
const signedEvent = finalizeEvent(eventTemplate, sk);
|
|
506
|
+
await this._publishToRelays(relays, signedEvent);
|
|
359
507
|
return signedEvent;
|
|
360
508
|
}
|
|
361
509
|
|
package/src/test-new.ts
ADDED
|
@@ -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: any) {
|
|
80
|
+
const duration = Date.now() - start;
|
|
81
|
+
if (e.name === '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);
|