@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.
- package/CHANGELOG.md +51 -0
- package/README.md +136 -0
- package/index.ts +69 -0
- package/openclaw.plugin.json +11 -0
- package/package.json +31 -0
- package/src/channel.test.ts +141 -0
- package/src/channel.ts +342 -0
- package/src/config-schema.ts +90 -0
- package/src/metrics.ts +464 -0
- package/src/nostr-bus.fuzz.test.ts +544 -0
- package/src/nostr-bus.integration.test.ts +452 -0
- package/src/nostr-bus.test.ts +199 -0
- package/src/nostr-bus.ts +741 -0
- package/src/nostr-profile-http.test.ts +378 -0
- package/src/nostr-profile-http.ts +500 -0
- package/src/nostr-profile-import.test.ts +120 -0
- package/src/nostr-profile-import.ts +259 -0
- package/src/nostr-profile.fuzz.test.ts +479 -0
- package/src/nostr-profile.test.ts +410 -0
- package/src/nostr-profile.ts +242 -0
- package/src/nostr-state-store.test.ts +129 -0
- package/src/nostr-state-store.ts +226 -0
- package/src/runtime.ts +14 -0
- package/src/seen-tracker.ts +271 -0
- package/src/types.test.ts +161 -0
- package/src/types.ts +99 -0
- package/test/setup.ts +5 -0
|
@@ -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
|
+
}
|