@openclaw/nostr 2026.3.12 → 2026.5.1-beta.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (48) hide show
  1. package/README.md +6 -0
  2. package/api.ts +10 -0
  3. package/channel-plugin-api.ts +1 -0
  4. package/index.ts +60 -36
  5. package/openclaw.plugin.json +190 -1
  6. package/package.json +41 -9
  7. package/runtime-api.ts +6 -0
  8. package/setup-api.ts +1 -0
  9. package/setup-entry.ts +9 -0
  10. package/setup-plugin-api.ts +3 -0
  11. package/src/channel-api.ts +15 -0
  12. package/src/channel.inbound.test.ts +176 -0
  13. package/src/channel.outbound.test.ts +89 -49
  14. package/src/channel.setup.ts +231 -0
  15. package/src/channel.test.ts +439 -71
  16. package/src/channel.ts +146 -284
  17. package/src/config-schema.ts +18 -12
  18. package/src/default-relays.ts +1 -0
  19. package/src/gateway.ts +302 -0
  20. package/src/inbound-direct-dm-runtime.ts +1 -0
  21. package/src/metrics.ts +6 -6
  22. package/src/nostr-bus.fuzz.test.ts +74 -247
  23. package/src/nostr-bus.inbound.test.ts +526 -0
  24. package/src/nostr-bus.integration.test.ts +88 -64
  25. package/src/nostr-bus.test.ts +22 -31
  26. package/src/nostr-bus.ts +206 -136
  27. package/src/nostr-key-utils.ts +94 -0
  28. package/src/nostr-profile-core.ts +134 -0
  29. package/src/nostr-profile-http-runtime.ts +6 -0
  30. package/src/nostr-profile-http.test.ts +310 -192
  31. package/src/nostr-profile-http.ts +51 -36
  32. package/src/nostr-profile-import.ts +3 -3
  33. package/src/nostr-profile-url-safety.ts +21 -0
  34. package/src/nostr-profile.fuzz.test.ts +7 -57
  35. package/src/nostr-profile.test.ts +16 -14
  36. package/src/nostr-profile.ts +13 -146
  37. package/src/nostr-state-store.test.ts +106 -2
  38. package/src/nostr-state-store.ts +46 -49
  39. package/src/runtime.ts +6 -3
  40. package/src/seen-tracker.ts +1 -1
  41. package/src/session-route.ts +25 -0
  42. package/src/setup-surface.ts +265 -0
  43. package/src/test-fixtures.ts +45 -0
  44. package/src/types.ts +26 -25
  45. package/test-api.ts +1 -0
  46. package/tsconfig.json +16 -0
  47. package/CHANGELOG.md +0 -110
  48. package/src/types.test.ts +0 -175
