@hashtree/core 0.1.3 → 0.1.4

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 (68) hide show
  1. package/dist/builder.d.ts.map +1 -1
  2. package/dist/builder.js +2 -1
  3. package/dist/builder.js.map +1 -1
  4. package/dist/compare.d.ts +2 -0
  5. package/dist/compare.d.ts.map +1 -0
  6. package/dist/compare.js +8 -0
  7. package/dist/compare.js.map +1 -0
  8. package/dist/encrypted.d.ts.map +1 -1
  9. package/dist/encrypted.js +28 -18
  10. package/dist/encrypted.js.map +1 -1
  11. package/dist/hashtree.js +1 -1
  12. package/dist/hashtree.js.map +1 -1
  13. package/dist/index.d.ts +1 -1
  14. package/dist/index.d.ts.map +1 -1
  15. package/dist/index.js.map +1 -1
  16. package/dist/store/blossom.d.ts.map +1 -1
  17. package/dist/store/blossom.js +52 -22
  18. package/dist/store/blossom.js.map +1 -1
  19. package/dist/store/fallback.d.ts +3 -1
  20. package/dist/store/fallback.d.ts.map +1 -1
  21. package/dist/store/fallback.js +60 -24
  22. package/dist/store/fallback.js.map +1 -1
  23. package/dist/tree/create.d.ts.map +1 -1
  24. package/dist/tree/create.js +2 -1
  25. package/dist/tree/create.js.map +1 -1
  26. package/dist/types.d.ts +14 -2
  27. package/dist/types.d.ts.map +1 -1
  28. package/package.json +6 -3
  29. package/dist/resolver/index.d.ts +0 -5
  30. package/dist/resolver/index.d.ts.map +0 -1
  31. package/dist/resolver/index.js +0 -5
  32. package/dist/resolver/index.js.map +0 -1
  33. package/dist/resolver/nostr.d.ts +0 -82
  34. package/dist/resolver/nostr.d.ts.map +0 -1
  35. package/dist/resolver/nostr.js +0 -868
  36. package/dist/resolver/nostr.js.map +0 -1
  37. package/dist/store/dexie.d.ts +0 -44
  38. package/dist/store/dexie.d.ts.map +0 -1
  39. package/dist/store/dexie.js +0 -196
  40. package/dist/store/dexie.js.map +0 -1
  41. package/dist/store/opfs.d.ts +0 -56
  42. package/dist/store/opfs.d.ts.map +0 -1
  43. package/dist/store/opfs.js +0 -200
  44. package/dist/store/opfs.js.map +0 -1
  45. package/dist/webrtc/index.d.ts +0 -4
  46. package/dist/webrtc/index.d.ts.map +0 -1
  47. package/dist/webrtc/index.js +0 -4
  48. package/dist/webrtc/index.js.map +0 -1
  49. package/dist/webrtc/lruCache.d.ts +0 -20
  50. package/dist/webrtc/lruCache.d.ts.map +0 -1
  51. package/dist/webrtc/lruCache.js +0 -59
  52. package/dist/webrtc/lruCache.js.map +0 -1
  53. package/dist/webrtc/peer.d.ts +0 -122
  54. package/dist/webrtc/peer.d.ts.map +0 -1
  55. package/dist/webrtc/peer.js +0 -583
  56. package/dist/webrtc/peer.js.map +0 -1
  57. package/dist/webrtc/protocol.d.ts +0 -76
  58. package/dist/webrtc/protocol.d.ts.map +0 -1
  59. package/dist/webrtc/protocol.js +0 -167
  60. package/dist/webrtc/protocol.js.map +0 -1
  61. package/dist/webrtc/store.d.ts +0 -190
  62. package/dist/webrtc/store.d.ts.map +0 -1
  63. package/dist/webrtc/store.js +0 -1043
  64. package/dist/webrtc/store.js.map +0 -1
  65. package/dist/webrtc/types.d.ts +0 -196
  66. package/dist/webrtc/types.d.ts.map +0 -1
  67. package/dist/webrtc/types.js +0 -46
  68. package/dist/webrtc/types.js.map +0 -1
