@openclaw/nostr 2026.1.29 → 2026.2.1

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/src/nostr-bus.ts CHANGED
@@ -7,20 +7,7 @@ import {
7
7
  type Event,
8
8
  } from "nostr-tools";
9
9
  import { decrypt, encrypt } from "nostr-tools/nip04";
10
-
11
- import {
12
- readNostrBusState,
13
- writeNostrBusState,
14
- computeSinceTimestamp,
15
- readNostrProfileState,
16
- writeNostrProfileState,
17
- } from "./nostr-state-store.js";
18
- import {
19
- publishProfile as publishProfileFn,
20
- type ProfilePublishResult,
21
- } from "./nostr-profile.js";
22
10
  import type { NostrProfile } from "./config-schema.js";
23
- import { createSeenTracker, type SeenTracker } from "./seen-tracker.js";
24
11
  import {
25
12
  createMetrics,
26
13
  createNoopMetrics,
@@ -28,6 +15,15 @@ import {
28
15
  type MetricsSnapshot,
29
16
  type MetricEvent,
30
17
  } from "./metrics.js";
18
+ import { publishProfile as publishProfileFn, type ProfilePublishResult } from "./nostr-profile.js";
19
+ import {
20
+ readNostrBusState,
21
+ writeNostrBusState,
22
+ computeSinceTimestamp,
23
+ readNostrProfileState,
24
+ writeNostrProfileState,
25
+ } from "./nostr-state-store.js";
26
+ import { createSeenTracker, type SeenTracker } from "./seen-tracker.js";
31
27
 
32
28
  export const DEFAULT_RELAYS = ["wss://relay.damus.io", "wss://nos.lol"];
33
29
 
@@ -39,11 +35,6 @@ const STARTUP_LOOKBACK_SEC = 120; // tolerate relay lag / clock skew
39
35
  const MAX_PERSISTED_EVENT_IDS = 5000;
40
36
  const STATE_PERSIST_DEBOUNCE_MS = 5000; // Debounce state writes
41
37
 
42
- // Reconnect configuration (exponential backoff with jitter)
43
- const RECONNECT_BASE_MS = 1000; // 1 second base
44
- const RECONNECT_MAX_MS = 60000; // 60 seconds max
45
- const RECONNECT_JITTER = 0.3; // ±30% jitter
46
-
47
38
  // Circuit breaker configuration
48
39
  const CIRCUIT_BREAKER_THRESHOLD = 5; // failures before opening
49
40
  const CIRCUIT_BREAKER_RESET_MS = 30000; // 30 seconds before half-open
@@ -66,7 +57,7 @@ export interface NostrBusOptions {
66
57
  onMessage: (
67
58
  pubkey: string,
68
59
  text: string,
69
- reply: (text: string) => Promise<void>
60
+ reply: (text: string) => Promise<void>,
70
61
  ) => Promise<void>;
71
62
  /** Called on errors (optional) */
72
63
  onError?: (error: Error, context: string) => void;
@@ -129,7 +120,7 @@ function createCircuitBreaker(
129
120
  relay: string,
130
121
  metrics: NostrMetrics,
131
122
  threshold: number = CIRCUIT_BREAKER_THRESHOLD,
132
- resetMs: number = CIRCUIT_BREAKER_RESET_MS
123
+ resetMs: number = CIRCUIT_BREAKER_RESET_MS,
133
124
  ): CircuitBreaker {
134
125
  const state: CircuitBreakerState = {
135
126
  state: "closed",
@@ -140,7 +131,9 @@ function createCircuitBreaker(
140
131
 
141
132
  return {
142
133
  canAttempt(): boolean {
143
- if (state.state === "closed") return true;
134
+ if (state.state === "closed") {
135
+ return true;
136
+ }
144
137
 
145
138
  if (state.state === "open") {
146
139
  // Check if enough time has passed to try half-open
@@ -246,10 +239,14 @@ function createRelayHealthTracker(): RelayHealthTracker {
246
239
 
247
240
  getScore(relay: string): number {
248
241
  const s = stats.get(relay);
249
- if (!s) return 0.5; // Unknown relay gets neutral score
242
+ if (!s) {
243
+ return 0.5;
244
+ } // Unknown relay gets neutral score
250
245
 
251
246
  const total = s.successCount + s.failureCount;
252
- if (total === 0) return 0.5;
247
+ if (total === 0) {
248
+ return 0.5;
249
+ }
253
250
 
254
251
  // Success rate (0-1)
255
252
  const successRate = s.successCount / total;
@@ -262,33 +259,18 @@ function createRelayHealthTracker(): RelayHealthTracker {
262
259
  : 0;
263
260
 
264
261
  // Latency penalty (lower is better)
265
- const avgLatency =
266
- s.latencyCount > 0 ? s.latencySum / s.latencyCount : 1000;
262
+ const avgLatency = s.latencyCount > 0 ? s.latencySum / s.latencyCount : 1000;
267
263
  const latencyPenalty = Math.min(0.2, avgLatency / 10000);
268
264
 
269
265
  return Math.max(0, Math.min(1, successRate + recencyBonus - latencyPenalty));
270
266
  },
271
267
 
272
268
  getSortedRelays(relays: string[]): string[] {
273
- return [...relays].sort((a, b) => this.getScore(b) - this.getScore(a));
269
+ return [...relays].toSorted((a, b) => this.getScore(b) - this.getScore(a));
274
270
  },
275
271
  };
276
272
  }
277
273
 
278
- // ============================================================================
279
- // Reconnect with Exponential Backoff + Jitter
280
- // ============================================================================
281
-
282
- function computeReconnectDelay(attempt: number): number {
283
- // Exponential backoff: base * 2^attempt
284
- const exponential = RECONNECT_BASE_MS * Math.pow(2, attempt);
285
- const capped = Math.min(exponential, RECONNECT_MAX_MS);
286
-
287
- // Add jitter: ±JITTER%
288
- const jitter = capped * RECONNECT_JITTER * (Math.random() * 2 - 1);
289
- return Math.max(RECONNECT_BASE_MS, capped + jitter);
290
- }
291
-
292
274
  // ============================================================================
293
275
  // Key Validation
294
276
  // ============================================================================
@@ -310,9 +292,7 @@ export function validatePrivateKey(key: string): Uint8Array {
310
292
 
311
293
  // Handle hex format
312
294
  if (!/^[0-9a-fA-F]{64}$/.test(trimmed)) {
313
- throw new Error(
314
- "Private key must be 64 hex characters or nsec bech32 format"
315
- );
295
+ throw new Error("Private key must be 64 hex characters or nsec bech32 format");
316
296
  }
317
297
 
318
298
  // Convert hex string to Uint8Array
@@ -338,9 +318,7 @@ export function getPublicKeyFromPrivate(privateKey: string): string {
338
318
  /**
339
319
  * Start the Nostr DM bus - subscribes to NIP-04 encrypted DMs
340
320
  */
341
- export async function startNostrBus(
342
- options: NostrBusOptions
343
- ): Promise<NostrBusHandle> {
321
+ export async function startNostrBus(options: NostrBusOptions): Promise<NostrBusHandle> {
344
322
  const {
345
323
  privateKey,
346
324
  relays = DEFAULT_RELAYS,
@@ -396,9 +374,7 @@ export async function startNostrBus(
396
374
  // Debounced state persistence
397
375
  let pendingWrite: ReturnType<typeof setTimeout> | undefined;
398
376
  let lastProcessedAt = state?.lastProcessedAt ?? gatewayStartedAt;
399
- let recentEventIds = (state?.recentEventIds ?? []).slice(
400
- -MAX_PERSISTED_EVENT_IDS
401
- );
377
+ let recentEventIds = (state?.recentEventIds ?? []).slice(-MAX_PERSISTED_EVENT_IDS);
402
378
 
403
379
  function scheduleStatePersist(eventCreatedAt: number, eventId: string): void {
404
380
  lastProcessedAt = Math.max(lastProcessedAt, eventCreatedAt);
@@ -407,7 +383,9 @@ export async function startNostrBus(
407
383
  recentEventIds = recentEventIds.slice(-MAX_PERSISTED_EVENT_IDS);
408
384
  }
409
385
 
410
- if (pendingWrite) clearTimeout(pendingWrite);
386
+ if (pendingWrite) {
387
+ clearTimeout(pendingWrite);
388
+ }
411
389
  pendingWrite = setTimeout(() => {
412
390
  writeNostrBusState({
413
391
  accountId,
@@ -471,7 +449,7 @@ export async function startNostrBus(
471
449
  // Decrypt the message
472
450
  let plaintext: string;
473
451
  try {
474
- plaintext = await decrypt(sk, event.pubkey, event.content);
452
+ plaintext = decrypt(sk, event.pubkey, event.content);
475
453
  metrics.emit("decrypt.success");
476
454
  } catch (err) {
477
455
  metrics.emit("decrypt.failure");
@@ -491,7 +469,7 @@ export async function startNostrBus(
491
469
  metrics,
492
470
  circuitBreakers,
493
471
  healthTracker,
494
- onError
472
+ onError,
495
473
  );
496
474
  };
497
475
 
@@ -510,31 +488,24 @@ export async function startNostrBus(
510
488
  }
511
489
  }
512
490
 
513
- const sub = pool.subscribeMany(
514
- relays,
515
- [{ kinds: [4], "#p": [pk], since }],
516
- {
517
- onevent: handleEvent,
518
- oneose: () => {
519
- // EOSE handler - called when all stored events have been received
520
- for (const relay of relays) {
521
- metrics.emit("relay.message.eose", 1, { relay });
522
- }
523
- onEose?.(relays.join(", "));
524
- },
525
- onclose: (reason) => {
526
- // Handle subscription close
527
- for (const relay of relays) {
528
- metrics.emit("relay.message.closed", 1, { relay });
529
- options.onDisconnect?.(relay);
530
- }
531
- onError?.(
532
- new Error(`Subscription closed: ${reason}`),
533
- "subscription"
534
- );
535
- },
536
- }
537
- );
491
+ const sub = pool.subscribeMany(relays, [{ kinds: [4], "#p": [pk], since }], {
492
+ onevent: handleEvent,
493
+ oneose: () => {
494
+ // EOSE handler - called when all stored events have been received
495
+ for (const relay of relays) {
496
+ metrics.emit("relay.message.eose", 1, { relay });
497
+ }
498
+ onEose?.(relays.join(", "));
499
+ },
500
+ onclose: (reason) => {
501
+ // Handle subscription close
502
+ for (const relay of relays) {
503
+ metrics.emit("relay.message.closed", 1, { relay });
504
+ options.onDisconnect?.(relay);
505
+ }
506
+ onError?.(new Error(`Subscription closed: ${reason.join(", ")}`), "subscription");
507
+ },
508
+ });
538
509
 
539
510
  // Public sendDm function
540
511
  const sendDm = async (toPubkey: string, text: string): Promise<void> => {
@@ -547,7 +518,7 @@ export async function startNostrBus(
547
518
  metrics,
548
519
  circuitBreakers,
549
520
  healthTracker,
550
- onError
521
+ onError,
551
522
  );
552
523
  };
553
524
 
@@ -629,9 +600,9 @@ async function sendEncryptedDm(
629
600
  metrics: NostrMetrics,
630
601
  circuitBreakers: Map<string, CircuitBreaker>,
631
602
  healthTracker: RelayHealthTracker,
632
- onError?: (error: Error, context: string) => void
603
+ onError?: (error: Error, context: string) => void,
633
604
  ): Promise<void> {
634
- const ciphertext = await encrypt(sk, toPubkey, text);
605
+ const ciphertext = encrypt(sk, toPubkey, text);
635
606
  const reply = finalizeEvent(
636
607
  {
637
608
  kind: 4,
@@ -639,7 +610,7 @@ async function sendEncryptedDm(
639
610
  tags: [["p", toPubkey]],
640
611
  created_at: Math.floor(Date.now() / 1000),
641
612
  },
642
- sk
613
+ sk,
643
614
  );
644
615
 
645
616
  // Sort relays by health score (best first)
@@ -657,6 +628,7 @@ async function sendEncryptedDm(
657
628
 
658
629
  const startTime = Date.now();
659
630
  try {
631
+ // oxlint-disable-next-line typescript/await-thenable typesciript/no-floating-promises
660
632
  await pool.publish([relay], reply);
661
633
  const latency = Date.now() - startTime;
662
634
 
@@ -689,7 +661,9 @@ async function sendEncryptedDm(
689
661
  * Check if a string looks like a valid Nostr pubkey (hex or npub)
690
662
  */
691
663
  export function isValidPubkey(input: string): boolean {
692
- if (typeof input !== "string") return false;
664
+ if (typeof input !== "string") {
665
+ return false;
666
+ }
693
667
  const trimmed = input.trim();
694
668
 
695
669
  // npub format
@@ -2,10 +2,9 @@
2
2
  * Tests for Nostr Profile HTTP Handler
3
3
  */
4
4
 
5
- import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
6
5
  import { IncomingMessage, ServerResponse } from "node:http";
7
6
  import { Socket } from "node:net";
8
-
7
+ import { describe, it, expect, vi, beforeEach } from "vitest";
9
8
  import {
10
9
  createNostrProfileHttpHandler,
11
10
  type NostrProfileHttpContext,
@@ -30,11 +29,7 @@ import { importProfileFromRelays } from "./nostr-profile-import.js";
30
29
  // Test Helpers
31
30
  // ============================================================================
32
31
 
33
- function createMockRequest(
34
- method: string,
35
- url: string,
36
- body?: unknown
37
- ): IncomingMessage {
32
+ function createMockRequest(method: string, url: string, body?: unknown): IncomingMessage {
38
33
  const socket = new Socket();
39
34
  const req = new IncomingMessage(socket);
40
35
  req.method = method;
@@ -56,8 +51,10 @@ function createMockRequest(
56
51
  return req;
57
52
  }
58
53
 
59
- function createMockResponse(): ServerResponse & { _getData: () => string; _getStatusCode: () => number } {
60
- const socket = new Socket();
54
+ function createMockResponse(): ServerResponse & {
55
+ _getData: () => string;
56
+ _getStatusCode: () => number;
57
+ } {
61
58
  const res = new ServerResponse({} as IncomingMessage);
62
59
 
63
60
  let data = "";
@@ -69,7 +66,10 @@ function createMockResponse(): ServerResponse & { _getData: () => string; _getSt
69
66
  };
70
67
 
71
68
  res.end = function (chunk?: unknown) {
72
- if (chunk) data += String(chunk);
69
+ if (chunk) {
70
+ // eslint-disable-next-line @typescript-eslint/no-base-to-string
71
+ data += String(chunk);
72
+ }
73
73
  return this;
74
74
  };
75
75
 
@@ -9,9 +9,8 @@
9
9
 
10
10
  import type { IncomingMessage, ServerResponse } from "node:http";
11
11
  import { z } from "zod";
12
-
13
- import { NostrProfileSchema, type NostrProfile } from "./config-schema.js";
14
12
  import { publishNostrProfile, getNostrProfileState } from "./channel.js";
13
+ import { NostrProfileSchema, type NostrProfile } from "./config-schema.js";
15
14
  import { importProfileFromRelays, mergeProfiles } from "./nostr-profile-import.js";
16
15
 
17
16
  // ============================================================================
@@ -113,33 +112,53 @@ function isPrivateIp(ip: string): boolean {
113
112
  // Handle IPv4
114
113
  const ipv4Match = ip.match(/^(\d+)\.(\d+)\.(\d+)\.(\d+)$/);
115
114
  if (ipv4Match) {
116
- const [, a, b, c] = ipv4Match.map(Number);
115
+ const [, a, b] = ipv4Match.map(Number);
117
116
  // 127.0.0.0/8 (loopback)
118
- if (a === 127) return true;
117
+ if (a === 127) {
118
+ return true;
119
+ }
119
120
  // 10.0.0.0/8 (private)
120
- if (a === 10) return true;
121
+ if (a === 10) {
122
+ return true;
123
+ }
121
124
  // 172.16.0.0/12 (private)
122
- if (a === 172 && b >= 16 && b <= 31) return true;
125
+ if (a === 172 && b >= 16 && b <= 31) {
126
+ return true;
127
+ }
123
128
  // 192.168.0.0/16 (private)
124
- if (a === 192 && b === 168) return true;
129
+ if (a === 192 && b === 168) {
130
+ return true;
131
+ }
125
132
  // 169.254.0.0/16 (link-local)
126
- if (a === 169 && b === 254) return true;
133
+ if (a === 169 && b === 254) {
134
+ return true;
135
+ }
127
136
  // 0.0.0.0/8
128
- if (a === 0) return true;
137
+ if (a === 0) {
138
+ return true;
139
+ }
129
140
  return false;
130
141
  }
131
142
 
132
143
  // Handle IPv6
133
144
  const ipLower = ip.toLowerCase().replace(/^\[|\]$/g, "");
134
145
  // ::1 (loopback)
135
- if (ipLower === "::1") return true;
146
+ if (ipLower === "::1") {
147
+ return true;
148
+ }
136
149
  // fe80::/10 (link-local)
137
- if (ipLower.startsWith("fe80:")) return true;
150
+ if (ipLower.startsWith("fe80:")) {
151
+ return true;
152
+ }
138
153
  // fc00::/7 (unique local)
139
- if (ipLower.startsWith("fc") || ipLower.startsWith("fd")) return true;
154
+ if (ipLower.startsWith("fc") || ipLower.startsWith("fd")) {
155
+ return true;
156
+ }
140
157
  // ::ffff:x.x.x.x (IPv4-mapped IPv6) - extract and check IPv4
141
158
  const v4Mapped = ipLower.match(/^::ffff:(\d+\.\d+\.\d+\.\d+)$/);
142
- if (v4Mapped) return isPrivateIp(v4Mapped[1]);
159
+ if (v4Mapped) {
160
+ return isPrivateIp(v4Mapped[1]);
161
+ }
143
162
 
144
163
  return false;
145
164
  }
@@ -176,7 +195,7 @@ function validateUrlSafety(urlStr: string): { ok: true } | { ok: false; error: s
176
195
  }
177
196
 
178
197
  // Export for use in import validation
179
- export { validateUrlSafety }
198
+ export { validateUrlSafety };
180
199
 
181
200
  // ============================================================================
182
201
  // Validation Schemas
@@ -249,7 +268,7 @@ function parseAccountIdFromPath(pathname: string): string | null {
249
268
  // ============================================================================
250
269
 
251
270
  export function createNostrProfileHttpHandler(
252
- ctx: NostrProfileHttpContext
271
+ ctx: NostrProfileHttpContext,
253
272
  ): (req: IncomingMessage, res: ServerResponse) => Promise<boolean> {
254
273
  return async (req, res) => {
255
274
  const url = new URL(req.url ?? "/", `http://${req.headers.host ?? "localhost"}`);
@@ -303,7 +322,7 @@ export function createNostrProfileHttpHandler(
303
322
  async function handleGetProfile(
304
323
  accountId: string,
305
324
  ctx: NostrProfileHttpContext,
306
- res: ServerResponse
325
+ res: ServerResponse,
307
326
  ): Promise<true> {
308
327
  const configProfile = ctx.getConfigProfile(accountId);
309
328
  const publishState = await getNostrProfileState(accountId);
@@ -324,7 +343,7 @@ async function handleUpdateProfile(
324
343
  accountId: string,
325
344
  ctx: NostrProfileHttpContext,
326
345
  req: IncomingMessage,
327
- res: ServerResponse
346
+ res: ServerResponse,
328
347
  ): Promise<true> {
329
348
  // Rate limiting
330
349
  if (!checkRateLimit(accountId)) {
@@ -423,7 +442,7 @@ async function handleImportProfile(
423
442
  accountId: string,
424
443
  ctx: NostrProfileHttpContext,
425
444
  req: IncomingMessage,
426
- res: ServerResponse
445
+ res: ServerResponse,
427
446
  ): Promise<true> {
428
447
  // Get account info
429
448
  const accountInfo = ctx.getAccountInfo(accountId);
@@ -2,10 +2,9 @@
2
2
  * Tests for Nostr Profile Import
3
3
  */
4
4
 
5
- import { describe, it, expect, vi, beforeEach } from "vitest";
6
-
7
- import { mergeProfiles, type ProfileImportOptions } from "./nostr-profile-import.js";
5
+ import { describe, it, expect } from "vitest";
8
6
  import type { NostrProfile } from "./config-schema.js";
7
+ import { mergeProfiles } from "./nostr-profile-import.js";
9
8
 
10
9
  // Note: importProfileFromRelays requires real network calls or complex mocking
11
10
  // of nostr-tools SimplePool, so we focus on unit testing mergeProfiles
@@ -6,10 +6,9 @@
6
6
  */
7
7
 
8
8
  import { SimplePool, verifyEvent, type Event } from "nostr-tools";
9
-
10
- import { contentToProfile, type ProfileContent } from "./nostr-profile.js";
11
9
  import type { NostrProfile } from "./config-schema.js";
12
10
  import { validateUrlSafety } from "./nostr-profile-http.js";
11
+ import { contentToProfile, type ProfileContent } from "./nostr-profile.js";
13
12
 
14
13
  // ============================================================================
15
14
  // Types
@@ -84,7 +83,7 @@ function sanitizeProfileUrls(profile: NostrProfile): NostrProfile {
84
83
  * - Parses and returns the profile
85
84
  */
86
85
  export async function importProfileFromRelays(
87
- opts: ProfileImportOptions
86
+ opts: ProfileImportOptions,
88
87
  ): Promise<ProfileImportResult> {
89
88
  const { pubkey, relays, timeoutMs = DEFAULT_TIMEOUT_MS } = opts;
90
89
 
@@ -148,7 +147,7 @@ export async function importProfileFromRelays(
148
147
  resolve();
149
148
  }
150
149
  },
151
- }
150
+ },
152
151
  );
153
152
 
154
153
  // Clean up subscription after timeout
@@ -241,10 +240,14 @@ export async function importProfileFromRelays(
241
240
  */
242
241
  export function mergeProfiles(
243
242
  local: NostrProfile | undefined,
244
- imported: NostrProfile | undefined
243
+ imported: NostrProfile | undefined,
245
244
  ): NostrProfile {
246
- if (!imported) return local ?? {};
247
- if (!local) return imported;
245
+ if (!imported) {
246
+ return local ?? {};
247
+ }
248
+ if (!local) {
249
+ return imported;
250
+ }
248
251
 
249
252
  return {
250
253
  name: local.name ?? imported.name,
@@ -1,18 +1,15 @@
1
1
  import { describe, expect, it } from "vitest";
2
- import { getPublicKey } from "nostr-tools";
2
+ import type { NostrProfile } from "./config-schema.js";
3
3
  import {
4
4
  createProfileEvent,
5
5
  profileToContent,
6
6
  validateProfile,
7
7
  sanitizeProfileForDisplay,
8
8
  } from "./nostr-profile.js";
9
- import type { NostrProfile } from "./config-schema.js";
10
9
 
11
10
  // Test private key
12
11
  const TEST_HEX_KEY = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
13
- const TEST_SK = new Uint8Array(
14
- TEST_HEX_KEY.match(/.{2}/g)!.map((byte) => parseInt(byte, 16))
15
- );
12
+ const TEST_SK = new Uint8Array(TEST_HEX_KEY.match(/.{2}/g)!.map((byte) => parseInt(byte, 16)));
16
13
 
17
14
  // ============================================================================
18
15
  // Unicode Attack Vectors
@@ -101,8 +98,7 @@ describe("profile unicode attacks", () => {
101
98
  });
102
99
 
103
100
  it("handles excessive combining characters (Zalgo text)", () => {
104
- const zalgo =
105
- "t̷̢̧̨̡̛̛̛͎̩̝̪̲̲̞̠̹̗̩͓̬̱̪̦͙̬̲̤͙̱̫̝̪̱̫̯̬̭̠̖̲̥̖̫̫̤͇̪̣̫̪̖̱̯̣͎̯̲̱̤̪̣̖̲̪̯͓̖̤̫̫̲̱̲̫̲̖̫̪̯̱̱̪̖̯e̶̡̧̨̧̛̛̛̖̪̯̱̪̯̖̪̱̪̯̖̪̯̖̪̱̪̯̖̪̯̖̪̱̪̯̖̪̯̖̪̱̪̯̖̪̯̖̪̱̪̯̖̪̯̖̪̱̪̯̖̪̯̖̪̱̪s̶̨̧̛̛̖̪̱̪̯̖̪̯̖̪̱̪̯̖̪̯̖̪̱̪̯̖̪̯̖̪̱̪̯̖̪̯̖̪̱̪̯̖̪̯̖̪̱̪̯̖̪̯̖̪̱̪̯̖̪̯̖̪̱̪̯̖̪̯̖̪̱̪̯t";
101
+ const zalgo = "t̷̢̧̨̡̛̛̛͎̩̝̪̲̲̞̠̹̗̩͓̬̱̪̦͙̬̲̤͙̱̫̝̪̱̫̯̬̭̠̖̲̥̖̫̫̤͇̪̣̫̪̖̱̯̣͎̯̲̱̤̪̣̖̲̪̯͓̖̤̫̫̲̱̲̫̲̖̫̪̯̱̱̪̖̯e̶̡̧̨̧̛̛̛̖̪̯̱̪̯̖̪̱̪̯̖̪̯̖̪̱̪̯̖̪̯̖̪̱̪̯̖̪̯̖̪̱̪̯̖̪̯̖̪̱̪̯̖̪̯̖̪̱̪̯̖̪̯̖̪̱̪s̶̨̧̛̛̖̪̱̪̯̖̪̯̖̪̱̪̯̖̪̯̖̪̱̪̯̖̪̯̖̪̱̪̯̖̪̯̖̪̱̪̯̖̪̯̖̪̱̪̯̖̪̯̖̪̱̪̯̖̪̯̖̪̱̪̯̖̪̯̖̪̱̪̯t";
106
102
  const profile: NostrProfile = {
107
103
  name: zalgo.slice(0, 256), // Truncate to fit limit
108
104
  };
@@ -402,7 +398,9 @@ describe("profile type confusion", () => {
402
398
  });
403
399
 
404
400
  it("rejects object as picture", () => {
405
- const result = validateProfile({ picture: { url: "https://example.com" } as unknown as string });
401
+ const result = validateProfile({
402
+ picture: { url: "https://example.com" } as unknown as string,
403
+ });
406
404
  expect(result.valid).toBe(false);
407
405
  });
408
406
 
@@ -423,7 +421,7 @@ describe("profile type confusion", () => {
423
421
 
424
422
  it("handles prototype pollution attempt", () => {
425
423
  const malicious = JSON.parse('{"__proto__": {"polluted": true}}') as unknown;
426
- const result = validateProfile(malicious);
424
+ validateProfile(malicious);
427
425
  // Should not pollute Object.prototype
428
426
  expect(({} as Record<string, unknown>).polluted).toBeUndefined();
429
427
  });
@@ -1,5 +1,6 @@
1
- import { describe, expect, it, vi, beforeEach } from "vitest";
2
1
  import { verifyEvent, getPublicKey } from "nostr-tools";
2
+ import { describe, expect, it, vi, beforeEach } from "vitest";
3
+ import type { NostrProfile } from "./config-schema.js";
3
4
  import {
4
5
  createProfileEvent,
5
6
  profileToContent,
@@ -8,13 +9,10 @@ import {
8
9
  sanitizeProfileForDisplay,
9
10
  type ProfileContent,
10
11
  } from "./nostr-profile.js";
11
- import type { NostrProfile } from "./config-schema.js";
12
12
 
13
13
  // Test private key (DO NOT use in production - this is a known test key)
14
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
- );
15
+ const TEST_SK = new Uint8Array(TEST_HEX_KEY.match(/.{2}/g)!.map((byte) => parseInt(byte, 16)));
18
16
  const TEST_PUBKEY = getPublicKey(TEST_SK);
19
17
 
20
18
  // ============================================================================
@@ -88,7 +86,9 @@ describe("contentToProfile", () => {
88
86
  it("handles empty content", () => {
89
87
  const content: ProfileContent = {};
90
88
  const profile = contentToProfile(content);
91
- expect(Object.keys(profile).filter((k) => profile[k as keyof NostrProfile] !== undefined)).toHaveLength(0);
89
+ expect(
90
+ Object.keys(profile).filter((k) => profile[k as keyof NostrProfile] !== undefined),
91
+ ).toHaveLength(0);
92
92
  });
93
93
 
94
94
  it("round-trips profile data", () => {
@@ -300,7 +300,7 @@ describe("sanitizeProfileForDisplay", () => {
300
300
  const sanitized = sanitizeProfileForDisplay(profile);
301
301
 
302
302
  expect(sanitized.about).toBe(
303
- 'Check out &lt;img src=&quot;x&quot; onerror=&quot;alert(1)&quot;&gt;'
303
+ "Check out &lt;img src=&quot;x&quot; onerror=&quot;alert(1)&quot;&gt;",
304
304
  );
305
305
  });
306
306