@kodelyth/nostr 2026.5.42 → 2026.6.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.
Files changed (47) hide show
  1. package/klaw.plugin.json +185 -2
  2. package/package.json +17 -4
  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,632 +0,0 @@
1
- /**
2
- * Tests for Nostr Profile HTTP Handler
3
- */
4
-
5
- import { IncomingMessage, ServerResponse } from "node:http";
6
- import { Socket } from "node:net";
7
- import { afterAll, beforeEach, describe, expect, it, vi } from "vitest";
8
- import {
9
- clearNostrProfileRateLimitStateForTest,
10
- createNostrProfileHttpHandler,
11
- getNostrProfileRateLimitStateSizeForTest,
12
- isNostrProfileRateLimitedForTest,
13
- type NostrProfileHttpContext,
14
- } from "./nostr-profile-http.js";
15
-
16
- const runtimeScopeMock = vi.hoisted(() => vi.fn());
17
-
18
- vi.mock("./nostr-profile-http-runtime.js", async () => {
19
- const webhookIngress = await import("klaw/plugin-sdk/webhook-ingress");
20
- const requestGuards = await import("klaw/plugin-sdk/webhook-request-guards");
21
- return {
22
- createFixedWindowRateLimiter: webhookIngress.createFixedWindowRateLimiter,
23
- readJsonBodyWithLimit: requestGuards.readJsonBodyWithLimit,
24
- requestBodyErrorToText: requestGuards.requestBodyErrorToText,
25
- getPluginRuntimeGatewayRequestScope: runtimeScopeMock,
26
- };
27
- });
28
-
29
- // Mock the channel exports
30
- vi.mock("./channel.js", () => ({
31
- publishNostrProfile: vi.fn(),
32
- getNostrProfileState: vi.fn(),
33
- }));
34
-
35
- // Mock the import module
36
- vi.mock("./nostr-profile-import.js", () => ({
37
- importProfileFromRelays: vi.fn(),
38
- mergeProfiles: vi.fn((local, imported) => ({ ...imported, ...local })),
39
- }));
40
-
41
- import { publishNostrProfile, getNostrProfileState } from "./channel.js";
42
- import { importProfileFromRelays } from "./nostr-profile-import.js";
43
- import { TEST_HEX_PUBLIC_KEY, TEST_SETUP_RELAY_URLS } from "./test-fixtures.js";
44
-
45
- // ============================================================================
46
- // Test Helpers
47
- // ============================================================================
48
-
49
- const TEST_PROFILE_RELAY_URL = TEST_SETUP_RELAY_URLS[0];
50
-
51
- afterAll(() => {
52
- runtimeScopeMock.mockReset();
53
- });
54
-
55
- function setGatewayRuntimeScopes(scopes: readonly string[] | undefined): void {
56
- if (!scopes) {
57
- runtimeScopeMock.mockReturnValue(undefined);
58
- return;
59
- }
60
- runtimeScopeMock.mockReturnValue({
61
- client: {
62
- connect: {
63
- scopes: [...scopes],
64
- },
65
- },
66
- });
67
- }
68
-
69
- function responseChunkText(chunk: unknown): string {
70
- if (typeof chunk === "string") {
71
- return chunk;
72
- }
73
- if (Buffer.isBuffer(chunk)) {
74
- return chunk.toString();
75
- }
76
- return "";
77
- }
78
-
79
- function createMockRequest(
80
- method: string,
81
- url: string,
82
- body?: unknown,
83
- opts?: { headers?: Record<string, string>; remoteAddress?: string },
84
- ): IncomingMessage {
85
- const socket = new Socket();
86
- Object.defineProperty(socket, "remoteAddress", {
87
- value: opts?.remoteAddress ?? "127.0.0.1",
88
- configurable: true,
89
- });
90
- const req = new IncomingMessage(socket);
91
- req.method = method;
92
- req.url = url;
93
- req.headers = { host: "localhost:3000", ...opts?.headers };
94
-
95
- if (body) {
96
- const bodyStr = JSON.stringify(body);
97
- process.nextTick(() => {
98
- req.emit("data", Buffer.from(bodyStr));
99
- req.emit("end");
100
- });
101
- } else {
102
- process.nextTick(() => {
103
- req.emit("end");
104
- });
105
- }
106
-
107
- return req;
108
- }
109
-
110
- type MockResponse = {
111
- _getData: () => string;
112
- _getStatusCode: () => number;
113
- write: (chunk: unknown) => boolean;
114
- end: (chunk?: unknown) => MockResponse;
115
- statusCode: number;
116
- };
117
-
118
- function createMockResponse(): MockResponse {
119
- let data = "";
120
- let statusCode = 200;
121
- const res = Object.assign(new ServerResponse({} as IncomingMessage), {
122
- _getData: () => data,
123
- _getStatusCode: () => statusCode,
124
- }) as MockResponse;
125
-
126
- res.write = function (chunk: unknown) {
127
- data += responseChunkText(chunk);
128
- return true;
129
- };
130
-
131
- res.end = function (chunk?: unknown) {
132
- if (chunk) {
133
- data += responseChunkText(chunk);
134
- }
135
- return this;
136
- };
137
-
138
- Object.defineProperty(res, "statusCode", {
139
- get: () => statusCode,
140
- set: (code: number) => {
141
- statusCode = code;
142
- },
143
- });
144
-
145
- return res;
146
- }
147
-
148
- function createMockContext(overrides?: Partial<NostrProfileHttpContext>): NostrProfileHttpContext {
149
- return {
150
- getConfigProfile: vi.fn().mockReturnValue(undefined),
151
- updateConfigProfile: vi.fn().mockResolvedValue(undefined),
152
- getAccountInfo: vi.fn().mockReturnValue({
153
- pubkey: TEST_HEX_PUBLIC_KEY,
154
- relays: [TEST_PROFILE_RELAY_URL],
155
- }),
156
- log: {
157
- info: vi.fn(),
158
- warn: vi.fn(),
159
- error: vi.fn(),
160
- },
161
- ...overrides,
162
- };
163
- }
164
-
165
- function createProfileHttpHarness(
166
- method: string,
167
- url: string,
168
- options?: {
169
- body?: unknown;
170
- ctx?: Partial<NostrProfileHttpContext>;
171
- req?: Parameters<typeof createMockRequest>[3];
172
- },
173
- ) {
174
- const ctx = createMockContext(options?.ctx);
175
- const handler = createNostrProfileHttpHandler(ctx);
176
- const req = createMockRequest(method, url, options?.body, options?.req);
177
- const res = createMockResponse();
178
-
179
- return {
180
- ctx,
181
- req,
182
- res,
183
- run: () => handler(req, res as unknown as ServerResponse),
184
- };
185
- }
186
-
187
- function expectOkResponse(res: MockResponse) {
188
- expect(res["_getStatusCode"]()).toBe(200);
189
- const data = JSON.parse(res["_getData"]());
190
- expect(data.ok).toBe(true);
191
- return data;
192
- }
193
-
194
- function mockSuccessfulProfileImport() {
195
- vi.mocked(importProfileFromRelays).mockResolvedValue({
196
- ok: true,
197
- profile: {
198
- name: "imported",
199
- displayName: "Imported User",
200
- },
201
- event: {
202
- id: "evt123",
203
- pubkey: TEST_HEX_PUBLIC_KEY,
204
- created_at: 1234567890,
205
- },
206
- relaysQueried: [TEST_PROFILE_RELAY_URL],
207
- sourceRelay: TEST_PROFILE_RELAY_URL,
208
- });
209
- }
210
-
211
- async function expectAdminScopeRejected(params: {
212
- scopes: readonly string[] | undefined;
213
- method: string;
214
- url: string;
215
- body: unknown;
216
- expectOperationNotCalled: () => void;
217
- }) {
218
- setGatewayRuntimeScopes(params.scopes);
219
- const { ctx, res, run } = createProfileHttpHarness(params.method, params.url, {
220
- body: params.body,
221
- });
222
-
223
- await run();
224
-
225
- expect(res["_getStatusCode"]()).toBe(403);
226
- const data = JSON.parse(res["_getData"]());
227
- expect(data.error).toBe("missing scope: operator.admin");
228
- params.expectOperationNotCalled();
229
- expect(ctx.updateConfigProfile).not.toHaveBeenCalled();
230
- }
231
-
232
- // ============================================================================
233
- // Tests
234
- // ============================================================================
235
-
236
- describe("nostr-profile-http", () => {
237
- beforeEach(() => {
238
- vi.clearAllMocks();
239
- clearNostrProfileRateLimitStateForTest();
240
- setGatewayRuntimeScopes(["operator.admin"]);
241
- });
242
-
243
- describe("route matching", () => {
244
- it("returns false for non-nostr paths", async () => {
245
- const { run } = createProfileHttpHarness("GET", "/api/channels/telegram/profile");
246
- const result = await run();
247
-
248
- expect(result).toBe(false);
249
- });
250
-
251
- it("returns false for paths without accountId", async () => {
252
- const { run } = createProfileHttpHarness("GET", "/api/channels/nostr/");
253
- const result = await run();
254
-
255
- expect(result).toBe(false);
256
- });
257
-
258
- it("handles /api/channels/nostr/:accountId/profile", async () => {
259
- const { run } = createProfileHttpHarness("GET", "/api/channels/nostr/default/profile");
260
-
261
- vi.mocked(getNostrProfileState).mockResolvedValue(null);
262
-
263
- const result = await run();
264
-
265
- expect(result).toBe(true);
266
- });
267
- });
268
-
269
- describe("GET /api/channels/nostr/:accountId/profile", () => {
270
- it("returns profile and publish state", async () => {
271
- const { res, run } = createProfileHttpHarness("GET", "/api/channels/nostr/default/profile", {
272
- ctx: {
273
- getConfigProfile: vi.fn().mockReturnValue({
274
- name: "testuser",
275
- displayName: "Test User",
276
- }),
277
- },
278
- });
279
-
280
- vi.mocked(getNostrProfileState).mockResolvedValue({
281
- lastPublishedAt: 1234567890,
282
- lastPublishedEventId: "abc123",
283
- lastPublishResults: { [TEST_PROFILE_RELAY_URL]: "ok" },
284
- });
285
-
286
- await run();
287
-
288
- expect(res["_getStatusCode"]()).toBe(200);
289
- const data = JSON.parse(res["_getData"]());
290
- expect(data.ok).toBe(true);
291
- expect(data.profile.name).toBe("testuser");
292
- expect(data.publishState.lastPublishedAt).toBe(1234567890);
293
- });
294
- });
295
-
296
- describe("PUT /api/channels/nostr/:accountId/profile", () => {
297
- function mockPublishSuccess() {
298
- vi.mocked(publishNostrProfile).mockResolvedValue({
299
- eventId: "event123",
300
- createdAt: 1234567890,
301
- successes: [TEST_PROFILE_RELAY_URL],
302
- failures: [],
303
- });
304
- }
305
-
306
- function expectBadRequestResponse(res: ReturnType<typeof createMockResponse>) {
307
- expect(res["_getStatusCode"]()).toBe(400);
308
- const data = JSON.parse(res["_getData"]());
309
- expect(data.ok).toBe(false);
310
- return data;
311
- }
312
-
313
- async function expectPrivatePictureRejected(pictureUrl: string) {
314
- const { res, run } = createProfileHttpHarness("PUT", "/api/channels/nostr/default/profile", {
315
- body: {
316
- name: "hacker",
317
- picture: pictureUrl,
318
- },
319
- });
320
-
321
- await run();
322
-
323
- const data = expectBadRequestResponse(res);
324
- expect(data.error).toContain("private");
325
- }
326
-
327
- it("validates profile and publishes", async () => {
328
- const { ctx, res, run } = createProfileHttpHarness(
329
- "PUT",
330
- "/api/channels/nostr/default/profile",
331
- {
332
- body: {
333
- name: "satoshi",
334
- displayName: "Satoshi Nakamoto",
335
- about: "Creator of Bitcoin",
336
- },
337
- },
338
- );
339
-
340
- mockPublishSuccess();
341
-
342
- await run();
343
-
344
- const data = expectOkResponse(res);
345
- expect(data.eventId).toBe("event123");
346
- expect(data.successes).toContain(TEST_PROFILE_RELAY_URL);
347
- expect(data.persisted).toBe(true);
348
- expect(ctx.updateConfigProfile).toHaveBeenCalled();
349
- });
350
-
351
- it("rejects profile mutation from non-loopback remote address", async () => {
352
- const { res, run } = createProfileHttpHarness("PUT", "/api/channels/nostr/default/profile", {
353
- body: { name: "attacker" },
354
- req: { remoteAddress: "198.51.100.10" },
355
- });
356
-
357
- await run();
358
- expect(res["_getStatusCode"]()).toBe(403);
359
- });
360
-
361
- it("rejects cross-origin profile mutation attempts", async () => {
362
- const { res, run } = createProfileHttpHarness("PUT", "/api/channels/nostr/default/profile", {
363
- body: { name: "attacker" },
364
- req: { headers: { origin: "https://evil.example" } },
365
- });
366
-
367
- await run();
368
- expect(res["_getStatusCode"]()).toBe(403);
369
- });
370
-
371
- it("rejects profile mutation with cross-site sec-fetch-site header", async () => {
372
- const { res, run } = createProfileHttpHarness("PUT", "/api/channels/nostr/default/profile", {
373
- body: { name: "attacker" },
374
- req: { headers: { "sec-fetch-site": "cross-site" } },
375
- });
376
-
377
- await run();
378
- expect(res["_getStatusCode"]()).toBe(403);
379
- });
380
-
381
- it("rejects profile mutation when forwarded client ip is non-loopback", async () => {
382
- const { res, run } = createProfileHttpHarness("PUT", "/api/channels/nostr/default/profile", {
383
- body: { name: "attacker" },
384
- req: { headers: { "x-forwarded-for": "203.0.113.99, 127.0.0.1" } },
385
- });
386
-
387
- await run();
388
- expect(res["_getStatusCode"]()).toBe(403);
389
- });
390
-
391
- it("rejects profile mutation when gateway caller is missing operator.admin", async () => {
392
- await expectAdminScopeRejected({
393
- scopes: ["operator.read"],
394
- method: "PUT",
395
- url: "/api/channels/nostr/default/profile",
396
- body: { name: "attacker" },
397
- expectOperationNotCalled: () => expect(publishNostrProfile).not.toHaveBeenCalled(),
398
- });
399
- });
400
-
401
- it("rejects profile mutation when gateway scope context is missing", async () => {
402
- await expectAdminScopeRejected({
403
- scopes: undefined,
404
- method: "PUT",
405
- url: "/api/channels/nostr/default/profile",
406
- body: { name: "attacker" },
407
- expectOperationNotCalled: () => expect(publishNostrProfile).not.toHaveBeenCalled(),
408
- });
409
- });
410
-
411
- it("rejects private IP in picture URL (SSRF protection)", async () => {
412
- await expectPrivatePictureRejected("https://127.0.0.1/evil.jpg");
413
- });
414
-
415
- it("rejects ISATAP-embedded private IPv4 in picture URL", async () => {
416
- await expectPrivatePictureRejected("https://[2001:db8:1234::5efe:127.0.0.1]/evil.jpg");
417
- });
418
-
419
- it("rejects non-https URLs", async () => {
420
- const { res, run } = createProfileHttpHarness("PUT", "/api/channels/nostr/default/profile", {
421
- body: {
422
- name: "test",
423
- picture: "http://example.com/pic.jpg",
424
- },
425
- });
426
-
427
- await run();
428
-
429
- const data = expectBadRequestResponse(res);
430
- // The schema validation catches non-https URLs before SSRF check
431
- expect(data.error).toBe("Validation failed");
432
- expect(Array.isArray(data.details)).toBe(true);
433
- expect(data.details).toEqual(["picture: URL must use https:// protocol"]);
434
- });
435
-
436
- it("does not persist if all relays fail", async () => {
437
- const { ctx, res, run } = createProfileHttpHarness(
438
- "PUT",
439
- "/api/channels/nostr/default/profile",
440
- {
441
- body: {
442
- name: "test",
443
- },
444
- },
445
- );
446
-
447
- vi.mocked(publishNostrProfile).mockResolvedValue({
448
- eventId: "event123",
449
- createdAt: 1234567890,
450
- successes: [],
451
- failures: [{ relay: TEST_PROFILE_RELAY_URL, error: "timeout" }],
452
- });
453
-
454
- await run();
455
-
456
- expect(res["_getStatusCode"]()).toBe(200);
457
- const data = JSON.parse(res["_getData"]());
458
- expect(data.persisted).toBe(false);
459
- expect(ctx.updateConfigProfile).not.toHaveBeenCalled();
460
- });
461
-
462
- it("enforces rate limiting", async () => {
463
- mockPublishSuccess();
464
-
465
- // Make 6 requests (limit is 5/min)
466
- for (let i = 0; i < 6; i++) {
467
- const { res, run } = createProfileHttpHarness(
468
- "PUT",
469
- "/api/channels/nostr/rate-test/profile",
470
- {
471
- body: {
472
- name: `user${i}`,
473
- },
474
- },
475
- );
476
- await run();
477
-
478
- if (i < 5) {
479
- expectOkResponse(res);
480
- } else {
481
- expect(res["_getStatusCode"]()).toBe(429);
482
- const data = JSON.parse(res["_getData"]());
483
- expect(data.error).toContain("Rate limit");
484
- }
485
- }
486
- });
487
-
488
- it("caps tracked rate-limit keys to prevent unbounded growth", () => {
489
- const now = 1_000_000;
490
- for (let i = 0; i < 2_500; i += 1) {
491
- isNostrProfileRateLimitedForTest(`rate-cap-${i}`, now);
492
- }
493
- expect(getNostrProfileRateLimitStateSizeForTest()).toBeLessThanOrEqual(2_048);
494
- });
495
-
496
- it("prunes stale rate-limit keys after the window elapses", () => {
497
- const now = 2_000_000;
498
- for (let i = 0; i < 100; i += 1) {
499
- isNostrProfileRateLimitedForTest(`rate-stale-${i}`, now);
500
- }
501
- expect(getNostrProfileRateLimitStateSizeForTest()).toBe(100);
502
-
503
- isNostrProfileRateLimitedForTest("fresh", now + 60_001);
504
- expect(getNostrProfileRateLimitStateSizeForTest()).toBe(1);
505
- });
506
- });
507
-
508
- describe("POST /api/channels/nostr/:accountId/profile/import", () => {
509
- function expectImportSuccessResponse(res: ReturnType<typeof createMockResponse>) {
510
- const data = expectOkResponse(res);
511
- expect(data.imported.name).toBe("imported");
512
- return data;
513
- }
514
-
515
- it("imports profile from relays", async () => {
516
- const { res, run } = createProfileHttpHarness(
517
- "POST",
518
- "/api/channels/nostr/default/profile/import",
519
- { body: {} },
520
- );
521
-
522
- mockSuccessfulProfileImport();
523
-
524
- await run();
525
-
526
- const data = expectImportSuccessResponse(res);
527
- expect(data.saved).toBe(false); // autoMerge not requested
528
- });
529
-
530
- it("rejects import mutation from non-loopback remote address", async () => {
531
- const { res, run } = createProfileHttpHarness(
532
- "POST",
533
- "/api/channels/nostr/default/profile/import",
534
- {
535
- body: {},
536
- req: { remoteAddress: "203.0.113.10" },
537
- },
538
- );
539
-
540
- await run();
541
- expect(res["_getStatusCode"]()).toBe(403);
542
- });
543
-
544
- it("rejects cross-origin import mutation attempts", async () => {
545
- const { res, run } = createProfileHttpHarness(
546
- "POST",
547
- "/api/channels/nostr/default/profile/import",
548
- {
549
- body: {},
550
- req: { headers: { origin: "https://evil.example" } },
551
- },
552
- );
553
-
554
- await run();
555
- expect(res["_getStatusCode"]()).toBe(403);
556
- });
557
-
558
- it("rejects import mutation when x-real-ip is non-loopback", async () => {
559
- const { res, run } = createProfileHttpHarness(
560
- "POST",
561
- "/api/channels/nostr/default/profile/import",
562
- {
563
- body: {},
564
- req: { headers: { "x-real-ip": "198.51.100.55" } },
565
- },
566
- );
567
-
568
- await run();
569
- expect(res["_getStatusCode"]()).toBe(403);
570
- });
571
-
572
- it("rejects profile import when gateway caller is missing operator.admin", async () => {
573
- await expectAdminScopeRejected({
574
- scopes: ["operator.read"],
575
- method: "POST",
576
- url: "/api/channels/nostr/default/profile/import",
577
- body: { autoMerge: true },
578
- expectOperationNotCalled: () => expect(importProfileFromRelays).not.toHaveBeenCalled(),
579
- });
580
- });
581
-
582
- it("rejects profile import when gateway scope context is missing", async () => {
583
- await expectAdminScopeRejected({
584
- scopes: undefined,
585
- method: "POST",
586
- url: "/api/channels/nostr/default/profile/import",
587
- body: { autoMerge: true },
588
- expectOperationNotCalled: () => expect(importProfileFromRelays).not.toHaveBeenCalled(),
589
- });
590
- });
591
-
592
- it("auto-merges when requested", async () => {
593
- const { ctx, res, run } = createProfileHttpHarness(
594
- "POST",
595
- "/api/channels/nostr/default/profile/import",
596
- {
597
- body: { autoMerge: true },
598
- ctx: {
599
- getConfigProfile: vi.fn().mockReturnValue({ about: "local bio" }),
600
- },
601
- },
602
- );
603
-
604
- mockSuccessfulProfileImport();
605
-
606
- await run();
607
-
608
- const data = expectImportSuccessResponse(res);
609
- expect(data.saved).toBe(true);
610
- expect(ctx.updateConfigProfile).toHaveBeenCalled();
611
- });
612
-
613
- it("returns error when account not found", async () => {
614
- const { res, run } = createProfileHttpHarness(
615
- "POST",
616
- "/api/channels/nostr/unknown/profile/import",
617
- {
618
- body: {},
619
- ctx: {
620
- getAccountInfo: vi.fn().mockReturnValue(null),
621
- },
622
- },
623
- );
624
-
625
- await run();
626
-
627
- expect(res["_getStatusCode"]()).toBe(404);
628
- const data = JSON.parse(res["_getData"]());
629
- expect(data.error).toContain("not found");
630
- });
631
- });
632
- });