@openclaw/nostr 2026.1.29

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.
@@ -0,0 +1,259 @@
1
+ /**
2
+ * Nostr Profile Import
3
+ *
4
+ * Fetches and verifies kind:0 profile events from relays.
5
+ * Used to import existing profiles before editing.
6
+ */
7
+
8
+ import { SimplePool, verifyEvent, type Event } from "nostr-tools";
9
+
10
+ import { contentToProfile, type ProfileContent } from "./nostr-profile.js";
11
+ import type { NostrProfile } from "./config-schema.js";
12
+ import { validateUrlSafety } from "./nostr-profile-http.js";
13
+
14
+ // ============================================================================
15
+ // Types
16
+ // ============================================================================
17
+
18
+ export interface ProfileImportResult {
19
+ /** Whether the import was successful */
20
+ ok: boolean;
21
+ /** The imported profile (if found and valid) */
22
+ profile?: NostrProfile;
23
+ /** The raw event (for advanced users) */
24
+ event?: {
25
+ id: string;
26
+ pubkey: string;
27
+ created_at: number;
28
+ };
29
+ /** Error message if import failed */
30
+ error?: string;
31
+ /** Which relays responded */
32
+ relaysQueried: string[];
33
+ /** Which relay provided the winning event */
34
+ sourceRelay?: string;
35
+ }
36
+
37
+ export interface ProfileImportOptions {
38
+ /** The public key to fetch profile for */
39
+ pubkey: string;
40
+ /** Relay URLs to query */
41
+ relays: string[];
42
+ /** Timeout per relay in milliseconds (default: 5000) */
43
+ timeoutMs?: number;
44
+ }
45
+
46
+ // ============================================================================
47
+ // Constants
48
+ // ============================================================================
49
+
50
+ const DEFAULT_TIMEOUT_MS = 5000;
51
+
52
+ // ============================================================================
53
+ // Profile Import
54
+ // ============================================================================
55
+
56
+ /**
57
+ * Sanitize URLs in an imported profile to prevent SSRF attacks.
58
+ * Removes any URLs that don't pass SSRF validation.
59
+ */
60
+ function sanitizeProfileUrls(profile: NostrProfile): NostrProfile {
61
+ const result = { ...profile };
62
+ const urlFields = ["picture", "banner", "website"] as const;
63
+
64
+ for (const field of urlFields) {
65
+ const value = result[field];
66
+ if (value && typeof value === "string") {
67
+ const validation = validateUrlSafety(value);
68
+ if (!validation.ok) {
69
+ // Remove unsafe URL
70
+ delete result[field];
71
+ }
72
+ }
73
+ }
74
+
75
+ return result;
76
+ }
77
+
78
+ /**
79
+ * Fetch the latest kind:0 profile event for a pubkey from relays.
80
+ *
81
+ * - Queries all relays in parallel
82
+ * - Takes the event with the highest created_at
83
+ * - Verifies the event signature
84
+ * - Parses and returns the profile
85
+ */
86
+ export async function importProfileFromRelays(
87
+ opts: ProfileImportOptions
88
+ ): Promise<ProfileImportResult> {
89
+ const { pubkey, relays, timeoutMs = DEFAULT_TIMEOUT_MS } = opts;
90
+
91
+ if (!pubkey || !/^[0-9a-fA-F]{64}$/.test(pubkey)) {
92
+ return {
93
+ ok: false,
94
+ error: "Invalid pubkey format (must be 64 hex characters)",
95
+ relaysQueried: [],
96
+ };
97
+ }
98
+
99
+ if (relays.length === 0) {
100
+ return {
101
+ ok: false,
102
+ error: "No relays configured",
103
+ relaysQueried: [],
104
+ };
105
+ }
106
+
107
+ const pool = new SimplePool();
108
+ const relaysQueried: string[] = [];
109
+
110
+ try {
111
+ // Query all relays for kind:0 events from this pubkey
112
+ const events: Array<{ event: Event; relay: string }> = [];
113
+
114
+ // Create timeout promise
115
+ const timeoutPromise = new Promise<void>((resolve) => {
116
+ setTimeout(resolve, timeoutMs);
117
+ });
118
+
119
+ // Create subscription promise
120
+ const subscriptionPromise = new Promise<void>((resolve) => {
121
+ let completed = 0;
122
+
123
+ for (const relay of relays) {
124
+ relaysQueried.push(relay);
125
+
126
+ const sub = pool.subscribeMany(
127
+ [relay],
128
+ [
129
+ {
130
+ kinds: [0],
131
+ authors: [pubkey],
132
+ limit: 1,
133
+ },
134
+ ],
135
+ {
136
+ onevent(event) {
137
+ events.push({ event, relay });
138
+ },
139
+ oneose() {
140
+ completed++;
141
+ if (completed >= relays.length) {
142
+ resolve();
143
+ }
144
+ },
145
+ onclose() {
146
+ completed++;
147
+ if (completed >= relays.length) {
148
+ resolve();
149
+ }
150
+ },
151
+ }
152
+ );
153
+
154
+ // Clean up subscription after timeout
155
+ setTimeout(() => {
156
+ sub.close();
157
+ }, timeoutMs);
158
+ }
159
+ });
160
+
161
+ // Wait for either all relays to respond or timeout
162
+ await Promise.race([subscriptionPromise, timeoutPromise]);
163
+
164
+ // No events found
165
+ if (events.length === 0) {
166
+ return {
167
+ ok: false,
168
+ error: "No profile found on any relay",
169
+ relaysQueried,
170
+ };
171
+ }
172
+
173
+ // Find the event with the highest created_at (newest wins for replaceable events)
174
+ let bestEvent: { event: Event; relay: string } | null = null;
175
+ for (const item of events) {
176
+ if (!bestEvent || item.event.created_at > bestEvent.event.created_at) {
177
+ bestEvent = item;
178
+ }
179
+ }
180
+
181
+ if (!bestEvent) {
182
+ return {
183
+ ok: false,
184
+ error: "No valid profile event found",
185
+ relaysQueried,
186
+ };
187
+ }
188
+
189
+ // Verify the event signature
190
+ const isValid = verifyEvent(bestEvent.event);
191
+ if (!isValid) {
192
+ return {
193
+ ok: false,
194
+ error: "Profile event has invalid signature",
195
+ relaysQueried,
196
+ sourceRelay: bestEvent.relay,
197
+ };
198
+ }
199
+
200
+ // Parse the profile content
201
+ let content: ProfileContent;
202
+ try {
203
+ content = JSON.parse(bestEvent.event.content) as ProfileContent;
204
+ } catch {
205
+ return {
206
+ ok: false,
207
+ error: "Profile event has invalid JSON content",
208
+ relaysQueried,
209
+ sourceRelay: bestEvent.relay,
210
+ };
211
+ }
212
+
213
+ // Convert to our profile format
214
+ const profile = contentToProfile(content);
215
+
216
+ // Sanitize URLs from imported profile to prevent SSRF when auto-merging
217
+ const sanitizedProfile = sanitizeProfileUrls(profile);
218
+
219
+ return {
220
+ ok: true,
221
+ profile: sanitizedProfile,
222
+ event: {
223
+ id: bestEvent.event.id,
224
+ pubkey: bestEvent.event.pubkey,
225
+ created_at: bestEvent.event.created_at,
226
+ },
227
+ relaysQueried,
228
+ sourceRelay: bestEvent.relay,
229
+ };
230
+ } finally {
231
+ pool.close(relays);
232
+ }
233
+ }
234
+
235
+ /**
236
+ * Merge imported profile with local profile.
237
+ *
238
+ * Strategy:
239
+ * - For each field, prefer local if set, otherwise use imported
240
+ * - This preserves user customizations while filling in missing data
241
+ */
242
+ export function mergeProfiles(
243
+ local: NostrProfile | undefined,
244
+ imported: NostrProfile | undefined
245
+ ): NostrProfile {
246
+ if (!imported) return local ?? {};
247
+ if (!local) return imported;
248
+
249
+ return {
250
+ name: local.name ?? imported.name,
251
+ displayName: local.displayName ?? imported.displayName,
252
+ about: local.about ?? imported.about,
253
+ picture: local.picture ?? imported.picture,
254
+ banner: local.banner ?? imported.banner,
255
+ website: local.website ?? imported.website,
256
+ nip05: local.nip05 ?? imported.nip05,
257
+ lud16: local.lud16 ?? imported.lud16,
258
+ };
259
+ }