@kodelyth/nostr 2026.5.42 → 2026.6.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 (47) hide show
  1. package/klaw.plugin.json +185 -2
  2. package/package.json +19 -6
  3. package/api.ts +0 -10
  4. package/channel-plugin-api.ts +0 -1
  5. package/index.ts +0 -95
  6. package/runtime-api.ts +0 -6
  7. package/setup-api.ts +0 -1
  8. package/setup-entry.ts +0 -9
  9. package/setup-plugin-api.ts +0 -3
  10. package/src/channel-api.ts +0 -11
  11. package/src/channel.inbound.test.ts +0 -187
  12. package/src/channel.outbound.test.ts +0 -163
  13. package/src/channel.setup.ts +0 -234
  14. package/src/channel.test.ts +0 -526
  15. package/src/channel.ts +0 -215
  16. package/src/config-schema.ts +0 -98
  17. package/src/default-relays.ts +0 -1
  18. package/src/gateway.ts +0 -321
  19. package/src/inbound-direct-dm-runtime.ts +0 -1
  20. package/src/metrics.ts +0 -458
  21. package/src/nostr-bus.fuzz.test.ts +0 -382
  22. package/src/nostr-bus.inbound.test.ts +0 -526
  23. package/src/nostr-bus.integration.test.ts +0 -477
  24. package/src/nostr-bus.test.ts +0 -231
  25. package/src/nostr-bus.ts +0 -789
  26. package/src/nostr-key-utils.ts +0 -94
  27. package/src/nostr-profile-core.ts +0 -134
  28. package/src/nostr-profile-http-runtime.ts +0 -6
  29. package/src/nostr-profile-http.test.ts +0 -632
  30. package/src/nostr-profile-http.ts +0 -583
  31. package/src/nostr-profile-import.test.ts +0 -119
  32. package/src/nostr-profile-import.ts +0 -262
  33. package/src/nostr-profile-url-safety.ts +0 -21
  34. package/src/nostr-profile.fuzz.test.ts +0 -430
  35. package/src/nostr-profile.test.ts +0 -415
  36. package/src/nostr-profile.ts +0 -144
  37. package/src/nostr-state-store.test.ts +0 -237
  38. package/src/nostr-state-store.ts +0 -206
  39. package/src/runtime.ts +0 -9
  40. package/src/seen-tracker.ts +0 -289
  41. package/src/session-route.ts +0 -25
  42. package/src/setup-surface.ts +0 -264
  43. package/src/test-fixtures.ts +0 -45
  44. package/src/types.ts +0 -117
  45. package/test/setup.ts +0 -5
  46. package/test-api.ts +0 -1
  47. package/tsconfig.json +0 -16
