holosphere 2.0.0-alpha13 → 2.0.0-alpha15

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. package/dist/2019-ATLjawsU.cjs +8 -0
  2. package/dist/{2019-Cp3uYhyY.cjs.map → 2019-ATLjawsU.cjs.map} +1 -1
  3. package/dist/{2019-CLMqIAfQ.js → 2019-BfjzDRje.js} +1667 -1721
  4. package/dist/{2019-CLMqIAfQ.js.map → 2019-BfjzDRje.js.map} +1 -1
  5. package/dist/{browser-nUQt1cnB.js → browser-CKTczilW.js} +2 -2
  6. package/dist/{browser-nUQt1cnB.js.map → browser-CKTczilW.js.map} +1 -1
  7. package/dist/{browser-D6cNVl0v.cjs → browser-GOg6KKOV.cjs} +2 -2
  8. package/dist/{browser-D6cNVl0v.cjs.map → browser-GOg6KKOV.cjs.map} +1 -1
  9. package/dist/cjs/holosphere.cjs +1 -1
  10. package/dist/esm/holosphere.js +25 -21
  11. package/dist/{index-CoAjtqsD.js → index-BdnrGafX.js} +2 -2
  12. package/dist/{index-CoAjtqsD.js.map → index-BdnrGafX.js.map} +1 -1
  13. package/dist/{index-BN_uoxQK.js → index-C3Cag0SV.js} +2558 -495
  14. package/dist/index-C3Cag0SV.js.map +1 -0
  15. package/dist/index-ChpSfdYS.cjs +29 -0
  16. package/dist/index-ChpSfdYS.cjs.map +1 -0
  17. package/dist/{index-DJjGSwXG.cjs → index-D_QecZNu.cjs} +2 -2
  18. package/dist/{index-DJjGSwXG.cjs.map → index-D_QecZNu.cjs.map} +1 -1
  19. package/dist/{index-Z5TstN1e.js → index-nMC3dWZ5.js} +2 -2
  20. package/dist/{index-Z5TstN1e.js.map → index-nMC3dWZ5.js.map} +1 -1
  21. package/dist/{index-Cp3tI53z.cjs → index-z5HWfWMu.cjs} +2 -2
  22. package/dist/{index-Cp3tI53z.cjs.map → index-z5HWfWMu.cjs.map} +1 -1
  23. package/dist/{indexeddb-storage-CZK5A7XH.cjs → indexeddb-storage-DWSeL-YF.cjs} +2 -2
  24. package/dist/{indexeddb-storage-CZK5A7XH.cjs.map → indexeddb-storage-DWSeL-YF.cjs.map} +1 -1
  25. package/dist/{indexeddb-storage-bpA01pAU.js → indexeddb-storage-dx01N0ET.js} +2 -2
  26. package/dist/{indexeddb-storage-bpA01pAU.js.map → indexeddb-storage-dx01N0ET.js.map} +1 -1
  27. package/dist/{memory-storage-BqhmytP_.js → memory-storage-BPIfkpcf.js} +2 -2
  28. package/dist/{memory-storage-BqhmytP_.js.map → memory-storage-BPIfkpcf.js.map} +1 -1
  29. package/dist/{memory-storage-B1k8Jszd.cjs → memory-storage-CKUGDq2d.cjs} +2 -2
  30. package/dist/{memory-storage-B1k8Jszd.cjs.map → memory-storage-CKUGDq2d.cjs.map} +1 -1
  31. package/package.json +3 -1
  32. package/scripts/test-ndk-direct.js +104 -0
  33. package/src/crypto/key-store.js +356 -0
  34. package/src/crypto/lens-keys.js +205 -0
  35. package/src/crypto/secp256k1.js +181 -18
  36. package/src/federation/handshake.js +317 -23
  37. package/src/federation/hologram.js +25 -17
  38. package/src/federation/registry.js +779 -59
  39. package/src/index.js +416 -27
  40. package/src/lib/federation-methods.js +308 -4
  41. package/src/storage/nostr-async.js +144 -86
  42. package/src/storage/nostr-client.js +77 -18
  43. package/src/storage/nostr-wrapper.js +4 -1
  44. package/src/storage/unified-storage.js +5 -4
  45. package/src/subscriptions/manager.js +1 -1
  46. package/vitest.config.js +6 -1
  47. package/dist/2019-Cp3uYhyY.cjs +0 -8
  48. package/dist/index-BN_uoxQK.js.map +0 -1
  49. package/dist/index-V8EHMYEY.cjs +0 -29
  50. package/dist/index-V8EHMYEY.cjs.map +0 -1
