@hashtree/nostr 0.1.13 → 0.2.3

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 (67) hide show
  1. package/README.md +11 -33
  2. package/dist/eventCollection.d.ts +1 -1
  3. package/dist/eventCollection.d.ts.map +1 -1
  4. package/dist/eventCollection.js +2 -7
  5. package/dist/eventCollection.js.map +1 -1
  6. package/dist/eventKeys.d.ts +1 -0
  7. package/dist/eventKeys.d.ts.map +1 -1
  8. package/dist/eventKeys.js +7 -1
  9. package/dist/eventKeys.js.map +1 -1
  10. package/dist/events.d.ts +2 -0
  11. package/dist/events.d.ts.map +1 -1
  12. package/dist/events.js +41 -61
  13. package/dist/events.js.map +1 -1
  14. package/dist/index.d.ts +4 -3
  15. package/dist/index.d.ts.map +1 -1
  16. package/dist/index.js +10 -6
  17. package/dist/index.js.map +1 -1
  18. package/dist/resolver/nostr.d.ts.map +1 -1
  19. package/dist/resolver/nostr.js +17 -12
  20. package/dist/resolver/nostr.js.map +1 -1
  21. package/dist/snapshot.d.ts +5 -0
  22. package/dist/snapshot.d.ts.map +1 -1
  23. package/dist/snapshot.js +9 -2
  24. package/dist/snapshot.js.map +1 -1
  25. package/dist/treeEventSnapshots.d.ts.map +1 -1
  26. package/dist/treeEventSnapshots.js +6 -11
  27. package/dist/treeEventSnapshots.js.map +1 -1
  28. package/package.json +18 -14
  29. package/src/eventCollection.ts +189 -0
  30. package/src/eventKeys.ts +155 -0
  31. package/src/eventValidation.ts +71 -0
  32. package/src/events.ts +900 -0
  33. package/src/index.ts +122 -0
  34. package/src/replaceablePublish.ts +204 -0
  35. package/src/resolver/index.ts +14 -0
  36. package/src/resolver/nostr.ts +1210 -0
  37. package/src/snapshot.ts +213 -0
  38. package/src/treeEventSnapshotPermalinks.ts +150 -0
  39. package/src/treeEventSnapshots.ts +280 -0
  40. package/dist/webrtc/index.d.ts +0 -6
  41. package/dist/webrtc/index.d.ts.map +0 -1
  42. package/dist/webrtc/index.js +0 -7
  43. package/dist/webrtc/index.js.map +0 -1
  44. package/dist/webrtc/lruCache.d.ts +0 -20
  45. package/dist/webrtc/lruCache.d.ts.map +0 -1
  46. package/dist/webrtc/lruCache.js +0 -59
  47. package/dist/webrtc/lruCache.js.map +0 -1
  48. package/dist/webrtc/peer.d.ts +0 -133
  49. package/dist/webrtc/peer.d.ts.map +0 -1
  50. package/dist/webrtc/peer.js +0 -617
  51. package/dist/webrtc/peer.js.map +0 -1
  52. package/dist/webrtc/peerSelector.d.ts +0 -2
  53. package/dist/webrtc/peerSelector.d.ts.map +0 -1
  54. package/dist/webrtc/peerSelector.js +0 -2
  55. package/dist/webrtc/peerSelector.js.map +0 -1
  56. package/dist/webrtc/protocol.d.ts +0 -3
  57. package/dist/webrtc/protocol.d.ts.map +0 -1
  58. package/dist/webrtc/protocol.js +0 -2
  59. package/dist/webrtc/protocol.js.map +0 -1
  60. package/dist/webrtc/store.d.ts +0 -217
  61. package/dist/webrtc/store.d.ts.map +0 -1
  62. package/dist/webrtc/store.js +0 -1320
  63. package/dist/webrtc/store.js.map +0 -1
  64. package/dist/webrtc/types.d.ts +0 -101
  65. package/dist/webrtc/types.d.ts.map +0 -1
  66. package/dist/webrtc/types.js +0 -5
  67. package/dist/webrtc/types.js.map +0 -1
