@openclaw/nostr 2026.3.13 → 2026.5.2-beta.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/README.md +6 -0
- package/api.ts +10 -0
- package/channel-plugin-api.ts +1 -0
- package/index.ts +60 -36
- package/openclaw.plugin.json +190 -1
- package/package.json +41 -9
- package/runtime-api.ts +6 -0
- package/setup-api.ts +1 -0
- package/setup-entry.ts +9 -0
- package/setup-plugin-api.ts +3 -0
- package/src/channel-api.ts +15 -0
- package/src/channel.inbound.test.ts +176 -0
- package/src/channel.outbound.test.ts +89 -49
- package/src/channel.setup.ts +231 -0
- package/src/channel.test.ts +439 -71
- package/src/channel.ts +147 -283
- package/src/config-schema.ts +18 -12
- package/src/default-relays.ts +1 -0
- package/src/gateway.ts +302 -0
- package/src/inbound-direct-dm-runtime.ts +1 -0
- package/src/metrics.ts +6 -6
- package/src/nostr-bus.fuzz.test.ts +74 -247
- package/src/nostr-bus.inbound.test.ts +526 -0
- package/src/nostr-bus.integration.test.ts +88 -64
- package/src/nostr-bus.test.ts +22 -31
- package/src/nostr-bus.ts +206 -136
- package/src/nostr-key-utils.ts +94 -0
- package/src/nostr-profile-core.ts +134 -0
- package/src/nostr-profile-http-runtime.ts +6 -0
- package/src/nostr-profile-http.test.ts +276 -167
- package/src/nostr-profile-http.ts +51 -36
- package/src/nostr-profile-import.ts +3 -3
- package/src/nostr-profile-url-safety.ts +21 -0
- package/src/nostr-profile.fuzz.test.ts +7 -57
- package/src/nostr-profile.test.ts +16 -14
- package/src/nostr-profile.ts +13 -146
- package/src/nostr-state-store.test.ts +106 -2
- package/src/nostr-state-store.ts +46 -49
- package/src/runtime.ts +6 -3
- package/src/seen-tracker.ts +1 -1
- package/src/session-route.ts +25 -0
- package/src/setup-surface.ts +265 -0
- package/src/test-fixtures.ts +45 -0
- package/src/types.ts +26 -25
- package/test-api.ts +1 -0
- package/tsconfig.json +16 -0
- package/CHANGELOG.md +0 -116
- 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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
557
|
+
rejectAndMarkSeen("event.rejected.invalid_signature");
|
|
441
558
|
onError?.(new Error("Invalid signature"), `event ${event.id}`);
|
|
442
559
|
return;
|
|
443
560
|
}
|
|
444
561
|
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
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
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
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
|
-
|
|
636
|
-
|
|
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, "&")
|
|
118
|
+
.replace(/</g, "<")
|
|
119
|
+
.replace(/>/g, ">")
|
|
120
|
+
.replace(/"/g, """)
|
|
121
|
+
.replace(/'/g, "'");
|
|
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";
|