@@ -1,151 +1,519 @@
1
- import { describe, expect, it } from "vitest";
2
- import { nostrPlugin } from "./channel.js";
1
+ import {
2
+ createPluginSetupWizardConfigure,
3
+ createTestWizardPrompter,
4
+ runSetupWizardConfigure,
5
+ } from "openclaw/plugin-sdk/plugin-test-runtime";
6
+ import type { WizardPrompter } from "openclaw/plugin-sdk/plugin-test-runtime";
7
+ import { describe, expect, it, vi } from "vitest";
8
+ import type { OpenClawConfig } from "../runtime-api.js";
9
+ import { nostrSetupWizard } from "./setup-surface.js";
10
+ import {
11
+ TEST_HEX_PRIVATE_KEY,
12
+ TEST_SETUP_RELAY_URLS,
13
+ buildResolvedNostrAccount,
14
+ createConfiguredNostrCfg,
15
+ } from "./test-fixtures.js";
16
+ import { listNostrAccountIds, resolveDefaultNostrAccountId, resolveNostrAccount } from "./types.js";
17
+
18
+ function normalizeNostrTestEntry(entry: string): string {
19
+ return entry
20
+ .trim()
21
+ .replace(/^nostr:/i, "")
22
+ .toLowerCase();
23
+ }
24
+
25
+ function resolveNostrTestDmPolicy(params: {
26
+ cfg: OpenClawConfig;
27
+ account: ReturnType<typeof resolveNostrAccount>;
28
+ }) {
29
+ return {
30
+ cfg: params.cfg,
31
+ accountId: params.account.accountId,
32
+ policy: params.account.config.dmPolicy ?? "pairing",
33
+ allowFrom: params.account.config.allowFrom ?? [],
34
+ normalizeEntry: normalizeNostrTestEntry,
35
+ };
36
+ }
37
+
38
+ const nostrTestPlugin = {
39
+ id: "nostr",
40
+ meta: {
41
+ label: "Nostr",
42
+ docsPath: "/channels/nostr",
43
+ blurb: "Decentralized DMs via Nostr relays (NIP-04)",
44
+ },
45
+ capabilities: {
46
+ chatTypes: ["direct"],
47
+ media: false,
48
+ },
49
+ config: {
50
+ listAccountIds: listNostrAccountIds,
51
+ resolveAccount: (cfg: OpenClawConfig, accountId?: string | null) =>
52
+ resolveNostrAccount({ cfg, accountId }),
53
+ },
54
+ messaging: {
55
+ normalizeTarget: (target: string) => normalizeNostrTestEntry(target),
56
+ targetResolver: {
57
+ looksLikeId: (input: string) => {
58
+ const trimmed = input.trim();
59
+ return trimmed.startsWith("npub1") || /^[0-9a-fA-F]{64}$/.test(trimmed);
60
+ },
61
+ },
62
+ },
63
+ outbound: {
64
+ deliveryMode: "direct",
65
+ textChunkLimit: 4000,
66
+ },
67
+ pairing: {
68
+ idLabel: "nostrPubkey",
69
+ normalizeAllowEntry: normalizeNostrTestEntry,
70
+ },
71
+ security: {
72
+ resolveDmPolicy: resolveNostrTestDmPolicy,
73
+ },
74
+ status: {
75
+ defaultRuntime: {
76
+ accountId: "default",
77
+ running: false,
78
+ lastStartAt: null,
79
+ lastStopAt: null,
80
+ lastError: null,
81
+ },
82
+ },
83
+ setupWizard: nostrSetupWizard,
84
+ setup: {
85
+ resolveAccountId: ({
86
+ cfg,
87
+ accountId,
88
+ }: {
89
+ cfg: OpenClawConfig;
90
+ accountId?: string;
91
+ input: unknown;
92
+ }) => accountId?.trim() || resolveDefaultNostrAccountId(cfg),
93
+ },
94
+ };
95
+
96
+ const nostrConfigure = createPluginSetupWizardConfigure(nostrTestPlugin);
97
+
98
+ function requireNostrLooksLikeId() {
99
+ const looksLikeId = nostrTestPlugin.messaging?.targetResolver?.looksLikeId;
100
+ if (!looksLikeId) {
101
+ throw new Error("nostr messaging.targetResolver.looksLikeId missing");
102
+ }
103
+ return looksLikeId;
104
+ }
105
+
106
+ function requireNostrNormalizeTarget() {
107
+ const normalize = nostrTestPlugin.messaging?.normalizeTarget;
108
+ if (!normalize) {
109
+ throw new Error("nostr messaging.normalizeTarget missing");
110
+ }
111
+ return normalize;
112
+ }
113
+
114
+ function requireNostrPairingNormalizer() {
115
+ const normalize = nostrTestPlugin.pairing?.normalizeAllowEntry;
116
+ if (!normalize) {
117
+ throw new Error("nostr pairing.normalizeAllowEntry missing");
118
+ }
119
+ return normalize;
120
+ }
121
+
122
+ function requireNostrResolveDmPolicy() {
123
+ const resolveDmPolicy = nostrTestPlugin.security?.resolveDmPolicy;
124
+ if (!resolveDmPolicy) {
125
+ throw new Error("nostr security.resolveDmPolicy missing");
126
+ }
127
+ return resolveDmPolicy;
128
+ }
3
129
 