@@ -1,415 +0,0 @@
1
- import { verifyEvent, getPublicKey } from "nostr-tools";
2
- import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
3
- import type { NostrProfile } from "./config-schema.js";
4
- import {
5
- createProfileEvent,
6
- profileToContent,
7
- contentToProfile,
8
- validateProfile,
9
- sanitizeProfileForDisplay,
10
- type ProfileContent,
11
- } from "./nostr-profile.js";
12
- import { TEST_HEX_PRIVATE_KEY_BYTES } from "./test-fixtures.js";
13
-
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
- }
19
-
20
- // ============================================================================
21
- // Profile Content Conversion Tests
22
- // ============================================================================
23
-
24
- describe("profileToContent", () => {
25
- it("converts full profile to NIP-01 content format", () => {
26
- const profile: NostrProfile = {
27
- name: "testuser",
28
- displayName: "Test User",
29
- about: "A test user for unit testing",
30
- picture: "https://example.com/avatar.png",
31
- banner: "https://example.com/banner.png",
32
- website: "https://example.com",
33
- nip05: "testuser@example.com",
34
- lud16: "testuser@walletofsatoshi.com",
35
- };
36
-
37
- const content = profileToContent(profile);
38
-
39
- expect(content.name).toBe("testuser");
40
- expect(content.display_name).toBe("Test User");
41
- expect(content.about).toBe("A test user for unit testing");
42
- expect(content.picture).toBe("https://example.com/avatar.png");
43
- expect(content.banner).toBe("https://example.com/banner.png");
44
- expect(content.website).toBe("https://example.com");
45
- expect(content.nip05).toBe("testuser@example.com");
46
- expect(content.lud16).toBe("testuser@walletofsatoshi.com");
47
- });
48
-
49
- it("omits undefined fields from content", () => {
50
- const profile: NostrProfile = {
51
- name: "minimaluser",
52
- };
53
-
54
- const content = profileToContent(profile);
55
-
56
- expect(content.name).toBe("minimaluser");
57
- expect("display_name" in content).toBe(false);
58
- expect("about" in content).toBe(false);
59
- expect("picture" in content).toBe(false);
60
- });
61
-
62
- it("handles empty profile", () => {
63
- const profile: NostrProfile = {};
64
- const content = profileToContent(profile);
65
- expect(Object.keys(content)).toHaveLength(0);
66
- });
67
- });
68
-
69
- describe("contentToProfile", () => {
70
- it("converts NIP-01 content to profile format", () => {
71
- const content: ProfileContent = {
72
- name: "testuser",
73
- display_name: "Test User",
74
- about: "A test user",
75
- picture: "https://example.com/avatar.png",
76
- nip05: "test@example.com",
77
- };
78
-
79
- const profile = contentToProfile(content);
80
-
81
- expect(profile.name).toBe("testuser");
82
- expect(profile.displayName).toBe("Test User");
83
- expect(profile.about).toBe("A test user");
84
- expect(profile.picture).toBe("https://example.com/avatar.png");
85
- expect(profile.nip05).toBe("test@example.com");
86
- });
87
-
88
- it("handles empty content", () => {
89
- const content: ProfileContent = {};
90
- const profile = contentToProfile(content);
91
- expect(
92
- Object.keys(profile).filter((k) => profile[k as keyof NostrProfile] !== undefined),
93
- ).toHaveLength(0);
94
- });
95
-
96
- it("round-trips profile data", () => {
97
- const original: NostrProfile = {
98
- name: "roundtrip",
99
- displayName: "Round Trip Test",
100
- about: "Testing round-trip conversion",
101
- };
102
-
103
- const content = profileToContent(original);
104
- const restored = contentToProfile(content);
105
-
106
- expect(restored.name).toBe(original.name);
107
- expect(restored.displayName).toBe(original.displayName);
108
- expect(restored.about).toBe(original.about);
109
- });
110
- });
111
-
112
- // ============================================================================
113
- // Event Creation Tests
114
- // ============================================================================
115
-
116
- describe("createProfileEvent", () => {
117
- beforeEach(() => {
118
- vi.useFakeTimers();
119
- vi.setSystemTime(new Date("2024-01-15T12:00:00Z"));
120
- });
121
-
122
- afterEach(() => {
123
- vi.useRealTimers();
124
- });
125
-
126
- it("creates a valid kind:0 event", () => {
127
- const profile: NostrProfile = {
128
- name: "testbot",
129
- about: "A test bot",
130
- };
131
-
132
- const event = createTestProfileEvent(profile);
133
-
134
- expect(event.kind).toBe(0);
135
- expect(event.pubkey).toBe(TEST_PUBKEY);
136
- expect(event.tags).toStrictEqual([]);
137
- expect(event.id).toMatch(/^[0-9a-f]{64}$/);
138
- expect(event.sig).toMatch(/^[0-9a-f]{128}$/);
139
- });
140
-
141
- it("includes profile content as JSON in event content", () => {
142
- const profile: NostrProfile = {
143
- name: "jsontest",
144
- displayName: "JSON Test User",
145
- about: "Testing JSON serialization",
146
- };
147
-
148
- const event = createTestProfileEvent(profile);
149
- const parsedContent = JSON.parse(event.content) as ProfileContent;
150
-
151
- expect(parsedContent.name).toBe("jsontest");
152
- expect(parsedContent.display_name).toBe("JSON Test User");
153
- expect(parsedContent.about).toBe("Testing JSON serialization");
154
- });
155
-
156
- it("produces a verifiable signature", () => {
157
- const profile: NostrProfile = { name: "signaturetest" };
158
- const event = createTestProfileEvent(profile);
159
-
160
- expect(verifyEvent(event)).toBe(true);
161
- });
162
-
163
- it("uses current timestamp when no lastPublishedAt provided", () => {
164
- const profile: NostrProfile = { name: "timestamptest" };
165
- const event = createTestProfileEvent(profile);
166
-
167
- const expectedTimestamp = Math.floor(Date.now() / 1000);
168
- expect(event.created_at).toBe(expectedTimestamp);
169
- });
170
-
171
- it("ensures monotonic timestamp when lastPublishedAt is in the future", () => {
172
- // Current time is 2024-01-15T12:00:00Z = 1705320000
173
- const futureTimestamp = 1705320000 + 3600; // 1 hour in the future
174
- const profile: NostrProfile = { name: "monotonictest" };
175
-
176
- const event = createTestProfileEvent(profile, futureTimestamp);
177
-
178
- expect(event.created_at).toBe(futureTimestamp + 1);
179
- });
180
-
181
- it("uses current time when lastPublishedAt is in the past", () => {
182
- const pastTimestamp = 1705320000 - 3600; // 1 hour in the past
183
- const profile: NostrProfile = { name: "pasttest" };
184
-
185
- const event = createTestProfileEvent(profile, pastTimestamp);
186
-
187
- const expectedTimestamp = Math.floor(Date.now() / 1000);
188
- expect(event.created_at).toBe(expectedTimestamp);
189
- });
190
- });
191
-
192
- // ============================================================================
193
- // Profile Validation Tests
194
- // ============================================================================
195
-
196
- describe("validateProfile", () => {
197
- it("validates a correct profile", () => {
198
- const profile = {
199
- name: "validuser",
200
- about: "A valid user",
201
- picture: "https://example.com/pic.png",
202
- };
203
-
204
- const result = validateProfile(profile);
205
-
206
- expect(result.valid).toBe(true);
207
- expect(result.profile?.name).toBe("validuser");
208
- expect(result.profile?.about).toBe("A valid user");
209
- expect(result.profile?.picture).toBe("https://example.com/pic.png");
210
- expect(result).not.toHaveProperty("errors");
211
- });
212
-
213
- it("rejects profile with invalid URL", () => {
214
- const profile = {
215
- name: "invalidurl",
216
- picture: "http://insecure.example.com/pic.png", // HTTP not HTTPS
217
- };
218
-
219
- const result = validateProfile(profile);
220
-
221
- expect(result.valid).toBe(false);
222
- expect(result.errors).toEqual(["picture: URL must use https:// protocol"]);
223
- });
224
-
225
- it("rejects profile with javascript: URL", () => {
226
- const profile = {
227
- name: "xssattempt",
228
- picture: "javascript:alert('xss')",
229
- };
230
-
231
- const result = validateProfile(profile);
232
-
233
- expect(result.valid).toBe(false);
234
- });
235
-
236
- it("rejects profile with data: URL", () => {
237
- const profile = {
238
- name: "dataurl",
239
- picture: "data:image/png;base64,abc123",
240
- };
241
-
242
- const result = validateProfile(profile);
243
-
244
- expect(result.valid).toBe(false);
245
- });
246
-
247
- it("rejects name exceeding 256 characters", () => {
248
- const profile = {
249
- name: "a".repeat(257),
250
- };
251
-
252
- const result = validateProfile(profile);
253
-
254
- expect(result.valid).toBe(false);
255
- expect(result.errors).toEqual(["name: Too big: expected string to have <=256 characters"]);
256
- });
257
-
258
- it("rejects about exceeding 2000 characters", () => {
259
- const profile = {
260
- about: "a".repeat(2001),
261
- };
262
-
263
- const result = validateProfile(profile);
264
-
265
- expect(result.valid).toBe(false);
266
- expect(result.errors).toEqual(["about: Too big: expected string to have <=2000 characters"]);
267
- });
268
-
269
- it("accepts empty profile", () => {
270
- const result = validateProfile({});
271
- expect(result.valid).toBe(true);
272
- });
273
-
274
- it("rejects null input", () => {
275
- const result = validateProfile(null);
276
- expect(result.valid).toBe(false);
277
- });
278
-
279
- it("rejects non-object input", () => {
280
- const result = validateProfile("not an object");
281
- expect(result.valid).toBe(false);
282
- });
283
- });
284
-
285
- // ============================================================================
286
- // Sanitization Tests
287
- // ============================================================================
288
-
289
- describe("sanitizeProfileForDisplay", () => {
290
- it("escapes HTML in name field", () => {
291
- const profile: NostrProfile = {
292
- name: "<script>alert('xss')</script>",
293
- };
294
-
295
- const sanitized = sanitizeProfileForDisplay(profile);
296
-
297
- expect(sanitized.name).toBe("&lt;script&gt;alert(&#039;xss&#039;)&lt;/script&gt;");
298
- });
299
-
300
- it("escapes HTML in about field", () => {
301
- const profile: NostrProfile = {
302
- about: 'Check out <img src="x" onerror="alert(1)">',
303
- };
304
-
305
- const sanitized = sanitizeProfileForDisplay(profile);
306
-
307
- expect(sanitized.about).toBe(
308
- "Check out &lt;img src=&quot;x&quot; onerror=&quot;alert(1)&quot;&gt;",
309
- );
310
- });
311
-
312
- it("preserves URLs without modification", () => {
313
- const profile: NostrProfile = {
314
- picture: "https://example.com/pic.png",
315
- website: "https://example.com",
316
- };
317
-
318
- const sanitized = sanitizeProfileForDisplay(profile);
319
-
320
- expect(sanitized.picture).toBe("https://example.com/pic.png");
321
- expect(sanitized.website).toBe("https://example.com");
322
- });
323
-
324
- it("handles undefined fields", () => {
325
- const profile: NostrProfile = {
326
- name: "test",
327
- };
328
-
329
- const sanitized = sanitizeProfileForDisplay(profile);
330
-
331
- expect(sanitized.name).toBe("test");
332
- expect(sanitized.about).toBeUndefined();
333
- expect(sanitized.picture).toBeUndefined();
334
- });
335
-
336
- it("escapes ampersands", () => {
337
- const profile: NostrProfile = {
338
- name: "Tom & Jerry",
339
- };
340
-
341
- const sanitized = sanitizeProfileForDisplay(profile);
342
-
343
- expect(sanitized.name).toBe("Tom &amp; Jerry");
344
- });
345
-
346
- it("escapes quotes", () => {
347
- const profile: NostrProfile = {
348
- about: 'Say "hello" to everyone',
349
- };
350
-
351
- const sanitized = sanitizeProfileForDisplay(profile);
352
-
353
- expect(sanitized.about).toBe("Say &quot;hello&quot; to everyone");
354
- });
355
- });
356
-
357
- // ============================================================================
358
- // Edge Cases
359
- // ============================================================================
360
-
361
- describe("edge cases", () => {
362
- it("handles emoji in profile fields", () => {
363
- const profile: NostrProfile = {
364
- name: "🤖 Bot",
365
- about: "I am a 🤖 robot! 🎉",
366
- };
367
-
368
- const content = profileToContent(profile);
369
- expect(content.name).toBe("🤖 Bot");
370
- expect(content.about).toBe("I am a 🤖 robot! 🎉");
371
-
372
- const event = createTestProfileEvent(profile);
373
- const parsed = JSON.parse(event.content) as ProfileContent;
374
- expect(parsed.name).toBe("🤖 Bot");
375
- });
376
-
377
- it("handles unicode in profile fields", () => {
378
- const profile: NostrProfile = {
379
- name: "日本語ユーザー",
380
- about: "Привет мир! 你好世界!",
381
- };
382
-
383
- const content = profileToContent(profile);
384
- expect(content.name).toBe("日本語ユーザー");
385
-
386
- const event = createTestProfileEvent(profile);
387
- expect(verifyEvent(event)).toBe(true);
388
- });
389
-
390
- it("handles newlines in about field", () => {
391
- const profile: NostrProfile = {
392
- about: "Line 1\nLine 2\nLine 3",
393
- };
394
-
395
- const content = profileToContent(profile);
396
- expect(content.about).toBe("Line 1\nLine 2\nLine 3");
397
-
398
- const event = createTestProfileEvent(profile);
399
- const parsed = JSON.parse(event.content) as ProfileContent;
400
- expect(parsed.about).toBe("Line 1\nLine 2\nLine 3");
401
- });
402
-
403
- it("handles maximum length fields", () => {
404
- const profile: NostrProfile = {
405
- name: "a".repeat(256),
406
- about: "b".repeat(2000),
407
- };
408
-
409
- const result = validateProfile(profile);
410
- expect(result.valid).toBe(true);
411
-
412
- const event = createTestProfileEvent(profile);
413
- expect(verifyEvent(event)).toBe(true);
414
- });
415
- });
@@ -1,144 +0,0 @@
1
- /**
2
- * Nostr Profile Management (NIP-01 kind:0)
3
- *
4
- * Profile events are "replaceable" - the latest created_at wins.
5
- * This module handles profile event creation and publishing.
6
- */
7
-
8
- import { formatErrorMessage } from "klaw/plugin-sdk/error-runtime";
9
- import { finalizeEvent, SimplePool, type Event } from "nostr-tools";
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";
19
-
20
- // ============================================================================
21
- // Types
22
- // ============================================================================
23
-
24
- /** Result of a profile publish attempt */
25
- export interface ProfilePublishResult {
26
- /** Event ID of the published profile */
27
- eventId: string;
28
- /** Relays that successfully received the event */
29
- successes: string[];
30
- /** Relays that failed with their error messages */
31
- failures: Array<{ relay: string; error: string }>;
32
- /** Unix timestamp when the event was created */
33
- createdAt: number;
34
- }
35
-
36
- // ============================================================================
37
- // Event Creation
38
- // ============================================================================
39
-
40
- /**
41
- * Create a signed kind:0 profile event.
42
- *
43
- * @param sk - Private key as Uint8Array (32 bytes)
44
- * @param profile - Profile data to include
45
- * @param lastPublishedAt - Previous profile timestamp (for monotonic guarantee)
46
- * @returns Signed Nostr event
47
- */
48
- export function createProfileEvent(
49
- sk: Uint8Array,
50
- profile: NostrProfile,
51
- lastPublishedAt?: number,
52
- ): Event {
53
- const content = profileToContent(profile);
54
- const contentJson = JSON.stringify(content);
55
-
56
- // Ensure monotonic timestamp (new event > previous)
57
- const now = Math.floor(Date.now() / 1000);
58
- const createdAt = lastPublishedAt !== undefined ? Math.max(now, lastPublishedAt + 1) : now;
59
-
60
- const event = finalizeEvent(
61
- {
62
- kind: 0,
63
- content: contentJson,
64
- tags: [],
65
- created_at: createdAt,
66
- },
67
- sk,
68
- );
69
-
70
- return event;
71
- }
72
-
73
- // ============================================================================
74
- // Profile Publishing
75
- // ============================================================================
76
-
77
- /** Per-relay publish timeout (ms) */
78
- const RELAY_PUBLISH_TIMEOUT_MS = 5000;
79
-
80
- /**
81
- * Publish a profile event to multiple relays.
82
- *
83
- * Best-effort: publishes to all relays in parallel, reports per-relay results.
84
- * Does NOT retry automatically - caller should handle retries if needed.
85
- *
86
- * @param pool - SimplePool instance for relay connections
87
- * @param relays - Array of relay WebSocket URLs
88
- * @param event - Signed profile event (kind:0)
89
- * @returns Publish results with successes and failures
90
- */
91
- async function publishProfileEvent(
92
- pool: SimplePool,
93
- relays: string[],
94
- event: Event,
95
- ): Promise<ProfilePublishResult> {
96
- const successes: string[] = [];
97
- const failures: Array<{ relay: string; error: string }> = [];
98
-
99
- // Publish to each relay in parallel with timeout
100
- const publishPromises = relays.map(async (relay) => {
101
- try {
102
- const timeoutPromise = new Promise<never>((_, reject) => {
103
- setTimeout(() => reject(new Error("timeout")), RELAY_PUBLISH_TIMEOUT_MS);
104
- });
105
-
106
- await Promise.race([...pool.publish([relay], event), timeoutPromise]);
107
-
108
- successes.push(relay);
109
- } catch (err) {
110
- const errorMessage = formatErrorMessage(err);
111
- failures.push({ relay, error: errorMessage });
112
- }
113
- });
114
-
115
- await Promise.all(publishPromises);
116
-
117
- return {
118
- eventId: event.id,
119
- successes,
120
- failures,
121
- createdAt: event.created_at,
122
- };
123
- }
124
-
125
- /**
126
- * Create and publish a profile event in one call.
127
- *
128
- * @param pool - SimplePool instance
129
- * @param sk - Private key as Uint8Array
130
- * @param relays - Array of relay URLs
131
- * @param profile - Profile data
132
- * @param lastPublishedAt - Previous timestamp for monotonic ordering
133
- * @returns Publish results
134
- */
135
- export async function publishProfile(
136
- pool: SimplePool,
137
- sk: Uint8Array,
138
- relays: string[],
139
- profile: NostrProfile,
140
- lastPublishedAt?: number,
141
- ): Promise<ProfilePublishResult> {
142
- const event = createProfileEvent(sk, profile, lastPublishedAt);
143
- return publishProfileEvent(pool, relays, event);
144
- }