@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,583 +0,0 @@
1
- /**
2
- * Nostr Profile HTTP Handler
3
- *
4
- * Handles HTTP requests for profile management:
5
- * - PUT /api/channels/nostr/:accountId/profile - Update and publish profile
6
- * - POST /api/channels/nostr/:accountId/profile/import - Import from relays
7
- * - GET /api/channels/nostr/:accountId/profile - Get current profile state
8
- */
9
-
10
- import type { IncomingMessage, ServerResponse } from "node:http";
11
- import {
12
- normalizeLowercaseStringOrEmpty,
13
- normalizeOptionalLowercaseString,
14
- readStringValue,
15
- } from "klaw/plugin-sdk/string-coerce-runtime";
16
- import { z } from "zod";
17
- import { publishNostrProfile, getNostrProfileState } from "./channel.js";
18
- import { NostrProfileSchema, type NostrProfile } from "./config-schema.js";
19
- import {
20
- createFixedWindowRateLimiter,
21
- getPluginRuntimeGatewayRequestScope,
22
- readJsonBodyWithLimit,
23
- requestBodyErrorToText,
24
- } from "./nostr-profile-http-runtime.js";
25
- import { importProfileFromRelays, mergeProfiles } from "./nostr-profile-import.js";
26
- import { validateUrlSafety } from "./nostr-profile-url-safety.js";
27
-
28
- // ============================================================================
29
- // Types
30
- // ============================================================================
31
-
32
- export interface NostrProfileHttpContext {
33
- /** Get current profile from config */
34
- getConfigProfile: (accountId: string) => NostrProfile | undefined;
35
- /** Update profile in config (after successful publish) */
36
- updateConfigProfile: (accountId: string, profile: NostrProfile) => Promise<void>;
37
- /** Get account's public key and relays */
38
- getAccountInfo: (accountId: string) => { pubkey: string; relays: string[] } | null;
39
- /** Logger */
40
- log?: {
41
- info: (msg: string) => void;
42
- warn: (msg: string) => void;
43
- error: (msg: string) => void;
44
- };
45
- }
46
-
47
- // ============================================================================
48
- // Rate Limiting
49
- // ============================================================================
50
-
51
- const RATE_LIMIT_WINDOW_MS = 60_000; // 1 minute
52
- const RATE_LIMIT_MAX_REQUESTS = 5; // 5 requests per minute
53
- const RATE_LIMIT_MAX_TRACKED_KEYS = 2_048;
54
- const profileRateLimiter = createFixedWindowRateLimiter({
55
- windowMs: RATE_LIMIT_WINDOW_MS,
56
- maxRequests: RATE_LIMIT_MAX_REQUESTS,
57
- maxTrackedKeys: RATE_LIMIT_MAX_TRACKED_KEYS,
58
- });
59
-
60
- export function clearNostrProfileRateLimitStateForTest(): void {
61
- profileRateLimiter.clear();
62
- }
63
-
64
- export function getNostrProfileRateLimitStateSizeForTest(): number {
65
- return profileRateLimiter.size();
66
- }
67
-
68
- export function isNostrProfileRateLimitedForTest(accountId: string, nowMs: number): boolean {
69
- return profileRateLimiter.isRateLimited(accountId, nowMs);
70
- }
71
-
72
- function checkRateLimit(accountId: string): boolean {
73
- return !profileRateLimiter.isRateLimited(accountId);
74
- }
75
-
76
- // ============================================================================
77
- // Mutex for Concurrent Publish Prevention
78
- // ============================================================================
79
-
80
- const publishLocks = new Map<string, Promise<void>>();
81
-
82
- async function withPublishLock<T>(accountId: string, fn: () => Promise<T>): Promise<T> {
83
- // Atomic mutex using promise chaining - prevents TOCTOU race condition
84
- const prev = publishLocks.get(accountId) ?? Promise.resolve();
85
- let resolve: () => void;
86
- const next = new Promise<void>((r) => {
87
- resolve = r;
88
- });
89
- // Atomically replace the lock before awaiting - any concurrent request
90
- // will now wait on our `next` promise
91
- publishLocks.set(accountId, next);
92
-
93
- // Wait for previous operation to complete
94
- await prev.catch(() => {});
95
-
96
- try {
97
- return await fn();
98
- } finally {
99
- resolve!();
100
- // Clean up if we're the last in chain
101
- if (publishLocks.get(accountId) === next) {
102
- publishLocks.delete(accountId);
103
- }
104
- }
105
- }
106
-
107
- // ============================================================================
108
- // Validation Schemas
109
- // ============================================================================
110
-
111
- // NIP-05 format: user@domain.com
112
- const nip05FormatSchema = z
113
- .string()
114
- .regex(/^[a-z0-9._-]+@[a-z0-9.-]+\.[a-z]{2,}$/i, "Invalid NIP-05 format (user@domain.com)")
115
- .optional();
116
-
117
- // LUD-16 Lightning address format: user@domain.com
118
- const lud16FormatSchema = z
119
- .string()
120
- .regex(/^[a-z0-9._-]+@[a-z0-9.-]+\.[a-z]{2,}$/i, "Invalid Lightning address format")
121
- .optional();
122
-
123
- // Extended profile schema with additional format validation
124
- const ProfileUpdateSchema = NostrProfileSchema.extend({
125
- nip05: nip05FormatSchema,
126
- lud16: lud16FormatSchema,
127
- });
128
-
129
- const PROFILE_MUTATION_SCOPE = "operator.admin";
130
-
131
- // ============================================================================
132
- // Request Helpers
133
- // ============================================================================
134
-
135
- function sendJson(res: ServerResponse, status: number, body: unknown): void {
136
- res.statusCode = status;
137
- res.setHeader("Content-Type", "application/json; charset=utf-8");
138
- res.end(JSON.stringify(body));
139
- }
140
-
141
- async function readJsonBody(
142
- req: IncomingMessage,
143
- maxBytes = 64 * 1024,
144
- timeoutMs = 30_000,
145
- ): Promise<unknown> {
146
- const result = await readJsonBodyWithLimit(req, {
147
- maxBytes,
148
- timeoutMs,
149
- emptyObjectOnEmpty: true,
150
- });
151
- if (result.ok) {
152
- return result.value;
153
- }
154
- if (result.code === "PAYLOAD_TOO_LARGE") {
155
- throw new Error("Request body too large");
156
- }
157
- if (result.code === "REQUEST_BODY_TIMEOUT") {
158
- throw new Error(requestBodyErrorToText("REQUEST_BODY_TIMEOUT"));
159
- }
160
- if (result.code === "CONNECTION_CLOSED") {
161
- throw new Error(requestBodyErrorToText("CONNECTION_CLOSED"));
162
- }
163
- throw new Error(result.code === "INVALID_JSON" ? "Invalid JSON" : result.error);
164
- }
165
-
166
- function parseAccountIdFromPath(pathname: string): string | null {
167
- // Match: /api/channels/nostr/:accountId/profile
168
- const match = pathname.match(/^\/api\/channels\/nostr\/([^/]+)\/profile/);
169
- return match?.[1] ?? null;
170
- }
171
-
172
- function isLoopbackRemoteAddress(remoteAddress: string | undefined): boolean {
173
- if (!remoteAddress) {
174
- return false;
175
- }
176
-
177
- const ipLower = normalizeLowercaseStringOrEmpty(remoteAddress).replace(/^\[|\]$/g, "");
178
-
179
- // IPv6 loopback
180
- if (ipLower === "::1") {
181
- return true;
182
- }
183
-
184
- // IPv4 loopback (127.0.0.0/8)
185
- if (ipLower === "127.0.0.1" || ipLower.startsWith("127.")) {
186
- return true;
187
- }
188
-
189
- // IPv4-mapped IPv6
190
- const v4Mapped = ipLower.match(/^::ffff:(\d+\.\d+\.\d+\.\d+)$/);
191
- if (v4Mapped) {
192
- return isLoopbackRemoteAddress(v4Mapped[1]);
193
- }
194
-
195
- return false;
196
- }
197
-
198
- function isLoopbackOriginLike(value: string): boolean {
199
- try {
200
- const url = new URL(value);
201
- const hostname = normalizeLowercaseStringOrEmpty(url.hostname);
202
- return hostname === "localhost" || hostname === "127.0.0.1" || hostname === "::1";
203
- } catch {
204
- return false;
205
- }
206
- }
207
-
208
- function firstHeaderValue(value: string | string[] | undefined): string | undefined {
209
- if (Array.isArray(value)) {
210
- return value[0];
211
- }
212
- return readStringValue(value);
213
- }
214
-
215
- function normalizeIpCandidate(raw: string): string {
216
- const unquoted = raw.trim().replace(/^"|"$/g, "");
217
- const bracketedWithOptionalPort = unquoted.match(/^\[([^[\]]+)\](?::\d+)?$/);
218
- if (bracketedWithOptionalPort) {
219
- return bracketedWithOptionalPort[1] ?? "";
220
- }
221
- const ipv4WithPort = unquoted.match(/^(\d+\.\d+\.\d+\.\d+):\d+$/);
222
- if (ipv4WithPort) {
223
- return ipv4WithPort[1] ?? "";
224
- }
225
- return unquoted;
226
- }
227
-
228
- function hasNonLoopbackForwardedClient(req: IncomingMessage): boolean {
229
- const forwardedFor = firstHeaderValue(req.headers["x-forwarded-for"]);
230
- if (forwardedFor) {
231
- for (const hop of forwardedFor.split(",")) {
232
- const candidate = normalizeIpCandidate(hop);
233
- if (!candidate) {
234
- continue;
235
- }
236
- if (!isLoopbackRemoteAddress(candidate)) {
237
- return true;
238
- }
239
- }
240
- }
241
-
242
- const realIp = firstHeaderValue(req.headers["x-real-ip"]);
243
- if (realIp) {
244
- const candidate = normalizeIpCandidate(realIp);
245
- if (candidate && !isLoopbackRemoteAddress(candidate)) {
246
- return true;
247
- }
248
- }
249
-
250
- return false;
251
- }
252
-
253
- function enforceLoopbackMutationGuards(
254
- ctx: NostrProfileHttpContext,
255
- req: IncomingMessage,
256
- res: ServerResponse,
257
- ): boolean {
258
- // Mutation endpoints are local-control-plane only.
259
- const remoteAddress = req.socket.remoteAddress;
260
- if (!isLoopbackRemoteAddress(remoteAddress)) {
261
- ctx.log?.warn?.(`Rejected mutation from non-loopback remoteAddress=${String(remoteAddress)}`);
262
- sendJson(res, 403, { ok: false, error: "Forbidden" });
263
- return false;
264
- }
265
-
266
- // If a proxy exposes client-origin headers showing a non-loopback client,
267
- // treat this as a remote request and deny mutation.
268
- if (hasNonLoopbackForwardedClient(req)) {
269
- ctx.log?.warn?.("Rejected mutation with non-loopback forwarded client headers");
270
- sendJson(res, 403, { ok: false, error: "Forbidden" });
271
- return false;
272
- }
273
-
274
- const secFetchSite = normalizeOptionalLowercaseString(
275
- firstHeaderValue(req.headers["sec-fetch-site"]),
276
- );
277
- if (secFetchSite === "cross-site") {
278
- ctx.log?.warn?.("Rejected mutation with cross-site sec-fetch-site header");
279
- sendJson(res, 403, { ok: false, error: "Forbidden" });
280
- return false;
281
- }
282
-
283
- // CSRF guard: browsers send Origin/Referer on cross-site requests.
284
- const origin = firstHeaderValue(req.headers.origin);
285
- if (typeof origin === "string" && !isLoopbackOriginLike(origin)) {
286
- ctx.log?.warn?.(`Rejected mutation with non-loopback origin=${origin}`);
287
- sendJson(res, 403, { ok: false, error: "Forbidden" });
288
- return false;
289
- }
290
-
291
- const referer = firstHeaderValue(req.headers.referer ?? req.headers.referrer);
292
- if (typeof referer === "string" && !isLoopbackOriginLike(referer)) {
293
- ctx.log?.warn?.(`Rejected mutation with non-loopback referer=${referer}`);
294
- sendJson(res, 403, { ok: false, error: "Forbidden" });
295
- return false;
296
- }
297
-
298
- return true;
299
- }
300
-
301
- function enforceGatewayMutationScope(
302
- ctx: NostrProfileHttpContext,
303
- accountId: string,
304
- res: ServerResponse,
305
- ): boolean {
306
- const runtimeScopes = getPluginRuntimeGatewayRequestScope()?.client?.connect?.scopes;
307
- const scopes = Array.isArray(runtimeScopes) ? runtimeScopes : [];
308
- if (scopes.includes(PROFILE_MUTATION_SCOPE)) {
309
- return true;
310
- }
311
- ctx.log?.warn?.(`[${accountId}] Rejected profile mutation missing ${PROFILE_MUTATION_SCOPE}`);
312
- sendJson(res, 403, { ok: false, error: `missing scope: ${PROFILE_MUTATION_SCOPE}` });
313
- return false;
314
- }
315
-
316
- // ============================================================================
317
- // HTTP Handler
318
- // ============================================================================
319
-
320
- export function createNostrProfileHttpHandler(
321
- ctx: NostrProfileHttpContext,
322
- ): (req: IncomingMessage, res: ServerResponse) => Promise<boolean> {
323
- return async (req, res) => {
324
- const url = new URL(req.url ?? "/", `http://${req.headers.host ?? "localhost"}`);
325
-
326
- // Only handle /api/channels/nostr/:accountId/profile paths
327
- if (!url.pathname.startsWith("/api/channels/nostr/")) {
328
- return false;
329
- }
330
-
331
- const accountId = parseAccountIdFromPath(url.pathname);
332
- if (!accountId) {
333
- return false;
334
- }
335
-
336
- const isImport = url.pathname.endsWith("/profile/import");
337
- const isProfilePath = url.pathname.endsWith("/profile") || isImport;
338
-
339
- if (!isProfilePath) {
340
- return false;
341
- }
342
-
343
- // Handle different HTTP methods
344
- try {
345
- if (req.method === "GET" && !isImport) {
346
- return await handleGetProfile(accountId, ctx, res);
347
- }
348
-
349
- if (req.method === "PUT" && !isImport) {
350
- return await handleUpdateProfile(accountId, ctx, req, res);
351
- }
352
-
353
- if (req.method === "POST" && isImport) {
354
- return await handleImportProfile(accountId, ctx, req, res);
355
- }
356
-
357
- // Method not allowed
358
- sendJson(res, 405, { ok: false, error: "Method not allowed" });
359
- return true;
360
- } catch (err) {
361
- ctx.log?.error(`Profile HTTP error: ${String(err)}`);
362
- sendJson(res, 500, { ok: false, error: "Internal server error" });
363
- return true;
364
- }
365
- };
366
- }
367
-
368
- // ============================================================================
369
- // GET /api/channels/nostr/:accountId/profile
370
- // ============================================================================
371
-
372
- async function handleGetProfile(
373
- accountId: string,
374
- ctx: NostrProfileHttpContext,
375
- res: ServerResponse,
376
- ): Promise<true> {
377
- const configProfile = ctx.getConfigProfile(accountId);
378
- const publishState = await getNostrProfileState(accountId);
379
-
380
- sendJson(res, 200, {
381
- ok: true,
382
- profile: configProfile ?? null,
383
- publishState: publishState ?? null,
384
- });
385
- return true;
386
- }
387
-
388
- // ============================================================================
389
- // PUT /api/channels/nostr/:accountId/profile
390
- // ============================================================================
391
-
392
- async function handleUpdateProfile(
393
- accountId: string,
394
- ctx: NostrProfileHttpContext,
395
- req: IncomingMessage,
396
- res: ServerResponse,
397
- ): Promise<true> {
398
- if (!enforceGatewayMutationScope(ctx, accountId, res)) {
399
- return true;
400
- }
401
- if (!enforceLoopbackMutationGuards(ctx, req, res)) {
402
- return true;
403
- }
404
-
405
- // Rate limiting
406
- if (!checkRateLimit(accountId)) {
407
- sendJson(res, 429, { ok: false, error: "Rate limit exceeded (5 requests/minute)" });
408
- return true;
409
- }
410
-
411
- // Parse body
412
- let body: unknown;
413
- try {
414
- body = await readJsonBody(req);
415
- } catch (err) {
416
- sendJson(res, 400, { ok: false, error: String(err) });
417
- return true;
418
- }
419
-
420
- // Validate profile
421
- const parseResult = ProfileUpdateSchema.safeParse(body);
422
- if (!parseResult.success) {
423
- const errors = parseResult.error.issues.map((i) => `${i.path.join(".")}: ${i.message}`);
424
- sendJson(res, 400, { ok: false, error: "Validation failed", details: errors });
425
- return true;
426
- }
427
-
428
- const profile = parseResult.data;
429
-
430
- // SSRF check for picture URL
431
- if (profile.picture) {
432
- const pictureCheck = validateUrlSafety(profile.picture);
433
- if (!pictureCheck.ok) {
434
- sendJson(res, 400, { ok: false, error: `picture: ${pictureCheck.error}` });
435
- return true;
436
- }
437
- }
438
-
439
- // SSRF check for banner URL
440
- if (profile.banner) {
441
- const bannerCheck = validateUrlSafety(profile.banner);
442
- if (!bannerCheck.ok) {
443
- sendJson(res, 400, { ok: false, error: `banner: ${bannerCheck.error}` });
444
- return true;
445
- }
446
- }
447
-
448
- // SSRF check for website URL
449
- if (profile.website) {
450
- const websiteCheck = validateUrlSafety(profile.website);
451
- if (!websiteCheck.ok) {
452
- sendJson(res, 400, { ok: false, error: `website: ${websiteCheck.error}` });
453
- return true;
454
- }
455
- }
456
-
457
- // Merge with existing profile to preserve unknown fields
458
- const existingProfile = ctx.getConfigProfile(accountId) ?? {};
459
- const mergedProfile: NostrProfile = {
460
- ...existingProfile,
461
- ...profile,
462
- };
463
-
464
- // Publish with mutex to prevent concurrent publishes
465
- try {
466
- const result = await withPublishLock(accountId, async () => {
467
- return await publishNostrProfile(accountId, mergedProfile);
468
- });
469
-
470
- // Only persist if at least one relay succeeded
471
- if (result.successes.length > 0) {
472
- await ctx.updateConfigProfile(accountId, mergedProfile);
473
- ctx.log?.info(`[${accountId}] Profile published to ${result.successes.length} relay(s)`);
474
- } else {
475
- ctx.log?.warn(`[${accountId}] Profile publish failed on all relays`);
476
- }
477
-
478
- sendJson(res, 200, {
479
- ok: true,
480
- eventId: result.eventId,
481
- createdAt: result.createdAt,
482
- successes: result.successes,
483
- failures: result.failures,
484
- persisted: result.successes.length > 0,
485
- });
486
- } catch (err) {
487
- ctx.log?.error(`[${accountId}] Profile publish error: ${String(err)}`);
488
- sendJson(res, 500, { ok: false, error: `Publish failed: ${String(err)}` });
489
- }
490
-
491
- return true;
492
- }
493
-
494
- // ============================================================================
495
- // POST /api/channels/nostr/:accountId/profile/import
496
- // ============================================================================
497
-
498
- async function handleImportProfile(
499
- accountId: string,
500
- ctx: NostrProfileHttpContext,
501
- req: IncomingMessage,
502
- res: ServerResponse,
503
- ): Promise<true> {
504
- if (!enforceGatewayMutationScope(ctx, accountId, res)) {
505
- return true;
506
- }
507
- if (!enforceLoopbackMutationGuards(ctx, req, res)) {
508
- return true;
509
- }
510
-
511
- // Get account info
512
- const accountInfo = ctx.getAccountInfo(accountId);
513
- if (!accountInfo) {
514
- sendJson(res, 404, { ok: false, error: `Account not found: ${accountId}` });
515
- return true;
516
- }
517
-
518
- const { pubkey, relays } = accountInfo;
519
-
520
- if (!pubkey) {
521
- sendJson(res, 400, { ok: false, error: "Account has no public key configured" });
522
- return true;
523
- }
524
-
525
- // Parse options from body
526
- let autoMerge = false;
527
- try {
528
- const body = await readJsonBody(req);
529
- if (typeof body === "object" && body !== null) {
530
- autoMerge = (body as { autoMerge?: boolean }).autoMerge === true;
531
- }
532
- } catch {
533
- // Ignore body parse errors - use defaults
534
- }
535
-
536
- ctx.log?.info(`[${accountId}] Importing profile for ${pubkey.slice(0, 8)}...`);
537
-
538
- // Import from relays
539
- const result = await importProfileFromRelays({
540
- pubkey,
541
- relays,
542
- timeoutMs: 10_000, // 10 seconds for import
543
- });
544
-
545
- if (!result.ok) {
546
- sendJson(res, 200, {
547
- ok: false,
548
- error: result.error,
549
- relaysQueried: result.relaysQueried,
550
- });
551
- return true;
552
- }
553
-
554
- // If autoMerge is requested, merge and save
555
- if (autoMerge && result.profile) {
556
- const localProfile = ctx.getConfigProfile(accountId);
557
- const merged = mergeProfiles(localProfile, result.profile);
558
- await ctx.updateConfigProfile(accountId, merged);
559
- ctx.log?.info(`[${accountId}] Profile imported and merged`);
560
-
561
- sendJson(res, 200, {
562
- ok: true,
563
- imported: result.profile,
564
- merged,
565
- saved: true,
566
- event: result.event,
567
- sourceRelay: result.sourceRelay,
568
- relaysQueried: result.relaysQueried,
569
- });
570
- return true;
571
- }
572
-
573
- // Otherwise, just return the imported profile for review
574
- sendJson(res, 200, {
575
- ok: true,
576
- imported: result.profile,
577
- saved: false,
578
- event: result.event,
579
- sourceRelay: result.sourceRelay,
580
- relaysQueried: result.relaysQueried,
581
- });
582
- return true;
583
- }
@@ -1,119 +0,0 @@
1
- /**
2
- * Tests for Nostr Profile Import
3
- */
4
-
5
- import { describe, it, expect } from "vitest";
6
- import type { NostrProfile } from "./config-schema.js";
7
- import { mergeProfiles } from "./nostr-profile-import.js";
8
-
9
- // Note: importProfileFromRelays requires real network calls or complex mocking
10
- // of nostr-tools SimplePool, so we focus on unit testing mergeProfiles
11
-
12
- describe("nostr-profile-import", () => {
13
- describe("mergeProfiles", () => {
14
- it("returns empty object when both are undefined", () => {
15
- const result = mergeProfiles(undefined, undefined);
16
- expect(result).toStrictEqual({});
17
- });
18
-
19
- it("returns imported when local is undefined", () => {
20
- const imported: NostrProfile = {
21
- name: "imported",
22
- displayName: "Imported User",
23
- about: "Bio from relay",
24
- };
25
- const result = mergeProfiles(undefined, imported);
26
- expect(result).toEqual(imported);
27
- });
28
-
29
- it("returns local when imported is undefined", () => {
30
- const local: NostrProfile = {
31
- name: "local",
32
- displayName: "Local User",
33
- };
34
- const result = mergeProfiles(local, undefined);
35
- expect(result).toEqual(local);
36
- });
37
-
38
- it("prefers local values over imported", () => {
39
- const local: NostrProfile = {
40
- name: "localname",
41
- about: "Local bio",
42
- };
43
- const imported: NostrProfile = {
44
- name: "importedname",
45
- displayName: "Imported Display",
46
- about: "Imported bio",
47
- picture: "https://example.com/pic.jpg",
48
- };
49
-
50
- const result = mergeProfiles(local, imported);
51
-
52
- expect(result.name).toBe("localname"); // local wins
53
- expect(result.displayName).toBe("Imported Display"); // imported fills gap
54
- expect(result.about).toBe("Local bio"); // local wins
55
- expect(result.picture).toBe("https://example.com/pic.jpg"); // imported fills gap
56
- });
57
-
58
- it("fills all missing fields from imported", () => {
59
- const local: NostrProfile = {
60
- name: "myname",
61
- };
62
- const imported: NostrProfile = {
63
- name: "theirname",
64
- displayName: "Their Name",
65
- about: "Their bio",
66
- picture: "https://example.com/pic.jpg",
67
- banner: "https://example.com/banner.jpg",
68
- website: "https://example.com",
69
- nip05: "user@example.com",
70
- lud16: "user@getalby.com",
71
- };
72
-
73
- const result = mergeProfiles(local, imported);
74
-
75
- expect(result.name).toBe("myname");
76
- expect(result.displayName).toBe("Their Name");
77
- expect(result.about).toBe("Their bio");
78
- expect(result.picture).toBe("https://example.com/pic.jpg");
79
- expect(result.banner).toBe("https://example.com/banner.jpg");
80
- expect(result.website).toBe("https://example.com");
81
- expect(result.nip05).toBe("user@example.com");
82
- expect(result.lud16).toBe("user@getalby.com");
83
- });
84
-
85
- it("handles empty strings as falsy (prefers imported)", () => {
86
- const local: NostrProfile = {
87
- name: "",
88
- displayName: "",
89
- };
90
- const imported: NostrProfile = {
91
- name: "imported",
92
- displayName: "Imported",
93
- };
94
-
95
- const result = mergeProfiles(local, imported);
96
-
97
- // Empty strings are still strings, so they "win" over imported
98
- // This is JavaScript nullish coalescing behavior
99
- expect(result.name).toBe("");
100
- expect(result.displayName).toBe("");
101
- });
102
-
103
- it("handles null values in local (prefers imported)", () => {
104
- const local: NostrProfile = {
105
- name: undefined,
106
- displayName: undefined,
107
- };
108
- const imported: NostrProfile = {
109
- name: "imported",
110
- displayName: "Imported",
111
- };
112
-
113
- const result = mergeProfiles(local, imported);
114
-
115
- expect(result.name).toBe("imported");
116
- expect(result.displayName).toBe("Imported");
117
- });
118
- });
119
- });