4
130
  describe("nostrPlugin", () => {
5
131
  describe("meta", () => {
6
132
  it("has correct id", () => {
7
- expect(nostrPlugin.id).toBe("nostr");
133
+ expect(nostrTestPlugin.id).toBe("nostr");
8
134
  });
9
135
 
10
136
  it("has required meta fields", () => {
11
- expect(nostrPlugin.meta.label).toBe("Nostr");
12
- expect(nostrPlugin.meta.docsPath).toBe("/channels/nostr");
13
- expect(nostrPlugin.meta.blurb).toContain("NIP-04");
137
+ expect(nostrTestPlugin.meta.label).toBe("Nostr");
138
+ expect(nostrTestPlugin.meta.docsPath).toBe("/channels/nostr");
139
+ expect(nostrTestPlugin.meta.blurb).toContain("NIP-04");
14
140
  });
15
141
  });
16
142
 
17
143
  describe("capabilities", () => {
18
144
  it("supports direct messages", () => {
19
- expect(nostrPlugin.capabilities.chatTypes).toContain("direct");
145
+ expect(nostrTestPlugin.capabilities.chatTypes).toContain("direct");
20
146
  });
21
147
 
22
148
  it("does not support groups (MVP)", () => {
23
- expect(nostrPlugin.capabilities.chatTypes).not.toContain("group");
149
+ expect(nostrTestPlugin.capabilities.chatTypes).not.toContain("group");
24
150
  });
25
151
 
26
152
  it("does not support media (MVP)", () => {
27
- expect(nostrPlugin.capabilities.media).toBe(false);
153
+ expect(nostrTestPlugin.capabilities.media).toBe(false);
28
154
  });
29
155
  });
30
156
 
31
157
  describe("config adapter", () => {
32
- it("has required config functions", () => {
33
- expect(nostrPlugin.config.listAccountIds).toBeTypeOf("function");
34
- expect(nostrPlugin.config.resolveAccount).toBeTypeOf("function");
35
- expect(nostrPlugin.config.isConfigured).toBeTypeOf("function");
36
- });
37
-
38
158
  it("listAccountIds returns empty array for unconfigured", () => {
39
159
  const cfg = { channels: {} };
40
- const ids = nostrPlugin.config.listAccountIds(cfg);
160
+ const ids = nostrTestPlugin.config.listAccountIds(cfg);
41
161
  expect(ids).toEqual([]);
42
162
  });
43
163
 
44
164
  it("listAccountIds returns default for configured", () => {
45
- const cfg = {
46
- channels: {
47
- nostr: {
48
- privateKey: "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
49
- },
50
- },
51
- };
52
- const ids = nostrPlugin.config.listAccountIds(cfg);
165
+ const cfg = createConfiguredNostrCfg();
166
+ const ids = nostrTestPlugin.config.listAccountIds(cfg);
53
167
  expect(ids).toContain("default");
54
168
  });
55
169
  });
56
170
 
57
171
  describe("messaging", () => {
58
- it("has target resolver", () => {
59
- expect(nostrPlugin.messaging?.targetResolver?.looksLikeId).toBeTypeOf("function");
60
- });
61
-
62
172
  it("recognizes npub as valid target", () => {
63
- const looksLikeId = nostrPlugin.messaging?.targetResolver?.looksLikeId;
64
- if (!looksLikeId) {
65
- return;
66
- }
173
+ const looksLikeId = requireNostrLooksLikeId();
67
174
 
68
175
  expect(looksLikeId("npub1xyz123")).toBe(true);
69
176
  });
70
177
 
71
178
  it("recognizes hex pubkey as valid target", () => {
72
- const looksLikeId = nostrPlugin.messaging?.targetResolver?.looksLikeId;
73
- if (!looksLikeId) {
74
- return;
75
- }
179
+ const looksLikeId = requireNostrLooksLikeId();
76
180
 
77
- const hexPubkey = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
78
- expect(looksLikeId(hexPubkey)).toBe(true);
181
+ expect(looksLikeId(TEST_HEX_PRIVATE_KEY)).toBe(true);
79
182
  });
80
183
 
81
184
  it("rejects invalid input", () => {
82
- const looksLikeId = nostrPlugin.messaging?.targetResolver?.looksLikeId;
83
- if (!looksLikeId) {
84
- return;
85
- }
185
+ const looksLikeId = requireNostrLooksLikeId();
86
186
 
87
187
  expect(looksLikeId("not-a-pubkey")).toBe(false);
88
188
  expect(looksLikeId("")).toBe(false);
89
189
  });
90
190
 
91
- it("normalizeTarget strips nostr: prefix", () => {
92
- const normalize = nostrPlugin.messaging?.normalizeTarget;
93
- if (!normalize) {
94
- return;
95
- }
191
+ it("normalizeTarget strips spaced nostr prefixes", () => {
192
+ const normalize = requireNostrNormalizeTarget();
96
193
 
97
- const hexPubkey = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
98
- expect(normalize(`nostr:${hexPubkey}`)).toBe(hexPubkey);
194
+ expect(normalize(`nostr:${TEST_HEX_PRIVATE_KEY}`)).toBe(TEST_HEX_PRIVATE_KEY);
195
+ expect(normalize(` nostr:${TEST_HEX_PRIVATE_KEY} `)).toBe(TEST_HEX_PRIVATE_KEY);
99
196
  });
100
197
  });
101
198
 
102
199
  describe("outbound", () => {
103
200
  it("has correct delivery mode", () => {
104
- expect(nostrPlugin.outbound?.deliveryMode).toBe("direct");
201
+ expect(nostrTestPlugin.outbound?.deliveryMode).toBe("direct");
105
202
  });
106
203
 
107
204
  it("has reasonable text chunk limit", () => {
108
- expect(nostrPlugin.outbound?.textChunkLimit).toBe(4000);
205
+ expect(nostrTestPlugin.outbound?.textChunkLimit).toBe(4000);
109
206
  });
110
207
  });
111
208
 
112
209
  describe("pairing", () => {
113
210
  it("has id label for pairing", () => {
114
- expect(nostrPlugin.pairing?.idLabel).toBe("nostrPubkey");
211
+ expect(nostrTestPlugin.pairing?.idLabel).toBe("nostrPubkey");
115
212
  });
116
213
 
117
- it("normalizes nostr: prefix in allow entries", () => {
118
- const normalize = nostrPlugin.pairing?.normalizeAllowEntry;
119
- if (!normalize) {
120
- return;
121
- }
214
+ it("normalizes spaced nostr prefixes in allow entries", () => {
215
+ const normalize = requireNostrPairingNormalizer();
122
216
 
123
- const hexPubkey = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
124
- expect(normalize(`nostr:${hexPubkey}`)).toBe(hexPubkey);
217
+ expect(normalize(`nostr:${TEST_HEX_PRIVATE_KEY}`)).toBe(TEST_HEX_PRIVATE_KEY);
218
+ expect(normalize(` nostr:${TEST_HEX_PRIVATE_KEY} `)).toBe(TEST_HEX_PRIVATE_KEY);
125
219
  });
126
220
  });
127
221
 
128
222
  describe("security", () => {
129
- it("has resolveDmPolicy function", () => {
130
- expect(nostrPlugin.security?.resolveDmPolicy).toBeTypeOf("function");
131
- });
132
- });
223
+ it("normalizes dm allowlist entries through the dm policy adapter", () => {
224
+ const resolveDmPolicy = requireNostrResolveDmPolicy();
133
225
 
134
- describe("gateway", () => {
135
- it("has startAccount function", () => {
136
- expect(nostrPlugin.gateway?.startAccount).toBeTypeOf("function");
226
+ const cfg = createConfiguredNostrCfg({
227
+ dmPolicy: "allowlist",
228
+ allowFrom: [` nostr:${TEST_HEX_PRIVATE_KEY} `],
229
+ });
230
+ const account = buildResolvedNostrAccount({
231
+ config: cfg.channels.nostr,
232
+ });
233
+
234
+ const result = resolveDmPolicy({ cfg, account });
235
+ if (!result) {
236
+ throw new Error("nostr resolveDmPolicy returned null");
237
+ }
238
+
239
+ expect(result.policy).toBe("allowlist");
240
+ expect(result.allowFrom).toEqual([` nostr:${TEST_HEX_PRIVATE_KEY} `]);
241
+ expect(result.normalizeEntry?.(` nostr:${TEST_HEX_PRIVATE_KEY} `)).toBe(
242
+ TEST_HEX_PRIVATE_KEY,
243
+ );
137
244
  });
138
245
  });
139
246
 
140
247
  describe("status", () => {
141
248
  it("has default runtime", () => {
142
- expect(nostrPlugin.status?.defaultRuntime).toBeDefined();
143
- expect(nostrPlugin.status?.defaultRuntime?.accountId).toBe("default");
144
- expect(nostrPlugin.status?.defaultRuntime?.running).toBe(false);
249
+ expect(nostrTestPlugin.status?.defaultRuntime).toEqual({
250
+ accountId: "default",
251
+ running: false,
252
+ lastStartAt: null,
253
+ lastStopAt: null,
254
+ lastError: null,
255
+ });
256
+ });
257
+ });
258
+ });
259
+
260
+ describe("nostr setup wizard", () => {
261
+ it("configures a private key and relay URLs", async () => {
262
+ const prompter = createTestWizardPrompter({
263
+ text: vi.fn(async ({ message }: { message: string }) => {
264
+ if (message === "Nostr private key (nsec... or hex)") {
265
+ return TEST_HEX_PRIVATE_KEY;
266
+ }
267
+ if (message === "Relay URLs (comma-separated, optional)") {
268
+ return TEST_SETUP_RELAY_URLS.join(", ");
269
+ }
270
+ throw new Error(`Unexpected prompt: ${message}`);
271
+ }) as WizardPrompter["text"],
272
+ });
273
+
274
+ const result = await runSetupWizardConfigure({
275
+ configure: nostrConfigure,
276
+ cfg: {} as OpenClawConfig,
277
+ prompter,
278
+ options: {},
279
+ });
280
+
281
+ expect(result.accountId).toBe("default");
282
+ expect(result.cfg.channels?.nostr?.enabled).toBe(true);
283
+ expect(result.cfg.channels?.nostr?.privateKey).toBe(TEST_HEX_PRIVATE_KEY);
284
+ expect(result.cfg.channels?.nostr?.relays).toEqual(TEST_SETUP_RELAY_URLS);
285
+ });
286
+
287
+ it("preserves the selected named account label during setup", async () => {
288
+ const prompter = createTestWizardPrompter({
289
+ text: vi.fn(async ({ message }: { message: string }) => {
290
+ if (message === "Nostr private key (nsec... or hex)") {
291
+ return TEST_HEX_PRIVATE_KEY;
292
+ }
293
+ if (message === "Relay URLs (comma-separated, optional)") {
294
+ return "";
295
+ }
296
+ throw new Error(`Unexpected prompt: ${message}`);
297
+ }) as WizardPrompter["text"],
298
+ });
299
+
300
+ const result = await runSetupWizardConfigure({
301
+ configure: nostrConfigure,
302
+ cfg: {} as OpenClawConfig,
303
+ prompter,
304
+ options: {},
305
+ accountOverrides: {
306
+ nostr: "work",
307
+ },
308
+ });
309
+
310
+ expect(result.accountId).toBe("work");
311
+ expect(result.cfg.channels?.nostr?.defaultAccount).toBe("work");
312
+ expect(result.cfg.channels?.nostr?.privateKey).toBe(TEST_HEX_PRIVATE_KEY);
313
+ });
314
+
315
+ it("uses configured defaultAccount when setup accountId is omitted", () => {
316
+ expect(
317
+ nostrTestPlugin.setup?.resolveAccountId?.({
318
+ cfg: createConfiguredNostrCfg({ defaultAccount: "work" }) as OpenClawConfig,
319
+ accountId: undefined,
320
+ input: {},
321
+ } as never),
322
+ ).toBe("work");
323
+ });
324
+ });
325
+
326
+ describe("nostr account helpers", () => {
327
+ describe("listNostrAccountIds", () => {
328
+ it("returns empty array when not configured", () => {
329
+ const cfg = { channels: {} };
330
+ expect(listNostrAccountIds(cfg)).toEqual([]);
331
+ });
332
+
333
+ it("returns empty array when nostr section exists but no privateKey", () => {
334
+ const cfg = { channels: { nostr: { enabled: true } } };
335
+ expect(listNostrAccountIds(cfg)).toEqual([]);
336
+ });
337
+
338
+ it("returns default when privateKey is configured", () => {
339
+ const cfg = createConfiguredNostrCfg();
340
+ expect(listNostrAccountIds(cfg)).toEqual(["default"]);
145
341
  });
146
342
 
147
- it("has buildAccountSnapshot function", () => {
148
- expect(nostrPlugin.status?.buildAccountSnapshot).toBeTypeOf("function");
343
+ it("returns configured defaultAccount when privateKey is configured", () => {
344
+ const cfg = createConfiguredNostrCfg({ defaultAccount: "work" });
345
+ expect(listNostrAccountIds(cfg)).toEqual(["work"]);
346
+ });
347
+
348
+ it("does not treat unresolved SecretRef privateKey as configured", () => {
349
+ const cfg = {
350
+ channels: {
351
+ nostr: {
352
+ privateKey: {
353
+ source: "env",
354
+ provider: "default",
355
+ id: "NOSTR_PRIVATE_KEY",
356
+ },
357
+ },
358
+ },
359
+ };
360
+ expect(listNostrAccountIds(cfg)).toEqual([]);
361
+ });
362
+ });
363
+
364
+ describe("resolveDefaultNostrAccountId", () => {
365
+ it("returns default when configured", () => {
366
+ const cfg = createConfiguredNostrCfg();
367
+ expect(resolveDefaultNostrAccountId(cfg)).toBe("default");
368
+ });
369
+
370
+ it("returns default when not configured", () => {
371
+ const cfg = { channels: {} };
372
+ expect(resolveDefaultNostrAccountId(cfg)).toBe("default");
373
+ });
374
+
375
+ it("prefers configured defaultAccount when present", () => {
376
+ const cfg = createConfiguredNostrCfg({ defaultAccount: "work" });
377
+ expect(resolveDefaultNostrAccountId(cfg)).toBe("work");
378
+ });
379
+ });
380
+
381
+ describe("resolveNostrAccount", () => {
382
+ it("resolves configured account", () => {
383
+ const cfg = createConfiguredNostrCfg({
384
+ name: "Test Bot",
385
+ relays: ["wss://test.relay"],
386
+ dmPolicy: "pairing" as const,
387
+ });
388
+ const account = resolveNostrAccount({ cfg });
389
+
390
+ expect(account.accountId).toBe("default");
391
+ expect(account.name).toBe("Test Bot");
392
+ expect(account.enabled).toBe(true);
393
+ expect(account.configured).toBe(true);
394
+ expect(account.privateKey).toBe(TEST_HEX_PRIVATE_KEY);
395
+ expect(account.publicKey).toMatch(/^[0-9a-f]{64}$/);
396
+ expect(account.relays).toEqual(["wss://test.relay"]);
397
+ });
398
+
399
+ it("resolves unconfigured account with defaults", () => {
400
+ const cfg = { channels: {} };
401
+ const account = resolveNostrAccount({ cfg });
402
+
403
+ expect(account.accountId).toBe("default");
404
+ expect(account.enabled).toBe(true);
405
+ expect(account.configured).toBe(false);
406
+ expect(account.privateKey).toBe("");
407
+ expect(account.publicKey).toBe("");
408
+ expect(account.relays).toContain("wss://relay.damus.io");
409
+ expect(account.relays).toContain("wss://nos.lol");
410
+ });
411
+
412
+ it("handles disabled channel", () => {
413
+ const cfg = createConfiguredNostrCfg({ enabled: false });
414
+ const account = resolveNostrAccount({ cfg });
415
+
416
+ expect(account.enabled).toBe(false);
417
+ expect(account.configured).toBe(true);
418
+ });
419
+
420
+ it("handles custom accountId parameter", () => {
421
+ const cfg = createConfiguredNostrCfg();
422
+ const account = resolveNostrAccount({ cfg, accountId: "custom" });
423
+
424
+ expect(account.accountId).toBe("custom");
425
+ });
426
+
427
+ it("handles allowFrom config", () => {
428
+ const cfg = createConfiguredNostrCfg({
429
+ allowFrom: ["npub1test", "0123456789abcdef"],
430
+ });
431
+ const account = resolveNostrAccount({ cfg });
432
+
433
+ expect(account.config.allowFrom).toEqual(["npub1test", "0123456789abcdef"]);
434
+ });
435
+
436
+ it("handles invalid private key gracefully", () => {
437
+ const cfg = {
438
+ channels: {
439
+ nostr: {
440
+ privateKey: "invalid-key",
441
+ },
442
+ },
443
+ };
444
+ const account = resolveNostrAccount({ cfg });
445
+
446
+ expect(account.configured).toBe(true);
447
+ expect(account.publicKey).toBe("");
448
+ });
449
+
450
+ it("does not treat unresolved SecretRef privateKey as configured", () => {
451
+ const secretRef = {
452
+ source: "env" as const,
453
+ provider: "default",
454
+ id: "NOSTR_PRIVATE_KEY",
455
+ };
456
+ const cfg = {
457
+ channels: {
458
+ nostr: {
459
+ privateKey: secretRef,
460
+ },
461
+ },
462
+ };
463
+ const account = resolveNostrAccount({ cfg });
464
+
465
+ expect(account.configured).toBe(false);
466
+ expect(account.privateKey).toBe("");
467
+ expect(account.publicKey).toBe("");
468
+ expect(account.config.privateKey).toEqual(secretRef);
469
+ });
470
+
471
+ it("preserves all config options", () => {
472
+ const cfg = createConfiguredNostrCfg({
473
+ name: "Bot",
474
+ enabled: true,
475
+ relays: ["wss://relay1", "wss://relay2"],
476
+ dmPolicy: "allowlist" as const,
477
+ allowFrom: ["pubkey1", "pubkey2"],
478
+ });
479
+ const account = resolveNostrAccount({ cfg });
480
+
481
+ expect(account.config).toEqual({
482
+ privateKey: TEST_HEX_PRIVATE_KEY,
483
+ name: "Bot",
484
+ enabled: true,
485
+ relays: ["wss://relay1", "wss://relay2"],
486
+ dmPolicy: "allowlist",
487
+ allowFrom: ["pubkey1", "pubkey2"],
488
+ });
489
+ });
490
+ });
491
+
492
+ describe("setup wizard", () => {
493
+ it("keeps unresolved SecretRef privateKey visible without marking the account configured", () => {
494
+ const secretRef = {
495
+ source: "env" as const,
496
+ provider: "default",
497
+ id: "NOSTR_PRIVATE_KEY",
498
+ };
499
+ const cfg = {
500
+ channels: {
501
+ nostr: {
502
+ privateKey: secretRef,
503
+ },
504
+ },
505
+ };
506
+ const credential = nostrSetupWizard.credentials?.[0];
507
+ if (!credential?.inspect) {
508
+ throw new Error("nostr setup credential inspect missing");
509
+ }
510
+
511
+ expect(credential.inspect({ cfg, accountId: "default" })).toEqual({
512
+ accountConfigured: false,
513
+ hasConfiguredValue: true,
514
+ resolvedValue: undefined,
515
+ envValue: undefined,
516
+ });
149
517
  });
150
518
  });
151
519
  });