@@ -3,11 +3,13 @@
3
3
  *
4
4
  * Provides Promise-based wrappers and async patterns for Nostr operations.
5
5
  * Includes local-first data access, query deduplication, subscription management,
6
- * and background refresh capabilities for optimal performance.
6
+ * background refresh capabilities, and symmetric encryption for secure federation.
7
7
  *
8
8
  * @module storage/nostr-async
9
9
  */
10
10
 
11
+ import { encryptWithKey, decryptWithKey, isEncrypted } from '../crypto/lens-keys.js';
12
+
11
13
  /**
12
14
  * Global subscription manager to prevent duplicate subscriptions.
13
15
  * Maps: subscriptionKey -> subscription object
@@ -36,6 +38,40 @@ const pendingQueries = new Map();
36
38
  */
37
39
  const QUERY_DEDUP_WINDOW = 2000;
38
40
 
41
+ /**
42
+ * Parse event content, handling decryption if needed.
43
+ *
44
+ * @private
45
+ * @param {Object} event - Nostr event with content and tags
46
+ * @param {Buffer|null} lensKey - Symmetric key for decryption (optional)
47
+ * @returns {Object|null} Parsed data or null if parsing/decryption fails
48
+ */
49
+ function _parseEventContent(event, lensKey = null) {
50
+ if (!event || !event.content) return null;
51
+
52
+ try {
53
+ // Check if event is encrypted (has ['encrypted', 'symmetric'] tag)
54
+ const encryptedTag = event.tags?.find(t => t[0] === 'encrypted');
55
+ const isSymmetricEncrypted = encryptedTag && encryptedTag[1] === 'symmetric';
56
+
57
+ if (isSymmetricEncrypted) {
58
+ if (!lensKey) {
59
+ // Encrypted but no key provided - can't decrypt
60
+ return null;
61
+ }
62
+ // Decrypt and parse
63
+ const decrypted = decryptWithKey(lensKey, event.content);
64
+ return JSON.parse(decrypted);
65
+ } else {
66
+ // Not encrypted, parse directly
67
+ return JSON.parse(event.content);
68
+ }
69
+ } catch (error) {
70
+ // Parsing or decryption failed
71
+ return null;
72
+ }
73
+ }
74
+
39
75
  /**
40
76
  * Write data as Nostr event (parameterized replaceable event).
41
77
  *
@@ -45,10 +81,14 @@ const QUERY_DEDUP_WINDOW = 2000;
45
81
  * @param {number|Object} [kindOrOptions=30000] - Event kind (number) or options object
46
82
  * @param {number} [kindOrOptions.kind=30000] - Event kind
47
83
  * @param {string} [kindOrOptions.signingKey] - Private key to sign with (hex format)
84
+ * @param {Buffer} [kindOrOptions.lensKey] - Symmetric key for encryption (32-byte Buffer)
48
85
  * @returns {Promise<Object>} Published event result with relay responses
49
86
  * @example
50
87
  * const result = await nostrPut(client, 'myapp/holon123/items/item1', { name: 'Test' });
51
88
  * console.log(result.event.id); // Event ID
89
+ *
90
+ * // With encryption:
91
+ * const result = await nostrPut(client, 'myapp/holon123/items/item1', { name: 'Test' }, { lensKey: key });
52
92
  */