@@ -0,0 +1,1210 @@
1
+ /**
2
+ * NostrRefResolver - Maps npub/treename keys to merkle root hashes (refs)
3
+ *
4
+ * Key format: "npub1.../treename"
5
+ *
6
+ * This resolver provides direct callback subscriptions that bypass React's
7
+ * render cycle. Components can subscribe to hash changes and update directly
8
+ * (e.g., MediaSource append) without triggering re-renders.
9
+ */
10
+ import type {
11
+ RefResolver,
12
+ CID,
13
+ RefResolverListEntry,
14
+ SubscribeVisibilityInfo,
15
+ PublishOptions,
16
+ PublishResult,
17
+ RefResolverSubscriptionMetadata,
18
+ } from '@hashtree/core';
19
+ import { fromHex, toHex, cid } from '@hashtree/core';
20
+ import {
21
+ encryptKeyForLink,
22
+ computeKeyId,
23
+ generateLinkKey,
24
+ type TreeVisibility,
25
+ } from '@hashtree/core';
26
+ import {
27
+ HASHTREE_LABEL,
28
+ HASHTREE_ROOT_KIND,
29
+ HASHTREE_ROOT_KINDS,
30
+ } from '../snapshot.js';
31
+
32
+ // Nostr event structure (minimal)
33
+ export interface NostrEvent {
34
+ id?: string;
35
+ pubkey: string;
36
+ kind: number;
37
+ content: string;
38
+ tags: string[][];
39
+ created_at: number;
40
+ }
41
+
42
+ // Type definitions for nip19
43
+ export interface Nip19Like {
44
+ decode(str: string): { type: string; data: unknown };
45
+ npubEncode(pubkey: string): string;
46
+ }
47
+
48
+ // Filter for querying events
49
+ export interface NostrFilter {
50
+ kinds?: number[];
51
+ authors?: string[];
52
+ '#d'?: string[];
53
+ '#l'?: string[];
54
+ }
55
+
56
+ // Subscription entry
57
+ interface SubscriptionEntry {
58
+ unsubscribe: () => void;
59
+ callbacks: Set<(
60
+ cid: CID | null,
61
+ visibilityInfo?: SubscribeVisibilityInfo,
62
+ metadata?: RefResolverSubscriptionMetadata
63
+ ) => void>;
64
+ currentHash: string | null;
65
+ currentKey: string | null;
66
+ currentVisibility: SubscribeVisibilityInfo | null;
67
+ latestCreatedAt: number;
68
+ latestEventId: string | null;
69
+ }
70
+
71
+ type CachedListEntry = ParsedTreeVisibility & { created_at: number; eventId?: string };
72
+
73
+ /**
74
+ * Parsed visibility info from a nostr event
75
+ */
76
+ export interface ParsedTreeVisibility {
77
+ hash: string;
78
+ visibility: TreeVisibility;
79
+ /** All l-tags attached to the tree event */
80
+ labels?: string[];
81
+ /** Plaintext key (for public trees) */
82
+ key?: string;
83
+ /** Encrypted key (for link-visible trees) - decrypt with link key */
84
+ encryptedKey?: string;
85
+ /** Key ID (for link-visible trees) - identifies which link key to use */
86
+ keyId?: string;
87
+ /** Self-encrypted key (for private/link-visible trees) - decrypt with NIP-44 */
88
+ selfEncryptedKey?: string;
89
+ /** Self-encrypted link key (for link-visible trees) - allows owner to recover link key for sharing */
90
+ selfEncryptedLinkKey?: string;
91
+ }
92
+
93
+ interface LegacyContentPayload {
94
+ hash?: string;
95
+ key?: string;
96
+ visibility?: TreeVisibility;
97
+ encryptedKey?: string;
98
+ keyId?: string;
99
+ selfEncryptedKey?: string;
100
+ selfEncryptedLinkKey?: string;
101
+ }
102
+
103
+ function hasLabel(event: NostrEvent, label: string): boolean {
104
+ return event.tags.some(tag => tag[0] === 'l' && tag[1] === label);
105
+ }
106
+
107
+ function hasAnyLabel(event: NostrEvent): boolean {
108
+ return event.tags.some(tag => tag[0] === 'l');
109
+ }
110
+
111
+ function uniqueLabels(labels: string[]): string[] {
112
+ const seen = new Set<string>();
113
+ const result: string[] = [];
114
+ for (const label of labels) {
115
+ if (!label || seen.has(label)) continue;
116
+ seen.add(label);
117
+ result.push(label);
118
+ }
119
+ return result;
120
+ }
121
+
122
+ function compareReplaceableEventOrder(
123
+ candidateCreatedAt: number,
124
+ candidateEventId: string | null | undefined,
125
+ currentCreatedAt: number,
126
+ currentEventId: string | null | undefined,
127
+ ): number {
128
+ if (candidateCreatedAt !== currentCreatedAt) {
129
+ return candidateCreatedAt - currentCreatedAt;
130
+ }
131
+
132
+ const candidateId = candidateEventId ?? '';
133
+ const currentId = currentEventId ?? '';
134
+ if (candidateId === currentId) {
135
+ return 0;
136
+ }
137
+
138
+ if (!candidateId) return -1;
139
+ if (!currentId) return 1;
140
+ return currentId.localeCompare(candidateId);
141
+ }
142
+
143
+ function parseLabels(event: NostrEvent): string[] {
144
+ return uniqueLabels(
145
+ event.tags
146
+ .filter(tag => tag[0] === 'l')
147
+ .map(tag => tag[1])
148
+ .filter((label): label is string => typeof label === 'string' && label.length > 0)
149
+ );
150
+ }
151
+
152
+ function parseLegacyContent(event: NostrEvent): LegacyContentPayload | null {
153
+ const content = event.content?.trim();
154
+ if (!content) return null;
155
+
156
+ try {
157
+ const parsed = JSON.parse(content);
158
+ if (parsed && typeof parsed === 'object') {
159
+ const payload = parsed as Record<string, unknown>;
160
+ return {
161
+ hash: typeof payload.hash === 'string' ? payload.hash : undefined,
162
+ key: typeof payload.key === 'string' ? payload.key : undefined,
163
+ visibility: typeof payload.visibility === 'string' ? payload.visibility as TreeVisibility : undefined,
164
+ encryptedKey: typeof payload.encryptedKey === 'string' ? payload.encryptedKey : undefined,
165
+ keyId: typeof payload.keyId === 'string' ? payload.keyId : undefined,
166
+ selfEncryptedKey: typeof payload.selfEncryptedKey === 'string' ? payload.selfEncryptedKey : undefined,
167
+ selfEncryptedLinkKey: typeof payload.selfEncryptedLinkKey === 'string' ? payload.selfEncryptedLinkKey : undefined,
168
+ };
169
+ }
170
+ } catch {
171
+ // Ignore JSON parse errors.
172
+ }
173
+
174
+ if (/^[0-9a-fA-F]{64}$/.test(content)) {
175
+ return { hash: content };
176
+ }
177
+
178
+ return null;
179
+ }
180
+
181
+ /**
182
+ * Parse hash and visibility info from a nostr event
183
+ * Supports all visibility levels: public, link-visible, private
184
+ */
185
+ function parseHashAndVisibility(event: NostrEvent): ParsedTreeVisibility | null {
186
+ const hashTag = event.tags.find(t => t[0] === 'hash')?.[1];
187
+ const legacyContent = hashTag ? null : parseLegacyContent(event);
188
+ const hash = hashTag ?? legacyContent?.hash;
189
+ if (!hash) return null;
190
+
191
+ const keyTag = event.tags.find(t => t[0] === 'key')?.[1];
192
+ const encryptedKeyTag = event.tags.find(t => t[0] === 'encryptedKey')?.[1];
193
+ const keyIdTag = event.tags.find(t => t[0] === 'keyId')?.[1];
194
+ const selfEncryptedKeyTag = event.tags.find(t => t[0] === 'selfEncryptedKey')?.[1];
195
+ const selfEncryptedLinkKeyTag = event.tags.find(t => t[0] === 'selfEncryptedLinkKey')?.[1];
196
+
197
+ const key = keyTag ?? legacyContent?.key;
198
+ const encryptedKey = encryptedKeyTag ?? legacyContent?.encryptedKey;
199
+ const keyId = keyIdTag ?? legacyContent?.keyId;
200
+ const selfEncryptedKey = selfEncryptedKeyTag ?? legacyContent?.selfEncryptedKey;
201
+ const selfEncryptedLinkKey = selfEncryptedLinkKeyTag ?? legacyContent?.selfEncryptedLinkKey;
202
+ const labels = parseLabels(event);
203
+
204
+ let visibility: TreeVisibility;
205
+ if (encryptedKey) {
206
+ // encryptedKey means link-visible (shareable via link)
207
+ // May also have selfEncryptedKey for owner access
208
+ visibility = 'link-visible';
209
+ } else if (selfEncryptedKey) {
210
+ // Only selfEncryptedKey (no encryptedKey) means private
211
+ visibility = 'private';
212
+ } else {
213
+ visibility = legacyContent?.visibility ?? 'public';
214
+ }
215
+
216
+ return { hash, visibility, labels, key, encryptedKey, keyId, selfEncryptedKey, selfEncryptedLinkKey };
217
+ }
218
+
219
+ /**
220
+ * Legacy parse function for backwards compatibility
221
+ * @deprecated Use parseHashAndVisibility instead
222
+ */
223
+ function parseHashAndKey(event: NostrEvent): { hash: string; key?: string } | null {
224
+ const result = parseHashAndVisibility(event);
225
+ if (!result) return null;
226
+ return { hash: result.hash, key: result.key };
227
+ }
228
+
229
+ /** Event type for publishing (created_at optional - usually auto-set by NDK) */
230
+ export type NostrPublishEvent = Omit<NostrEvent, 'id' | 'pubkey' | 'created_at'> & { created_at?: number };
231
+
232
+ /**
233
+ * Visibility callbacks for encryption/decryption
234
+ * Used for private trees (NIP-44 self-encryption)
235
+ */
236
+ export interface VisibilityCallbacks {
237
+ /** NIP-44 encrypt data to a pubkey */
238
+ encrypt?: (data: Uint8Array, toPubkey: string) => Promise<string>;
239
+ /** NIP-44 decrypt data from a pubkey */
240
+ decrypt?: (ciphertext: string, fromPubkey: string) => Promise<Uint8Array | null>;
241
+ /** Get link key from URL context (for link-visible trees) */
242
+ getLinkKey?: () => Uint8Array | null;
243
+ }
244
+
245
+ export interface NostrRefResolverConfig {
246
+ /** Subscribe to nostr events - returns unsubscribe function */
247
+ subscribe: (filter: NostrFilter, onEvent: (event: NostrEvent) => void) => () => void;
248
+ /** Publish a nostr event - returns true on success. created_at is optional (for delete events) */
249
+ publish: (event: NostrPublishEvent) => Promise<boolean>;
250
+ /** Get current user's pubkey (for ownership checks) */
251
+ getPubkey: () => string | null;
252
+ /** nip19 encode/decode functions from nostr-tools */
253
+ nip19: Nip19Like;
254
+ /** Optional visibility callbacks for private/link-visible trees */
255
+ visibility?: VisibilityCallbacks;
256
+ }
257
+
258
+ /**
259
+ * Create a NostrRefResolver instance
260
+ */
261
+
262
+ export function createNostrRefResolver(config: NostrRefResolverConfig): RefResolver {
263
+ const { subscribe: nostrSubscribe, publish: nostrPublish, getPubkey, nip19, visibility } = config;
264
+
265
+ // Active subscriptions by key
266
+ const subscriptions = new Map<string, SubscriptionEntry>();
267
+
268
+ // Active list subscriptions by npub prefix
269
+ // Supports multiple callbacks per npub (multiple components can subscribe)
270
+ interface ListSubscriptionEntry {
271
+ npub: string;
272
+ entriesByDTag: Map<string, CachedListEntry>;
273
+ callbacks: Set<(entries: RefResolverListEntry[]) => void>;
274
+ unsubscribe: () => void;
275
+ }
276
+ const listSubscriptions = new Map<string, ListSubscriptionEntry>();
277
+
278
+ // Persistent local cache for list entries (survives subscription lifecycle)
279
+ // Key is npub, value is map of tree name -> entry
280
+ const localListCache = new Map<string, Map<string, CachedListEntry>>();
281
+
282
+ function nextReplaceableCreatedAt(key: string, npubStr: string, treeName: string, minimum: number): number {
283
+ let latestKnown = 0;
284
+
285
+ const activeSub = subscriptions.get(key);
286
+ if (activeSub?.latestCreatedAt) {
287
+ latestKnown = Math.max(latestKnown, activeSub.latestCreatedAt);
288
+ }
289
+
290
+ const cachedEntry = localListCache.get(npubStr)?.get(treeName);
291
+ if (cachedEntry?.created_at) {
292
+ latestKnown = Math.max(latestKnown, cachedEntry.created_at);
293
+ }
294
+
295
+ const activeListEntry = listSubscriptions.get(npubStr)?.entriesByDTag.get(treeName);
296
+ if (activeListEntry?.created_at) {
297
+ latestKnown = Math.max(latestKnown, activeListEntry.created_at);
298
+ }
299
+
300
+ return latestKnown >= minimum ? latestKnown + 1 : minimum;
301
+ }
302
+
303
+ /**
304
+ * Parse a pointer key into pubkey and tree name
305
+ * Key format: "npub1.../treename" or "npub1.../path/to/treename"
306
+ */
307
+ function parseKey(key: string): { pubkey: string; treeName: string } | null {
308
+ const slashIdx = key.indexOf('/');
309
+ if (slashIdx === -1) return null;
310
+
311
+ const npubStr = key.slice(0, slashIdx);
312
+ const treeName = key.slice(slashIdx + 1);
313
+ if (!treeName) return null;
314
+
315
+ try {
316
+ const decoded = nip19.decode(npubStr);
317
+ if (decoded.type !== 'npub') return null;
318
+ return { pubkey: decoded.data as string, treeName };
319
+ } catch {
320
+ return null;
321
+ }
322
+ }
323
+
324
+ return {
325
+ /**
326
+ * Resolve a key to its current CID.
327
+ * Waits indefinitely until found - caller should apply timeout if needed.
328
+ */
329
+ async resolve(key: string): Promise<CID | null> {
330
+ const parsed = parseKey(key);
331
+ if (!parsed) return null;
332
+
333
+ const { pubkey, treeName } = parsed;
334
+
335
+ return new Promise((resolve) => {
336
+ let latestData: { hash: string; key?: string } | null = null;
337
+ let latestCreatedAt = 0;
338
+ let latestEventId: string | null = null;
339
+ let resolveTimeout: ReturnType<typeof setTimeout> | null = null;
340
+
341
+ const doResolve = () => {
342
+ unsubscribe();
343
+ if (latestData) {
344
+ resolve(cid(fromHex(latestData.hash), latestData.key ? fromHex(latestData.key) : undefined));
345
+ } else {
346
+ resolve(null);
347
+ }
348
+ };
349
+
350
+ const unsubscribe = nostrSubscribe(
351
+ {
352
+ kinds: [...HASHTREE_ROOT_KINDS],
353
+ authors: [pubkey],
354
+ '#d': [treeName],
355
+ },
356
+ (event) => {
357
+ const dTag = event.tags.find(t => t[0] === 'd')?.[1];
358
+ if (dTag !== treeName) return;
359
+ if (hasAnyLabel(event) && !hasLabel(event, HASHTREE_LABEL)) return;
360
+
361
+ const hashAndKey = parseHashAndKey(event);
362
+ if (!hashAndKey) return;
363
+
364
+ if (compareReplaceableEventOrder(
365
+ event.created_at || 0,
366
+ event.id ?? null,
367
+ latestCreatedAt,
368
+ latestEventId,
369
+ ) > 0) {
370
+ latestCreatedAt = event.created_at || 0;
371
+ latestEventId = event.id ?? null;
372
+ latestData = hashAndKey;
373
+ }
374
+
375
+ // Debounce: wait 100ms after last event to allow newer events to arrive
376
+ // This handles the case where relay sends multiple events for replaceable events
377
+ if (resolveTimeout) {
378
+ clearTimeout(resolveTimeout);
379
+ }
380
+ resolveTimeout = setTimeout(doResolve, 100);
381
+ }
382
+ );
383
+ });
384
+ },
385
+
386
+ /**
387
+ * Subscribe to CID changes for a key.
388
+ * Callback fires on each update (including initial value).
389
+ * This runs outside React render cycle.
390
+ */
391
+ subscribe(
392
+ key: string,
393
+ callback: (
394
+ cid: CID | null,
395
+ visibilityInfo?: SubscribeVisibilityInfo,
396
+ metadata?: RefResolverSubscriptionMetadata
397
+ ) => void
398
+ ): () => void {
399
+ const parsed = parseKey(key);
400
+ if (!parsed) {
401
+ callback(null);
402
+ return () => {};
403
+ }
404
+
405
+ const { pubkey, treeName } = parsed;
406
+
407
+ // Check if we already have a subscription for this key
408
+ let sub = subscriptions.get(key);
409
+
410
+ if (sub) {
411
+ // Add callback to existing subscription
412
+ sub.callbacks.add(callback);
413
+ // Fire immediately with current value
414
+ console.log('[Resolver] Reusing subscription for', key, {
415
+ hasCurrentHash: !!sub.currentHash,
416
+ currentVisibility: sub.currentVisibility ? JSON.stringify(sub.currentVisibility) : 'null'
417
+ });
418
+ if (sub.currentHash) {
419
+ const keyBytes = sub.currentKey ? fromHex(sub.currentKey) : undefined;
420
+ callback(
421
+ cid(fromHex(sub.currentHash), keyBytes),
422
+ sub.currentVisibility ?? undefined,
423
+ {
424
+ updatedAt: sub.latestCreatedAt,
425
+ eventId: sub.latestEventId ?? undefined,
426
+ }
427
+ );
428
+ }
429
+ } else {
430
+ // Helper to notify all callbacks
431
+ const notifyCallbacks = (subEntry: SubscriptionEntry) => {
432
+ if (!subEntry.currentHash) return;
433
+ const keyBytes = subEntry.currentKey ? fromHex(subEntry.currentKey) : undefined;
434
+ const visibilityInfo = subEntry.currentVisibility ?? undefined;
435
+ const metadata = {
436
+ updatedAt: subEntry.latestCreatedAt,
437
+ eventId: subEntry.latestEventId ?? undefined,
438
+ };
439
+ for (const cb of subEntry.callbacks) {
440
+ try {
441
+ cb(cid(fromHex(subEntry.currentHash), keyBytes), visibilityInfo, metadata);
442
+ } catch (e) {
443
+ console.error('Resolver callback error:', e);
444
+ }
445
+ }
446
+ };
447
+
448
+ // Create new subscription for live updates
449
+ const unsubscribe = nostrSubscribe(
450
+ {
451
+ kinds: [...HASHTREE_ROOT_KINDS],
452
+ authors: [pubkey],
453
+ '#d': [treeName],
454
+ },
455
+ (event) => {
456
+ const dTag = event.tags.find(t => t[0] === 'd')?.[1];
457
+ if (dTag !== treeName) return;
458
+ if (hasAnyLabel(event) && !hasLabel(event, HASHTREE_LABEL)) return;
459
+
460
+ const subEntry = subscriptions.get(key);
461
+ if (!subEntry) return;
462
+
463
+ const visibilityData = parseHashAndVisibility(event);
464
+ console.log('[Resolver] Event received for', key, {
465
+ visibilityData: visibilityData ? {
466
+ visibility: visibilityData.visibility,
467
+ hasSelfEncryptedLinkKey: !!visibilityData.selfEncryptedLinkKey,
468
+ hasEncryptedKey: !!visibilityData.encryptedKey
469
+ } : 'null'
470
+ });
471
+ if (!visibilityData) return;
472
+
473
+ const eventCreatedAt = event.created_at || 0;
474
+ const newHash = visibilityData.hash;
475
+ const newKey = visibilityData.key;
476
+ const eventOrder = compareReplaceableEventOrder(
477
+ eventCreatedAt,
478
+ event.id ?? null,
479
+ subEntry.latestCreatedAt,
480
+ subEntry.latestEventId,
481
+ );
482
+
483
+ // Only update if this event is newer, or if it matches the same source event but
484
+ // provides data we haven't stored yet.
485
+ const currentVisibilityJson = JSON.stringify(subEntry.currentVisibility ?? null);
486
+ const nextVisibilityJson = JSON.stringify({
487
+ visibility: visibilityData.visibility,
488
+ encryptedKey: visibilityData.encryptedKey,
489
+ keyId: visibilityData.keyId,
490
+ selfEncryptedKey: visibilityData.selfEncryptedKey,
491
+ selfEncryptedLinkKey: visibilityData.selfEncryptedLinkKey,
492
+ });
493
+ const hasChangedData =
494
+ newHash !== subEntry.currentHash ||
495
+ (newKey ?? null) !== subEntry.currentKey ||
496
+ nextVisibilityJson !== currentVisibilityJson;
497
+
498
+ if (eventOrder > 0 || (eventOrder === 0 && hasChangedData)) {
499
+ subEntry.currentHash = newHash;
500
+ subEntry.currentKey = newKey || null;
501
+ subEntry.latestCreatedAt = eventCreatedAt;
502
+ subEntry.latestEventId = event.id ?? null;
503
+
504
+ // Build visibility info for callback
505
+ const visibilityInfo: SubscribeVisibilityInfo = {
506
+ visibility: visibilityData.visibility,
507
+ encryptedKey: visibilityData.encryptedKey,
508
+ keyId: visibilityData.keyId,
509
+ selfEncryptedKey: visibilityData.selfEncryptedKey,
510
+ selfEncryptedLinkKey: visibilityData.selfEncryptedLinkKey,
511
+ };
512
+ subEntry.currentVisibility = visibilityInfo;
513
+
514
+ // Update localListCache so other subscriptions can find this data
515
+ const npubStr = key.split('/')[0];
516
+ let npubCache = localListCache.get(npubStr);
517
+ if (!npubCache) {
518
+ npubCache = new Map();
519
+ localListCache.set(npubStr, npubCache);
520
+ }
521
+ npubCache.set(treeName, {
522
+ hash: newHash,
523
+ visibility: visibilityData.visibility,
524
+ key: newKey,
525
+ encryptedKey: visibilityData.encryptedKey,
526
+ keyId: visibilityData.keyId,
527
+ selfEncryptedKey: visibilityData.selfEncryptedKey,
528
+ selfEncryptedLinkKey: visibilityData.selfEncryptedLinkKey,
529
+ created_at: eventCreatedAt,
530
+ eventId: event.id,
531
+ });
532
+
533
+ notifyCallbacks(subEntry);
534
+ }
535
+ }
536
+ );
537
+
538
+ // Check localListCache for initial values (from recent publish calls)
539
+ const npubStr = key.split('/')[0];
540
+ const cachedEntry = localListCache.get(npubStr)?.get(treeName);
541
+
542
+ sub = {
543
+ unsubscribe,
544
+ callbacks: new Set([callback]),
545
+ currentHash: cachedEntry?.hash ?? null,
546
+ currentKey: cachedEntry?.key ?? null,
547
+ currentVisibility: cachedEntry ? {
548
+ visibility: cachedEntry.visibility,
549
+ encryptedKey: cachedEntry.encryptedKey,
550
+ keyId: cachedEntry.keyId,
551
+ selfEncryptedKey: cachedEntry.selfEncryptedKey,
552
+ selfEncryptedLinkKey: cachedEntry.selfEncryptedLinkKey,
553
+ } : null,
554
+ latestCreatedAt: cachedEntry?.created_at ?? 0,
555
+ latestEventId: cachedEntry?.eventId ?? null,
556
+ };
557
+ subscriptions.set(key, sub);
558
+
559
+ // Fire callback immediately with cached value if available
560
+ if (cachedEntry?.hash) {
561
+ const keyBytes = cachedEntry.key ? fromHex(cachedEntry.key) : undefined;
562
+ callback(
563
+ cid(fromHex(cachedEntry.hash), keyBytes),
564
+ sub.currentVisibility ?? undefined,
565
+ {
566
+ updatedAt: sub.latestCreatedAt,
567
+ eventId: sub.latestEventId ?? undefined,
568
+ }
569
+ );
570
+ }
571
+ }
572
+
573
+ // Return unsubscribe function
574
+ return () => {
575
+ const subEntry = subscriptions.get(key);
576
+ if (!subEntry) return;
577
+
578
+ subEntry.callbacks.delete(callback);
579
+
580
+ // If no more callbacks, close the subscription
581
+ if (subEntry.callbacks.size === 0) {
582
+ subEntry.unsubscribe();
583
+ subscriptions.delete(key);
584
+ }
585
+ };
586
+ },
587
+
588
+ /**
589
+ * Publish/update a pointer
590
+ * Handles all visibility types: public, link-visible, private
591
+ * Updates local cache immediately (optimistic), then publishes to network.
592
+ * @param key - The key to publish to
593
+ * @param rootCid - The CID to publish (with encryption key if encrypted)
594
+ * @param options - Publish options (visibility, linkKey, labels)
595
+ * @returns Result with success status and linkKey (for link-visible)
596
+ */
597
+ async publish(key: string, rootCid: CID, options?: PublishOptions): Promise<PublishResult> {
598
+ const parsed = parseKey(key);
599
+ if (!parsed) return { success: false };
600
+
601
+ const { treeName } = parsed;
602
+ const pubkey = getPubkey();
603
+
604
+ if (!pubkey) return { success: false };
605
+
606
+ const visibilityType = options?.visibility ?? 'public';
607
+ const hashHex = toHex(rootCid.hash);
608
+ const chkKey = rootCid.key; // Raw encryption key (Uint8Array)
609
+ const chkKeyHex = chkKey ? toHex(chkKey) : undefined;
610
+ const now = Math.floor(Date.now() / 1000);
611
+ const npubStr = key.split('/')[0];
612
+ const publishCreatedAt = nextReplaceableCreatedAt(key, npubStr, treeName, now);
613
+ const publishMs = Math.max(Date.now(), publishCreatedAt * 1000);
614
+
615
+ // Build visibility info for caches and tags
616
+ const visibilityInfo: SubscribeVisibilityInfo = { visibility: visibilityType };
617
+ let resultLinkKey: Uint8Array | undefined;
618
+
619
+ // Build nostr event tags
620
+ const tags: string[][] = [
621
+ ['d', treeName],
622
+ ['l', HASHTREE_LABEL],
623
+ ['hash', hashHex],
624
+ ['ms', String(publishMs)],
625
+ ];
626
+
627
+ // Add directory prefix labels for discoverability
628
+ const parts = treeName.split('/');
629
+ for (let i = 1; i < parts.length; i++) {
630
+ const prefix = parts.slice(0, i).join('/');
631
+ tags.push(['l', prefix]);
632
+ }
633
+
634
+ // Add extra labels if provided
635
+ if (options?.labels) {
636
+ for (const label of options.labels) {
637
+ tags.push(['l', label]);
638
+ }
639
+ }
640
+ const allLabels = uniqueLabels(tags.filter(tag => tag[0] === 'l').map(tag => tag[1]));
641
+
642
+ // Handle visibility-specific encryption and tags
643
+ if (chkKey) {
644
+ switch (visibilityType) {
645
+ case 'public':
646
+ // Plaintext key - anyone can access
647
+ tags.push(['key', chkKeyHex!]);
648
+ visibilityInfo.encryptedKey = undefined;
649
+ visibilityInfo.keyId = undefined;
650
+ visibilityInfo.selfEncryptedKey = undefined;
651
+ break;
652
+
653
+ case 'link-visible': {
654
+ // Encrypt key with link key (XOR one-time pad)
655
+ const linkKey = options?.linkKey ?? generateLinkKey();
656
+ resultLinkKey = linkKey;
657
+ const encryptedKey = encryptKeyForLink(chkKey, linkKey);
658
+ const keyId = await computeKeyId(linkKey);
659
+ tags.push(['encryptedKey', toHex(encryptedKey)]);
660
+ tags.push(['keyId', toHex(keyId)]);
661
+ visibilityInfo.encryptedKey = toHex(encryptedKey);
662
+ visibilityInfo.keyId = toHex(keyId);
663
+
664
+ if (visibility?.encrypt) {
665
+ // Encrypt contentKey to self (so owner can always access without linkKey)
666
+ try {
667
+ const selfEncrypted = await visibility.encrypt(chkKey, pubkey);
668
+ tags.push(['selfEncryptedKey', selfEncrypted]);
669
+ visibilityInfo.selfEncryptedKey = selfEncrypted;
670
+ } catch (e) {
671
+ console.error('Failed to self-encrypt content key:', e);
672
+ }
673
+
674
+ // Encrypt linkKey to self for URL recovery (so owner can share URLs easily)
675
+ // Owner can also derive linkKey from XOR(encryptedKey, contentKey)
676
+ try {
677
+ const selfEncryptedLinkKey = await visibility.encrypt(linkKey, pubkey);
678
+ tags.push(['selfEncryptedLinkKey', selfEncryptedLinkKey]);
679
+ visibilityInfo.selfEncryptedLinkKey = selfEncryptedLinkKey;
680
+ } catch (e) {
681
+ console.error('Failed to self-encrypt link key:', e);
682
+ }
683
+ }
684
+ break;
685
+ }
686
+
687
+ case 'private': {
688
+ // Encrypt key to self using NIP-44
689
+ if (!visibility?.encrypt) {
690
+ console.error('Cannot publish private tree: no encrypt callback provided');
691
+ return { success: false };
692
+ }
693
+ try {
694
+ const selfEncrypted = await visibility.encrypt(chkKey, pubkey);
695
+ tags.push(['selfEncryptedKey', selfEncrypted]);
696
+ visibilityInfo.selfEncryptedKey = selfEncrypted;
697
+ } catch (e) {
698
+ console.error('Failed to encrypt key:', e);
699
+ return { success: false };
700
+ }
701
+ break;
702
+ }
703
+ }
704
+ }
705
+
706
+ // 1. Update local caches FIRST (optimistic update for instant UI)
707
+
708
+ // Update local list cache (persists even without active subscription)
709
+ let npubCache = localListCache.get(npubStr);
710
+ if (!npubCache) {
711
+ npubCache = new Map();
712
+ localListCache.set(npubStr, npubCache);
713
+ }
714
+ npubCache.set(treeName, {
715
+ hash: hashHex,
716
+ visibility: visibilityType,
717
+ labels: allLabels,
718
+ key: visibilityType === 'public' ? chkKeyHex : undefined,
719
+ encryptedKey: visibilityInfo.encryptedKey,
720
+ keyId: visibilityInfo.keyId,
721
+ selfEncryptedKey: visibilityInfo.selfEncryptedKey,
722
+ selfEncryptedLinkKey: visibilityInfo.selfEncryptedLinkKey,
723
+ created_at: publishCreatedAt,
724
+ eventId: undefined,
725
+ });
726
+
727
+ // Update active subscription state
728
+ const sub = subscriptions.get(key);
729
+ if (sub) {
730
+ sub.currentHash = hashHex;
731
+ sub.currentKey = visibilityType === 'public' ? chkKeyHex || null : null;
732
+ sub.latestCreatedAt = publishCreatedAt;
733
+ sub.latestEventId = null;
734
+ sub.currentVisibility = visibilityInfo;
735
+ // Notify callbacks with CID
736
+ for (const cb of sub.callbacks) {
737
+ try {
738
+ cb(rootCid, visibilityInfo);
739
+ } catch (e) {
740
+ console.error('Resolver callback error:', e);
741
+ }
742
+ }
743
+ }
744
+
745
+ // Update active list subscriptions
746
+ const listSub = listSubscriptions.get(npubStr);
747
+ if (listSub) {
748
+ const existingEntry = listSub.entriesByDTag.get(treeName);
749
+ const existingWasDeleted = existingEntry && !existingEntry.hash;
750
+ const timeDiff = existingEntry ? now - existingEntry.created_at : 0;
751
+
752
+ // Skip if this would undelete a recently-deleted entry (within 30 seconds)
753
+ if (existingWasDeleted && hashHex && timeDiff < 30) {
754
+ // Blocked stale undelete
755
+ } else {
756
+ listSub.entriesByDTag.set(treeName, {
757
+ hash: hashHex,
758
+ visibility: visibilityType,
759
+ labels: allLabels,
760
+ key: visibilityType === 'public' ? chkKeyHex : undefined,
761
+ encryptedKey: visibilityInfo.encryptedKey,
762
+ keyId: visibilityInfo.keyId,
763
+ selfEncryptedKey: visibilityInfo.selfEncryptedKey,
764
+ selfEncryptedLinkKey: visibilityInfo.selfEncryptedLinkKey,
765
+ created_at: publishCreatedAt,
766
+ eventId: undefined,
767
+ });
768
+ // Emit updated state immediately to ALL callbacks
769
+ const result: RefResolverListEntry[] = [];
770
+ for (const [dTag, entry] of listSub.entriesByDTag) {
771
+ if (!entry.hash) continue; // Skip deleted trees
772
+ result.push({
773
+ key: `${npubStr}/${dTag}`,
774
+ cid: cid(fromHex(entry.hash), entry.key ? fromHex(entry.key) : undefined),
775
+ labels: entry.labels,
776
+ visibility: entry.visibility,
777
+ encryptedKey: entry.encryptedKey,
778
+ keyId: entry.keyId,
779
+ selfEncryptedKey: entry.selfEncryptedKey,
780
+ selfEncryptedLinkKey: entry.selfEncryptedLinkKey,
781
+ createdAt: entry.created_at,
782
+ });
783
+ }
784
+ for (const cb of listSub.callbacks) {
785
+ try {
786
+ cb(result);
787
+ } catch (e) {
788
+ console.error('List callback error:', e);
789
+ }
790
+ }
791
+ }
792
+ }
793
+
794
+ // 2. Publish to network
795
+ try {
796
+ const success = await nostrPublish({
797
+ kind: HASHTREE_ROOT_KIND,
798
+ content: '',
799
+ tags,
800
+ created_at: publishCreatedAt,
801
+ });
802
+ if (!success) {
803
+ return { success: false, linkKey: resultLinkKey };
804
+ }
805
+ } catch (e) {
806
+ console.error('Failed to publish to nostr:', e);
807
+ return { success: false, linkKey: resultLinkKey };
808
+ }
809
+
810
+ return { success: true, linkKey: resultLinkKey };
811
+ },
812
+
813
+ /**
814
+ * List all trees for a user.
815
+ * Streams results as they arrive - returns unsubscribe function.
816
+ * Caller decides when to stop listening.
817
+ * Supports multiple subscribers per npub - all callbacks receive updates.
818
+ */
819
+ list(prefix: string, callback: (entries: RefResolverListEntry[]) => void): () => void {
820
+ const parts = prefix.split('/');
821
+ if (parts.length === 0) {
822
+ callback([]);
823
+ return () => {};
824
+ }
825
+
826
+ const npubStr = parts[0];
827
+ let pubkey: string;
828
+
829
+ try {
830
+ const decoded = nip19.decode(npubStr);
831
+ if (decoded.type !== 'npub') {
832
+ callback([]);
833
+ return () => {};
834
+ }
835
+ pubkey = decoded.data as string;
836
+ } catch {
837
+ callback([]);
838
+ return () => {};
839
+ }
840
+
841
+ // Check if we already have a subscription for this npub
842
+ const existingSub = listSubscriptions.get(npubStr);
843
+
844
+ if (existingSub) {
845
+ // Add callback to existing subscription
846
+ existingSub.callbacks.add(callback);
847
+
848
+ // Fire immediately with current state
849
+ const result: RefResolverListEntry[] = [];
850
+ for (const [dTag, entry] of existingSub.entriesByDTag) {
851
+ if (!entry.hash) {
852
+ continue;
853
+ }
854
+ result.push({
855
+ key: `${npubStr}/${dTag}`,
856
+ cid: cid(fromHex(entry.hash), entry.key ? fromHex(entry.key) : undefined),
857
+ labels: entry.labels,
858
+ visibility: entry.visibility,
859
+ encryptedKey: entry.encryptedKey,
860
+ keyId: entry.keyId,
861
+ selfEncryptedKey: entry.selfEncryptedKey,
862
+ selfEncryptedLinkKey: entry.selfEncryptedLinkKey,
863
+ createdAt: entry.created_at,
864
+ });
865
+ }
866
+ callback(result);
867
+
868
+ // Return unsubscribe that removes this callback
869
+ return () => {
870
+ existingSub.callbacks.delete(callback);
871
+ // If no more callbacks, clean up the subscription
872
+ if (existingSub.callbacks.size === 0) {
873
+ existingSub.unsubscribe();
874
+ listSubscriptions.delete(npubStr);
875
+ }
876
+ };
877
+ }
878
+
879
+ // Create new subscription
880
+ const entriesByDTag = new Map<string, CachedListEntry>();
881
+ const callbacks = new Set<(entries: RefResolverListEntry[]) => void>([callback]);
882
+
883
+ // Pre-populate from local cache (for instant display of locally-created trees)
884
+ const cachedEntries = localListCache.get(npubStr);
885
+ if (cachedEntries) {
886
+ for (const [treeName, entry] of cachedEntries) {
887
+ entriesByDTag.set(treeName, entry);
888
+ }
889
+ }
890
+
891
+ const emitCurrentState = () => {
892
+ const result: RefResolverListEntry[] = [];
893
+ for (const [dTag, entry] of entriesByDTag) {
894
+ // Skip entries with empty/null hash (deleted trees)
895
+ if (!entry.hash) continue;
896
+ result.push({
897
+ key: `${npubStr}/${dTag}`,
898
+ cid: cid(fromHex(entry.hash), entry.key ? fromHex(entry.key) : undefined),
899
+ labels: entry.labels,
900
+ visibility: entry.visibility,
901
+ encryptedKey: entry.encryptedKey,
902
+ keyId: entry.keyId,
903
+ selfEncryptedKey: entry.selfEncryptedKey,
904
+ selfEncryptedLinkKey: entry.selfEncryptedLinkKey,
905
+ createdAt: entry.created_at,
906
+ });
907
+ }
908
+ // Notify ALL callbacks
909
+ for (const cb of callbacks) {
910
+ try {
911
+ cb(result);
912
+ } catch (e) {
913
+ console.error('List callback error:', e);
914
+ }
915
+ }
916
+ };
917
+
918
+ // Emit cached entries immediately if any
919
+ if (entriesByDTag.size > 0) {
920
+ emitCurrentState();
921
+ }
922
+
923
+ const unsubscribe = nostrSubscribe(
924
+ {
925
+ kinds: [...HASHTREE_ROOT_KINDS],
926
+ authors: [pubkey],
927
+ '#l': [HASHTREE_LABEL],
928
+ },
929
+ (event) => {
930
+ const dTag = event.tags.find(t => t[0] === 'd')?.[1];
931
+ if (!dTag) return;
932
+
933
+ const parsed = parseHashAndVisibility(event);
934
+ const hasHash = !!parsed?.hash;
935
+ const existing = entriesByDTag.get(dTag);
936
+ const eventTime = event.created_at || 0;
937
+
938
+ if (existing) {
939
+ const existingIsDeleted = !existing.hash;
940
+ const timeDiff = eventTime - existing.created_at;
941
+ const eventOrder = compareReplaceableEventOrder(
942
+ eventTime,
943
+ event.id ?? null,
944
+ existing.created_at,
945
+ existing.eventId,
946
+ );
947
+
948
+ // Block stale events from "undeleting" within 30 seconds
949
+ if (existingIsDeleted && hasHash && timeDiff < 30) {
950
+ return;
951
+ }
952
+ if (eventOrder < 0) return;
953
+ if (eventOrder === 0 && existing.hash === (parsed?.hash ?? '')) return;
954
+ }
955
+
956
+ // Update the entry
957
+ const entryData = {
958
+ hash: parsed?.hash ?? '',
959
+ visibility: parsed?.visibility ?? 'public',
960
+ labels: parsed?.labels,
961
+ key: parsed?.key,
962
+ encryptedKey: parsed?.encryptedKey,
963
+ keyId: parsed?.keyId,
964
+ selfEncryptedKey: parsed?.selfEncryptedKey,
965
+ selfEncryptedLinkKey: parsed?.selfEncryptedLinkKey,
966
+ created_at: eventTime,
967
+ eventId: event.id,
968
+ };
969
+ entriesByDTag.set(dTag, entryData);
970
+
971
+ // Also update localListCache so subscribe() can find this data
972
+ // This enables cross-function caching: list() receives data, subscribe() can use it
973
+ let npubCache = localListCache.get(npubStr);
974
+ if (!npubCache) {
975
+ npubCache = new Map();
976
+ localListCache.set(npubStr, npubCache);
977
+ }
978
+ npubCache.set(dTag, entryData);
979
+
980
+ emitCurrentState();
981
+ }
982
+ );
983
+
984
+ // Register this list subscription
985
+ listSubscriptions.set(npubStr, {
986
+ npub: npubStr,
987
+ entriesByDTag,
988
+ callbacks,
989
+ unsubscribe,
990
+ });
991
+
992
+ return () => {
993
+ callbacks.delete(callback);
994
+ if (callbacks.size === 0) {
995
+ unsubscribe();
996
+ listSubscriptions.delete(npubStr);
997
+ }
998
+ };
999
+ },
1000
+
1001
+ /**
1002
+ * Stop all subscriptions
1003
+ */
1004
+ stop(): void {
1005
+ for (const [, sub] of subscriptions) {
1006
+ sub.unsubscribe();
1007
+ }
1008
+ subscriptions.clear();
1009
+ listSubscriptions.clear();
1010
+ },
1011
+
1012
+ /**
1013
+ * Delete a tree by publishing event without hash tag
1014
+ * This nullifies the tree - it will be filtered from list results
1015
+ */
1016
+ async delete(key: string): Promise<boolean> {
1017
+ const parsed = parseKey(key);
1018
+ if (!parsed) return false;
1019
+
1020
+ const { treeName } = parsed;
1021
+ const pubkey = getPubkey();
1022
+ if (!pubkey) return false;
1023
+
1024
+ const now = Math.floor(Date.now() / 1000);
1025
+ const npubStr = key.split('/')[0];
1026
+ const deleteCreatedAt = nextReplaceableCreatedAt(key, npubStr, treeName, now + 1);
1027
+ const deleteMs = Math.max(Date.now(), deleteCreatedAt * 1000);
1028
+
1029
+ // Update local list cache with empty hash (marks as deleted)
1030
+ let npubCache = localListCache.get(npubStr);
1031
+ if (!npubCache) {
1032
+ npubCache = new Map();
1033
+ localListCache.set(npubStr, npubCache);
1034
+ }
1035
+ npubCache.set(treeName, {
1036
+ hash: '', // Empty hash marks as deleted
1037
+ visibility: 'public',
1038
+ key: undefined,
1039
+ created_at: deleteCreatedAt,
1040
+ eventId: undefined,
1041
+ });
1042
+
1043
+ // Update active subscription state
1044
+ const sub = subscriptions.get(key);
1045
+ if (sub) {
1046
+ sub.currentHash = null;
1047
+ sub.currentKey = null;
1048
+ sub.latestCreatedAt = deleteCreatedAt;
1049
+ sub.latestEventId = null;
1050
+ sub.currentVisibility = null;
1051
+ // Notify callbacks with null CID
1052
+ for (const cb of sub.callbacks) {
1053
+ try {
1054
+ cb(null);
1055
+ } catch (e) {
1056
+ console.error('Resolver callback error:', e);
1057
+ }
1058
+ }
1059
+ }
1060
+
1061
+ // Update active list subscriptions - set hash to empty and emit
1062
+ const listSub = listSubscriptions.get(npubStr);
1063
+ if (listSub) {
1064
+ listSub.entriesByDTag.set(treeName, {
1065
+ hash: '', // Empty hash marks as deleted
1066
+ visibility: 'public',
1067
+ key: undefined,
1068
+ created_at: deleteCreatedAt,
1069
+ eventId: undefined,
1070
+ });
1071
+ // Emit - filter out empty hashes (deleted trees)
1072
+ const result: RefResolverListEntry[] = [];
1073
+ for (const [dTag, entry] of listSub.entriesByDTag) {
1074
+ if (!entry.hash) continue;
1075
+ result.push({
1076
+ key: `${npubStr}/${dTag}`,
1077
+ cid: cid(fromHex(entry.hash), entry.key ? fromHex(entry.key) : undefined),
1078
+ labels: entry.labels,
1079
+ visibility: entry.visibility,
1080
+ encryptedKey: entry.encryptedKey,
1081
+ keyId: entry.keyId,
1082
+ selfEncryptedKey: entry.selfEncryptedKey,
1083
+ selfEncryptedLinkKey: entry.selfEncryptedLinkKey,
1084
+ createdAt: entry.created_at,
1085
+ });
1086
+ }
1087
+ for (const cb of listSub.callbacks) {
1088
+ try {
1089
+ cb(result);
1090
+ } catch (e) {
1091
+ console.error('List callback error:', e);
1092
+ }
1093
+ }
1094
+ }
1095
+
1096
+ // 2. Publish to Nostr - event without hash tag
1097
+ // Use now + 1 to ensure delete timestamp is strictly higher than any create event
1098
+ // This is critical for NIP-01: when timestamps are equal, the lowest event ID wins.
1099
+ nostrPublish({
1100
+ kind: HASHTREE_ROOT_KIND,
1101
+ content: '',
1102
+ tags: [
1103
+ ['d', treeName],
1104
+ ['l', HASHTREE_LABEL],
1105
+ ['ms', String(deleteMs)],
1106
+ // No hash tag = deleted
1107
+ ],
1108
+ created_at: deleteCreatedAt,
1109
+ }).catch(e => console.error('Failed to publish delete to nostr:', e));
1110
+
1111
+ return true;
1112
+ },
1113
+
1114
+ /**
1115
+ * Inject a local list entry (for instant UI updates)
1116
+ * This updates the local cache to make trees appear immediately.
1117
+ */
1118
+ injectListEntry(entry: RefResolverListEntry): void {
1119
+ const parts = entry.key.split('/');
1120
+ if (parts.length !== 2) return;
1121
+ const [npubStr, treeName] = parts;
1122
+
1123
+ const now = Math.floor(Date.now() / 1000);
1124
+ const hasHash = !!toHex(entry.cid.hash);
1125
+ const injectedCreatedAt = nextReplaceableCreatedAt(entry.key, npubStr, treeName, now);
1126
+
1127
+ // Update the local list cache
1128
+ let npubCache = localListCache.get(npubStr);
1129
+ if (!npubCache) {
1130
+ npubCache = new Map();
1131
+ localListCache.set(npubStr, npubCache);
1132
+ }
1133
+
1134
+ const existing = npubCache.get(treeName);
1135
+ const existingWasDeletedCache = existing && !existing.hash;
1136
+ const timeDiffCache = existing ? now - existing.created_at : 0;
1137
+
1138
+ // Skip if this would undelete a recently-deleted entry (within 30 seconds)
1139
+ if (existingWasDeletedCache && hasHash && timeDiffCache < 30) {
1140
+ return;
1141
+ }
1142
+
1143
+ if (!existing || compareReplaceableEventOrder(injectedCreatedAt, null, existing.created_at, existing.eventId) >= 0) {
1144
+ npubCache.set(treeName, {
1145
+ hash: toHex(entry.cid.hash),
1146
+ visibility: entry.visibility ?? 'public',
1147
+ labels: entry.labels,
1148
+ key: entry.cid.key ? toHex(entry.cid.key) : undefined,
1149
+ encryptedKey: entry.encryptedKey,
1150
+ keyId: entry.keyId,
1151
+ selfEncryptedKey: entry.selfEncryptedKey,
1152
+ selfEncryptedLinkKey: entry.selfEncryptedLinkKey,
1153
+ created_at: injectedCreatedAt,
1154
+ eventId: undefined,
1155
+ });
1156
+ }
1157
+
1158
+ // If there's an active list subscription for this npub, update it too
1159
+ const listSub = listSubscriptions.get(npubStr);
1160
+ if (listSub) {
1161
+ const existingSub = listSub.entriesByDTag.get(treeName);
1162
+ const existingWasDeleted = existingSub && !existingSub.hash;
1163
+ const timeDiff = existingSub ? now - existingSub.created_at : 0;
1164
+
1165
+ // Skip if this would undelete a recently-deleted entry (within 30 seconds)
1166
+ if (existingWasDeleted && hasHash && timeDiff < 30) {
1167
+ return;
1168
+ }
1169
+
1170
+ if (!existingSub || compareReplaceableEventOrder(injectedCreatedAt, null, existingSub.created_at, existingSub.eventId) >= 0) {
1171
+ listSub.entriesByDTag.set(treeName, {
1172
+ hash: toHex(entry.cid.hash),
1173
+ visibility: entry.visibility ?? 'public',
1174
+ labels: entry.labels,
1175
+ key: entry.cid.key ? toHex(entry.cid.key) : undefined,
1176
+ encryptedKey: entry.encryptedKey,
1177
+ keyId: entry.keyId,
1178
+ selfEncryptedKey: entry.selfEncryptedKey,
1179
+ selfEncryptedLinkKey: entry.selfEncryptedLinkKey,
1180
+ created_at: injectedCreatedAt,
1181
+ eventId: undefined,
1182
+ });
1183
+ // Emit updated state to ALL callbacks
1184
+ const result: RefResolverListEntry[] = [];
1185
+ for (const [dTag, e] of listSub.entriesByDTag) {
1186
+ if (!e.hash) continue; // Skip deleted trees
1187
+ result.push({
1188
+ key: `${npubStr}/${dTag}`,
1189
+ cid: cid(fromHex(e.hash), e.key ? fromHex(e.key) : undefined),
1190
+ labels: e.labels,
1191
+ visibility: e.visibility,
1192
+ encryptedKey: e.encryptedKey,
1193
+ keyId: e.keyId,
1194
+ selfEncryptedKey: e.selfEncryptedKey,
1195
+ selfEncryptedLinkKey: e.selfEncryptedLinkKey,
1196
+ createdAt: e.created_at,
1197
+ });
1198
+ }
1199
+ for (const cb of listSub.callbacks) {
1200
+ try {
1201
+ cb(result);
1202
+ } catch (err) {
1203
+ console.error('List callback error:', err);
1204
+ }
1205
+ }
1206
+ }
1207
+ }
1208
+ },
1209
+ };
1210
+ }