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