@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
package/src/nostr-bus.ts CHANGED
@@ -1,13 +1,11 @@
1
- import {
2
- SimplePool,
3
- finalizeEvent,
4
- getPublicKey,
5
- verifyEvent,
6
- nip19,
7
- type Event,
8
- } from "nostr-tools";
1
+ import { SimplePool, finalizeEvent, getPublicKey, verifyEvent, type Event } from "nostr-tools";
9
2
  import { decrypt, encrypt } from "nostr-tools/nip04";
3
+ import {
4
+ createDirectDmPreCryptoGuardPolicy,
5
+ type DirectDmPreCryptoGuardPolicyOverrides,
6
+ } from "openclaw/plugin-sdk/direct-dm-guard-policy";
10
7
  import type { NostrProfile } from "./config-schema.js";
8
+ import { DEFAULT_RELAYS } from "./default-relays.js";
11
9
  import {
12
10
  createMetrics,
13
11
  createNoopMetrics,
@@ -15,6 +13,7 @@ import {
15
13
  type MetricsSnapshot,
16
14
  type MetricEvent,
17
15
  } from "./metrics.js";
16
+ import { validatePrivateKey } from "./nostr-key-utils.js";
18
17
  import { publishProfile as publishProfileFn, type ProfilePublishResult } from "./nostr-profile.js";
19
18
  import {
20
19
  readNostrBusState,
@@ -25,8 +24,6 @@ import {
25
24
  } from "./nostr-state-store.js";
26
25
  import { createSeenTracker, type SeenTracker } from "./seen-tracker.js";
27
26
 
28
- export const DEFAULT_RELAYS = ["wss://relay.damus.io", "wss://nos.lol"];
29
-
30
27
  // ============================================================================
31
28
  // Constants
32
29
  // ============================================================================
@@ -34,6 +31,7 @@ export const DEFAULT_RELAYS = ["wss://relay.damus.io", "wss://nos.lol"];
34
31
  const STARTUP_LOOKBACK_SEC = 120; // tolerate relay lag / clock skew
35
32
  const MAX_PERSISTED_EVENT_IDS = 5000;
36
33
  const STATE_PERSIST_DEBOUNCE_MS = 5000; // Debounce state writes
34
+ const DEFAULT_INBOUND_GUARD_POLICY = createDirectDmPreCryptoGuardPolicy();
37
35
 
38
36
  // Circuit breaker configuration
39
37
  const CIRCUIT_BREAKER_THRESHOLD = 5; // failures before opening
@@ -46,7 +44,7 @@ const HEALTH_WINDOW_MS = 60000; // 1 minute window for health stats
46
44
  // Types
47
45
  // ============================================================================
48
46
 
49
- export interface NostrBusOptions {
47
+ interface NostrBusOptions {
50
48
  /** Private key in hex or nsec format */
51
49
  privateKey: string;
52
50
  /** WebSocket relay URLs (defaults to damus + nos.lol) */
@@ -58,7 +56,15 @@ export interface NostrBusOptions {
58
56
  pubkey: string,
59
57
  text: string,
60
58
  reply: (text: string) => Promise<void>,
59
+ meta: { eventId: string; createdAt: number },
61
60
  ) => Promise<void>;
61
+ /** Called after signature verification and before decrypt to allow sender policy checks (optional) */
62
+ authorizeSender?: (params: {
63
+ senderPubkey: string;
64
+ reply: (text: string) => Promise<void>;
65
+ }) => Promise<"allow" | "block" | "pairing">;
66
+ /** Override pre-crypto DM guardrails for tests or future channel tuning (optional) */
67
+ guardPolicy?: DirectDmPreCryptoGuardPolicyOverrides;
62
68
  /** Called on errors (optional) */
63
69
  onError?: (error: Error, context: string) => void;
64
70
  /** Called on connection status changes (optional) */
@@ -75,6 +81,62 @@ export interface NostrBusOptions {
75
81
  seenTtlMs?: number;
76
82
  }
77
83
 
84
+ type FixedWindowRateLimiter = {
85
+ isRateLimited: (key: string, nowMs?: number) => boolean;
86
+ size: () => number;
87
+ clear: () => void;
88
+ };
89
+
90
+ function createFixedWindowRateLimiter(params: {
91
+ windowMs: number;
92
+ maxRequests: number;
93
+ maxTrackedKeys: number;
94
+ }): FixedWindowRateLimiter {
95
+ const windowMs = Math.max(1, Math.floor(params.windowMs));
96
+ const maxRequests = Math.max(1, Math.floor(params.maxRequests));
97
+ const maxTrackedKeys = Math.max(1, Math.floor(params.maxTrackedKeys));
98
+ const state = new Map<string, { count: number; windowStartMs: number }>();
99
+
100
+ const touch = (key: string, value: { count: number; windowStartMs: number }) => {
101
+ state.delete(key);
102
+ state.set(key, value);
103
+ };
104
+
105
+ const prune = (nowMs: number) => {
106
+ for (const [key, entry] of state) {
107
+ if (nowMs - entry.windowStartMs >= windowMs) {
108
+ state.delete(key);
109
+ }
110
+ }
111
+ while (state.size > maxTrackedKeys) {
112
+ const oldest = state.keys().next().value;
113
+ if (!oldest) {
114
+ break;
115
+ }
116
+ state.delete(oldest);
117
+ }
118
+ };
119
+
120
+ return {
121
+ isRateLimited: (key: string, nowMs = Date.now()) => {
122
+ if (!key) {
123
+ return false;
124
+ }
125
+ prune(nowMs);
126
+ const existing = state.get(key);
127
+ if (!existing || nowMs - existing.windowStartMs >= windowMs) {
128
+ touch(key, { count: 1, windowStartMs: nowMs });
129
+ return false;
130
+ }
131
+ const nextCount = existing.count + 1;
132
+ touch(key, { count: nextCount, windowStartMs: existing.windowStartMs });
133
+ return nextCount > maxRequests;
134
+ },
135
+ size: () => state.size,
136
+ clear: () => state.clear(),
137
+ };
138
+ }
139
+
78
140
  export interface NostrBusHandle {
79
141
  /** Stop the bus and close connections */
80
142
  close: () => void;
@@ -271,46 +333,6 @@ function createRelayHealthTracker(): RelayHealthTracker {
271
333
  };
272
334
  }
273
335
 
274
- // ============================================================================
275
- // Key Validation
276
- // ============================================================================
277
-
278
- /**
279
- * Validate and normalize a private key (accepts hex or nsec format)
280
- */
281
- export function validatePrivateKey(key: string): Uint8Array {
282
- const trimmed = key.trim();
283
-
284
- // Handle nsec (bech32) format
285
- if (trimmed.startsWith("nsec1")) {
286
- const decoded = nip19.decode(trimmed);
287
- if (decoded.type !== "nsec") {
288
- throw new Error("Invalid nsec key: wrong type");
289
- }
290
- return decoded.data;
291
- }
292
-
293
- // Handle hex format
294
- if (!/^[0-9a-fA-F]{64}$/.test(trimmed)) {
295
- throw new Error("Private key must be 64 hex characters or nsec bech32 format");
296
- }
297
-
298
- // Convert hex string to Uint8Array
299
- const bytes = new Uint8Array(32);
300
- for (let i = 0; i < 32; i++) {
301
- bytes[i] = parseInt(trimmed.slice(i * 2, i * 2 + 2), 16);
302
- }
303
- return bytes;
304
- }
305
-
306
- /**
307
- * Get public key from private key (hex or nsec format)
308
- */
309
- export function getPublicKeyFromPrivate(privateKey: string): string {
310
- const sk = validatePrivateKey(privateKey);
311
- return getPublicKey(sk);
312
- }
313
-
314
336
  // ============================================================================
315
337
  // Main Bus
316
338
  // ============================================================================
@@ -323,6 +345,7 @@ export async function startNostrBus(options: NostrBusOptions): Promise<NostrBusH
323
345
  privateKey,
324
346
  relays = DEFAULT_RELAYS,
325
347
  onMessage,
348
+ authorizeSender,
326
349
  onError,
327
350
  onEose,
328
351
  onMetric,
@@ -335,6 +358,14 @@ export async function startNostrBus(options: NostrBusOptions): Promise<NostrBusH
335
358
  const pool = new SimplePool();
336
359
  const accountId = options.accountId ?? pk.slice(0, 16);
337
360
  const gatewayStartedAt = Math.floor(Date.now() / 1000);
361
+ const guardPolicy = createDirectDmPreCryptoGuardPolicy({
362
+ ...DEFAULT_INBOUND_GUARD_POLICY,
363
+ ...options.guardPolicy,
364
+ rateLimit: {
365
+ ...DEFAULT_INBOUND_GUARD_POLICY.rateLimit,
366
+ ...options.guardPolicy?.rateLimit,
367
+ },
368
+ });
338
369
 
339
370
  // Initialize metrics
340
371
  const metrics = onMetric ? createMetrics(onMetric) : createNoopMetrics();
@@ -397,6 +428,23 @@ export async function startNostrBus(options: NostrBusOptions): Promise<NostrBusH
397
428
  }
398
429
 
399
430
  const inflight = new Set<string>();
431
+ const perSenderRateLimiter = createFixedWindowRateLimiter({
432
+ windowMs: guardPolicy.rateLimit.windowMs,
433
+ maxRequests: guardPolicy.rateLimit.maxPerSenderPerWindow,
434
+ maxTrackedKeys: guardPolicy.rateLimit.maxTrackedSenderKeys,
435
+ });
436
+ const globalRateLimiter = createFixedWindowRateLimiter({
437
+ windowMs: guardPolicy.rateLimit.windowMs,
438
+ maxRequests: guardPolicy.rateLimit.maxGlobalPerWindow,
439
+ maxTrackedKeys: 1,
440
+ });
441
+
442
+ const updateRateLimiterSizeMetric = () => {
443
+ metrics.emit(
444
+ "memory.rate_limiter_entries",
445
+ perSenderRateLimiter.size() + globalRateLimiter.size(),
446
+ );
447
+ };
400
448
 
401
449
  // Event handler
402
450
  async function handleEvent(event: Event): Promise<void> {
@@ -410,15 +458,34 @@ export async function startNostrBus(options: NostrBusOptions): Promise<NostrBusH
410
458
  }
411
459
  inflight.add(event.id);
412
460
 
461
+ const markSeen = () => {
462
+ seen.add(event.id);
463
+ metrics.emit("memory.seen_tracker_size", seen.size());
464
+ };
465
+ const rejectAndMarkSeen = (metric: Parameters<typeof metrics.emit>[0]) => {
466
+ markSeen();
467
+ metrics.emit(metric);
468
+ };
469
+
413
470
  // Self-message loop prevention: skip our own messages
414
471
  if (event.pubkey === pk) {
415
- metrics.emit("event.rejected.self_message");
472
+ rejectAndMarkSeen("event.rejected.self_message");
416
473
  return;
417
474
  }
418
475
 
419
476
  // Skip events older than our `since` (relay may ignore filter)
420
477
  if (event.created_at < since) {
421
- metrics.emit("event.rejected.stale");
478
+ rejectAndMarkSeen("event.rejected.stale");
479
+ return;
480
+ }
481
+
482
+ if (event.created_at > Math.floor(Date.now() / 1000) + guardPolicy.maxFutureSkewSec) {
483
+ metrics.emit("event.rejected.future");
484
+ return;
485
+ }
486
+
487
+ if (!guardPolicy.allowedKinds.includes(event.kind)) {
488
+ rejectAndMarkSeen("event.rejected.wrong_kind");
422
489
  return;
423
490
  }
424
491
 
@@ -431,20 +498,81 @@ export async function startNostrBus(options: NostrBusOptions): Promise<NostrBusH
431
498
  }
432
499
  }
433
500
  if (!targetsUs) {
434
- metrics.emit("event.rejected.wrong_kind");
501
+ rejectAndMarkSeen("event.rejected.wrong_kind");
502
+ return;
503
+ }
504
+
505
+ const replyTo = async (text: string): Promise<void> => {
506
+ await sendEncryptedDm(
507
+ pool,
508
+ sk,
509
+ event.pubkey,
510
+ text,
511
+ relays,
512
+ metrics,
513
+ circuitBreakers,
514
+ healthTracker,
515
+ onError,
516
+ );
517
+ };
518
+
519
+ const rejectIfGlobalRateLimited = (): boolean => {
520
+ updateRateLimiterSizeMetric();
521
+ if (globalRateLimiter.isRateLimited("global")) {
522
+ metrics.emit("rate_limit.global");
523
+ metrics.emit("event.rejected.rate_limited");
524
+ updateRateLimiterSizeMetric();
525
+ return true;
526
+ }
527
+ updateRateLimiterSizeMetric();
528
+ return false;
529
+ };
530
+
531
+ const rejectIfVerifiedSenderRateLimited = (): boolean => {
532
+ updateRateLimiterSizeMetric();
533
+ if (perSenderRateLimiter.isRateLimited(event.pubkey)) {
534
+ metrics.emit("rate_limit.per_sender");
535
+ metrics.emit("event.rejected.rate_limited");
536
+ updateRateLimiterSizeMetric();
537
+ return true;
538
+ }
539
+ updateRateLimiterSizeMetric();
540
+ return false;
541
+ };
542
+
543
+ if (Buffer.byteLength(event.content, "utf8") > guardPolicy.maxCiphertextBytes) {
544
+ if (rejectIfGlobalRateLimited()) {
545
+ return;
546
+ }
547
+ rejectAndMarkSeen("event.rejected.oversized_ciphertext");
548
+ return;
549
+ }
550
+
551
+ if (rejectIfGlobalRateLimited()) {
435
552
  return;
436
553
  }
437
554
 
438
555
  // Verify signature (must pass before we trust the event)
439
556
  if (!verifyEvent(event)) {
440
- metrics.emit("event.rejected.invalid_signature");
557
+ rejectAndMarkSeen("event.rejected.invalid_signature");
441
558
  onError?.(new Error("Invalid signature"), `event ${event.id}`);
442
559
  return;
443
560
  }
444
561
 
445
- // Mark seen AFTER verify (don't cache invalid IDs)
446
- seen.add(event.id);
447
- metrics.emit("memory.seen_tracker_size", seen.size());
562
+ if (rejectIfVerifiedSenderRateLimited()) {
563
+ return;
564
+ }
565
+
566
+ if (authorizeSender) {
567
+ const decision = await authorizeSender({
568
+ senderPubkey: event.pubkey,
569
+ reply: replyTo,
570
+ });
571
+ if (decision !== "allow") {
572
+ markSeen();
573
+ return;
574
+ }
575
+ }
448
576
 
449
577
  // Decrypt the message
450
578
  let plaintext: string;
@@ -452,29 +580,27 @@ export async function startNostrBus(options: NostrBusOptions): Promise<NostrBusH
452
580
  plaintext = decrypt(sk, event.pubkey, event.content);
453
581
  metrics.emit("decrypt.success");
454
582
  } catch (err) {
583
+ markSeen();
455
584
  metrics.emit("decrypt.failure");
456
585
  metrics.emit("event.rejected.decrypt_failed");
457
586
  onError?.(err as Error, `decrypt from ${event.pubkey}`);
458
587
  return;
459
588
  }
