ncc-05 1.1.3 → 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 +185 -54
- package/dist/test-new.d.ts +1 -0
- package/dist/test-new.js +94 -0
- package/package.json +1 -1
- package/src/index.ts +227 -62
- 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,18 +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
|
-
|
|
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
|
+
}
|
|
93
155
|
}
|
|
94
156
|
}
|
|
157
|
+
catch (e) {
|
|
158
|
+
console.warn(`[NCC-05] Gossip discovery failed: ${e.message}`);
|
|
159
|
+
// Proceed with bootstrap relays
|
|
160
|
+
}
|
|
95
161
|
}
|
|
96
162
|
const filter = {
|
|
97
163
|
authors: [hexPubkey],
|
|
@@ -99,57 +165,90 @@ export class NCC05Resolver {
|
|
|
99
165
|
'#d': [identifier],
|
|
100
166
|
limit: 10
|
|
101
167
|
};
|
|
102
|
-
const
|
|
103
|
-
const timeoutPromise = new Promise((r) => setTimeout(() => r(null), this.timeout));
|
|
104
|
-
const result = await Promise.race([queryPromise, timeoutPromise]);
|
|
105
|
-
if (!result || (Array.isArray(result) && result.length === 0))
|
|
106
|
-
return null;
|
|
107
|
-
const validEvents = result
|
|
108
|
-
.filter(e => verifyEvent(e))
|
|
109
|
-
.sort((a, b) => b.created_at - a.created_at);
|
|
110
|
-
if (validEvents.length === 0)
|
|
111
|
-
return null;
|
|
112
|
-
const latestEvent = validEvents[0];
|
|
168
|
+
const sk = secretKey ? ensureUint8Array(secretKey) : undefined;
|
|
113
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];
|
|
114
182
|
let content = latestEvent.content;
|
|
115
|
-
//
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
const
|
|
122
|
-
const
|
|
123
|
-
const
|
|
124
|
-
|
|
125
|
-
|
|
183
|
+
// Security: Robust multi-recipient detection
|
|
184
|
+
const isWrapped = content.includes('"wraps"') &&
|
|
185
|
+
content.includes('"ciphertext"') &&
|
|
186
|
+
content.startsWith('{');
|
|
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
|
+
}
|
|
203
|
+
}
|
|
204
|
+
catch (e) {
|
|
205
|
+
throw new NCC05DecryptionError("Failed to decrypt wrapped content");
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
else if (sk && !content.startsWith('{')) {
|
|
209
|
+
// Standard NIP-44 (likely encrypted if not starting with {)
|
|
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");
|
|
126
216
|
}
|
|
127
217
|
}
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
218
|
+
// Security: Safe JSON parsing
|
|
219
|
+
let payload;
|
|
220
|
+
try {
|
|
221
|
+
payload = JSON.parse(content);
|
|
132
222
|
}
|
|
133
|
-
|
|
134
|
-
|
|
223
|
+
catch (e) {
|
|
224
|
+
return null; // Invalid JSON
|
|
225
|
+
}
|
|
226
|
+
if (!payload || !payload.endpoints || !Array.isArray(payload.endpoints)) {
|
|
135
227
|
return null;
|
|
228
|
+
}
|
|
136
229
|
// Freshness validation
|
|
137
230
|
const now = Math.floor(Date.now() / 1000);
|
|
138
231
|
if (now > payload.updated_at + payload.ttl) {
|
|
139
232
|
if (options.strict)
|
|
140
233
|
return null;
|
|
141
|
-
console.warn('NCC-05 record
|
|
234
|
+
console.warn('NCC-05 record expired');
|
|
142
235
|
}
|
|
143
236
|
return payload;
|
|
144
237
|
}
|
|
145
238
|
catch (e) {
|
|
146
|
-
|
|
239
|
+
if (e instanceof NCC05Error)
|
|
240
|
+
throw e;
|
|
241
|
+
throw new NCC05RelayError(`Relay query failed: ${e.message}`);
|
|
147
242
|
}
|
|
148
243
|
}
|
|
149
244
|
/**
|
|
150
245
|
* Closes connections to all relays in the pool.
|
|
151
246
|
*/
|
|
152
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.
|
|
153
252
|
this.pool.close(this.bootstrapRelays);
|
|
154
253
|
}
|
|
155
254
|
}
|
|
@@ -158,15 +257,45 @@ export class NCC05Resolver {
|
|
|
158
257
|
*/
|
|
159
258
|
export class NCC05Publisher {
|
|
160
259
|
pool;
|
|
260
|
+
timeout;
|
|
161
261
|
/**
|
|
162
262
|
* @param options - Configuration for the publisher.
|
|
163
263
|
*/
|
|
164
264
|
constructor(options = {}) {
|
|
165
|
-
this.pool = new SimplePool();
|
|
166
|
-
if (options.websocketImplementation) {
|
|
265
|
+
this.pool = options.pool || new SimplePool();
|
|
266
|
+
if (!options.pool && options.websocketImplementation) {
|
|
167
267
|
// @ts-ignore
|
|
168
268
|
this.pool.websocketImplementation = options.websocketImplementation;
|
|
169
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.
|
|
170
299
|
}
|
|
171
300
|
/**
|
|
172
301
|
* Publishes a single record encrypted for multiple recipients using the wrapping pattern.
|
|
@@ -180,13 +309,14 @@ export class NCC05Publisher {
|
|
|
180
309
|
* @returns The signed Nostr event.
|
|
181
310
|
*/
|
|
182
311
|
async publishWrapped(relays, secretKey, recipients, payload, identifier = 'addr') {
|
|
312
|
+
const sk = ensureUint8Array(secretKey);
|
|
183
313
|
const sessionKey = generateSecretKey();
|
|
184
314
|
const sessionKeyHex = Array.from(sessionKey).map(b => b.toString(16).padStart(2, '0')).join('');
|
|
185
315
|
const selfConversation = nip44.getConversationKey(sessionKey, getPublicKey(sessionKey));
|
|
186
316
|
const ciphertext = nip44.encrypt(JSON.stringify(payload), selfConversation);
|
|
187
317
|
const wraps = {};
|
|
188
318
|
for (const rPk of recipients) {
|
|
189
|
-
const conversationKey = nip44.getConversationKey(
|
|
319
|
+
const conversationKey = nip44.getConversationKey(sk, rPk);
|
|
190
320
|
wraps[rPk] = nip44.encrypt(sessionKeyHex, conversationKey);
|
|
191
321
|
}
|
|
192
322
|
const wrappedContent = { ciphertext, wraps };
|
|
@@ -196,8 +326,8 @@ export class NCC05Publisher {
|
|
|
196
326
|
tags: [['d', identifier]],
|
|
197
327
|
content: JSON.stringify(wrappedContent),
|
|
198
328
|
};
|
|
199
|
-
const signedEvent = finalizeEvent(eventTemplate,
|
|
200
|
-
await
|
|
329
|
+
const signedEvent = finalizeEvent(eventTemplate, sk);
|
|
330
|
+
await this._publishToRelays(relays, signedEvent);
|
|
201
331
|
return signedEvent;
|
|
202
332
|
}
|
|
203
333
|
/**
|
|
@@ -210,12 +340,13 @@ export class NCC05Publisher {
|
|
|
210
340
|
* @returns The signed Nostr event.
|
|
211
341
|
*/
|
|
212
342
|
async publish(relays, secretKey, payload, options = {}) {
|
|
213
|
-
const
|
|
343
|
+
const sk = ensureUint8Array(secretKey);
|
|
344
|
+
const myPubkey = getPublicKey(sk);
|
|
214
345
|
const identifier = options.identifier || 'addr';
|
|
215
346
|
let content = JSON.stringify(payload);
|
|
216
347
|
if (!options.public) {
|
|
217
348
|
const encryptionTarget = options.recipientPubkey || myPubkey;
|
|
218
|
-
const conversationKey = nip44.getConversationKey(
|
|
349
|
+
const conversationKey = nip44.getConversationKey(sk, encryptionTarget);
|
|
219
350
|
content = nip44.encrypt(content, conversationKey);
|
|
220
351
|
}
|
|
221
352
|
const eventTemplate = {
|
|
@@ -225,8 +356,8 @@ export class NCC05Publisher {
|
|
|
225
356
|
tags: [['d', identifier]],
|
|
226
357
|
content: content,
|
|
227
358
|
};
|
|
228
|
-
const signedEvent = finalizeEvent(eventTemplate,
|
|
229
|
-
await
|
|
359
|
+
const signedEvent = finalizeEvent(eventTemplate, sk);
|
|
360
|
+
await this._publishToRelays(relays, signedEvent);
|
|
230
361
|
return signedEvent;
|
|
231
362
|
}
|
|
232
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,17 +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
|
-
|
|
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
|
+
}
|
|
175
260
|
}
|
|
261
|
+
} catch (e: any) {
|
|
262
|
+
console.warn(`[NCC-05] Gossip discovery failed: ${e.message}`);
|
|
263
|
+
// Proceed with bootstrap relays
|
|
176
264
|
}
|
|
177
265
|
}
|
|
178
266
|
|
|
@@ -183,55 +271,91 @@ export class NCC05Resolver {
|
|
|
183
271
|
limit: 10
|
|
184
272
|
};
|
|
185
273
|
|
|
186
|
-
const
|
|
187
|
-
const timeoutPromise = new Promise<null>((r) => setTimeout(() => r(null), this.timeout));
|
|
188
|
-
const result = await Promise.race([queryPromise, timeoutPromise]);
|
|
189
|
-
|
|
190
|
-
if (!result || (Array.isArray(result) && result.length === 0)) return null;
|
|
191
|
-
|
|
192
|
-
const validEvents = (result as Event[])
|
|
193
|
-
.filter(e => verifyEvent(e))
|
|
194
|
-
.sort((a, b) => b.created_at - a.created_at);
|
|
195
|
-
|
|
196
|
-
if (validEvents.length === 0) return null;
|
|
197
|
-
const latestEvent = validEvents[0];
|
|
274
|
+
const sk = secretKey ? ensureUint8Array(secretKey) : undefined;
|
|
198
275
|
|
|
199
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
|
+
|
|
200
294
|
let content = latestEvent.content;
|
|
201
295
|
|
|
202
|
-
//
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
const
|
|
210
|
-
const
|
|
211
|
-
const
|
|
296
|
+
// Security: Robust multi-recipient detection
|
|
297
|
+
const isWrapped = content.includes('"wraps"') &&
|
|
298
|
+
content.includes('"ciphertext"') &&
|
|
299
|
+
content.startsWith('{');
|
|
300
|
+
|
|
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];
|
|
212
306
|
|
|
213
|
-
|
|
214
|
-
|
|
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");
|
|
325
|
+
}
|
|
326
|
+
} else if (sk && !content.startsWith('{')) {
|
|
327
|
+
// Standard NIP-44 (likely encrypted if not starting with {)
|
|
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");
|
|
215
333
|
}
|
|
216
|
-
} else if (secretKey) {
|
|
217
|
-
// Standard NIP-44
|
|
218
|
-
const conversationKey = nip44.getConversationKey(secretKey, hexPubkey);
|
|
219
|
-
content = nip44.decrypt(latestEvent.content, conversationKey);
|
|
220
334
|
}
|
|
221
335
|
|
|
222
|
-
|
|
223
|
-
|
|
336
|
+
// Security: Safe JSON parsing
|
|
337
|
+
let payload: NCC05Payload;
|
|
338
|
+
try {
|
|
339
|
+
payload = JSON.parse(content) as NCC05Payload;
|
|
340
|
+
} catch (e) {
|
|
341
|
+
return null; // Invalid JSON
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
if (!payload || !payload.endpoints || !Array.isArray(payload.endpoints)) {
|
|
345
|
+
return null;
|
|
346
|
+
}
|
|
224
347
|
|
|
225
348
|
// Freshness validation
|
|
226
349
|
const now = Math.floor(Date.now() / 1000);
|
|
227
350
|
if (now > payload.updated_at + payload.ttl) {
|
|
228
351
|
if (options.strict) return null;
|
|
229
|
-
console.warn('NCC-05 record
|
|
352
|
+
console.warn('NCC-05 record expired');
|
|
230
353
|
}
|
|
231
354
|
|
|
232
355
|
return payload;
|
|
233
356
|
} catch (e) {
|
|
234
|
-
|
|
357
|
+
if (e instanceof NCC05Error) throw e;
|
|
358
|
+
throw new NCC05RelayError(`Relay query failed: ${(e as Error).message}`);
|
|
235
359
|
}
|
|
236
360
|
}
|
|
237
361
|
|
|
@@ -239,6 +363,10 @@ export class NCC05Resolver {
|
|
|
239
363
|
* Closes connections to all relays in the pool.
|
|
240
364
|
*/
|
|
241
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.
|
|
242
370
|
this.pool.close(this.bootstrapRelays);
|
|
243
371
|
}
|
|
244
372
|
}
|
|
@@ -248,16 +376,51 @@ export class NCC05Resolver {
|
|
|
248
376
|
*/
|
|
249
377
|
export class NCC05Publisher {
|
|
250
378
|
private pool: SimplePool;
|
|
379
|
+
private timeout: number;
|
|
251
380
|
|
|
252
381
|
/**
|
|
253
382
|
* @param options - Configuration for the publisher.
|
|
254
383
|
*/
|
|
255
|
-
constructor(options:
|
|
256
|
-
this.pool = new SimplePool();
|
|
257
|
-
if (options.websocketImplementation) {
|
|
384
|
+
constructor(options: PublisherOptions = {}) {
|
|
385
|
+
this.pool = options.pool || new SimplePool();
|
|
386
|
+
if (!options.pool && options.websocketImplementation) {
|
|
258
387
|
// @ts-ignore
|
|
259
388
|
this.pool.websocketImplementation = options.websocketImplementation;
|
|
260
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.
|
|
261
424
|
}
|
|
262
425
|
|
|
263
426
|
/**
|
|
@@ -273,11 +436,12 @@ export class NCC05Publisher {
|
|
|
273
436
|
*/
|
|
274
437
|
async publishWrapped(
|
|
275
438
|
relays: string[],
|
|
276
|
-
secretKey: Uint8Array,
|
|
439
|
+
secretKey: string | Uint8Array,
|
|
277
440
|
recipients: string[],
|
|
278
441
|
payload: NCC05Payload,
|
|
279
442
|
identifier: string = 'addr'
|
|
280
443
|
): Promise<Event> {
|
|
444
|
+
const sk = ensureUint8Array(secretKey);
|
|
281
445
|
const sessionKey = generateSecretKey();
|
|
282
446
|
const sessionKeyHex = Array.from(sessionKey).map(b => b.toString(16).padStart(2, '0')).join('');
|
|
283
447
|
|
|
@@ -286,7 +450,7 @@ export class NCC05Publisher {
|
|
|
286
450
|
|
|
287
451
|
const wraps: Record<string, string> = {};
|
|
288
452
|
for (const rPk of recipients) {
|
|
289
|
-
const conversationKey = nip44.getConversationKey(
|
|
453
|
+
const conversationKey = nip44.getConversationKey(sk, rPk);
|
|
290
454
|
wraps[rPk] = nip44.encrypt(sessionKeyHex, conversationKey);
|
|
291
455
|
}
|
|
292
456
|
|
|
@@ -299,8 +463,8 @@ export class NCC05Publisher {
|
|
|
299
463
|
content: JSON.stringify(wrappedContent),
|
|
300
464
|
};
|
|
301
465
|
|
|
302
|
-
const signedEvent = finalizeEvent(eventTemplate,
|
|
303
|
-
await
|
|
466
|
+
const signedEvent = finalizeEvent(eventTemplate, sk);
|
|
467
|
+
await this._publishToRelays(relays, signedEvent);
|
|
304
468
|
return signedEvent;
|
|
305
469
|
}
|
|
306
470
|
|
|
@@ -315,17 +479,18 @@ export class NCC05Publisher {
|
|
|
315
479
|
*/
|
|
316
480
|
async publish(
|
|
317
481
|
relays: string[],
|
|
318
|
-
secretKey: Uint8Array,
|
|
482
|
+
secretKey: string | Uint8Array,
|
|
319
483
|
payload: NCC05Payload,
|
|
320
484
|
options: { identifier?: string, recipientPubkey?: string, public?: boolean } = {}
|
|
321
485
|
): Promise<Event> {
|
|
322
|
-
const
|
|
486
|
+
const sk = ensureUint8Array(secretKey);
|
|
487
|
+
const myPubkey = getPublicKey(sk);
|
|
323
488
|
const identifier = options.identifier || 'addr';
|
|
324
489
|
let content = JSON.stringify(payload);
|
|
325
490
|
|
|
326
491
|
if (!options.public) {
|
|
327
492
|
const encryptionTarget = options.recipientPubkey || myPubkey;
|
|
328
|
-
const conversationKey = nip44.getConversationKey(
|
|
493
|
+
const conversationKey = nip44.getConversationKey(sk, encryptionTarget);
|
|
329
494
|
content = nip44.encrypt(content, conversationKey);
|
|
330
495
|
}
|
|
331
496
|
|
|
@@ -337,8 +502,8 @@ export class NCC05Publisher {
|
|
|
337
502
|
content: content,
|
|
338
503
|
};
|
|
339
504
|
|
|
340
|
-
const signedEvent = finalizeEvent(eventTemplate,
|
|
341
|
-
await
|
|
505
|
+
const signedEvent = finalizeEvent(eventTemplate, sk);
|
|
506
|
+
await this._publishToRelays(relays, signedEvent);
|
|
342
507
|
return signedEvent;
|
|
343
508
|
}
|
|
344
509
|
|
|
@@ -348,4 +513,4 @@ export class NCC05Publisher {
|
|
|
348
513
|
close(relays: string[]) {
|
|
349
514
|
this.pool.close(relays);
|
|
350
515
|
}
|
|
351
|
-
}
|
|
516
|
+
}
|
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);
|