@hashtree/nostr 0.1.14 → 0.2.6

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