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.
- package/dist/2019-ATLjawsU.cjs +8 -0
- package/dist/{2019-Cp3uYhyY.cjs.map → 2019-ATLjawsU.cjs.map} +1 -1
- package/dist/{2019-CLMqIAfQ.js → 2019-BfjzDRje.js} +1667 -1721
- package/dist/{2019-CLMqIAfQ.js.map → 2019-BfjzDRje.js.map} +1 -1
- package/dist/{browser-nUQt1cnB.js → browser-CKTczilW.js} +2 -2
- package/dist/{browser-nUQt1cnB.js.map → browser-CKTczilW.js.map} +1 -1
- package/dist/{browser-D6cNVl0v.cjs → browser-GOg6KKOV.cjs} +2 -2
- package/dist/{browser-D6cNVl0v.cjs.map → browser-GOg6KKOV.cjs.map} +1 -1
- package/dist/cjs/holosphere.cjs +1 -1
- package/dist/esm/holosphere.js +25 -21
- package/dist/{index-CoAjtqsD.js → index-BdnrGafX.js} +2 -2
- package/dist/{index-CoAjtqsD.js.map → index-BdnrGafX.js.map} +1 -1
- package/dist/{index-BN_uoxQK.js → index-C3Cag0SV.js} +2558 -495
- package/dist/index-C3Cag0SV.js.map +1 -0
- package/dist/index-ChpSfdYS.cjs +29 -0
- package/dist/index-ChpSfdYS.cjs.map +1 -0
- package/dist/{index-DJjGSwXG.cjs → index-D_QecZNu.cjs} +2 -2
- package/dist/{index-DJjGSwXG.cjs.map → index-D_QecZNu.cjs.map} +1 -1
- package/dist/{index-Z5TstN1e.js → index-nMC3dWZ5.js} +2 -2
- package/dist/{index-Z5TstN1e.js.map → index-nMC3dWZ5.js.map} +1 -1
- package/dist/{index-Cp3tI53z.cjs → index-z5HWfWMu.cjs} +2 -2
- package/dist/{index-Cp3tI53z.cjs.map → index-z5HWfWMu.cjs.map} +1 -1
- package/dist/{indexeddb-storage-CZK5A7XH.cjs → indexeddb-storage-DWSeL-YF.cjs} +2 -2
- package/dist/{indexeddb-storage-CZK5A7XH.cjs.map → indexeddb-storage-DWSeL-YF.cjs.map} +1 -1
- package/dist/{indexeddb-storage-bpA01pAU.js → indexeddb-storage-dx01N0ET.js} +2 -2
- package/dist/{indexeddb-storage-bpA01pAU.js.map → indexeddb-storage-dx01N0ET.js.map} +1 -1
- package/dist/{memory-storage-BqhmytP_.js → memory-storage-BPIfkpcf.js} +2 -2
- package/dist/{memory-storage-BqhmytP_.js.map → memory-storage-BPIfkpcf.js.map} +1 -1
- package/dist/{memory-storage-B1k8Jszd.cjs → memory-storage-CKUGDq2d.cjs} +2 -2
- package/dist/{memory-storage-B1k8Jszd.cjs.map → memory-storage-CKUGDq2d.cjs.map} +1 -1
- package/package.json +3 -1
- package/scripts/test-ndk-direct.js +104 -0
- package/src/crypto/key-store.js +356 -0
- package/src/crypto/lens-keys.js +205 -0
- package/src/crypto/secp256k1.js +181 -18
- package/src/federation/handshake.js +317 -23
- package/src/federation/hologram.js +25 -17
- package/src/federation/registry.js +779 -59
- package/src/index.js +416 -27
- package/src/lib/federation-methods.js +308 -4
- package/src/storage/nostr-async.js +144 -86
- package/src/storage/nostr-client.js +77 -18
- package/src/storage/nostr-wrapper.js +4 -1
- package/src/storage/unified-storage.js +5 -4
- package/src/subscriptions/manager.js +1 -1
- package/vitest.config.js +6 -1
- package/dist/2019-Cp3uYhyY.cjs +0 -8
- package/dist/index-BN_uoxQK.js.map +0 -1
- package/dist/index-V8EHMYEY.cjs +0 -29
- 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
|
-
*
|
|
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
|
-
|
|
65
|
-
],
|
|
66
|
-
content: JSON.stringify(data),
|
|
117
|
+
tags,
|
|
118
|
+
content,
|
|
67
119
|
};
|
|
68
120
|
|
|
69
|
-
const publishOptions =
|
|
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
|
-
|
|
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
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
169
|
+
// Skip null/undefined or deleted items
|
|
170
|
+
if (!data || data._deleted) {
|
|
171
|
+
return null;
|
|
172
|
+
}
|
|
118
173
|
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
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
|
-
|
|
136
|
-
const data = JSON.parse(persistedEvent.content);
|
|
192
|
+
const data = _parseEventContent(persistedEvent, lensKey);
|
|
137
193
|
|
|
138
|
-
|
|
139
|
-
|
|
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
|
-
|
|
144
|
-
|
|
145
|
-
|
|
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
|
-
|
|
154
|
-
|
|
155
|
-
|
|
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
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
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
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
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
|
-
|
|
384
|
-
const data = JSON.parse(event.content);
|
|
439
|
+
const data = _parseEventContent(event, lensKey);
|
|
385
440
|
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
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
|
-
|
|
393
|
-
|
|
394
|
-
|
|
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
|
-
//
|
|
809
|
-
|
|
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 !==
|
|
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:
|
|
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: [
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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 =
|
|
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
|
|
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
|
-
//
|
|
1115
|
-
|
|
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
|
|
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 =
|
|
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
|
|
22
|
+
testTimeout,
|
|
23
|
+
setupFiles: ['./tests/utils/relay-config.js'],
|
|
19
24
|
},
|
|
20
25
|
});
|