@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,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("&lt;script&gt;alert(&#039;xss&#039;)&lt;/script&gt;");
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 &lt;img src=&quot;x&quot; onerror=&quot;alert(1)&quot;&gt;'
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 &amp; 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 &quot;hello&quot; 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, "&amp;")
226
+ .replace(/</g, "&lt;")
227
+ .replace(/>/g, "&gt;")
228
+ .replace(/"/g, "&quot;")
229
+ .replace(/'/g, "&#039;");
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
+ }