@openclaw/nostr 2026.3.13 → 2026.5.1-beta.2

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.
Files changed (48) hide show
  1. package/README.md +6 -0
  2. package/api.ts +10 -0
  3. package/channel-plugin-api.ts +1 -0
  4. package/index.ts +60 -36
  5. package/openclaw.plugin.json +190 -1
  6. package/package.json +41 -9
  7. package/runtime-api.ts +6 -0
  8. package/setup-api.ts +1 -0
  9. package/setup-entry.ts +9 -0
  10. package/setup-plugin-api.ts +3 -0
  11. package/src/channel-api.ts +15 -0
  12. package/src/channel.inbound.test.ts +176 -0
  13. package/src/channel.outbound.test.ts +89 -49
  14. package/src/channel.setup.ts +231 -0
  15. package/src/channel.test.ts +439 -71
  16. package/src/channel.ts +146 -283
  17. package/src/config-schema.ts +18 -12
  18. package/src/default-relays.ts +1 -0
  19. package/src/gateway.ts +302 -0
  20. package/src/inbound-direct-dm-runtime.ts +1 -0
  21. package/src/metrics.ts +6 -6
  22. package/src/nostr-bus.fuzz.test.ts +74 -247
  23. package/src/nostr-bus.inbound.test.ts +526 -0
  24. package/src/nostr-bus.integration.test.ts +88 -64
  25. package/src/nostr-bus.test.ts +22 -31
  26. package/src/nostr-bus.ts +206 -136
  27. package/src/nostr-key-utils.ts +94 -0
  28. package/src/nostr-profile-core.ts +134 -0
  29. package/src/nostr-profile-http-runtime.ts +6 -0
  30. package/src/nostr-profile-http.test.ts +276 -167
  31. package/src/nostr-profile-http.ts +51 -36
  32. package/src/nostr-profile-import.ts +3 -3
  33. package/src/nostr-profile-url-safety.ts +21 -0
  34. package/src/nostr-profile.fuzz.test.ts +7 -57
  35. package/src/nostr-profile.test.ts +16 -14
  36. package/src/nostr-profile.ts +13 -146
  37. package/src/nostr-state-store.test.ts +106 -2
  38. package/src/nostr-state-store.ts +46 -49
  39. package/src/runtime.ts +6 -3
  40. package/src/seen-tracker.ts +1 -1
  41. package/src/session-route.ts +25 -0
  42. package/src/setup-surface.ts +265 -0
  43. package/src/test-fixtures.ts +45 -0
  44. package/src/types.ts +26 -25
  45. package/test-api.ts +1 -0
  46. package/tsconfig.json +16 -0
  47. package/CHANGELOG.md +0 -116
  48. package/src/types.test.ts +0 -175
@@ -8,21 +8,38 @@
8
8
  */
9
9
 
10
10
  import type { IncomingMessage, ServerResponse } from "node:http";
11
+ import { z } from "openclaw/plugin-sdk/zod";
12
+ import { publishNostrProfile, getNostrProfileState } from "./channel.js";
13
+ import { NostrProfileSchema, type NostrProfile } from "./config-schema.js";
11
14
  import {
12
15
  createFixedWindowRateLimiter,
13
- isBlockedHostnameOrIp,
16
+ getPluginRuntimeGatewayRequestScope,
14
17
  readJsonBodyWithLimit,
15
18
  requestBodyErrorToText,
16
- } from "openclaw/plugin-sdk/nostr";
17
- import { z } from "zod";
18
- import { publishNostrProfile, getNostrProfileState } from "./channel.js";
19
- import { NostrProfileSchema, type NostrProfile } from "./config-schema.js";
19
+ } from "./nostr-profile-http-runtime.js";
20
20
  import { importProfileFromRelays, mergeProfiles } from "./nostr-profile-import.js";
21
+ import { validateUrlSafety } from "./nostr-profile-url-safety.js";
21
22
 