53
93
  export async function nostrPut(client, path, data, kindOrOptions = 30000) {
54
94
  // Support both old signature (kind as number) and new signature (options object)
@@ -56,17 +96,32 @@ export async function nostrPut(client, path, data, kindOrOptions = 30000) {
56
96
  ? { kind: kindOrOptions }
57
97
  : kindOrOptions;
58
98
  const kind = options.kind || 30000;
99
+ const { lensKey } = options;
100
+
101
+ // Prepare content - encrypt if lensKey is provided
102
+ let content;
103
+ const tags = [['d', path]];
104
+
105
+ if (lensKey) {
106
+ // Encrypt data with symmetric key
107
+ content = encryptWithKey(lensKey, JSON.stringify(data));
108
+ // Add encryption marker tag
109
+ tags.push(['encrypted', 'symmetric']);
110
+ } else {
111
+ content = JSON.stringify(data);
112
+ }
59
113
 
60
114
  const dataEvent = {
61
115
  kind,
62
116
  created_at: Math.floor(Date.now() / 1000),
63
- tags: [
64
- ['d', path], // d-tag for parameterized replaceable events
65
- ],
66
- content: JSON.stringify(data),
117
+ tags,
118
+ content,
67
119
  };
68
120
 
69
- const publishOptions = options.signingKey ? { signingKey: options.signingKey } : {};
121
+ const publishOptions = {
122
+ ...(options.signingKey && { signingKey: options.signingKey }),
123
+ ...(options.waitForRelays && { waitForRelays: options.waitForRelays }),
124
+ };
70
125
  const result = await client.publish(dataEvent, publishOptions);
71
126
  return result;
72
127
  }
@@ -76,6 +131,7 @@ export async function nostrPut(client, path, data, kindOrOptions = 30000) {
76
131
  *
77
132
  * LOCAL-FIRST: Checks persistent storage first, never blocks on network.
78
133
  * Uses query deduplication to prevent duplicate relay queries within a time window.
134
+ * Supports decryption of encrypted content when lensKey is provided.
79
135
  *
80
136
  * @param {Object} client - NostrClient instance
81
137
  * @param {string} path - Path identifier
@@ -85,16 +141,21 @@ export async function nostrPut(client, path, data, kindOrOptions = 30000) {
85
141
  * @param {boolean} [options.includeAuthor=false] - If true, adds _author field to returned data
86
142
  * @param {boolean} [options.skipPersistent=false] - If true, skip persistent storage check
87
143
  * @param {number} [options.timeout=30000] - Query timeout in milliseconds
144
+ * @param {Buffer} [options.lensKey] - Symmetric key for decryption (32-byte Buffer)
88
145
  * @returns {Promise<Object|null>} Data or null if not found
89
146
  * @example
90
147
  * const data = await nostrGet(client, 'myapp/holon1/items/item1');
91
148
  * if (data) {
92
149
  * console.log(data.name);
93
150
  * }
151
+ *
152
+ * // With decryption:
153
+ * const data = await nostrGet(client, 'myapp/holon1/items/item1', 30000, { lensKey: key });
94
154
  */
95
155
  export async function nostrGet(client, path, kind = 30000, options = {}) {
96
156
  const timeout = options.timeout !== undefined ? options.timeout : 30000;
97
157
  const authors = options.authors || [client.publicKey];
158
+ const { lensKey } = options;
98
159
 
99
160
  // CHECK LRU CACHE FIRST (always up-to-date, even before batched persist)
100
161
  // This ensures immediate reads after writes return fresh data
@@ -103,23 +164,19 @@ export async function nostrGet(client, path, kind = 30000, options = {}) {
103
164
  if (cachedEvent && cachedEvent.content) {
104
165
  // Verify author is in requested authors list
105
166
  if (authors.includes(cachedEvent.pubkey)) {
106
- try {
107
- const data = JSON.parse(cachedEvent.content);
108
-
109
- // Skip null/undefined or deleted items
110
- if (!data || data._deleted) {
111
- return null;
112
- }
167
+ const data = _parseEventContent(cachedEvent, lensKey);
113
168
 
114
- // Optionally include author information
115
- if (options.includeAuthor) {
116
- data._author = cachedEvent.pubkey;
117
- }
169
+ // Skip null/undefined or deleted items
170
+ if (!data || data._deleted) {
171
+ return null;
172
+ }
118
173
 
119
- return data;
120
- } catch (error) {
121
- // Fall through if parsing fails
174
+ // Optionally include author information
175
+ if (options.includeAuthor) {
176
+ data._author = cachedEvent.pubkey;
122
177
  }
178
+
179
+ return data;
123
180
  }
124
181
  }
125
182
  }
@@ -132,29 +189,30 @@ export async function nostrGet(client, path, kind = 30000, options = {}) {
132
189
  if (!authors.includes(persistedEvent.pubkey)) {
133
190
  // Author mismatch - fall through to relay query
134
191
  } else {
135
- try {
136
- const data = JSON.parse(persistedEvent.content);
192
+ const data = _parseEventContent(persistedEvent, lensKey);
137
193
 
138
- // Skip null/undefined or deleted items
139
- if (!data || data._deleted) {
194
+ // Skip null/undefined or deleted items or decryption failures
195
+ if (!data || data._deleted) {
196
+ // If data is null due to missing key, fall through to relay query
197
+ // (might have a different version of the event)
198
+ if (persistedEvent.tags?.find(t => t[0] === 'encrypted') && !lensKey) {
199
+ // Encrypted but no key - can't read, return null
140
200
  return null;
141
201
  }
202
+ return null;
203
+ }
142
204
 
143
- // Optionally include author information
144
- if (options.includeAuthor) {
145
- data._author = persistedEvent.pubkey;
146
- }
147
-
148
- // Trigger background refresh from relays (fire-and-forget)
149
- if (client.refreshPathInBackground) {
150
- client.refreshPathInBackground(path, kind, { authors, timeout });
151
- }
205
+ // Optionally include author information
206
+ if (options.includeAuthor) {
207
+ data._author = persistedEvent.pubkey;
208
+ }
152
209
 
153
- return data;
154
- } catch (error) {
155
- // Fall through to relay query if parsing fails
156
- console.warn('[nostrGet] Failed to parse persisted event:', error);
210
+ // Trigger background refresh from relays (fire-and-forget)
211
+ if (client.refreshPathInBackground) {
212
+ client.refreshPathInBackground(path, kind, { authors, timeout });
157
213
  }
214
+
215
+ return data;
158
216
  }
159
217
  }
160
218
  }
@@ -203,6 +261,7 @@ export async function nostrGet(client, path, kind = 30000, options = {}) {
203
261
  * @returns {Promise<Object|null>} Data or null
204
262
  */
205
263
  async function _executeNostrGet(client, path, kind, authors, timeout, options) {
264
+ const { lensKey } = options;
206
265
  const filter = {
207
266
  kinds: [kind],
208
267
  authors: authors, // Support multiple authors for cross-holosphere queries
@@ -210,7 +269,7 @@ async function _executeNostrGet(client, path, kind, authors, timeout, options) {
210
269
  limit: authors.length, // Increase limit to get events from all authors
211
270
  };
212
271
 
213
- const events = await client.query(filter, { timeout });
272
+ const events = await client.query(filter, { timeout, forceRelay: options.forceRelay });
214
273
 
215
274
  // Filter by author (relays may not respect authors filter)
216
275
  const authoredEvents = events.filter(event => authors.includes(event.pubkey));
@@ -222,21 +281,18 @@ async function _executeNostrGet(client, path, kind, authors, timeout, options) {
222
281
  // Get most recent event (across allowed authors)
223
282
  const event = authoredEvents.sort((a, b) => b.created_at - a.created_at)[0];
224
283
 
225
- try {
226
- const data = JSON.parse(event.content);
227
- // Skip null/undefined or deleted items
228
- if (!data || data._deleted) {
229
- return null;
230
- }
231
- // Optionally include author information
232
- if (options.includeAuthor) {
233
- data._author = event.pubkey;
234
- }
235
- return data;
236
- } catch (error) {
237
- // Silent - return null for unparseable events
284
+ const data = _parseEventContent(event, lensKey);
285
+
286
+ // Skip null/undefined or deleted items
287
+ if (!data || data._deleted) {
238
288
  return null;
239
289
  }
290
+
291
+ // Optionally include author information
292
+ if (options.includeAuthor) {
293
+ data._author = event.pubkey;
294
+ }
295
+ return data;
240
296
  }
241
297
 
242
298
  /**
@@ -244,6 +300,7 @@ async function _executeNostrGet(client, path, kind, authors, timeout, options) {
244
300
  *
245
301
  * LOCAL-FIRST: Checks persistent storage first, never blocks on network.
246
302
  * Uses query deduplication to prevent duplicate relay queries within a time window.
303
+ * Supports decryption of encrypted content when lensKey is provided.
247
304
  *
248
305
  * @param {Object} client - NostrClient instance
249
306
  * @param {string} pathPrefix - Path prefix to match
@@ -254,6 +311,7 @@ async function _executeNostrGet(client, path, kind, authors, timeout, options) {
254
311
  * @param {boolean} [options.skipPersistent=false] - If true, skip persistent storage check
255
312
  * @param {number} [options.timeout=30000] - Query timeout in milliseconds
256
313
  * @param {number} [options.limit=1000] - Maximum number of events to retrieve
314
+ * @param {Buffer} [options.lensKey] - Symmetric key for decryption (32-byte Buffer)
257
315
  * @returns {Promise<Array>} Array of data objects
258
316
  * @example
259
317
  * const items = await nostrGetAll(client, 'myapp/holon1/items/');
@@ -263,6 +321,7 @@ export async function nostrGetAll(client, pathPrefix, kind = 30000, options = {}
263
321
  const timeout = options.timeout !== undefined ? options.timeout : 30000;
264
322
  const limit = options.limit || 1000; // Increased limit to get more events
265
323
  const authors = options.authors || [client.publicKey];
324
+ const { lensKey } = options;
266
325
 
267
326
  // LOCAL-FIRST: Check persistent storage FIRST (never blocks on network)
268
327
  if (!options.skipPersistent && client.persistentGetAll) {
@@ -283,23 +342,19 @@ export async function nostrGetAll(client, pathPrefix, kind = 30000, options = {}
283
342
  const existing = byPath.get(path);
284
343
 
285
344
  if (!existing || event.created_at > existing.created_at) {
286
- try {
287
- const data = JSON.parse(event.content);
288
-
289
- // Handle null/undefined or deleted items - remove from map if this is newer
290
- if (!data || data._deleted) {
291
- byPath.delete(path);
292
- continue;
293
- }
294
-
295
- // Optionally include author information
296
- if (options.includeAuthor) {
297
- data._author = event.pubkey;
298
- }
299
- byPath.set(path, { data, created_at: event.created_at });
300
- } catch (error) {
301
- // Skip invalid events
345
+ const data = _parseEventContent(event, lensKey);
346
+
347
+ // Handle null/undefined or deleted items - remove from map if this is newer
348
+ if (!data || data._deleted) {
349
+ byPath.delete(path);
350
+ continue;
302
351
  }
352
+
353
+ // Optionally include author information
354
+ if (options.includeAuthor) {
355
+ data._author = event.pubkey;
356
+ }
357
+ byPath.set(path, { data, created_at: event.created_at });
303
358
  }
304
359
  }
305
360
 
@@ -357,6 +412,7 @@ export async function nostrGetAll(client, pathPrefix, kind = 30000, options = {}
357
412
  * @returns {Promise<Array>} Array of data objects
358
413
  */
359
414
  async function _executeNostrGetAll(client, pathPrefix, kind, authors, timeout, limit, options) {
415
+ const { lensKey } = options;
360
416
  const filter = {
361
417
  kinds: [kind],
362
418
  authors: authors,
@@ -380,23 +436,19 @@ async function _executeNostrGetAll(client, pathPrefix, kind, authors, timeout, l
380
436
  const existing = byPath.get(dTag);
381
437
 
382
438
  if (!existing || event.created_at > existing.created_at) {
383
- try {
384
- const data = JSON.parse(event.content);
439
+ const data = _parseEventContent(event, lensKey);
385
440
 
386
- // Handle null/undefined or deleted items - remove from map if this is newer
387
- if (!data || data._deleted) {
388
- byPath.delete(dTag);
389
- continue;
390
- }
441
+ // Handle null/undefined or deleted items - remove from map if this is newer
442
+ if (!data || data._deleted) {
443
+ byPath.delete(dTag);
444
+ continue;
445
+ }
391
446
 
392
- // Optionally include author information
393
- if (options.includeAuthor) {
394
- data._author = event.pubkey;
395
- }
396
- byPath.set(dTag, { data, created_at: event.created_at });
397
- } catch (error) {
398
- // Silent - skip unparseable events
447
+ // Optionally include author information
448
+ if (options.includeAuthor) {
449
+ data._author = event.pubkey;
399
450
  }
451
+ byPath.set(dTag, { data, created_at: event.created_at });
400
452
  }
401
453
  }
402
454
 
@@ -805,8 +857,13 @@ export async function nostrSubscribe(client, path, callback, options = {}) {
805
857
  export async function nostrSubscribeMany(client, pathPrefix, callback, options = {}) {
806
858
  const kind = options.kind || 30000;
807
859
 
808
- // Create unique key for this subscription
809
- const subscriptionKey = `${client.publicKey}:${kind}:${pathPrefix}`;
860
+ // Extract the holon ID (author pubkey) from the path
861
+ // Path format: appName/holonId/lensName/
862
+ const pathParts = pathPrefix.split('/');
863
+ const targetAuthor = pathParts[1] || client.publicKey;
864
+
865
+ // Create unique key for this subscription (use target author, not client pubkey)
866
+ const subscriptionKey = `${targetAuthor}:${kind}:${pathPrefix}`;
810
867
 
811
868
  // Check if we already have an active subscription for this path
812
869
  const existingSub = globalSubscriptions.get(subscriptionKey);
@@ -849,7 +906,7 @@ export async function nostrSubscribeMany(client, pathPrefix, callback, options =
849
906
  seenEventIds.add(event.id);
850
907
 
851
908
  // Check if event is from a different author (relay not respecting filter)
852
- if (event.pubkey !== client.publicKey) {
909
+ if (event.pubkey !== targetAuthor) {
853
910
  rejectedCount++;
854
911
 
855
912
  // Only log periodically to avoid spam
@@ -858,7 +915,7 @@ export async function nostrSubscribeMany(client, pathPrefix, callback, options =
858
915
  console.warn('[nostrSubscribeMany] ⚠️ Relay not respecting authors filter!', {
859
916
  rejectedCount,
860
917
  acceptedCount,
861
- expected: client.publicKey,
918
+ expected: targetAuthor,
862
919
  message: 'Consider using a different relay or implementing private relay'
863
920
  });
864
921
  lastLogTime = now;
@@ -896,9 +953,10 @@ export async function nostrSubscribeMany(client, pathPrefix, callback, options =
896
953
  const subscriptionStartTime = Math.floor(Date.now() / 1000);
897
954
  const dataFilter = {
898
955
  kinds: [kind],
899
- authors: [client.publicKey],
956
+ authors: [targetAuthor], // Use target author (holon ID), not client pubkey
900
957
  since: subscriptionStartTime // Only get new events after subscription
901
958
  };
959
+ console.log('[nostrSubscribeMany] Subscribing to:', { pathPrefix, targetAuthor: targetAuthor.slice(0, 12), clientPubKey: client.publicKey.slice(0, 12) });
902
960
  const dataSubscription = await client.subscribe(dataFilter, handleDataEvent);
903
961
 
904
962
  // Use Page Visibility API to refresh when tab becomes visible
@@ -406,7 +406,13 @@ export class NostrClient {
406
406
  this.relays.forEach(r => globalNDKRelays.add(r));
407
407
 
408
408
  // Connect to relays
409
- await this.ndk.connect();
409
+ console.log('[NostrClient] Connecting to relays:', this.relays);
410
+ try {
411
+ await this.ndk.connect();
412
+ console.log('[NostrClient] NDK connect() completed. Pool stats:', this.ndk.pool?.stats?.());
413
+ } catch (err) {
414
+ console.error('[NostrClient] NDK connect() FAILED:', err.message);
415
+ }
410
416
 
411
417
  // Note: publicKey is already set synchronously in constructor using nostr-tools
412
418
  } else {
@@ -647,7 +653,16 @@ export class NostrClient {
647
653
  * @returns {Promise<Object|null>} The event or null if not found
648
654
  */
649
655
  async persistentGet(path) {
650
- await this._initReady;
656
+ // Add timeout to prevent hanging if initialization stalls
657
+ try {
658
+ await Promise.race([
659
+ this._initReady,
660
+ new Promise((_, reject) => setTimeout(() => reject(new Error('Init timeout')), 10000))
661
+ ]);
662
+ } catch (err) {
663
+ console.warn('[NostrClient] persistentGet: Init timeout, returning null');
664
+ return null;
665
+ }
651
666
  if (!this.persistentStorage) return null;
652
667
  try {
653
668
  const event = await this.persistentStorage.get(path);
@@ -745,8 +760,15 @@ export class NostrClient {
745
760
  * });
746
761
  */
747
762
  async publish(event, options = {}) {
748
- // Ensure initialization is complete
749
- await this._initReady;
763
+ // Ensure initialization is complete, with timeout to prevent hanging
764
+ try {
765
+ await Promise.race([
766
+ this._initReady,
767
+ new Promise((_, reject) => setTimeout(() => reject(new Error('Init timeout')), 15000))
768
+ ]);
769
+ } catch (err) {
770
+ console.warn('[NostrClient] publish: Init timeout, continuing with available state');
771
+ }
750
772
 
751
773
  const waitForRelays = options.waitForRelays || false;
752
774
 
@@ -943,12 +965,18 @@ export class NostrClient {
943
965
  const formattedResults = [];
944
966
 
945
967
  try {
946
- // Publish using NDK
947
- const publishedRelays = await ndkEvent.publish();
968
+ // Publish using NDK with timeout to prevent hanging
969
+ const publishPromise = ndkEvent.publish();
970
+ const timeoutPromise = new Promise((_, reject) =>
971
+ setTimeout(() => reject(new Error('Publish timeout')), 30000)
972
+ );
973
+
974
+ const publishedRelays = await Promise.race([publishPromise, timeoutPromise]);
948
975
 
949
976
  // Track results
950
977
  for (const relay of relays) {
951
- const wasPublished = publishedRelays.has(this.ndk.pool.getRelay(relay));
978
+ const ndkRelay = this.ndk.pool.getRelay(relay);
979
+ const wasPublished = publishedRelays.has(ndkRelay);
952
980
  if (wasPublished) {
953
981
  successful.push(relay);
954
982
  formattedResults.push({
@@ -966,6 +994,7 @@ export class NostrClient {
966
994
  }
967
995
  }
968
996
  } catch (error) {
997
+ // Publish failed - event is cached locally and queued for retry
969
998
  // All failed
970
999
  for (const relay of relays) {
971
1000
  failed.push(relay);
@@ -1015,10 +1044,18 @@ export class NostrClient {
1015
1044
  * });
1016
1045
  */
1017
1046
  async query(filter, options = {}) {
1018
- // Ensure initialization is complete
1019
- await this._initReady;
1047
+ // Ensure initialization is complete, with timeout to prevent hanging
1048
+ const queryTimeout = options.timeout !== undefined ? options.timeout : 30000;
1049
+ try {
1050
+ await Promise.race([
1051
+ this._initReady,
1052
+ new Promise((_, reject) => setTimeout(() => reject(new Error('Init timeout')), Math.min(queryTimeout, 10000)))
1053
+ ]);
1054
+ } catch (err) {
1055
+ console.warn('[NostrClient] query: Init timeout, continuing with available state');
1056
+ }
1020
1057
 
1021
- const timeout = options.timeout !== undefined ? options.timeout : 30000;
1058
+ const timeout = queryTimeout;
1022
1059
  const localFirst = options.localFirst !== false; // Default to true for speed
1023
1060
  const forceRelay = options.forceRelay === true;
1024
1061
 
@@ -1042,8 +1079,8 @@ export class NostrClient {
1042
1079
  return matchingEvents;
1043
1080
  }
1044
1081
 
1045
- // For first query before subscription initializes, wait briefly
1046
- if (isOwnDataQuery && subInfo && !subInfo.initialized) {
1082
+ // For first query before subscription initializes, wait briefly (unless forceRelay)
1083
+ if (!forceRelay && isOwnDataQuery && subInfo && !subInfo.initialized) {
1047
1084
  // Wait for subscription to initialize (up to timeout)
1048
1085
  await Promise.race([
1049
1086
  subInfo.initPromise,
@@ -1106,13 +1143,28 @@ export class NostrClient {
1106
1143
  let events = [];
1107
1144
 
1108
1145
  if (this.ndk) {
1109
- // Use NDK to fetch events
1110
- const fetchedEvents = await this.ndk.fetchEvents(filter, {
1146
+ // Use NDK to fetch events with timeout to prevent hanging on disconnected relays
1147
+ const fetchPromise = this.ndk.fetchEvents(filter, {
1111
1148
  closeOnEose: true,
1112
1149
  });
1113
1150
 
1114
- // Convert NDKEvent Set to raw event array
1115
- events = Array.from(fetchedEvents).map(e => e.rawEvent());
1151
+ // Add timeout wrapper to prevent infinite hang when no relays are connected
1152
+ const timeoutPromise = new Promise((_, reject) =>
1153
+ setTimeout(() => reject(new Error('Relay query timeout')), timeout || 30000)
1154
+ );
1155
+
1156
+ try {
1157
+ const fetchedEvents = await Promise.race([fetchPromise, timeoutPromise]);
1158
+ // Convert NDKEvent Set to raw event array
1159
+ events = Array.from(fetchedEvents).map(e => e.rawEvent());
1160
+ } catch (err) {
1161
+ if (err.message === 'Relay query timeout') {
1162
+ console.warn('[NostrClient] Relay query timed out, returning empty results');
1163
+ events = [];
1164
+ } else {
1165
+ throw err;
1166
+ }
1167
+ }
1116
1168
  }
1117
1169
 
1118
1170
  // CRITICAL: Filter out events from other authors (relay may not respect filter)
@@ -1352,12 +1404,19 @@ export class NostrClient {
1352
1404
  return localEvents;
1353
1405
  }
1354
1406
 
1355
- // Step 2: Query relays
1407
+ // Step 2: Query relays with timeout
1356
1408
  let relayEvents = [];
1357
1409
  try {
1358
- const fetchedEvents = await this.ndk.fetchEvents(filter, {
1410
+ const fetchPromise = this.ndk.fetchEvents(filter, {
1359
1411
  closeOnEose: true,
1360
1412
  });
1413
+
1414
+ // Add timeout to prevent hanging on disconnected relays
1415
+ const timeoutPromise = new Promise((_, reject) =>
1416
+ setTimeout(() => reject(new Error('Relay query timeout')), timeout)
1417
+ );
1418
+
1419
+ const fetchedEvents = await Promise.race([fetchPromise, timeoutPromise]);
1361
1420
  relayEvents = Array.from(fetchedEvents).map(e => e.rawEvent());
1362
1421
 
1363
1422
  // Filter out events from other authors
@@ -59,7 +59,10 @@ function encodePathComponent(component) {
59
59
  */
60
60
  export async function write(client, path, data, options = {}) {
61
61
  try {
62
- const putOptions = options.signingKey ? { signingKey: options.signingKey } : {};
62
+ const putOptions = {
63
+ ...(options.signingKey && { signingKey: options.signingKey }),
64
+ ...(options.waitForRelays && { waitForRelays: options.waitForRelays }),
65
+ };
63
66
  const result = await nostrPut(client, path, data, putOptions);
64
67
  // Check if at least one relay accepted the event
65
68
  // If no relays (testing mode), consider it successful if event was created
@@ -29,19 +29,20 @@ export function buildPath(appName, holonId, lensName, key = null) {
29
29
  * @param {Object} client - Client instance (nostr client or gun-compatible client)
30
30
  * @param {string} path - Storage path
31
31
  * @param {Object} data - Data to write
32
+ * @param {Object} [options={}] - Write options
32
33
  * @returns {Promise<boolean>} Success indicator
33
34
  */
34
- export async function write(client, path, data) {
35
+ export async function write(client, path, data, options = {}) {
35
36
  // Check if this is a GunDB client with backend methods (preferred - has write cache)
36
37
  if (client.write && client.gun) {
37
- return client.write(path, data);
38
+ return client.write(path, data, options);
38
39
  }
39
40
  // Fallback to direct gunWrapper (no write cache)
40
41
  if (client.gun) {
41
- return gunWrapper.write(client.gun, path, data);
42
+ return gunWrapper.write(client.gun, path, data, options);
42
43
  }
43
44
  // Default to Nostr
44
- return nostrWrapper.write(client, path, data);
45
+ return nostrWrapper.write(client, path, data, options);
45
46
  }
46
47
 
47
48
  /**
@@ -38,7 +38,7 @@ export async function createSubscription(client, path, callback, options = {}) {
38
38
 
39
39
  // Skip if already seen (prevent duplicates)
40
40
  // Include timestamp in unique key to allow updates to same item
41
- const uniqueKey = `${key}-${data?.id || ''}-${data?._meta?.timestamp || Date.now()}`;
41
+ const uniqueKey = `${key}-${data?.id || ''}-${data?._meta?.lastUpdated || data?._meta?.timestamp || Date.now()}`;
42
42
  if (seenKeys.has(uniqueKey)) {
43
43
  return;
44
44
  }
package/vitest.config.js CHANGED
@@ -1,5 +1,9 @@
1
1
  import { defineConfig } from 'vitest/config';
2
2
 
3
+ // Dynamic timeout based on USE_REAL_RELAYS environment variable
4
+ const isRealRelayMode = process.env.USE_REAL_RELAYS === 'true';
5
+ const testTimeout = isRealRelayMode ? 60000 : 10000;
6
+
3
7
  export default defineConfig({
4
8
  test: {
5
9
  include: ['tests/**/*.test.js'],
@@ -15,6 +19,7 @@ export default defineConfig({
15
19
  },
16
20
  environment: 'node',
17
21
  globals: false,
18
- testTimeout: 10000,
22
+ testTimeout,
23
+ setupFiles: ['./tests/utils/relay-config.js'],
19
24
  },
20
25
  });