@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/CHANGELOG.md +23 -0
- package/README.md +16 -16
- package/index.ts +6 -7
- package/openclaw.plugin.json +1 -3
- package/package.json +10 -7
- package/src/channel.test.ts +15 -5
- package/src/channel.ts +28 -17
- package/src/config-schema.ts +1 -1
- package/src/metrics.ts +33 -19
- package/src/nostr-bus.fuzz.test.ts +11 -22
- package/src/nostr-bus.integration.test.ts +2 -6
- package/src/nostr-bus.test.ts +3 -3
- package/src/nostr-bus.ts +56 -82
- package/src/nostr-profile-http.test.ts +10 -10
- package/src/nostr-profile-http.ts +37 -18
- package/src/nostr-profile-import.test.ts +2 -3
- package/src/nostr-profile-import.ts +10 -7
- package/src/nostr-profile.fuzz.test.ts +7 -9
- package/src/nostr-profile.test.ts +7 -7
- package/src/nostr-profile.ts +56 -21
- package/src/nostr-state-store.test.ts +10 -8
- package/src/nostr-state-store.ts +29 -29
- package/src/seen-tracker.ts +48 -16
- package/src/types.test.ts +1 -5
- package/src/types.ts +4 -2
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")
|
|
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)
|
|
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)
|
|
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].
|
|
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)
|
|
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 =
|
|
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
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
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 =
|
|
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")
|
|
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 & {
|
|
60
|
-
|
|
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)
|
|
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
|
|
115
|
+
const [, a, b] = ipv4Match.map(Number);
|
|
117
116
|
// 127.0.0.0/8 (loopback)
|
|
118
|
-
if (a === 127)
|
|
117
|
+
if (a === 127) {
|
|
118
|
+
return true;
|
|
119
|
+
}
|
|
119
120
|
// 10.0.0.0/8 (private)
|
|
120
|
-
if (a === 10)
|
|
121
|
+
if (a === 10) {
|
|
122
|
+
return true;
|
|
123
|
+
}
|
|
121
124
|
// 172.16.0.0/12 (private)
|
|
122
|
-
if (a === 172 && b >= 16 && b <= 31)
|
|
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)
|
|
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)
|
|
133
|
+
if (a === 169 && b === 254) {
|
|
134
|
+
return true;
|
|
135
|
+
}
|
|
127
136
|
// 0.0.0.0/8
|
|
128
|
-
if (a === 0)
|
|
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")
|
|
146
|
+
if (ipLower === "::1") {
|
|
147
|
+
return true;
|
|
148
|
+
}
|
|
136
149
|
// fe80::/10 (link-local)
|
|
137
|
-
if (ipLower.startsWith("fe80:"))
|
|
150
|
+
if (ipLower.startsWith("fe80:")) {
|
|
151
|
+
return true;
|
|
152
|
+
}
|
|
138
153
|
// fc00::/7 (unique local)
|
|
139
|
-
if (ipLower.startsWith("fc") || ipLower.startsWith("fd"))
|
|
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)
|
|
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
|
|
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)
|
|
247
|
-
|
|
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 {
|
|
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({
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
303
|
+
"Check out <img src="x" onerror="alert(1)">",
|
|
304
304
|
);
|
|
305
305
|
});
|
|
306
306
|
|