22
23
  // ============================================================================
23
24
  // Types
24
25
  // ============================================================================
25
26
 
27
+ function readStringValue(value: unknown): string | undefined {
28
+ return typeof value === "string" ? value : undefined;
29
+ }
30
+
31
+ function normalizeOptionalLowercaseString(value: unknown): string | undefined {
32
+ if (typeof value !== "string") {
33
+ return undefined;
34
+ }
35
+ const trimmed = value.trim();
36
+ return trimmed ? trimmed.toLowerCase() : undefined;
37
+ }
38
+
39
+ function normalizeLowercaseStringOrEmpty(value: unknown): string {
40
+ return normalizeOptionalLowercaseString(value) ?? "";
41
+ }
42
+
26
43
  export interface NostrProfileHttpContext {
27
44
  /** Get current profile from config */
28
45
  getConfigProfile: (accountId: string) => NostrProfile | undefined;
@@ -98,33 +115,6 @@ async function withPublishLock<T>(accountId: string, fn: () => Promise<T>): Prom
98
115
  }
99
116
  }
100
117
 
101
- // ============================================================================
102
- // SSRF Protection
103
- // ============================================================================
104
-
105
- function validateUrlSafety(urlStr: string): { ok: true } | { ok: false; error: string } {
106
- try {
107
- const url = new URL(urlStr);
108
-
109
- if (url.protocol !== "https:") {
110
- return { ok: false, error: "URL must use https:// protocol" };
111
- }
112
-
113
- const hostname = url.hostname.toLowerCase();
114
-
115
- if (isBlockedHostnameOrIp(hostname)) {
116
- return { ok: false, error: "URL must not point to private/internal addresses" };
117
- }
118
-
119
- return { ok: true };
120
- } catch {
121
- return { ok: false, error: "Invalid URL format" };
122
- }
123
- }
124
-
125
- // Export for use in import validation
126
- export { validateUrlSafety };
127
-
128
118
  // ============================================================================
129
119
  // Validation Schemas
130
120
  // ============================================================================
@@ -147,6 +137,8 @@ const ProfileUpdateSchema = NostrProfileSchema.extend({
147
137
  lud16: lud16FormatSchema,
148
138
  });
149
139
 
140
+ const PROFILE_MUTATION_SCOPE = "operator.admin";
141
+
150
142
  // ============================================================================
151
143
  // Request Helpers
152
144
  // ============================================================================
@@ -193,7 +185,7 @@ function isLoopbackRemoteAddress(remoteAddress: string | undefined): boolean {
193
185
  return false;
194
186
  }
195
187
 
196
- const ipLower = remoteAddress.toLowerCase().replace(/^\[|\]$/g, "");
188
+ const ipLower = normalizeLowercaseStringOrEmpty(remoteAddress).replace(/^\[|\]$/g, "");
197
189
 
198
190
  // IPv6 loopback