460
589
 
461
- // Create reply function (try relays by health score)
462
- const replyTo = async (text: string): Promise<void> => {
463
- await sendEncryptedDm(
464
- pool,
465
- sk,
466
- event.pubkey,
467
- text,
468
- relays,
469
- metrics,
470
- circuitBreakers,
471
- healthTracker,
472
- onError,
473
- );
474
- };
590
+ if (Buffer.byteLength(plaintext, "utf8") > guardPolicy.maxPlaintextBytes) {
591
+ markSeen();
592
+ metrics.emit("event.rejected.oversized_plaintext");
593
+ return;
594
+ }
475
595
 
476
596
  // Call the message handler
477
- await onMessage(event.pubkey, plaintext, replyTo);
597
+ await onMessage(event.pubkey, plaintext, replyTo, {
598
+ eventId: event.id,
599
+ createdAt: event.created_at,
600
+ });
601
+
602
+ // Only cache successful deliveries so handler failures can retry.
603
+ markSeen();
478
604
 
479
605
  // Mark as processed
480
606
  metrics.emit("event.processed");
@@ -569,6 +695,8 @@ export async function startNostrBus(options: NostrBusOptions): Promise<NostrBusH
569
695
  close: () => {
570
696
  sub.close();
571
697
  seen.stop();
698
+ perSenderRateLimiter.clear();
699
+ globalRateLimiter.clear();
572
700
  // Flush pending state write synchronously on close
573
701
  if (pendingWrite) {
574
702
  clearTimeout(pendingWrite);
@@ -632,8 +760,11 @@ async function sendEncryptedDm(
632
760
 
633
761
  const startTime = Date.now();
634
762
  try {
635
- // oxlint-disable-next-line typescript/await-thenable typesciript/no-floating-promises
636
- await pool.publish([relay], reply);
763
+ const [publishPromise] = pool.publish([relay], reply);
764
+ if (!publishPromise) {
765
+ throw new Error(`Failed to create publish promise for relay ${relay}`);
766
+ }
767
+ await publishPromise;
637
768
  const latency = Date.now() - startTime;
638
769
 
639
770
  // Record success
@@ -656,64 +787,3 @@ async function sendEncryptedDm(
656
787
 
657
788
  throw new Error(`Failed to publish to any relay: ${lastError?.message}`);
658
789
  }
659
-
660
- // ============================================================================
661
- // Pubkey Utilities
662
- // ============================================================================
663
-
664
- /**
665
- * Check if a string looks like a valid Nostr pubkey (hex or npub)
666
- */
667
- export function isValidPubkey(input: string): boolean {
668
- if (typeof input !== "string") {
669
- return false;
670
- }
671
- const trimmed = input.trim();
672
-
673
- // npub format
674
- if (trimmed.startsWith("npub1")) {
675
- try {
676
- const decoded = nip19.decode(trimmed);
677
- return decoded.type === "npub";
678
- } catch {
679
- return false;
680
- }
681
- }
682
-
683
- // Hex format
684
- return /^[0-9a-fA-F]{64}$/.test(trimmed);
685
- }
686
-
687
- /**
688
- * Normalize a pubkey to hex format (accepts npub or hex)
689
- */
690
- export function normalizePubkey(input: string): string {
691
- const trimmed = input.trim();
692
-
693
- // npub format - decode to hex
694
- if (trimmed.startsWith("npub1")) {
695
- const decoded = nip19.decode(trimmed);
696
- if (decoded.type !== "npub") {
697
- throw new Error("Invalid npub key");
698
- }
699
- // Convert Uint8Array to hex string
700
- return Array.from(decoded.data as unknown as Uint8Array)
701
- .map((b) => b.toString(16).padStart(2, "0"))
702
- .join("");
703
- }
704
-
705
- // Already hex - validate and return lowercase
706
- if (!/^[0-9a-fA-F]{64}$/.test(trimmed)) {
707
- throw new Error("Pubkey must be 64 hex characters or npub format");
708
- }
709
- return trimmed.toLowerCase();
710
- }
711
-
712
- /**
713
- * Convert a hex pubkey to npub format
714
- */
715
- export function pubkeyToNpub(hexPubkey: string): string {
716
- const normalized = normalizePubkey(hexPubkey);
717
- // npubEncode expects a hex string, not Uint8Array
718
- return nip19.npubEncode(normalized);
719
- }
@@ -0,0 +1,94 @@
1
+ import { getPublicKey, nip19 } from "nostr-tools";
2
+
3
+ /**
4
+ * Validate and normalize a private key (accepts hex or nsec format)
5
+ */
6
+ export function validatePrivateKey(key: string): Uint8Array {
7
+ const trimmed = key.trim();
8
+
9
+ // Handle nsec (bech32) format
10
+ if (trimmed.startsWith("nsec1")) {
11
+ const decoded = nip19.decode(trimmed);
12
+ if (decoded.type !== "nsec") {
13
+ throw new Error("Invalid nsec key: wrong type");
14
+ }
15
+ return decoded.data;
16
+ }
17
+
18
+ // Handle hex format
19
+ if (!/^[0-9a-fA-F]{64}$/.test(trimmed)) {
20
+ throw new Error("Private key must be 64 hex characters or nsec bech32 format");
21
+ }
22
+
23
+ // Convert hex string to Uint8Array
24
+ const bytes = new Uint8Array(32);
25
+ for (let i = 0; i < 32; i++) {
26
+ bytes[i] = Number.parseInt(trimmed.slice(i * 2, i * 2 + 2), 16);
27
+ }
28
+ return bytes;
29
+ }
30
+
31
+ /**
32
+ * Get public key from private key (hex or nsec format)
33
+ */
34
+ export function getPublicKeyFromPrivate(privateKey: string): string {
35
+ const sk = validatePrivateKey(privateKey);
36
+ return getPublicKey(sk);
37
+ }
38
+
39
+ /**
40
+ * Check if a string looks like a valid Nostr pubkey (hex or npub)
41
+ */
42
+ export function isValidPubkey(input: string): boolean {
43
+ if (typeof input !== "string") {
44
+ return false;
45
+ }
46
+ const trimmed = input.trim();
47
+
48
+ // npub format
49
+ if (trimmed.startsWith("npub1")) {
50
+ try {
51
+ const decoded = nip19.decode(trimmed);
52
+ return decoded.type === "npub";
53
+ } catch {
54
+ return false;
55
+ }
56
+ }
57
+
58
+ // Hex format
59
+ return /^[0-9a-fA-F]{64}$/.test(trimmed);
60
+ }
61
+
62
+ /**
63
+ * Normalize a pubkey to hex format (accepts npub or hex)
64
+ */
65
+ export function normalizePubkey(input: string): string {
66
+ const trimmed = input.trim();
67
+
68
+ // npub format - decode to hex
69
+ if (trimmed.startsWith("npub1")) {
70
+ const decoded = nip19.decode(trimmed);
71
+ if (decoded.type !== "npub") {
72
+ throw new Error("Invalid npub key");
73
+ }
74
+ // Convert Uint8Array to hex string
75
+ return Array.from(decoded.data as unknown as Uint8Array)
76
+ .map((b) => b.toString(16).padStart(2, "0"))
77
+ .join("");
78
+ }
79
+
80
+ // Already hex - validate and return lowercase
81
+ if (!/^[0-9a-fA-F]{64}$/.test(trimmed)) {
82
+ throw new Error("Pubkey must be 64 hex characters or npub format");
83
+ }
84
+ return trimmed.toLowerCase();
85
+ }
86
+
87
+ /**
88
+ * Convert a hex pubkey to npub format
89
+ */
90
+ export function pubkeyToNpub(hexPubkey: string): string {
91
+ const normalized = normalizePubkey(hexPubkey);
92
+ // npubEncode expects a hex string, not Uint8Array
93
+ return nip19.npubEncode(normalized);
94
+ }
@@ -0,0 +1,134 @@
1
+ import { type NostrProfile, NostrProfileSchema } from "./config-schema.js";
2
+
3
+ /** NIP-01 profile content (JSON inside kind:0 event). */
4
+ export interface ProfileContent {
5
+ name?: string;
6
+ display_name?: string;
7
+ about?: string;
8
+ picture?: string;
9
+ banner?: string;
10
+ website?: string;
11
+ nip05?: string;
12
+ lud16?: string;
13
+ }
14
+
15
+ /**
16
+ * Convert our config profile schema to NIP-01 content format.
17
+ * Strips undefined fields and validates URLs.
18
+ */
19
+ export function profileToContent(profile: NostrProfile): ProfileContent {
20
+ const validated = NostrProfileSchema.parse(profile);
21
+
22
+ const content: ProfileContent = {};
23
+
24
+ if (validated.name !== undefined) {
25
+ content.name = validated.name;
26
+ }
27
+ if (validated.displayName !== undefined) {
28
+ content.display_name = validated.displayName;
29
+ }
30
+ if (validated.about !== undefined) {
31
+ content.about = validated.about;
32
+ }
33
+ if (validated.picture !== undefined) {
34
+ content.picture = validated.picture;
35
+ }
36
+ if (validated.banner !== undefined) {
37
+ content.banner = validated.banner;
38
+ }
39
+ if (validated.website !== undefined) {
40
+ content.website = validated.website;
41
+ }
42
+ if (validated.nip05 !== undefined) {
43
+ content.nip05 = validated.nip05;
44
+ }
45
+ if (validated.lud16 !== undefined) {
46
+ content.lud16 = validated.lud16;
47
+ }
48
+
49
+ return content;
50
+ }
51
+
52
+ /**
53
+ * Convert NIP-01 content format back to our config profile schema.
54
+ * Useful for importing existing profiles from relays.
55
+ */
56
+ export function contentToProfile(content: ProfileContent): NostrProfile {
57
+ const profile: NostrProfile = {};
58
+
59
+ if (content.name !== undefined) {
60
+ profile.name = content.name;
61
+ }
62
+ if (content.display_name !== undefined) {
63
+ profile.displayName = content.display_name;
64
+ }
65
+ if (content.about !== undefined) {
66
+ profile.about = content.about;
67
+ }
68
+ if (content.picture !== undefined) {
69
+ profile.picture = content.picture;
70
+ }
71
+ if (content.banner !== undefined) {
72
+ profile.banner = content.banner;
73
+ }
74
+ if (content.website !== undefined) {
75
+ profile.website = content.website;
76
+ }
77
+ if (content.nip05 !== undefined) {
78
+ profile.nip05 = content.nip05;
79
+ }
80
+ if (content.lud16 !== undefined) {
81
+ profile.lud16 = content.lud16;
82
+ }
83
+
84
+ return profile;
85
+ }
86
+
87
+ /**
88
+ * Validate a profile without throwing (returns result object).
89
+ */
90
+ export function validateProfile(profile: unknown): {
91
+ valid: boolean;
92
+ profile?: NostrProfile;
93
+ errors?: string[];
94
+ } {
95
+ const result = NostrProfileSchema.safeParse(profile);
96
+
97
+ if (result.success) {
98
+ return { valid: true, profile: result.data };
99
+ }
100
+
101
+ return {
102
+ valid: false,
103
+ errors: result.error.issues.map((e) => `${e.path.join(".")}: ${e.message}`),
104
+ };
105
+ }
106
+
107
+ /**
108
+ * Sanitize profile text fields to prevent XSS when displaying in UI.
109
+ * Escapes HTML special characters.
110
+ */
111
+ export function sanitizeProfileForDisplay(profile: NostrProfile): NostrProfile {
112
+ const escapeHtml = (str: string | undefined): string | undefined => {
113
+ if (str === undefined) {
114
+ return undefined;
115
+ }
116
+ return str
117
+ .replace(/&/g, "&amp;")
118
+ .replace(/</g, "&lt;")
119
+ .replace(/>/g, "&gt;")
120
+ .replace(/"/g, "&quot;")
121
+ .replace(/'/g, "&#039;");
122
+ };
123
+
124
+ return {
125
+ name: escapeHtml(profile.name),
126
+ displayName: escapeHtml(profile.displayName),
127
+ about: escapeHtml(profile.about),
128
+ picture: profile.picture,
129
+ banner: profile.banner,
130
+ website: profile.website,
131
+ nip05: escapeHtml(profile.nip05),
132
+ lud16: escapeHtml(profile.lud16),
133
+ };
134
+ }
@@ -0,0 +1,6 @@
1
+ export {
2
+ readJsonBodyWithLimit,
3
+ requestBodyErrorToText,
4
+ } from "openclaw/plugin-sdk/webhook-request-guards";
5
+ export { createFixedWindowRateLimiter } from "openclaw/plugin-sdk/webhook-ingress";
6
+ export { getPluginRuntimeGatewayRequestScope } from "../runtime-api.js";