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