@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.
- package/README.md +11 -33
- package/dist/eventCollection.d.ts +1 -1
- package/dist/eventCollection.d.ts.map +1 -1
- package/dist/eventCollection.js +2 -7
- package/dist/eventCollection.js.map +1 -1
- package/dist/eventKeys.d.ts +1 -0
- package/dist/eventKeys.d.ts.map +1 -1
- package/dist/eventKeys.js +7 -1
- package/dist/eventKeys.js.map +1 -1
- package/dist/events.d.ts +2 -0
- package/dist/events.d.ts.map +1 -1
- package/dist/events.js +41 -61
- package/dist/events.js.map +1 -1
- package/dist/index.d.ts +4 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +10 -6
- package/dist/index.js.map +1 -1
- package/dist/resolver/nostr.d.ts.map +1 -1
- package/dist/resolver/nostr.js +17 -12
- package/dist/resolver/nostr.js.map +1 -1
- package/dist/snapshot.d.ts +5 -0
- package/dist/snapshot.d.ts.map +1 -1
- package/dist/snapshot.js +9 -2
- package/dist/snapshot.js.map +1 -1
- package/dist/treeEventSnapshots.d.ts.map +1 -1
- package/dist/treeEventSnapshots.js +6 -11
- package/dist/treeEventSnapshots.js.map +1 -1
- package/package.json +18 -14
- package/src/eventCollection.ts +189 -0
- package/src/eventKeys.ts +155 -0
- package/src/eventValidation.ts +71 -0
- package/src/events.ts +900 -0
- package/src/index.ts +122 -0
- package/src/replaceablePublish.ts +204 -0
- package/src/resolver/index.ts +14 -0
- package/src/resolver/nostr.ts +1210 -0
- package/src/snapshot.ts +213 -0
- package/src/treeEventSnapshotPermalinks.ts +150 -0
- package/src/treeEventSnapshots.ts +280 -0
- package/dist/webrtc/index.d.ts +0 -6
- package/dist/webrtc/index.d.ts.map +0 -1
- package/dist/webrtc/index.js +0 -7
- package/dist/webrtc/index.js.map +0 -1
- package/dist/webrtc/lruCache.d.ts +0 -20
- package/dist/webrtc/lruCache.d.ts.map +0 -1
- package/dist/webrtc/lruCache.js +0 -59
- package/dist/webrtc/lruCache.js.map +0 -1
- package/dist/webrtc/peer.d.ts +0 -133
- package/dist/webrtc/peer.d.ts.map +0 -1
- package/dist/webrtc/peer.js +0 -617
- package/dist/webrtc/peer.js.map +0 -1
- package/dist/webrtc/peerSelector.d.ts +0 -2
- package/dist/webrtc/peerSelector.d.ts.map +0 -1
- package/dist/webrtc/peerSelector.js +0 -2
- package/dist/webrtc/peerSelector.js.map +0 -1
- package/dist/webrtc/protocol.d.ts +0 -3
- package/dist/webrtc/protocol.d.ts.map +0 -1
- package/dist/webrtc/protocol.js +0 -2
- package/dist/webrtc/protocol.js.map +0 -1
- package/dist/webrtc/store.d.ts +0 -217
- package/dist/webrtc/store.d.ts.map +0 -1
- package/dist/webrtc/store.js +0 -1320
- package/dist/webrtc/store.js.map +0 -1
- package/dist/webrtc/types.d.ts +0 -101
- package/dist/webrtc/types.d.ts.map +0 -1
- package/dist/webrtc/types.js +0 -5
- 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
|
+
}
|