199
191
  if (ipLower === "::1") {
@@ -217,7 +209,7 @@ function isLoopbackRemoteAddress(remoteAddress: string | undefined): boolean {
217
209
  function isLoopbackOriginLike(value: string): boolean {
218
210
  try {
219
211
  const url = new URL(value);
220
- const hostname = url.hostname.toLowerCase();
212
+ const hostname = normalizeLowercaseStringOrEmpty(url.hostname);
221
213
  return hostname === "localhost" || hostname === "127.0.0.1" || hostname === "::1";
222
214
  } catch {
223
215
  return false;
@@ -228,7 +220,7 @@ function firstHeaderValue(value: string | string[] | undefined): string | undefi
228
220
  if (Array.isArray(value)) {
229
221
  return value[0];
230
222
  }
231
- return typeof value === "string" ? value : undefined;
223
+ return readStringValue(value);
232
224
  }
233
225
 
234
226
  function normalizeIpCandidate(raw: string): string {
@@ -290,7 +282,9 @@ function enforceLoopbackMutationGuards(
290
282
  return false;
291
283
  }
292
284
 
293
- const secFetchSite = firstHeaderValue(req.headers["sec-fetch-site"])?.trim().toLowerCase();
285
+ const secFetchSite = normalizeOptionalLowercaseString(
286
+ firstHeaderValue(req.headers["sec-fetch-site"]),
287
+ );
294
288
  if (secFetchSite === "cross-site") {
295
289
  ctx.log?.warn?.("Rejected mutation with cross-site sec-fetch-site header");
296
290
  sendJson(res, 403, { ok: false, error: "Forbidden" });
@@ -315,6 +309,21 @@ function enforceLoopbackMutationGuards(
315
309
  return true;
316
310
  }
317
311
 
312
+ function enforceGatewayMutationScope(
313
+ ctx: NostrProfileHttpContext,
314
+ accountId: string,
315
+ res: ServerResponse,
316
+ ): boolean {
317
+ const runtimeScopes = getPluginRuntimeGatewayRequestScope()?.client?.connect?.scopes;
318
+ const scopes = Array.isArray(runtimeScopes) ? runtimeScopes : [];
319
+ if (scopes.includes(PROFILE_MUTATION_SCOPE)) {
320
+ return true;
321
+ }
322
+ ctx.log?.warn?.(`[${accountId}] Rejected profile mutation missing ${PROFILE_MUTATION_SCOPE}`);
323
+ sendJson(res, 403, { ok: false, error: `missing scope: ${PROFILE_MUTATION_SCOPE}` });
324
+ return false;
325
+ }
326
+
318
327
  // ============================================================================
319
328
  // HTTP Handler
320
329
  // ============================================================================
@@ -397,6 +406,9 @@ async function handleUpdateProfile(
397
406
  req: IncomingMessage,
398
407
  res: ServerResponse,
399
408
  ): Promise<true> {
409
+ if (!enforceGatewayMutationScope(ctx, accountId, res)) {
410
+ return true;
411
+ }
400
412
  if (!enforceLoopbackMutationGuards(ctx, req, res)) {
401
413
  return true;
402
414
  }
@@ -500,6 +512,9 @@ async function handleImportProfile(
500
512
  req: IncomingMessage,
501
513
  res: ServerResponse,
502
514
  ): Promise<true> {
515
+ if (!enforceGatewayMutationScope(ctx, accountId, res)) {
516
+ return true;
517
+ }
503
518
  if (!enforceLoopbackMutationGuards(ctx, req, res)) {
504
519
  return true;
505
520
  }
@@ -7,14 +7,14 @@
7
7
 
8
8
  import { SimplePool, verifyEvent, type Event } from "nostr-tools";
9
9
  import type { NostrProfile } from "./config-schema.js";
10
- import { validateUrlSafety } from "./nostr-profile-http.js";
10
+ import { validateUrlSafety } from "./nostr-profile-url-safety.js";
11
11
  import { contentToProfile, type ProfileContent } from "./nostr-profile.js";
12
12
 
13
13
  // ============================================================================
14
14
  // Types
15
15
  // ============================================================================
16
16
 
17
- export interface ProfileImportResult {
17
+ interface ProfileImportResult {
18
18
  /** Whether the import was successful */
19
19
  ok: boolean;
20
20
  /** The imported profile (if found and valid) */
@@ -33,7 +33,7 @@ export interface ProfileImportResult {
33
33
  sourceRelay?: string;
34
34
  }
35
35
 
36
- export interface ProfileImportOptions {
36
+ interface ProfileImportOptions {
37
37
  /** The public key to fetch profile for */
38
38
  pubkey: string;
39
39
  /** Relay URLs to query */
@@ -0,0 +1,21 @@
1
+ import { isBlockedHostnameOrIp } from "openclaw/plugin-sdk/ssrf-runtime";
2
+
3
+ export function validateUrlSafety(urlStr: string): { ok: true } | { ok: false; error: string } {
4
+ try {
5
+ const url = new URL(urlStr);
6
+
7
+ if (url.protocol !== "https:") {
8
+ return { ok: false, error: "URL must use https:// protocol" };
9
+ }
10
+
11
+ const hostname = url.hostname.trim().toLowerCase();
12
+
13
+ if (isBlockedHostnameOrIp(hostname)) {
14
+ return { ok: false, error: "URL must not point to private/internal addresses" };
15
+ }
16
+
17
+ return { ok: true };
18
+ } catch {
19
+ return { ok: false, error: "Invalid URL format" };
20
+ }
21
+ }
@@ -1,15 +1,10 @@
1
1
  import { describe, expect, it } from "vitest";
2
2
  import type { NostrProfile } from "./config-schema.js";
3
3
  import {
4
- createProfileEvent,
5
4
  profileToContent,
6
- validateProfile,
7
5
  sanitizeProfileForDisplay,
8
- } from "./nostr-profile.js";
9
-
10
- // Test private key
11
- const TEST_HEX_KEY = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
12
- const TEST_SK = new Uint8Array(TEST_HEX_KEY.match(/.{2}/g)!.map((byte) => parseInt(byte, 16)));
6
+ validateProfile,
7
+ } from "./nostr-profile-core.js";
13
8
 
14
9
  // ============================================================================
15
10
  // Unicode Attack Vectors
@@ -51,9 +46,13 @@ describe("profile unicode attacks", () => {
51
46
  };
52
47
  const result = validateProfile(profile);
53
48
  expect(result.valid).toBe(true);
49
+ expect(result.profile).toBeDefined();
50
+ if (!result.profile) {
51
+ throw new Error("expected validated profile");
52
+ }
54
53
 
55
54
  // UI should escape or handle this
56
- const sanitized = sanitizeProfileForDisplay(result.profile!);
55
+ const sanitized = sanitizeProfileForDisplay(result.profile);
57
56
  expect(sanitized.name).toBeDefined();
58
57
  });
59
58
 
@@ -429,52 +428,3 @@ describe("profile type confusion", () => {
429
428
  expect(({} as Record<string, unknown>).polluted).toBeUndefined();
430
429
  });
431
430
  });
432
-
433
- // ============================================================================
434
- // Event Creation Edge Cases
435
- // ============================================================================
436
-
437
- describe("event creation edge cases", () => {
438
- it("handles profile with all fields at max length", () => {
439
- const profile: NostrProfile = {
440
- name: "a".repeat(256),
441
- displayName: "b".repeat(256),
442
- about: "c".repeat(2000),
443
- nip05: "d".repeat(200) + "@example.com",
444
- lud16: "e".repeat(200) + "@example.com",
445
- };
446
-
447
- const event = createProfileEvent(TEST_SK, profile);
448
- expect(event.kind).toBe(0);
449
-
450
- // Content should be parseable JSON
451
- expect(() => JSON.parse(event.content)).not.toThrow();
452
- });
453
-
454
- it("handles rapid sequential events with monotonic timestamps", () => {
455
- const profile: NostrProfile = { name: "rapid" };
456
-
457
- // Create events in quick succession
458
- let lastTimestamp = 0;
459
- for (let i = 0; i < 25; i++) {
460
- const event = createProfileEvent(TEST_SK, profile, lastTimestamp);
461
- expect(event.created_at).toBeGreaterThan(lastTimestamp);
462
- lastTimestamp = event.created_at;
463
- }
464
- });
465
-
466
- it("handles JSON special characters in content", () => {
467
- const profile: NostrProfile = {
468
- name: 'test"user',
469
- about: "line1\nline2\ttab\\backslash",
470
- };
471
-
472
- const event = createProfileEvent(TEST_SK, profile);
473
- const parsed = JSON.parse(event.content) as { name: string; about: string };
474
-
475
- expect(parsed.name).toBe('test"user');
476
- expect(parsed.about).toContain("\n");
477
- expect(parsed.about).toContain("\t");
478
- expect(parsed.about).toContain("\\");
479
- });
480
- });
@@ -9,11 +9,13 @@ import {
9
9
  sanitizeProfileForDisplay,
10
10
  type ProfileContent,
11
11
  } from "./nostr-profile.js";
12
+ import { TEST_HEX_PRIVATE_KEY_BYTES } from "./test-fixtures.js";
12
13
 
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(TEST_HEX_KEY.match(/.{2}/g)!.map((byte) => parseInt(byte, 16)));
16
- const TEST_PUBKEY = getPublicKey(TEST_SK);
14
+ const TEST_PUBKEY = getPublicKey(TEST_HEX_PRIVATE_KEY_BYTES);
15
+
16
+ function createTestProfileEvent(profile: NostrProfile, lastPublishedAt?: number) {
17
+ return createProfileEvent(TEST_HEX_PRIVATE_KEY_BYTES, profile, lastPublishedAt);
18
+ }
17
19
 
18
20
  // ============================================================================
19
21
  // Profile Content Conversion Tests
@@ -123,7 +125,7 @@ describe("createProfileEvent", () => {
123
125
  about: "A test bot",
124
126
  };
125
127
 
126
- const event = createProfileEvent(TEST_SK, profile);
128
+ const event = createTestProfileEvent(profile);
127
129
 
128
130
  expect(event.kind).toBe(0);
129
131
  expect(event.pubkey).toBe(TEST_PUBKEY);
@@ -139,7 +141,7 @@ describe("createProfileEvent", () => {
139
141
  about: "Testing JSON serialization",
140
142
  };
141
143
 
142
- const event = createProfileEvent(TEST_SK, profile);
144
+ const event = createTestProfileEvent(profile);
143
145
  const parsedContent = JSON.parse(event.content) as ProfileContent;
144
146
 
145
147
  expect(parsedContent.name).toBe("jsontest");
@@ -149,14 +151,14 @@ describe("createProfileEvent", () => {
149
151
 
150
152
  it("produces a verifiable signature", () => {
151
153
  const profile: NostrProfile = { name: "signaturetest" };
152
- const event = createProfileEvent(TEST_SK, profile);
154
+ const event = createTestProfileEvent(profile);
153
155
 
154
156
  expect(verifyEvent(event)).toBe(true);
155
157
  });
156
158
 
157
159
  it("uses current timestamp when no lastPublishedAt provided", () => {
158
160
  const profile: NostrProfile = { name: "timestamptest" };
159
- const event = createProfileEvent(TEST_SK, profile);
161
+ const event = createTestProfileEvent(profile);
160
162
 
161
163
  const expectedTimestamp = Math.floor(Date.now() / 1000);
162
164
  expect(event.created_at).toBe(expectedTimestamp);
@@ -167,7 +169,7 @@ describe("createProfileEvent", () => {
167
169
  const futureTimestamp = 1705320000 + 3600; // 1 hour in the future
168
170
  const profile: NostrProfile = { name: "monotonictest" };
169
171
 
170
- const event = createProfileEvent(TEST_SK, profile, futureTimestamp);
172
+ const event = createTestProfileEvent(profile, futureTimestamp);
171
173
 
172
174
  expect(event.created_at).toBe(futureTimestamp + 1);
173
175
  });
@@ -176,7 +178,7 @@ describe("createProfileEvent", () => {
176
178
  const pastTimestamp = 1705320000 - 3600; // 1 hour in the past
177
179
  const profile: NostrProfile = { name: "pasttest" };
178
180
 
179
- const event = createProfileEvent(TEST_SK, profile, pastTimestamp);
181
+ const event = createTestProfileEvent(profile, pastTimestamp);
180
182
 
181
183
  const expectedTimestamp = Math.floor(Date.now() / 1000);
182
184
  expect(event.created_at).toBe(expectedTimestamp);
@@ -364,7 +366,7 @@ describe("edge cases", () => {
364
366
  expect(content.name).toBe("🤖 Bot");
365
367
  expect(content.about).toBe("I am a 🤖 robot! 🎉");
366
368
 
367
- const event = createProfileEvent(TEST_SK, profile);
369
+ const event = createTestProfileEvent(profile);
368
370
  const parsed = JSON.parse(event.content) as ProfileContent;
369
371
  expect(parsed.name).toBe("🤖 Bot");
370
372
  });
@@ -378,7 +380,7 @@ describe("edge cases", () => {
378
380
  const content = profileToContent(profile);
379
381
  expect(content.name).toBe("日本語ユーザー");
380
382
 
381
- const event = createProfileEvent(TEST_SK, profile);
383
+ const event = createTestProfileEvent(profile);
382
384
  expect(verifyEvent(event)).toBe(true);
383
385
  });
384
386
 
@@ -390,7 +392,7 @@ describe("edge cases", () => {
390
392
  const content = profileToContent(profile);
391
393
  expect(content.about).toBe("Line 1\nLine 2\nLine 3");
392
394
 
393
- const event = createProfileEvent(TEST_SK, profile);
395
+ const event = createTestProfileEvent(profile);
394
396
  const parsed = JSON.parse(event.content) as ProfileContent;
395
397
  expect(parsed.about).toBe("Line 1\nLine 2\nLine 3");
396
398
  });
@@ -404,7 +406,7 @@ describe("edge cases", () => {
404
406
  const result = validateProfile(profile);
405
407
  expect(result.valid).toBe(true);
406
408
 
407
- const event = createProfileEvent(TEST_SK, profile);
409
+ const event = createTestProfileEvent(profile);
408
410
  expect(verifyEvent(event)).toBe(true);
409
411
  });
410
412
  });
@@ -6,7 +6,16 @@
6
6
  */
7
7
 
8
8
  import { finalizeEvent, SimplePool, type Event } from "nostr-tools";
9
- import { type NostrProfile, NostrProfileSchema } from "./config-schema.js";
9
+ import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
10
+ import type { NostrProfile } from "./config-schema.js";
11
+ import { profileToContent } from "./nostr-profile-core.js";
12
+ export {
13
+ contentToProfile,
14
+ profileToContent,
15
+ sanitizeProfileForDisplay,
16
+ validateProfile,
17
+ type ProfileContent,
18
+ } from "./nostr-profile-core.js";
10
19
 
11
20
  // ============================================================================
12
21
  // Types
@@ -24,94 +33,6 @@ export interface ProfilePublishResult {
24
33
  createdAt: number;
25
34
  }
26
35
 
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) {
53
- content.name = validated.name;
54
- }
55
- if (validated.displayName !== undefined) {
56
- content.display_name = validated.displayName;
57
- }
58
- if (validated.about !== undefined) {
59
- content.about = validated.about;
60
- }
61
- if (validated.picture !== undefined) {
62
- content.picture = validated.picture;
63
- }
64
- if (validated.banner !== undefined) {
65
- content.banner = validated.banner;
66
- }
67
- if (validated.website !== undefined) {
68
- content.website = validated.website;
69
- }
70
- if (validated.nip05 !== undefined) {
71
- content.nip05 = validated.nip05;
72
- }
73
- if (validated.lud16 !== undefined) {
74
- content.lud16 = validated.lud16;
75
- }
76
-
77
- return content;
78
- }
79
-
80
- /**
81
- * Convert NIP-01 content format back to our config profile schema.
82
- * Useful for importing existing profiles from relays.
83
- */
84
- export function contentToProfile(content: ProfileContent): NostrProfile {
85
- const profile: NostrProfile = {};
86
-
87
- if (content.name !== undefined) {
88
- profile.name = content.name;
89
- }
90
- if (content.display_name !== undefined) {
91
- profile.displayName = content.display_name;
92
- }
93
- if (content.about !== undefined) {
94
- profile.about = content.about;
95
- }
96
- if (content.picture !== undefined) {
97
- profile.picture = content.picture;
98
- }
99
- if (content.banner !== undefined) {
100
- profile.banner = content.banner;
101
- }
102
- if (content.website !== undefined) {
103
- profile.website = content.website;
104
- }
105
- if (content.nip05 !== undefined) {
106
- profile.nip05 = content.nip05;
107
- }
108
- if (content.lud16 !== undefined) {
109
- profile.lud16 = content.lud16;
110
- }
111
-
112
- return profile;
113
- }
114
-
115
36
  // ============================================================================
116
37
  // Event Creation
117
38
  // ============================================================================
@@ -167,7 +88,7 @@ const RELAY_PUBLISH_TIMEOUT_MS = 5000;
167
88
  * @param event - Signed profile event (kind:0)
168
89
  * @returns Publish results with successes and failures
169
90
  */
170
- export async function publishProfileEvent(
91
+ async function publishProfileEvent(
171
92
  pool: SimplePool,
172
93
  relays: string[],
173
94
  event: Event,
@@ -182,12 +103,11 @@ export async function publishProfileEvent(
182
103
  setTimeout(() => reject(new Error("timeout")), RELAY_PUBLISH_TIMEOUT_MS);
183
104
  });
184
105
 
185
- // oxlint-disable-next-line typescript/no-floating-promises
186
- await Promise.race([pool.publish([relay], event), timeoutPromise]);
106
+ await Promise.race([...pool.publish([relay], event), timeoutPromise]);
187
107
 
188
108
  successes.push(relay);
189
109
  } catch (err) {
190
- const errorMessage = err instanceof Error ? err.message : String(err);
110
+ const errorMessage = formatErrorMessage(err);
191
111
  failures.push({ relay, error: errorMessage });
192
112
  }
193
113
  });
@@ -222,56 +142,3 @@ export async function publishProfile(
222
142
  const event = createProfileEvent(sk, profile, lastPublishedAt);
223
143
  return publishProfileEvent(pool, relays, event);
224
144
  }
225
-
226
- // ============================================================================
227
- // Profile Validation Helpers
228
- // ============================================================================
229
-
230
- /**
231
- * Validate a profile without throwing (returns result object).
232
- */
233
- export function validateProfile(profile: unknown): {
234
- valid: boolean;
235
- profile?: NostrProfile;
236
- errors?: string[];
237
- } {
238
- const result = NostrProfileSchema.safeParse(profile);
239
-
240
- if (result.success) {
241
- return { valid: true, profile: result.data };
242
- }
243
-
244
- return {
245
- valid: false,
246
- errors: result.error.issues.map((e) => `${e.path.join(".")}: ${e.message}`),
247
- };
248
- }
249
-
250
- /**
251
- * Sanitize profile text fields to prevent XSS when displaying in UI.
252
- * Escapes HTML special characters.
253
- */
254
- export function sanitizeProfileForDisplay(profile: NostrProfile): NostrProfile {
255
- const escapeHtml = (str: string | undefined): string | undefined => {
256
- if (str === undefined) {
257
- return undefined;
258
- }
259
- return str
260
- .replace(/&/g, "&amp;")
261
- .replace(/</g, "&lt;")
262
- .replace(/>/g, "&gt;")
263
- .replace(/"/g, "&quot;")
264
- .replace(/'/g, "&#039;");
265
- };
266
-
267
- return {
268
- name: escapeHtml(profile.name),
269
- displayName: escapeHtml(profile.displayName),
270
- about: escapeHtml(profile.about),
271
- picture: profile.picture, // URLs already validated by schema
272
- banner: profile.banner,
273
- website: profile.website,
274
- nip05: escapeHtml(profile.nip05),
275
- lud16: escapeHtml(profile.lud16),
276
- };
277
- }