@@ -1,868 +0,0 @@
1
- import { fromHex, toHex, cid } from '../types.js';
2
- import { encryptKeyForLink, computeKeyId, generateLinkKey, } from '../visibility.js';
3
- function hasLabel(event, label) {
4
- return event.tags.some(tag => tag[0] === 'l' && tag[1] === label);
5
- }
6
- function hasAnyLabel(event) {
7
- return event.tags.some(tag => tag[0] === 'l');
8
- }
9
- function parseLegacyContent(event) {
10
- const content = event.content?.trim();
11
- if (!content)
12
- return null;
13
- try {
14
- const parsed = JSON.parse(content);
15
- if (parsed && typeof parsed === 'object') {
16
- const payload = parsed;
17
- return {
18
- hash: typeof payload.hash === 'string' ? payload.hash : undefined,
19
- key: typeof payload.key === 'string' ? payload.key : undefined,
20
- visibility: typeof payload.visibility === 'string' ? payload.visibility : undefined,
21
- encryptedKey: typeof payload.encryptedKey === 'string' ? payload.encryptedKey : undefined,
22
- keyId: typeof payload.keyId === 'string' ? payload.keyId : undefined,
23
- selfEncryptedKey: typeof payload.selfEncryptedKey === 'string' ? payload.selfEncryptedKey : undefined,
24
- selfEncryptedLinkKey: typeof payload.selfEncryptedLinkKey === 'string' ? payload.selfEncryptedLinkKey : undefined,
25
- };
26
- }
27
- }
28
- catch {
29
- // Ignore JSON parse errors.
30
- }
31
- if (/^[0-9a-fA-F]{64}$/.test(content)) {
32
- return { hash: content };
33
- }
34
- return null;
35
- }
36
- /**
37
- * Parse hash and visibility info from a nostr event
38
- * Supports all visibility levels: public, link-visible, private
39
- */
40
- function parseHashAndVisibility(event) {
41
- const hashTag = event.tags.find(t => t[0] === 'hash')?.[1];
42
- const legacyContent = hashTag ? null : parseLegacyContent(event);
43
- const hash = hashTag ?? legacyContent?.hash;
44
- if (!hash)
45
- return null;
46
- const keyTag = event.tags.find(t => t[0] === 'key')?.[1];
47
- const encryptedKeyTag = event.tags.find(t => t[0] === 'encryptedKey')?.[1];
48
- const keyIdTag = event.tags.find(t => t[0] === 'keyId')?.[1];
49
- const selfEncryptedKeyTag = event.tags.find(t => t[0] === 'selfEncryptedKey')?.[1];
50
- const selfEncryptedLinkKeyTag = event.tags.find(t => t[0] === 'selfEncryptedLinkKey')?.[1];
51
- const key = keyTag ?? legacyContent?.key;
52
- const encryptedKey = encryptedKeyTag ?? legacyContent?.encryptedKey;
53
- const keyId = keyIdTag ?? legacyContent?.keyId;
54
- const selfEncryptedKey = selfEncryptedKeyTag ?? legacyContent?.selfEncryptedKey;
55
- const selfEncryptedLinkKey = selfEncryptedLinkKeyTag ?? legacyContent?.selfEncryptedLinkKey;
56
- let visibility;
57
- if (encryptedKey) {
58
- // encryptedKey means link-visible (shareable via link)
59
- // May also have selfEncryptedKey for owner access
60
- visibility = 'link-visible';
61
- }
62
- else if (selfEncryptedKey) {
63
- // Only selfEncryptedKey (no encryptedKey) means private
64
- visibility = 'private';
65
- }
66
- else {
67
- visibility = legacyContent?.visibility ?? 'public';
68
- }
69
- return { hash, visibility, key, encryptedKey, keyId, selfEncryptedKey, selfEncryptedLinkKey };
70
- }
71
- /**
72
- * Legacy parse function for backwards compatibility
73
- * @deprecated Use parseHashAndVisibility instead
74
- */
75
- function parseHashAndKey(event) {
76
- const result = parseHashAndVisibility(event);
77
- if (!result)
78
- return null;
79
- return { hash: result.hash, key: result.key };
80
- }
81
- /**
82
- * Create a NostrRefResolver instance
83
- */
84
- export function createNostrRefResolver(config) {
85
- const { subscribe: nostrSubscribe, publish: nostrPublish, getPubkey, nip19, visibility } = config;
86
- // Active subscriptions by key
87
- const subscriptions = new Map();
88
- const listSubscriptions = new Map();
89
- // Persistent local cache for list entries (survives subscription lifecycle)
90
- // Key is npub, value is map of tree name -> entry
91
- const localListCache = new Map();
92
- /**
93
- * Parse a pointer key into pubkey and tree name
94
- * Key format: "npub1.../treename" or "npub1.../path/to/treename"
95
- */
96
- function parseKey(key) {
97
- const slashIdx = key.indexOf('/');
98
- if (slashIdx === -1)
99
- return null;
100
- const npubStr = key.slice(0, slashIdx);
101
- const treeName = key.slice(slashIdx + 1);
102
- if (!treeName)
103
- return null;
104
- try {
105
- const decoded = nip19.decode(npubStr);
106
- if (decoded.type !== 'npub')
107
- return null;
108
- return { pubkey: decoded.data, treeName };
109
- }
110
- catch {
111
- return null;
112
- }
113
- }
114
- return {
115
- /**
116
- * Resolve a key to its current CID.
117
- * Waits indefinitely until found - caller should apply timeout if needed.
118
- */
119
- async resolve(key) {
120
- const parsed = parseKey(key);
121
- if (!parsed)
122
- return null;
123
- const { pubkey, treeName } = parsed;
124
- return new Promise((resolve) => {
125
- let latestData = null;
126
- let latestCreatedAt = 0;
127
- let resolveTimeout = null;
128
- const doResolve = () => {
129
- unsubscribe();
130
- if (latestData) {
131
- resolve(cid(fromHex(latestData.hash), latestData.key ? fromHex(latestData.key) : undefined));
132
- }
133
- else {
134
- resolve(null);
135
- }
136
- };
137
- const unsubscribe = nostrSubscribe({
138
- kinds: [30078],
139
- authors: [pubkey],
140
- '#d': [treeName],
141
- }, (event) => {
142
- const dTag = event.tags.find(t => t[0] === 'd')?.[1];
143
- if (dTag !== treeName)
144
- return;
145
- if (hasAnyLabel(event) && !hasLabel(event, 'hashtree'))
146
- return;
147
- const hashAndKey = parseHashAndKey(event);
148
- if (!hashAndKey)
149
- return;
150
- if ((event.created_at || 0) > latestCreatedAt) {
151
- latestCreatedAt = event.created_at || 0;
152
- latestData = hashAndKey;
153
- }
154
- // Debounce: wait 100ms after last event to allow newer events to arrive
155
- // This handles the case where relay sends multiple events for replaceable events
156
- if (resolveTimeout) {
157
- clearTimeout(resolveTimeout);
158
- }
159
- resolveTimeout = setTimeout(doResolve, 100);
160
- });
161
- });
162
- },
163
- /**
164
- * Subscribe to CID changes for a key.
165
- * Callback fires on each update (including initial value).
166
- * This runs outside React render cycle.
167
- */
168
- subscribe(key, callback) {
169
- const parsed = parseKey(key);
170
- if (!parsed) {
171
- callback(null);
172
- return () => { };
173
- }
174
- const { pubkey, treeName } = parsed;
175
- // Check if we already have a subscription for this key
176
- let sub = subscriptions.get(key);
177
- if (sub) {
178
- // Add callback to existing subscription
179
- sub.callbacks.add(callback);
180
- // Fire immediately with current value
181
- console.log('[Resolver] Reusing subscription for', key, {
182
- hasCurrentHash: !!sub.currentHash,
183
- currentVisibility: sub.currentVisibility ? JSON.stringify(sub.currentVisibility) : 'null'
184
- });
185
- if (sub.currentHash) {
186
- const keyBytes = sub.currentKey ? fromHex(sub.currentKey) : undefined;
187
- callback(cid(fromHex(sub.currentHash), keyBytes), sub.currentVisibility ?? undefined);
188
- }
189
- }
190
- else {
191
- // Helper to notify all callbacks
192
- const notifyCallbacks = (subEntry) => {
193
- if (!subEntry.currentHash)
194
- return;
195
- const keyBytes = subEntry.currentKey ? fromHex(subEntry.currentKey) : undefined;
196
- const visibilityInfo = subEntry.currentVisibility ?? undefined;
197
- for (const cb of subEntry.callbacks) {
198
- try {
199
- cb(cid(fromHex(subEntry.currentHash), keyBytes), visibilityInfo);
200
- }
201
- catch (e) {
202
- console.error('Resolver callback error:', e);
203
- }
204
- }
205
- };
206
- // Create new subscription for live updates
207
- const unsubscribe = nostrSubscribe({
208
- kinds: [30078],
209
- authors: [pubkey],
210
- '#d': [treeName],
211
- }, (event) => {
212
- const dTag = event.tags.find(t => t[0] === 'd')?.[1];
213
- if (dTag !== treeName)
214
- return;
215
- if (hasAnyLabel(event) && !hasLabel(event, 'hashtree'))
216
- return;
217
- const subEntry = subscriptions.get(key);
218
- if (!subEntry)
219
- return;
220
- const visibilityData = parseHashAndVisibility(event);
221
- console.log('[Resolver] Event received for', key, {
222
- visibilityData: visibilityData ? {
223
- visibility: visibilityData.visibility,
224
- hasSelfEncryptedLinkKey: !!visibilityData.selfEncryptedLinkKey,
225
- hasEncryptedKey: !!visibilityData.encryptedKey
226
- } : 'null'
227
- });
228
- if (!visibilityData)
229
- return;
230
- const eventCreatedAt = event.created_at || 0;
231
- const newHash = visibilityData.hash;
232
- const newKey = visibilityData.key;
233
- // Only update if this event is newer (or same timestamp with different hash)
234
- if (eventCreatedAt >= subEntry.latestCreatedAt && newHash && newHash !== subEntry.currentHash) {
235
- subEntry.currentHash = newHash;
236
- subEntry.currentKey = newKey || null;
237
- subEntry.latestCreatedAt = eventCreatedAt;
238
- // Build visibility info for callback
239
- const visibilityInfo = {
240
- visibility: visibilityData.visibility,
241
- encryptedKey: visibilityData.encryptedKey,
242
- keyId: visibilityData.keyId,
243
- selfEncryptedKey: visibilityData.selfEncryptedKey,
244
- selfEncryptedLinkKey: visibilityData.selfEncryptedLinkKey,
245
- };
246
- subEntry.currentVisibility = visibilityInfo;
247
- // Update localListCache so other subscriptions can find this data
248
- const npubStr = key.split('/')[0];
249
- let npubCache = localListCache.get(npubStr);
250
- if (!npubCache) {
251
- npubCache = new Map();
252
- localListCache.set(npubStr, npubCache);
253
- }
254
- npubCache.set(treeName, {
255
- hash: newHash,
256
- visibility: visibilityData.visibility,
257
- key: newKey,
258
- encryptedKey: visibilityData.encryptedKey,
259
- keyId: visibilityData.keyId,
260
- selfEncryptedKey: visibilityData.selfEncryptedKey,
261
- selfEncryptedLinkKey: visibilityData.selfEncryptedLinkKey,
262
- created_at: eventCreatedAt,
263
- });
264
- notifyCallbacks(subEntry);
265
- }
266
- });
267
- // Check localListCache for initial values (from recent publish calls)
268
- const npubStr = key.split('/')[0];
269
- const cachedEntry = localListCache.get(npubStr)?.get(treeName);
270
- sub = {
271
- unsubscribe,
272
- callbacks: new Set([callback]),
273
- currentHash: cachedEntry?.hash ?? null,
274
- currentKey: cachedEntry?.key ?? null,
275
- currentVisibility: cachedEntry ? {
276
- visibility: cachedEntry.visibility,
277
- encryptedKey: cachedEntry.encryptedKey,
278
- keyId: cachedEntry.keyId,
279
- selfEncryptedKey: cachedEntry.selfEncryptedKey,
280
- selfEncryptedLinkKey: cachedEntry.selfEncryptedLinkKey,
281
- } : null,
282
- latestCreatedAt: cachedEntry?.created_at ?? 0,
283
- };
284
- subscriptions.set(key, sub);
285
- // Fire callback immediately with cached value if available
286
- if (cachedEntry?.hash) {
287
- const keyBytes = cachedEntry.key ? fromHex(cachedEntry.key) : undefined;
288
- callback(cid(fromHex(cachedEntry.hash), keyBytes), sub.currentVisibility ?? undefined);
289
- }
290
- }
291
- // Return unsubscribe function
292
- return () => {
293
- const subEntry = subscriptions.get(key);
294
- if (!subEntry)
295
- return;
296
- subEntry.callbacks.delete(callback);
297
- // If no more callbacks, close the subscription
298
- if (subEntry.callbacks.size === 0) {
299
- subEntry.unsubscribe();
300
- subscriptions.delete(key);
301
- }
302
- };
303
- },
304
- /**
305
- * Publish/update a pointer
306
- * Handles all visibility types: public, link-visible, private
307
- * Updates local cache immediately (optimistic), then publishes to network.
308
- * @param key - The key to publish to
309
- * @param rootCid - The CID to publish (with encryption key if encrypted)
310
- * @param options - Publish options (visibility, linkKey, labels)
311
- * @returns Result with success status and linkKey (for link-visible)
312
- */
313
- async publish(key, rootCid, options) {
314
- const parsed = parseKey(key);
315
- if (!parsed)
316
- return { success: false };
317
- const { treeName } = parsed;
318
- const pubkey = getPubkey();
319
- if (!pubkey)
320
- return { success: false };
321
- const visibilityType = options?.visibility ?? 'public';
322
- const hashHex = toHex(rootCid.hash);
323
- const chkKey = rootCid.key; // Raw encryption key (Uint8Array)
324
- const chkKeyHex = chkKey ? toHex(chkKey) : undefined;
325
- const now = Math.floor(Date.now() / 1000);
326
- const npubStr = key.split('/')[0];
327
- // Build visibility info for caches and tags
328
- const visibilityInfo = { visibility: visibilityType };
329
- let resultLinkKey;
330
- // Build nostr event tags
331
- const tags = [
332
- ['d', treeName],
333
- ['l', 'hashtree'],
334
- ['hash', hashHex],
335
- ];
336
- // Add directory prefix labels for discoverability
337
- const parts = treeName.split('/');
338
- for (let i = 1; i < parts.length; i++) {
339
- const prefix = parts.slice(0, i).join('/');
340
- tags.push(['l', prefix]);
341
- }
342
- // Add extra labels if provided
343
- if (options?.labels) {
344
- for (const label of options.labels) {
345
- tags.push(['l', label]);
346
- }
347
- }
348
- // Handle visibility-specific encryption and tags
349
- if (chkKey) {
350
- switch (visibilityType) {
351
- case 'public':
352
- // Plaintext key - anyone can access
353
- tags.push(['key', chkKeyHex]);
354
- visibilityInfo.encryptedKey = undefined;
355
- visibilityInfo.keyId = undefined;
356
- visibilityInfo.selfEncryptedKey = undefined;
357
- break;
358
- case 'link-visible': {
359
- // Encrypt key with link key (XOR one-time pad)
360
- const linkKey = options?.linkKey ?? generateLinkKey();
361
- resultLinkKey = linkKey;
362
- const encryptedKey = encryptKeyForLink(chkKey, linkKey);
363
- const keyId = await computeKeyId(linkKey);
364
- tags.push(['encryptedKey', toHex(encryptedKey)]);
365
- tags.push(['keyId', toHex(keyId)]);
366
- visibilityInfo.encryptedKey = toHex(encryptedKey);
367
- visibilityInfo.keyId = toHex(keyId);
368
- if (visibility?.encrypt) {
369
- // Encrypt contentKey to self (so owner can always access without linkKey)
370
- try {
371
- const selfEncrypted = await visibility.encrypt(chkKey, pubkey);
372
- tags.push(['selfEncryptedKey', selfEncrypted]);
373
- visibilityInfo.selfEncryptedKey = selfEncrypted;
374
- }
375
- catch (e) {
376
- console.error('Failed to self-encrypt content key:', e);
377
- }
378
- // Encrypt linkKey to self for URL recovery (so owner can share URLs easily)
379
- // Owner can also derive linkKey from XOR(encryptedKey, contentKey)
380
- try {
381
- const selfEncryptedLinkKey = await visibility.encrypt(linkKey, pubkey);
382
- tags.push(['selfEncryptedLinkKey', selfEncryptedLinkKey]);
383
- visibilityInfo.selfEncryptedLinkKey = selfEncryptedLinkKey;
384
- }
385
- catch (e) {
386
- console.error('Failed to self-encrypt link key:', e);
387
- }
388
- }
389
- break;
390
- }
391
- case 'private': {
392
- // Encrypt key to self using NIP-44
393
- if (!visibility?.encrypt) {
394
- console.error('Cannot publish private tree: no encrypt callback provided');
395
- return { success: false };
396
- }
397
- try {
398
- const selfEncrypted = await visibility.encrypt(chkKey, pubkey);
399
- tags.push(['selfEncryptedKey', selfEncrypted]);
400
- visibilityInfo.selfEncryptedKey = selfEncrypted;
401
- }
402
- catch (e) {
403
- console.error('Failed to encrypt key:', e);
404
- return { success: false };
405
- }
406
- break;
407
- }
408
- }
409
- }
410
- // 1. Update local caches FIRST (optimistic update for instant UI)
411
- // Update local list cache (persists even without active subscription)
412
- let npubCache = localListCache.get(npubStr);
413
- if (!npubCache) {
414
- npubCache = new Map();
415
- localListCache.set(npubStr, npubCache);
416
- }
417
- npubCache.set(treeName, {
418
- hash: hashHex,
419
- visibility: visibilityType,
420
- key: visibilityType === 'public' ? chkKeyHex : undefined,
421
- encryptedKey: visibilityInfo.encryptedKey,
422
- keyId: visibilityInfo.keyId,
423
- selfEncryptedKey: visibilityInfo.selfEncryptedKey,
424
- selfEncryptedLinkKey: visibilityInfo.selfEncryptedLinkKey,
425
- created_at: now,
426
- });
427
- // Update active subscription state
428
- const sub = subscriptions.get(key);
429
- if (sub) {
430
- sub.currentHash = hashHex;
431
- sub.currentKey = visibilityType === 'public' ? chkKeyHex || null : null;
432
- sub.latestCreatedAt = now;
433
- sub.currentVisibility = visibilityInfo;
434
- // Notify callbacks with CID
435
- for (const cb of sub.callbacks) {
436
- try {
437
- cb(rootCid, visibilityInfo);
438
- }
439
- catch (e) {
440
- console.error('Resolver callback error:', e);
441
- }
442
- }
443
- }
444
- // Update active list subscriptions
445
- const listSub = listSubscriptions.get(npubStr);
446
- if (listSub) {
447
- const existingEntry = listSub.entriesByDTag.get(treeName);
448
- const existingWasDeleted = existingEntry && !existingEntry.hash;
449
- const timeDiff = existingEntry ? now - existingEntry.created_at : 0;
450
- // Skip if this would undelete a recently-deleted entry (within 30 seconds)
451
- if (existingWasDeleted && hashHex && timeDiff < 30) {
452
- // Blocked stale undelete
453
- }
454
- else {
455
- listSub.entriesByDTag.set(treeName, {
456
- hash: hashHex,
457
- visibility: visibilityType,
458
- key: visibilityType === 'public' ? chkKeyHex : undefined,
459
- encryptedKey: visibilityInfo.encryptedKey,
460
- keyId: visibilityInfo.keyId,
461
- selfEncryptedKey: visibilityInfo.selfEncryptedKey,
462
- selfEncryptedLinkKey: visibilityInfo.selfEncryptedLinkKey,
463
- created_at: now,
464
- });
465
- // Emit updated state immediately to ALL callbacks
466
- const result = [];
467
- for (const [dTag, entry] of listSub.entriesByDTag) {
468
- if (!entry.hash)
469
- continue; // Skip deleted trees
470
- result.push({
471
- key: `${npubStr}/${dTag}`,
472
- cid: cid(fromHex(entry.hash), entry.key ? fromHex(entry.key) : undefined),
473
- visibility: entry.visibility,
474
- encryptedKey: entry.encryptedKey,
475
- keyId: entry.keyId,
476
- selfEncryptedKey: entry.selfEncryptedKey,
477
- selfEncryptedLinkKey: entry.selfEncryptedLinkKey,
478
- createdAt: entry.created_at,
479
- });
480
- }
481
- for (const cb of listSub.callbacks) {
482
- try {
483
- cb(result);
484
- }
485
- catch (e) {
486
- console.error('List callback error:', e);
487
- }
488
- }
489
- }
490
- }
491
- // 2. Publish to network
492
- try {
493
- const success = await nostrPublish({
494
- kind: 30078,
495
- content: '',
496
- tags,
497
- });
498
- if (!success) {
499
- return { success: false, linkKey: resultLinkKey };
500
- }
501
- }
502
- catch (e) {
503
- console.error('Failed to publish to nostr:', e);
504
- return { success: false, linkKey: resultLinkKey };
505
- }
506
- return { success: true, linkKey: resultLinkKey };
507
- },
508
- /**
509
- * List all trees for a user.
510
- * Streams results as they arrive - returns unsubscribe function.
511
- * Caller decides when to stop listening.
512
- * Supports multiple subscribers per npub - all callbacks receive updates.
513
- */
514
- list(prefix, callback) {
515
- const parts = prefix.split('/');
516
- if (parts.length === 0) {
517
- callback([]);
518
- return () => { };
519
- }
520
- const npubStr = parts[0];
521
- let pubkey;
522
- try {
523
- const decoded = nip19.decode(npubStr);
524
- if (decoded.type !== 'npub') {
525
- callback([]);
526
- return () => { };
527
- }
528
- pubkey = decoded.data;
529
- }
530
- catch {
531
- callback([]);
532
- return () => { };
533
- }
534
- // Check if we already have a subscription for this npub
535
- const existingSub = listSubscriptions.get(npubStr);
536
- if (existingSub) {
537
- // Add callback to existing subscription
538
- existingSub.callbacks.add(callback);
539
- // Fire immediately with current state
540
- const result = [];
541
- for (const [dTag, entry] of existingSub.entriesByDTag) {
542
- if (!entry.hash) {
543
- continue;
544
- }
545
- result.push({
546
- key: `${npubStr}/${dTag}`,
547
- cid: cid(fromHex(entry.hash), entry.key ? fromHex(entry.key) : undefined),
548
- visibility: entry.visibility,
549
- encryptedKey: entry.encryptedKey,
550
- keyId: entry.keyId,
551
- selfEncryptedKey: entry.selfEncryptedKey,
552
- selfEncryptedLinkKey: entry.selfEncryptedLinkKey,
553
- createdAt: entry.created_at,
554
- });
555
- }
556
- callback(result);
557
- // Return unsubscribe that removes this callback
558
- return () => {
559
- existingSub.callbacks.delete(callback);
560
- // If no more callbacks, clean up the subscription
561
- if (existingSub.callbacks.size === 0) {
562
- existingSub.unsubscribe();
563
- listSubscriptions.delete(npubStr);
564
- }
565
- };
566
- }
567
- // Create new subscription
568
- const entriesByDTag = new Map();
569
- const callbacks = new Set([callback]);
570
- // Pre-populate from local cache (for instant display of locally-created trees)
571
- const cachedEntries = localListCache.get(npubStr);
572
- if (cachedEntries) {
573
- for (const [treeName, entry] of cachedEntries) {
574
- entriesByDTag.set(treeName, entry);
575
- }
576
- }
577
- const emitCurrentState = () => {
578
- const result = [];
579
- for (const [dTag, entry] of entriesByDTag) {
580
- // Skip entries with empty/null hash (deleted trees)
581
- if (!entry.hash)
582
- continue;
583
- result.push({
584
- key: `${npubStr}/${dTag}`,
585
- cid: cid(fromHex(entry.hash), entry.key ? fromHex(entry.key) : undefined),
586
- visibility: entry.visibility,
587
- encryptedKey: entry.encryptedKey,
588
- keyId: entry.keyId,
589
- selfEncryptedKey: entry.selfEncryptedKey,
590
- selfEncryptedLinkKey: entry.selfEncryptedLinkKey,
591
- createdAt: entry.created_at,
592
- });
593
- }
594
- // Notify ALL callbacks
595
- for (const cb of callbacks) {
596
- try {
597
- cb(result);
598
- }
599
- catch (e) {
600
- console.error('List callback error:', e);
601
- }
602
- }
603
- };
604
- // Emit cached entries immediately if any
605
- if (entriesByDTag.size > 0) {
606
- emitCurrentState();
607
- }
608
- const unsubscribe = nostrSubscribe({
609
- kinds: [30078],
610
- authors: [pubkey],
611
- '#l': ['hashtree'],
612
- }, (event) => {
613
- const dTag = event.tags.find(t => t[0] === 'd')?.[1];
614
- if (!dTag)
615
- return;
616
- const parsed = parseHashAndVisibility(event);
617
- const hasHash = !!parsed?.hash;
618
- const existing = entriesByDTag.get(dTag);
619
- const eventTime = event.created_at || 0;
620
- // Timestamp-based update logic:
621
- // 1. Accept if no existing entry
622
- // 2. Accept if event is strictly newer
623
- // 3. Accept if same timestamp and this is a delete (no hash) overwriting a create (has hash)
624
- // 4. Reject if same timestamp and this would "undelete" (create overwriting delete)
625
- // 5. Reject if existing is deleted and new event is within 30s (prevent stale event undelete)
626
- if (existing) {
627
- const existingIsDeleted = !existing.hash;
628
- const timeDiff = eventTime - existing.created_at;
629
- // Block stale events from "undeleting" within 30 seconds
630
- if (existingIsDeleted && hasHash && timeDiff < 30) {
631
- return;
632
- }
633
- // Block same-timestamp undelete (create can't overwrite delete at same time)
634
- if (existingIsDeleted && hasHash && timeDiff === 0) {
635
- return;
636
- }
637
- // Only update if newer, or if same time and delete wins over create
638
- const shouldUpdate = eventTime > existing.created_at ||
639
- (eventTime === existing.created_at && !hasHash && existing.hash);
640
- if (!shouldUpdate)
641
- return;
642
- }
643
- // Update the entry
644
- const entryData = {
645
- hash: parsed?.hash ?? '',
646
- visibility: parsed?.visibility ?? 'public',
647
- key: parsed?.key,
648
- encryptedKey: parsed?.encryptedKey,
649
- keyId: parsed?.keyId,
650
- selfEncryptedKey: parsed?.selfEncryptedKey,
651
- selfEncryptedLinkKey: parsed?.selfEncryptedLinkKey,
652
- created_at: eventTime,
653
- };
654
- entriesByDTag.set(dTag, entryData);
655
- // Also update localListCache so subscribe() can find this data
656
- // This enables cross-function caching: list() receives data, subscribe() can use it
657
- let npubCache = localListCache.get(npubStr);
658
- if (!npubCache) {
659
- npubCache = new Map();
660
- localListCache.set(npubStr, npubCache);
661
- }
662
- npubCache.set(dTag, entryData);
663
- emitCurrentState();
664
- });
665
- // Register this list subscription
666
- listSubscriptions.set(npubStr, {
667
- npub: npubStr,
668
- entriesByDTag,
669
- callbacks,
670
- unsubscribe,
671
- });
672
- return () => {
673
- callbacks.delete(callback);
674
- if (callbacks.size === 0) {
675
- unsubscribe();
676
- listSubscriptions.delete(npubStr);
677
- }
678
- };
679
- },
680
- /**
681
- * Stop all subscriptions
682
- */
683
- stop() {
684
- for (const [, sub] of subscriptions) {
685
- sub.unsubscribe();
686
- }
687
- subscriptions.clear();
688
- listSubscriptions.clear();
689
- },
690
- /**
691
- * Delete a tree by publishing event without hash tag
692
- * This nullifies the tree - it will be filtered from list results
693
- */
694
- async delete(key) {
695
- const parsed = parseKey(key);
696
- if (!parsed)
697
- return false;
698
- const { treeName } = parsed;
699
- const pubkey = getPubkey();
700
- if (!pubkey)
701
- return false;
702
- const now = Math.floor(Date.now() / 1000);
703
- const npubStr = key.split('/')[0];
704
- // Update local list cache with empty hash (marks as deleted)
705
- let npubCache = localListCache.get(npubStr);
706
- if (!npubCache) {
707
- npubCache = new Map();
708
- localListCache.set(npubStr, npubCache);
709
- }
710
- npubCache.set(treeName, {
711
- hash: '', // Empty hash marks as deleted
712
- visibility: 'public',
713
- key: undefined,
714
- created_at: now,
715
- });
716
- // Update active subscription state
717
- const sub = subscriptions.get(key);
718
- if (sub) {
719
- sub.currentHash = null;
720
- sub.currentKey = null;
721
- sub.latestCreatedAt = now;
722
- sub.currentVisibility = null;
723
- // Notify callbacks with null CID
724
- for (const cb of sub.callbacks) {
725
- try {
726
- cb(null);
727
- }
728
- catch (e) {
729
- console.error('Resolver callback error:', e);
730
- }
731
- }
732
- }
733
- // Update active list subscriptions - set hash to empty and emit
734
- const listSub = listSubscriptions.get(npubStr);
735
- if (listSub) {
736
- listSub.entriesByDTag.set(treeName, {
737
- hash: '', // Empty hash marks as deleted
738
- visibility: 'public',
739
- key: undefined,
740
- created_at: now,
741
- });
742
- // Emit - filter out empty hashes (deleted trees)
743
- const result = [];
744
- for (const [dTag, entry] of listSub.entriesByDTag) {
745
- if (!entry.hash)
746
- continue;
747
- result.push({
748
- key: `${npubStr}/${dTag}`,
749
- cid: cid(fromHex(entry.hash), entry.key ? fromHex(entry.key) : undefined),
750
- visibility: entry.visibility,
751
- encryptedKey: entry.encryptedKey,
752
- keyId: entry.keyId,
753
- selfEncryptedKey: entry.selfEncryptedKey,
754
- selfEncryptedLinkKey: entry.selfEncryptedLinkKey,
755
- createdAt: entry.created_at,
756
- });
757
- }
758
- for (const cb of listSub.callbacks) {
759
- try {
760
- cb(result);
761
- }
762
- catch (e) {
763
- console.error('List callback error:', e);
764
- }
765
- }
766
- }
767
- // 2. Publish to Nostr - event without hash tag
768
- // Use now + 1 to ensure delete timestamp is strictly higher than any create event
769
- // This is critical for NIP-33: when timestamps are equal, event ID breaks the tie (random)
770
- nostrPublish({
771
- kind: 30078,
772
- content: '',
773
- tags: [
774
- ['d', treeName],
775
- ['l', 'hashtree'],
776
- // No hash tag = deleted
777
- ],
778
- created_at: now + 1,
779
- }).catch(e => console.error('Failed to publish delete to nostr:', e));
780
- return true;
781
- },
782
- /**
783
- * Inject a local list entry (for instant UI updates)
784
- * This updates the local cache to make trees appear immediately.
785
- */
786
- injectListEntry(entry) {
787
- const parts = entry.key.split('/');
788
- if (parts.length !== 2)
789
- return;
790
- const [npubStr, treeName] = parts;
791
- const now = Math.floor(Date.now() / 1000);
792
- const hasHash = !!toHex(entry.cid.hash);
793
- // Update the local list cache
794
- let npubCache = localListCache.get(npubStr);
795
- if (!npubCache) {
796
- npubCache = new Map();
797
- localListCache.set(npubStr, npubCache);
798
- }
799
- const existing = npubCache.get(treeName);
800
- const existingWasDeletedCache = existing && !existing.hash;
801
- const timeDiffCache = existing ? now - existing.created_at : 0;
802
- // Skip if this would undelete a recently-deleted entry (within 30 seconds)
803
- if (existingWasDeletedCache && hasHash && timeDiffCache < 30) {
804
- return;
805
- }
806
- if (!existing || now >= existing.created_at) {
807
- npubCache.set(treeName, {
808
- hash: toHex(entry.cid.hash),
809
- visibility: entry.visibility ?? 'public',
810
- key: entry.cid.key ? toHex(entry.cid.key) : undefined,
811
- encryptedKey: entry.encryptedKey,
812
- keyId: entry.keyId,
813
- selfEncryptedKey: entry.selfEncryptedKey,
814
- selfEncryptedLinkKey: entry.selfEncryptedLinkKey,
815
- created_at: now,
816
- });
817
- }
818
- // If there's an active list subscription for this npub, update it too
819
- const listSub = listSubscriptions.get(npubStr);
820
- if (listSub) {
821
- const existingSub = listSub.entriesByDTag.get(treeName);
822
- const existingWasDeleted = existingSub && !existingSub.hash;
823
- const timeDiff = existingSub ? now - existingSub.created_at : 0;
824
- // Skip if this would undelete a recently-deleted entry (within 30 seconds)
825
- if (existingWasDeleted && hasHash && timeDiff < 30) {
826
- return;
827
- }
828
- if (!existingSub || now >= existingSub.created_at) {
829
- listSub.entriesByDTag.set(treeName, {
830
- hash: toHex(entry.cid.hash),
831
- visibility: entry.visibility ?? 'public',
832
- key: entry.cid.key ? toHex(entry.cid.key) : undefined,
833
- encryptedKey: entry.encryptedKey,
834
- keyId: entry.keyId,
835
- selfEncryptedKey: entry.selfEncryptedKey,
836
- selfEncryptedLinkKey: entry.selfEncryptedLinkKey,
837
- created_at: now,
838
- });
839
- // Emit updated state to ALL callbacks
840
- const result = [];
841
- for (const [dTag, e] of listSub.entriesByDTag) {
842
- if (!e.hash)
843
- continue; // Skip deleted trees
844
- result.push({
845
- key: `${npubStr}/${dTag}`,
846
- cid: cid(fromHex(e.hash), e.key ? fromHex(e.key) : undefined),
847
- visibility: e.visibility,
848
- encryptedKey: e.encryptedKey,
849
- keyId: e.keyId,
850
- selfEncryptedKey: e.selfEncryptedKey,
851
- selfEncryptedLinkKey: e.selfEncryptedLinkKey,
852
- createdAt: e.created_at,
853
- });
854
- }
855
- for (const cb of listSub.callbacks) {
856
- try {
857
- cb(result);
858
- }
859
- catch (err) {
860
- console.error('List callback error:', err);
861
- }
862
- }
863
- }
864
- }
865
- },
866
- };
867
- }
868
- //# sourceMappingURL=nostr.js.map