@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,410 @@
|
|
|
1
|
+
import { describe, expect, it, vi, beforeEach } from "vitest";
|
|
2
|
+
import { verifyEvent, getPublicKey } from "nostr-tools";
|
|
3
|
+
import {
|
|
4
|
+
createProfileEvent,
|
|
5
|
+
profileToContent,
|
|
6
|
+
contentToProfile,
|
|
7
|
+
validateProfile,
|
|
8
|
+
sanitizeProfileForDisplay,
|
|
9
|
+
type ProfileContent,
|
|
10
|
+
} from "./nostr-profile.js";
|
|
11
|
+
import type { NostrProfile } from "./config-schema.js";
|
|
12
|
+
|
|
13
|
+
// Test private key (DO NOT use in production - this is a known test key)
|
|
14
|
+
const TEST_HEX_KEY = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
|
|
15
|
+
const TEST_SK = new Uint8Array(
|
|
16
|
+
TEST_HEX_KEY.match(/.{2}/g)!.map((byte) => parseInt(byte, 16))
|
|
17
|
+
);
|
|
18
|
+
const TEST_PUBKEY = getPublicKey(TEST_SK);
|
|
19
|
+
|
|
20
|
+
// ============================================================================
|
|
21
|
+
// Profile Content Conversion Tests
|
|
22
|
+
// ============================================================================
|
|
23
|
+
|
|
24
|
+
describe("profileToContent", () => {
|
|
25
|
+
it("converts full profile to NIP-01 content format", () => {
|
|
26
|
+
const profile: NostrProfile = {
|
|
27
|
+
name: "testuser",
|
|
28
|
+
displayName: "Test User",
|
|
29
|
+
about: "A test user for unit testing",
|
|
30
|
+
picture: "https://example.com/avatar.png",
|
|
31
|
+
banner: "https://example.com/banner.png",
|
|
32
|
+
website: "https://example.com",
|
|
33
|
+
nip05: "testuser@example.com",
|
|
34
|
+
lud16: "testuser@walletofsatoshi.com",
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
const content = profileToContent(profile);
|
|
38
|
+
|
|
39
|
+
expect(content.name).toBe("testuser");
|
|
40
|
+
expect(content.display_name).toBe("Test User");
|
|
41
|
+
expect(content.about).toBe("A test user for unit testing");
|
|
42
|
+
expect(content.picture).toBe("https://example.com/avatar.png");
|
|
43
|
+
expect(content.banner).toBe("https://example.com/banner.png");
|
|
44
|
+
expect(content.website).toBe("https://example.com");
|
|
45
|
+
expect(content.nip05).toBe("testuser@example.com");
|
|
46
|
+
expect(content.lud16).toBe("testuser@walletofsatoshi.com");
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it("omits undefined fields from content", () => {
|
|
50
|
+
const profile: NostrProfile = {
|
|
51
|
+
name: "minimaluser",
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
const content = profileToContent(profile);
|
|
55
|
+
|
|
56
|
+
expect(content.name).toBe("minimaluser");
|
|
57
|
+
expect("display_name" in content).toBe(false);
|
|
58
|
+
expect("about" in content).toBe(false);
|
|
59
|
+
expect("picture" in content).toBe(false);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it("handles empty profile", () => {
|
|
63
|
+
const profile: NostrProfile = {};
|
|
64
|
+
const content = profileToContent(profile);
|
|
65
|
+
expect(Object.keys(content)).toHaveLength(0);
|
|
66
|
+
});
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
describe("contentToProfile", () => {
|
|
70
|
+
it("converts NIP-01 content to profile format", () => {
|
|
71
|
+
const content: ProfileContent = {
|
|
72
|
+
name: "testuser",
|
|
73
|
+
display_name: "Test User",
|
|
74
|
+
about: "A test user",
|
|
75
|
+
picture: "https://example.com/avatar.png",
|
|
76
|
+
nip05: "test@example.com",
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
const profile = contentToProfile(content);
|
|
80
|
+
|
|
81
|
+
expect(profile.name).toBe("testuser");
|
|
82
|
+
expect(profile.displayName).toBe("Test User");
|
|
83
|
+
expect(profile.about).toBe("A test user");
|
|
84
|
+
expect(profile.picture).toBe("https://example.com/avatar.png");
|
|
85
|
+
expect(profile.nip05).toBe("test@example.com");
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it("handles empty content", () => {
|
|
89
|
+
const content: ProfileContent = {};
|
|
90
|
+
const profile = contentToProfile(content);
|
|
91
|
+
expect(Object.keys(profile).filter((k) => profile[k as keyof NostrProfile] !== undefined)).toHaveLength(0);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it("round-trips profile data", () => {
|
|
95
|
+
const original: NostrProfile = {
|
|
96
|
+
name: "roundtrip",
|
|
97
|
+
displayName: "Round Trip Test",
|
|
98
|
+
about: "Testing round-trip conversion",
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
const content = profileToContent(original);
|
|
102
|
+
const restored = contentToProfile(content);
|
|
103
|
+
|
|
104
|
+
expect(restored.name).toBe(original.name);
|
|
105
|
+
expect(restored.displayName).toBe(original.displayName);
|
|
106
|
+
expect(restored.about).toBe(original.about);
|
|
107
|
+
});
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
// ============================================================================
|
|
111
|
+
// Event Creation Tests
|
|
112
|
+
// ============================================================================
|
|
113
|
+
|
|
114
|
+
describe("createProfileEvent", () => {
|
|
115
|
+
beforeEach(() => {
|
|
116
|
+
vi.useFakeTimers();
|
|
117
|
+
vi.setSystemTime(new Date("2024-01-15T12:00:00Z"));
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
it("creates a valid kind:0 event", () => {
|
|
121
|
+
const profile: NostrProfile = {
|
|
122
|
+
name: "testbot",
|
|
123
|
+
about: "A test bot",
|
|
124
|
+
};
|
|
125
|
+
|
|
126
|
+
const event = createProfileEvent(TEST_SK, profile);
|
|
127
|
+
|
|
128
|
+
expect(event.kind).toBe(0);
|
|
129
|
+
expect(event.pubkey).toBe(TEST_PUBKEY);
|
|
130
|
+
expect(event.tags).toEqual([]);
|
|
131
|
+
expect(event.id).toMatch(/^[0-9a-f]{64}$/);
|
|
132
|
+
expect(event.sig).toMatch(/^[0-9a-f]{128}$/);
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
it("includes profile content as JSON in event content", () => {
|
|
136
|
+
const profile: NostrProfile = {
|
|
137
|
+
name: "jsontest",
|
|
138
|
+
displayName: "JSON Test User",
|
|
139
|
+
about: "Testing JSON serialization",
|
|
140
|
+
};
|
|
141
|
+
|
|
142
|
+
const event = createProfileEvent(TEST_SK, profile);
|
|
143
|
+
const parsedContent = JSON.parse(event.content) as ProfileContent;
|
|
144
|
+
|
|
145
|
+
expect(parsedContent.name).toBe("jsontest");
|
|
146
|
+
expect(parsedContent.display_name).toBe("JSON Test User");
|
|
147
|
+
expect(parsedContent.about).toBe("Testing JSON serialization");
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
it("produces a verifiable signature", () => {
|
|
151
|
+
const profile: NostrProfile = { name: "signaturetest" };
|
|
152
|
+
const event = createProfileEvent(TEST_SK, profile);
|
|
153
|
+
|
|
154
|
+
expect(verifyEvent(event)).toBe(true);
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
it("uses current timestamp when no lastPublishedAt provided", () => {
|
|
158
|
+
const profile: NostrProfile = { name: "timestamptest" };
|
|
159
|
+
const event = createProfileEvent(TEST_SK, profile);
|
|
160
|
+
|
|
161
|
+
const expectedTimestamp = Math.floor(Date.now() / 1000);
|
|
162
|
+
expect(event.created_at).toBe(expectedTimestamp);
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
it("ensures monotonic timestamp when lastPublishedAt is in the future", () => {
|
|
166
|
+
// Current time is 2024-01-15T12:00:00Z = 1705320000
|
|
167
|
+
const futureTimestamp = 1705320000 + 3600; // 1 hour in the future
|
|
168
|
+
const profile: NostrProfile = { name: "monotonictest" };
|
|
169
|
+
|
|
170
|
+
const event = createProfileEvent(TEST_SK, profile, futureTimestamp);
|
|
171
|
+
|
|
172
|
+
expect(event.created_at).toBe(futureTimestamp + 1);
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
it("uses current time when lastPublishedAt is in the past", () => {
|
|
176
|
+
const pastTimestamp = 1705320000 - 3600; // 1 hour in the past
|
|
177
|
+
const profile: NostrProfile = { name: "pasttest" };
|
|
178
|
+
|
|
179
|
+
const event = createProfileEvent(TEST_SK, profile, pastTimestamp);
|
|
180
|
+
|
|
181
|
+
const expectedTimestamp = Math.floor(Date.now() / 1000);
|
|
182
|
+
expect(event.created_at).toBe(expectedTimestamp);
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
vi.useRealTimers();
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
// ============================================================================
|
|
189
|
+
// Profile Validation Tests
|
|
190
|
+
// ============================================================================
|
|
191
|
+
|
|
192
|
+
describe("validateProfile", () => {
|
|
193
|
+
it("validates a correct profile", () => {
|
|
194
|
+
const profile = {
|
|
195
|
+
name: "validuser",
|
|
196
|
+
about: "A valid user",
|
|
197
|
+
picture: "https://example.com/pic.png",
|
|
198
|
+
};
|
|
199
|
+
|
|
200
|
+
const result = validateProfile(profile);
|
|
201
|
+
|
|
202
|
+
expect(result.valid).toBe(true);
|
|
203
|
+
expect(result.profile).toBeDefined();
|
|
204
|
+
expect(result.errors).toBeUndefined();
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
it("rejects profile with invalid URL", () => {
|
|
208
|
+
const profile = {
|
|
209
|
+
name: "invalidurl",
|
|
210
|
+
picture: "http://insecure.example.com/pic.png", // HTTP not HTTPS
|
|
211
|
+
};
|
|
212
|
+
|
|
213
|
+
const result = validateProfile(profile);
|
|
214
|
+
|
|
215
|
+
expect(result.valid).toBe(false);
|
|
216
|
+
expect(result.errors).toBeDefined();
|
|
217
|
+
expect(result.errors!.some((e) => e.includes("https://"))).toBe(true);
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
it("rejects profile with javascript: URL", () => {
|
|
221
|
+
const profile = {
|
|
222
|
+
name: "xssattempt",
|
|
223
|
+
picture: "javascript:alert('xss')",
|
|
224
|
+
};
|
|
225
|
+
|
|
226
|
+
const result = validateProfile(profile);
|
|
227
|
+
|
|
228
|
+
expect(result.valid).toBe(false);
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
it("rejects profile with data: URL", () => {
|
|
232
|
+
const profile = {
|
|
233
|
+
name: "dataurl",
|
|
234
|
+
picture: "data:image/png;base64,abc123",
|
|
235
|
+
};
|
|
236
|
+
|
|
237
|
+
const result = validateProfile(profile);
|
|
238
|
+
|
|
239
|
+
expect(result.valid).toBe(false);
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
it("rejects name exceeding 256 characters", () => {
|
|
243
|
+
const profile = {
|
|
244
|
+
name: "a".repeat(257),
|
|
245
|
+
};
|
|
246
|
+
|
|
247
|
+
const result = validateProfile(profile);
|
|
248
|
+
|
|
249
|
+
expect(result.valid).toBe(false);
|
|
250
|
+
expect(result.errors!.some((e) => e.includes("256"))).toBe(true);
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
it("rejects about exceeding 2000 characters", () => {
|
|
254
|
+
const profile = {
|
|
255
|
+
about: "a".repeat(2001),
|
|
256
|
+
};
|
|
257
|
+
|
|
258
|
+
const result = validateProfile(profile);
|
|
259
|
+
|
|
260
|
+
expect(result.valid).toBe(false);
|
|
261
|
+
expect(result.errors!.some((e) => e.includes("2000"))).toBe(true);
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
it("accepts empty profile", () => {
|
|
265
|
+
const result = validateProfile({});
|
|
266
|
+
expect(result.valid).toBe(true);
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
it("rejects null input", () => {
|
|
270
|
+
const result = validateProfile(null);
|
|
271
|
+
expect(result.valid).toBe(false);
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
it("rejects non-object input", () => {
|
|
275
|
+
const result = validateProfile("not an object");
|
|
276
|
+
expect(result.valid).toBe(false);
|
|
277
|
+
});
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
// ============================================================================
|
|
281
|
+
// Sanitization Tests
|
|
282
|
+
// ============================================================================
|
|
283
|
+
|
|
284
|
+
describe("sanitizeProfileForDisplay", () => {
|
|
285
|
+
it("escapes HTML in name field", () => {
|
|
286
|
+
const profile: NostrProfile = {
|
|
287
|
+
name: "<script>alert('xss')</script>",
|
|
288
|
+
};
|
|
289
|
+
|
|
290
|
+
const sanitized = sanitizeProfileForDisplay(profile);
|
|
291
|
+
|
|
292
|
+
expect(sanitized.name).toBe("<script>alert('xss')</script>");
|
|
293
|
+
});
|
|
294
|
+
|
|
295
|
+
it("escapes HTML in about field", () => {
|
|
296
|
+
const profile: NostrProfile = {
|
|
297
|
+
about: 'Check out <img src="x" onerror="alert(1)">',
|
|
298
|
+
};
|
|
299
|
+
|
|
300
|
+
const sanitized = sanitizeProfileForDisplay(profile);
|
|
301
|
+
|
|
302
|
+
expect(sanitized.about).toBe(
|
|
303
|
+
'Check out <img src="x" onerror="alert(1)">'
|
|
304
|
+
);
|
|
305
|
+
});
|
|
306
|
+
|
|
307
|
+
it("preserves URLs without modification", () => {
|
|
308
|
+
const profile: NostrProfile = {
|
|
309
|
+
picture: "https://example.com/pic.png",
|
|
310
|
+
website: "https://example.com",
|
|
311
|
+
};
|
|
312
|
+
|
|
313
|
+
const sanitized = sanitizeProfileForDisplay(profile);
|
|
314
|
+
|
|
315
|
+
expect(sanitized.picture).toBe("https://example.com/pic.png");
|
|
316
|
+
expect(sanitized.website).toBe("https://example.com");
|
|
317
|
+
});
|
|
318
|
+
|
|
319
|
+
it("handles undefined fields", () => {
|
|
320
|
+
const profile: NostrProfile = {
|
|
321
|
+
name: "test",
|
|
322
|
+
};
|
|
323
|
+
|
|
324
|
+
const sanitized = sanitizeProfileForDisplay(profile);
|
|
325
|
+
|
|
326
|
+
expect(sanitized.name).toBe("test");
|
|
327
|
+
expect(sanitized.about).toBeUndefined();
|
|
328
|
+
expect(sanitized.picture).toBeUndefined();
|
|
329
|
+
});
|
|
330
|
+
|
|
331
|
+
it("escapes ampersands", () => {
|
|
332
|
+
const profile: NostrProfile = {
|
|
333
|
+
name: "Tom & Jerry",
|
|
334
|
+
};
|
|
335
|
+
|
|
336
|
+
const sanitized = sanitizeProfileForDisplay(profile);
|
|
337
|
+
|
|
338
|
+
expect(sanitized.name).toBe("Tom & Jerry");
|
|
339
|
+
});
|
|
340
|
+
|
|
341
|
+
it("escapes quotes", () => {
|
|
342
|
+
const profile: NostrProfile = {
|
|
343
|
+
about: 'Say "hello" to everyone',
|
|
344
|
+
};
|
|
345
|
+
|
|
346
|
+
const sanitized = sanitizeProfileForDisplay(profile);
|
|
347
|
+
|
|
348
|
+
expect(sanitized.about).toBe("Say "hello" to everyone");
|
|
349
|
+
});
|
|
350
|
+
});
|
|
351
|
+
|
|
352
|
+
// ============================================================================
|
|
353
|
+
// Edge Cases
|
|
354
|
+
// ============================================================================
|
|
355
|
+
|
|
356
|
+
describe("edge cases", () => {
|
|
357
|
+
it("handles emoji in profile fields", () => {
|
|
358
|
+
const profile: NostrProfile = {
|
|
359
|
+
name: "🤖 Bot",
|
|
360
|
+
about: "I am a 🤖 robot! 🎉",
|
|
361
|
+
};
|
|
362
|
+
|
|
363
|
+
const content = profileToContent(profile);
|
|
364
|
+
expect(content.name).toBe("🤖 Bot");
|
|
365
|
+
expect(content.about).toBe("I am a 🤖 robot! 🎉");
|
|
366
|
+
|
|
367
|
+
const event = createProfileEvent(TEST_SK, profile);
|
|
368
|
+
const parsed = JSON.parse(event.content) as ProfileContent;
|
|
369
|
+
expect(parsed.name).toBe("🤖 Bot");
|
|
370
|
+
});
|
|
371
|
+
|
|
372
|
+
it("handles unicode in profile fields", () => {
|
|
373
|
+
const profile: NostrProfile = {
|
|
374
|
+
name: "日本語ユーザー",
|
|
375
|
+
about: "Привет мир! 你好世界!",
|
|
376
|
+
};
|
|
377
|
+
|
|
378
|
+
const content = profileToContent(profile);
|
|
379
|
+
expect(content.name).toBe("日本語ユーザー");
|
|
380
|
+
|
|
381
|
+
const event = createProfileEvent(TEST_SK, profile);
|
|
382
|
+
expect(verifyEvent(event)).toBe(true);
|
|
383
|
+
});
|
|
384
|
+
|
|
385
|
+
it("handles newlines in about field", () => {
|
|
386
|
+
const profile: NostrProfile = {
|
|
387
|
+
about: "Line 1\nLine 2\nLine 3",
|
|
388
|
+
};
|
|
389
|
+
|
|
390
|
+
const content = profileToContent(profile);
|
|
391
|
+
expect(content.about).toBe("Line 1\nLine 2\nLine 3");
|
|
392
|
+
|
|
393
|
+
const event = createProfileEvent(TEST_SK, profile);
|
|
394
|
+
const parsed = JSON.parse(event.content) as ProfileContent;
|
|
395
|
+
expect(parsed.about).toBe("Line 1\nLine 2\nLine 3");
|
|
396
|
+
});
|
|
397
|
+
|
|
398
|
+
it("handles maximum length fields", () => {
|
|
399
|
+
const profile: NostrProfile = {
|
|
400
|
+
name: "a".repeat(256),
|
|
401
|
+
about: "b".repeat(2000),
|
|
402
|
+
};
|
|
403
|
+
|
|
404
|
+
const result = validateProfile(profile);
|
|
405
|
+
expect(result.valid).toBe(true);
|
|
406
|
+
|
|
407
|
+
const event = createProfileEvent(TEST_SK, profile);
|
|
408
|
+
expect(verifyEvent(event)).toBe(true);
|
|
409
|
+
});
|
|
410
|
+
});
|
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Nostr Profile Management (NIP-01 kind:0)
|
|
3
|
+
*
|
|
4
|
+
* Profile events are "replaceable" - the latest created_at wins.
|
|
5
|
+
* This module handles profile event creation and publishing.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { finalizeEvent, SimplePool, type Event } from "nostr-tools";
|
|
9
|
+
import { type NostrProfile, NostrProfileSchema } from "./config-schema.js";
|
|
10
|
+
|
|
11
|
+
// ============================================================================
|
|
12
|
+
// Types
|
|
13
|
+
// ============================================================================
|
|
14
|
+
|
|
15
|
+
/** Result of a profile publish attempt */
|
|
16
|
+
export interface ProfilePublishResult {
|
|
17
|
+
/** Event ID of the published profile */
|
|
18
|
+
eventId: string;
|
|
19
|
+
/** Relays that successfully received the event */
|
|
20
|
+
successes: string[];
|
|
21
|
+
/** Relays that failed with their error messages */
|
|
22
|
+
failures: Array<{ relay: string; error: string }>;
|
|
23
|
+
/** Unix timestamp when the event was created */
|
|
24
|
+
createdAt: number;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** NIP-01 profile content (JSON inside kind:0 event) */
|
|
28
|
+
export interface ProfileContent {
|
|
29
|
+
name?: string;
|
|
30
|
+
display_name?: string;
|
|
31
|
+
about?: string;
|
|
32
|
+
picture?: string;
|
|
33
|
+
banner?: string;
|
|
34
|
+
website?: string;
|
|
35
|
+
nip05?: string;
|
|
36
|
+
lud16?: string;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// ============================================================================
|
|
40
|
+
// Profile Content Conversion
|
|
41
|
+
// ============================================================================
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Convert our config profile schema to NIP-01 content format.
|
|
45
|
+
* Strips undefined fields and validates URLs.
|
|
46
|
+
*/
|
|
47
|
+
export function profileToContent(profile: NostrProfile): ProfileContent {
|
|
48
|
+
const validated = NostrProfileSchema.parse(profile);
|
|
49
|
+
|
|
50
|
+
const content: ProfileContent = {};
|
|
51
|
+
|
|
52
|
+
if (validated.name !== undefined) content.name = validated.name;
|
|
53
|
+
if (validated.displayName !== undefined) content.display_name = validated.displayName;
|
|
54
|
+
if (validated.about !== undefined) content.about = validated.about;
|
|
55
|
+
if (validated.picture !== undefined) content.picture = validated.picture;
|
|
56
|
+
if (validated.banner !== undefined) content.banner = validated.banner;
|
|
57
|
+
if (validated.website !== undefined) content.website = validated.website;
|
|
58
|
+
if (validated.nip05 !== undefined) content.nip05 = validated.nip05;
|
|
59
|
+
if (validated.lud16 !== undefined) content.lud16 = validated.lud16;
|
|
60
|
+
|
|
61
|
+
return content;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Convert NIP-01 content format back to our config profile schema.
|
|
66
|
+
* Useful for importing existing profiles from relays.
|
|
67
|
+
*/
|
|
68
|
+
export function contentToProfile(content: ProfileContent): NostrProfile {
|
|
69
|
+
const profile: NostrProfile = {};
|
|
70
|
+
|
|
71
|
+
if (content.name !== undefined) profile.name = content.name;
|
|
72
|
+
if (content.display_name !== undefined) profile.displayName = content.display_name;
|
|
73
|
+
if (content.about !== undefined) profile.about = content.about;
|
|
74
|
+
if (content.picture !== undefined) profile.picture = content.picture;
|
|
75
|
+
if (content.banner !== undefined) profile.banner = content.banner;
|
|
76
|
+
if (content.website !== undefined) profile.website = content.website;
|
|
77
|
+
if (content.nip05 !== undefined) profile.nip05 = content.nip05;
|
|
78
|
+
if (content.lud16 !== undefined) profile.lud16 = content.lud16;
|
|
79
|
+
|
|
80
|
+
return profile;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// ============================================================================
|
|
84
|
+
// Event Creation
|
|
85
|
+
// ============================================================================
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Create a signed kind:0 profile event.
|
|
89
|
+
*
|
|
90
|
+
* @param sk - Private key as Uint8Array (32 bytes)
|
|
91
|
+
* @param profile - Profile data to include
|
|
92
|
+
* @param lastPublishedAt - Previous profile timestamp (for monotonic guarantee)
|
|
93
|
+
* @returns Signed Nostr event
|
|
94
|
+
*/
|
|
95
|
+
export function createProfileEvent(
|
|
96
|
+
sk: Uint8Array,
|
|
97
|
+
profile: NostrProfile,
|
|
98
|
+
lastPublishedAt?: number
|
|
99
|
+
): Event {
|
|
100
|
+
const content = profileToContent(profile);
|
|
101
|
+
const contentJson = JSON.stringify(content);
|
|
102
|
+
|
|
103
|
+
// Ensure monotonic timestamp (new event > previous)
|
|
104
|
+
const now = Math.floor(Date.now() / 1000);
|
|
105
|
+
const createdAt = lastPublishedAt !== undefined ? Math.max(now, lastPublishedAt + 1) : now;
|
|
106
|
+
|
|
107
|
+
const event = finalizeEvent(
|
|
108
|
+
{
|
|
109
|
+
kind: 0,
|
|
110
|
+
content: contentJson,
|
|
111
|
+
tags: [],
|
|
112
|
+
created_at: createdAt,
|
|
113
|
+
},
|
|
114
|
+
sk
|
|
115
|
+
);
|
|
116
|
+
|
|
117
|
+
return event;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// ============================================================================
|
|
121
|
+
// Profile Publishing
|
|
122
|
+
// ============================================================================
|
|
123
|
+
|
|
124
|
+
/** Per-relay publish timeout (ms) */
|
|
125
|
+
const RELAY_PUBLISH_TIMEOUT_MS = 5000;
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Publish a profile event to multiple relays.
|
|
129
|
+
*
|
|
130
|
+
* Best-effort: publishes to all relays in parallel, reports per-relay results.
|
|
131
|
+
* Does NOT retry automatically - caller should handle retries if needed.
|
|
132
|
+
*
|
|
133
|
+
* @param pool - SimplePool instance for relay connections
|
|
134
|
+
* @param relays - Array of relay WebSocket URLs
|
|
135
|
+
* @param event - Signed profile event (kind:0)
|
|
136
|
+
* @returns Publish results with successes and failures
|
|
137
|
+
*/
|
|
138
|
+
export async function publishProfileEvent(
|
|
139
|
+
pool: SimplePool,
|
|
140
|
+
relays: string[],
|
|
141
|
+
event: Event
|
|
142
|
+
): Promise<ProfilePublishResult> {
|
|
143
|
+
const successes: string[] = [];
|
|
144
|
+
const failures: Array<{ relay: string; error: string }> = [];
|
|
145
|
+
|
|
146
|
+
// Publish to each relay in parallel with timeout
|
|
147
|
+
const publishPromises = relays.map(async (relay) => {
|
|
148
|
+
try {
|
|
149
|
+
const timeoutPromise = new Promise<never>((_, reject) => {
|
|
150
|
+
setTimeout(() => reject(new Error("timeout")), RELAY_PUBLISH_TIMEOUT_MS);
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
await Promise.race([pool.publish([relay], event), timeoutPromise]);
|
|
154
|
+
|
|
155
|
+
successes.push(relay);
|
|
156
|
+
} catch (err) {
|
|
157
|
+
const errorMessage = err instanceof Error ? err.message : String(err);
|
|
158
|
+
failures.push({ relay, error: errorMessage });
|
|
159
|
+
}
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
await Promise.all(publishPromises);
|
|
163
|
+
|
|
164
|
+
return {
|
|
165
|
+
eventId: event.id,
|
|
166
|
+
successes,
|
|
167
|
+
failures,
|
|
168
|
+
createdAt: event.created_at,
|
|
169
|
+
};
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Create and publish a profile event in one call.
|
|
174
|
+
*
|
|
175
|
+
* @param pool - SimplePool instance
|
|
176
|
+
* @param sk - Private key as Uint8Array
|
|
177
|
+
* @param relays - Array of relay URLs
|
|
178
|
+
* @param profile - Profile data
|
|
179
|
+
* @param lastPublishedAt - Previous timestamp for monotonic ordering
|
|
180
|
+
* @returns Publish results
|
|
181
|
+
*/
|
|
182
|
+
export async function publishProfile(
|
|
183
|
+
pool: SimplePool,
|
|
184
|
+
sk: Uint8Array,
|
|
185
|
+
relays: string[],
|
|
186
|
+
profile: NostrProfile,
|
|
187
|
+
lastPublishedAt?: number
|
|
188
|
+
): Promise<ProfilePublishResult> {
|
|
189
|
+
const event = createProfileEvent(sk, profile, lastPublishedAt);
|
|
190
|
+
return publishProfileEvent(pool, relays, event);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// ============================================================================
|
|
194
|
+
// Profile Validation Helpers
|
|
195
|
+
// ============================================================================
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* Validate a profile without throwing (returns result object).
|
|
199
|
+
*/
|
|
200
|
+
export function validateProfile(profile: unknown): {
|
|
201
|
+
valid: boolean;
|
|
202
|
+
profile?: NostrProfile;
|
|
203
|
+
errors?: string[];
|
|
204
|
+
} {
|
|
205
|
+
const result = NostrProfileSchema.safeParse(profile);
|
|
206
|
+
|
|
207
|
+
if (result.success) {
|
|
208
|
+
return { valid: true, profile: result.data };
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
return {
|
|
212
|
+
valid: false,
|
|
213
|
+
errors: result.error.issues.map((e) => `${e.path.join(".")}: ${e.message}`),
|
|
214
|
+
};
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* Sanitize profile text fields to prevent XSS when displaying in UI.
|
|
219
|
+
* Escapes HTML special characters.
|
|
220
|
+
*/
|
|
221
|
+
export function sanitizeProfileForDisplay(profile: NostrProfile): NostrProfile {
|
|
222
|
+
const escapeHtml = (str: string | undefined): string | undefined => {
|
|
223
|
+
if (str === undefined) return undefined;
|
|
224
|
+
return str
|
|
225
|
+
.replace(/&/g, "&")
|
|
226
|
+
.replace(/</g, "<")
|
|
227
|
+
.replace(/>/g, ">")
|
|
228
|
+
.replace(/"/g, """)
|
|
229
|
+
.replace(/'/g, "'");
|
|
230
|
+
};
|
|
231
|
+
|
|
232
|
+
return {
|
|
233
|
+
name: escapeHtml(profile.name),
|
|
234
|
+
displayName: escapeHtml(profile.displayName),
|
|
235
|
+
about: escapeHtml(profile.about),
|
|
236
|
+
picture: profile.picture, // URLs already validated by schema
|
|
237
|
+
banner: profile.banner,
|
|
238
|
+
website: profile.website,
|
|
239
|
+
nip05: escapeHtml(profile.nip05),
|
|
240
|
+
lud16: escapeHtml(profile.lud16),
|
|
241
|
+
};
|
|
242
|
+
}
|