@openclaw/nostr 2026.3.13 → 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 -283
  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 +276 -167
  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 -116
  48. package/src/types.test.ts +0 -175
@@ -1,11 +1,13 @@
1
1
  import fs from "node:fs/promises";
2
2
  import os from "node:os";
3
3
  import path from "node:path";
4
- import type { PluginRuntime } from "openclaw/plugin-sdk/nostr";
5
4
  import { describe, expect, it } from "vitest";
5
+ import type { PluginRuntime } from "../runtime-api.js";
6
6
  import {
7
7
  readNostrBusState,
8
+ readNostrProfileState,
8
9
  writeNostrBusState,
10
+ writeNostrProfileState,
9
11
  computeSinceTimestamp,
10
12
  } from "./nostr-state-store.js";
11
13
  import { setNostrRuntime } from "./runtime.js";
@@ -18,7 +20,7 @@ async function withTempStateDir<T>(fn: (dir: string) => Promise<T>) {
18
20
  state: {
19
21
  resolveStateDir: (env, homedir) => {
20
22
  const stateEnv = env ?? process.env;
21
- const override = stateEnv.OPENCLAW_STATE_DIR?.trim() || stateEnv.CLAWDBOT_STATE_DIR?.trim();
23
+ const override = stateEnv.OPENCLAW_STATE_DIR?.trim();
22
24
  if (override) {
23
25
  return override;
24
26
  }
@@ -83,6 +85,108 @@ describe("nostr bus state store", () => {
83
85
  expect(stateB?.lastProcessedAt).toBe(2000);
84
86
  });
85
87
  });
88
+
89
+ it("upgrades v1 bus state files on read", async () => {
90
+ await withTempStateDir(async (dir) => {
91
+ const filePath = path.join(dir, "nostr", "bus-state-test-bot.json");
92
+ await fs.mkdir(path.dirname(filePath), { recursive: true });
93
+ await fs.writeFile(
94
+ filePath,
95
+ JSON.stringify({
96
+ version: 1,
97
+ lastProcessedAt: 1700000000,
98
+ gatewayStartedAt: 1700000100,
99
+ }),
100
+ "utf-8",
101
+ );
102
+
103
+ const state = await readNostrBusState({ accountId: "test-bot" });
104
+ expect(state).toEqual({
105
+ version: 2,
106
+ lastProcessedAt: 1700000000,
107
+ gatewayStartedAt: 1700000100,
108
+ recentEventIds: [],
109
+ });
110
+ });
111
+ });
112
+
113
+ it("drops malformed recent event ids while keeping the state", async () => {
114
+ await withTempStateDir(async (dir) => {
115
+ const filePath = path.join(dir, "nostr", "bus-state-test-bot.json");
116
+ await fs.mkdir(path.dirname(filePath), { recursive: true });
117
+ await fs.writeFile(
118
+ filePath,
119
+ JSON.stringify({
120
+ version: 2,
121
+ lastProcessedAt: 1700000000,
122
+ gatewayStartedAt: 1700000100,
123
+ recentEventIds: ["evt-1", 2, null],
124
+ }),
125
+ "utf-8",
126
+ );
127
+
128
+ const state = await readNostrBusState({ accountId: "test-bot" });
129
+ expect(state).toEqual({
130
+ version: 2,
131
+ lastProcessedAt: 1700000000,
132
+ gatewayStartedAt: 1700000100,
133
+ recentEventIds: ["evt-1"],
134
+ });
135
+ });
136
+ });
137
+ });
138
+
139
+ describe("nostr profile state store", () => {
140
+ it("persists and reloads profile publish state", async () => {
141
+ await withTempStateDir(async () => {
142
+ await writeNostrProfileState({
143
+ accountId: "test-bot",
144
+ lastPublishedAt: 1700000000,
145
+ lastPublishedEventId: "evt-1",
146
+ lastPublishResults: {
147
+ "wss://relay.example": "ok",
148
+ },
149
+ });
150
+
151
+ const state = await readNostrProfileState({ accountId: "test-bot" });
152
+ expect(state).toEqual({
153
+ version: 1,
154
+ lastPublishedAt: 1700000000,
155
+ lastPublishedEventId: "evt-1",
156
+ lastPublishResults: {
157
+ "wss://relay.example": "ok",
158
+ },
159
+ });
160
+ });
161
+ });
162
+
163
+ it("drops malformed relay results while keeping valid state fields", async () => {
164
+ await withTempStateDir(async (dir) => {
165
+ const filePath = path.join(dir, "nostr", "profile-state-test-bot.json");
166
+ await fs.mkdir(path.dirname(filePath), { recursive: true });
167
+ await fs.writeFile(
168
+ filePath,
169
+ JSON.stringify({
170
+ version: 1,
171
+ lastPublishedAt: 1700000000,
172
+ lastPublishedEventId: "evt-1",
173
+ lastPublishResults: {
174
+ "wss://relay.example": "ok",
175
+ "wss://relay.bad": "unknown",
176
+ },
177
+ }),
178
+ "utf-8",
179
+ );
180
+
181
+ const state = await readNostrProfileState({ accountId: "test-bot" });
182
+ expect(state).toEqual({
183
+ version: 1,
184
+ lastPublishedAt: 1700000000,
185
+ lastPublishedEventId: "evt-1",
186
+ lastPublishResults: null,
187
+ });
188
+ });
189
+ });
86
190
  });
87
191
 
88
192
  describe("computeSinceTimestamp", () => {
@@ -2,12 +2,14 @@ import crypto from "node:crypto";
2
2
  import fs from "node:fs/promises";
3
3
  import os from "node:os";
4
4
  import path from "node:path";
5
+ import { safeParseJsonWithSchema } from "openclaw/plugin-sdk/extension-shared";
6
+ import { z } from "zod";
5
7
  import { getNostrRuntime } from "./runtime.js";
6
8
 
7
9
  const STORE_VERSION = 2;
8
10
  const PROFILE_STATE_VERSION = 1;
9
11
 
10
- type NostrBusStateV1 = {
12
+ type _NostrBusStateV1 = {
11
13
  version: 1;
12
14
  /** Unix timestamp (seconds) of the last processed event */
13
15
  lastProcessedAt: number | null;
@@ -26,7 +28,7 @@ type NostrBusState = {
26
28
  };
27
29
 
28
30
  /** Profile publish state (separate from bus state) */
29
- export type NostrProfileState = {
31
+ type NostrProfileState = {
30
32
  version: 1;
31
33
  /** Unix timestamp (seconds) of last successful profile publish */
32
34
  lastPublishedAt: number | null;
@@ -36,6 +38,33 @@ export type NostrProfileState = {
36
38
  lastPublishResults: Record<string, "ok" | "failed" | "timeout"> | null;
37
39
  };
38
40
 
41
+ const NullableFiniteNumberSchema = z.number().finite().nullable().catch(null);
42
+ const NostrBusStateV1Schema = z.object({
43
+ version: z.literal(1),
44
+ lastProcessedAt: NullableFiniteNumberSchema,
45
+ gatewayStartedAt: NullableFiniteNumberSchema,
46
+ });
47
+
48
+ const NostrBusStateSchema = z.object({
49
+ version: z.literal(2),
50
+ lastProcessedAt: NullableFiniteNumberSchema,
51
+ gatewayStartedAt: NullableFiniteNumberSchema,
52
+ recentEventIds: z
53
+ .array(z.unknown())
54
+ .catch([])
55
+ .transform((ids) => ids.filter((id): id is string => typeof id === "string")),
56
+ });
57
+
58
+ const NostrProfileStateSchema = z.object({
59
+ version: z.literal(1),
60
+ lastPublishedAt: NullableFiniteNumberSchema,
61
+ lastPublishedEventId: z.string().nullable().catch(null),
62
+ lastPublishResults: z
63
+ .record(z.string(), z.enum(["ok", "failed", "timeout"]))
64
+ .nullable()
65
+ .catch(null),
66
+ });
67
+
39
68
  function normalizeAccountId(accountId?: string): string {
40
69
  const trimmed = accountId?.trim();
41
70
  if (!trimmed) {
@@ -60,36 +89,23 @@ function resolveNostrProfileStatePath(
60
89
  }
61
90
 
62
91
  function safeParseState(raw: string): NostrBusState | null {
63
- try {
64
- const parsed = JSON.parse(raw) as Partial<NostrBusState> & Partial<NostrBusStateV1>;
65
-
66
- if (parsed?.version === 2) {
67
- return {
68
- version: 2,
69
- lastProcessedAt: typeof parsed.lastProcessedAt === "number" ? parsed.lastProcessedAt : null,
70
- gatewayStartedAt:
71
- typeof parsed.gatewayStartedAt === "number" ? parsed.gatewayStartedAt : null,
72
- recentEventIds: Array.isArray(parsed.recentEventIds)
73
- ? parsed.recentEventIds.filter((x): x is string => typeof x === "string")
74
- : [],
75
- };
76
- }
77
-
78
- // Back-compat: v1 state files
79
- if (parsed?.version === 1) {
80
- return {
81
- version: 2,
82
- lastProcessedAt: typeof parsed.lastProcessedAt === "number" ? parsed.lastProcessedAt : null,
83
- gatewayStartedAt:
84
- typeof parsed.gatewayStartedAt === "number" ? parsed.gatewayStartedAt : null,
85
- recentEventIds: [],
86
- };
87
- }
92
+ const parsedV2 = safeParseJsonWithSchema(NostrBusStateSchema, raw);
93
+ if (parsedV2) {
94
+ return parsedV2;
95
+ }
88
96
 
89
- return null;
90
- } catch {
97
+ const parsedV1 = safeParseJsonWithSchema(NostrBusStateV1Schema, raw);
98
+ if (!parsedV1) {
91
99
  return null;
92
100
  }
101
+
102
+ // Back-compat: v1 state files
103
+ return {
104
+ version: 2,
105
+ lastProcessedAt: parsedV1.lastProcessedAt,
106
+ gatewayStartedAt: parsedV1.gatewayStartedAt,
107
+ recentEventIds: [],
108
+ };
93
109
  }
94
110
 
95
111
  export async function readNostrBusState(params: {
@@ -162,26 +178,7 @@ export function computeSinceTimestamp(
162
178
  // ============================================================================
163
179
 
164
180
  function safeParseProfileState(raw: string): NostrProfileState | null {
165
- try {
166
- const parsed = JSON.parse(raw) as Partial<NostrProfileState>;
167
-
168
- if (parsed?.version === 1) {
169
- return {
170
- version: 1,
171
- lastPublishedAt: typeof parsed.lastPublishedAt === "number" ? parsed.lastPublishedAt : null,
172
- lastPublishedEventId:
173
- typeof parsed.lastPublishedEventId === "string" ? parsed.lastPublishedEventId : null,
174
- lastPublishResults:
175
- parsed.lastPublishResults && typeof parsed.lastPublishResults === "object"
176
- ? parsed.lastPublishResults
177
- : null,
178
- };
179
- }
180
-
181
- return null;
182
- } catch {
183
- return null;
184
- }
181
+ return safeParseJsonWithSchema(NostrProfileStateSchema, raw);
185
182
  }
186
183
 
187
184
  export async function readNostrProfileState(params: {
package/src/runtime.ts CHANGED
@@ -1,6 +1,9 @@
1
- import { createPluginRuntimeStore } from "openclaw/plugin-sdk/compat";
2
- import type { PluginRuntime } from "openclaw/plugin-sdk/nostr";
1
+ import type { PluginRuntime } from "openclaw/plugin-sdk/core";
2
+ import { createPluginRuntimeStore } from "openclaw/plugin-sdk/runtime-store";
3
3
 
4
4
  const { setRuntime: setNostrRuntime, getRuntime: getNostrRuntime } =
5
- createPluginRuntimeStore<PluginRuntime>("Nostr runtime not initialized");
5
+ createPluginRuntimeStore<PluginRuntime>({
6
+ pluginId: "nostr",
7
+ errorMessage: "Nostr runtime not initialized",
8
+ });
6
9
  export { getNostrRuntime, setNostrRuntime };
@@ -3,7 +3,7 @@
3
3
  * Prevents unbounded memory growth under high load or abuse.
4
4
  */
5
5
 
6
- export interface SeenTrackerOptions {
6
+ interface SeenTrackerOptions {
7
7
  /** Maximum number of entries to track (default: 100,000) */
8
8
  maxEntries?: number;
9
9
  /** TTL in milliseconds (default: 1 hour) */
@@ -0,0 +1,25 @@
1
+ import {
2
+ buildChannelOutboundSessionRoute,
3
+ stripChannelTargetPrefix,
4
+ type ChannelOutboundSessionRouteParams,
5
+ } from "openclaw/plugin-sdk/core";
6
+
7
+ export function resolveNostrOutboundSessionRoute(params: ChannelOutboundSessionRouteParams) {
8
+ const target = stripChannelTargetPrefix(params.target, "nostr");
9
+ if (!target) {
10
+ return null;
11
+ }
12
+ return buildChannelOutboundSessionRoute({
13
+ cfg: params.cfg,
14
+ agentId: params.agentId,
15
+ channel: "nostr",
16
+ accountId: params.accountId,
17
+ peer: {
18
+ kind: "direct",
19
+ id: target,
20
+ },
21
+ chatType: "direct",
22
+ from: `nostr:${target}`,
23
+ to: `nostr:${target}`,
24
+ });
25
+ }
@@ -0,0 +1,265 @@
1
+ import type { ChannelSetupAdapter } from "openclaw/plugin-sdk/channel-setup";
2
+ import { DEFAULT_ACCOUNT_ID } from "openclaw/plugin-sdk/routing";
3
+ import {
4
+ hasConfiguredSecretInput,
5
+ normalizeSecretInputString,
6
+ } from "openclaw/plugin-sdk/secret-input";
7
+ import type { ChannelSetupDmPolicy, ChannelSetupWizard, DmPolicy } from "openclaw/plugin-sdk/setup";
8
+ import {
9
+ createStandardChannelSetupStatus,
10
+ createTopLevelChannelDmPolicy,
11
+ createTopLevelChannelParsedAllowFromPrompt,
12
+ formatDocsLink,
13
+ mergeAllowFromEntries,
14
+ parseSetupEntriesWithParser,
15
+ patchTopLevelChannelConfigSection,
16
+ splitSetupEntries,
17
+ } from "openclaw/plugin-sdk/setup";
18
+ import { DEFAULT_RELAYS } from "./default-relays.js";
19
+ import { getPublicKeyFromPrivate, normalizePubkey } from "./nostr-key-utils.js";
20
+ import { resolveDefaultNostrAccountId, resolveNostrAccount } from "./types.js";
21
+
22
+ const channel = "nostr" as const;
23
+
24
+ const NOSTR_SETUP_HELP_LINES = [
25
+ "Use a Nostr private key in nsec or 64-character hex format.",
26
+ "Relay URLs are optional. Leave blank to keep the default relay set.",
27
+ "Env vars supported: NOSTR_PRIVATE_KEY (default account only).",
28
+ `Docs: ${formatDocsLink("/channels/nostr", "channels/nostr")}`,
29
+ ];
30
+
31
+ const NOSTR_ALLOW_FROM_HELP_LINES = [
32
+ "Allowlist Nostr DMs by npub or hex pubkey.",
33
+ "Examples:",
34
+ "- npub1...",
35
+ "- nostr:npub1...",
36
+ "- 0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
37
+ "Multiple entries: comma-separated.",
38
+ `Docs: ${formatDocsLink("/channels/nostr", "channels/nostr")}`,
39
+ ];
40
+
41
+ function buildNostrSetupPatch(accountId: string, patch: Record<string, unknown>) {
42
+ return {
43
+ ...(accountId !== DEFAULT_ACCOUNT_ID ? { defaultAccount: accountId } : {}),
44
+ ...patch,
45
+ };
46
+ }
47
+
48
+ function parseRelayUrls(raw: string): { relays: string[]; error?: string } {
49
+ const entries = splitSetupEntries(raw);
50
+ const relays: string[] = [];
51
+ for (const entry of entries) {
52
+ try {
53
+ const parsed = new URL(entry);
54
+ if (parsed.protocol !== "ws:" && parsed.protocol !== "wss:") {
55
+ return { relays: [], error: `Relay must use ws:// or wss:// (${entry})` };
56
+ }
57
+ } catch {
58
+ return { relays: [], error: `Invalid relay URL: ${entry}` };
59
+ }
60
+ relays.push(entry);
61
+ }
62
+ return { relays: [...new Set(relays)] };
63
+ }
64
+
65
+ function parseNostrAllowFrom(raw: string): { entries: string[]; error?: string } {
66
+ return parseSetupEntriesWithParser(raw, (entry) => {
67
+ const cleaned = entry.replace(/^nostr:/i, "").trim();
68
+ try {
69
+ return { value: normalizePubkey(cleaned) };
70
+ } catch {
71
+ return { error: `Invalid Nostr pubkey: ${entry}` };
72
+ }
73
+ });
74
+ }
75
+
76
+ const promptNostrAllowFrom = createTopLevelChannelParsedAllowFromPrompt({
77
+ channel,
78
+ defaultAccountId: resolveDefaultNostrAccountId,
79
+ noteTitle: "Nostr allowlist",
80
+ noteLines: NOSTR_ALLOW_FROM_HELP_LINES,
81
+ message: "Nostr allowFrom",
82
+ placeholder: "npub1..., 0123abcd...",
83
+ parseEntries: parseNostrAllowFrom,
84
+ mergeEntries: ({ existing, parsed }) => mergeAllowFromEntries(existing, parsed),
85
+ });
86
+
87
+ const nostrDmPolicy: ChannelSetupDmPolicy = createTopLevelChannelDmPolicy({
88
+ label: "Nostr",
89
+ channel,
90
+ policyKey: "channels.nostr.dmPolicy",
91
+ allowFromKey: "channels.nostr.allowFrom",
92
+ getCurrent: (cfg) => (cfg.channels?.nostr?.dmPolicy as DmPolicy | undefined) ?? "pairing",
93
+ promptAllowFrom: promptNostrAllowFrom,
94
+ });
95
+
96
+ export const nostrSetupAdapter: ChannelSetupAdapter = {
97
+ resolveAccountId: ({ cfg, accountId }) => accountId?.trim() || resolveDefaultNostrAccountId(cfg),
98
+ applyAccountName: ({ cfg, accountId, name }) =>
99
+ patchTopLevelChannelConfigSection({
100
+ cfg,
101
+ channel,
102
+ patch: buildNostrSetupPatch(accountId, name?.trim() ? { name: name.trim() } : {}),
103
+ }),
104
+ validateInput: ({ input }) => {
105
+ const typedInput = input as {
106
+ useEnv?: boolean;
107
+ privateKey?: string;
108
+ relayUrls?: string;
109
+ };
110
+ if (!typedInput.useEnv) {
111
+ const privateKey = typedInput.privateKey?.trim();
112
+ if (!privateKey) {
113
+ return "Nostr requires --private-key or --use-env.";
114
+ }
115
+ try {
116
+ getPublicKeyFromPrivate(privateKey);
117
+ } catch {
118
+ return "Nostr private key must be valid nsec or 64-character hex.";
119
+ }
120
+ }
121
+ if (typedInput.relayUrls?.trim()) {
122
+ return parseRelayUrls(typedInput.relayUrls).error ?? null;
123
+ }
124
+ return null;
125
+ },
126
+ applyAccountConfig: ({ cfg, accountId, input }) => {
127
+ const typedInput = input as {
128
+ useEnv?: boolean;
129
+ privateKey?: string;
130
+ relayUrls?: string;
131
+ };
132
+ const relayResult = typedInput.relayUrls?.trim()
133
+ ? parseRelayUrls(typedInput.relayUrls)
134
+ : { relays: [] };
135
+ return patchTopLevelChannelConfigSection({
136
+ cfg,
137
+ channel,
138
+ enabled: true,
139
+ clearFields: typedInput.useEnv ? ["privateKey"] : undefined,
140
+ patch: buildNostrSetupPatch(accountId, {
141
+ ...(typedInput.useEnv ? {} : { privateKey: typedInput.privateKey?.trim() }),
142
+ ...(relayResult.relays.length > 0 ? { relays: relayResult.relays } : {}),
143
+ }),
144
+ });
145
+ },
146
+ };
147
+
148
+ export const nostrSetupWizard: ChannelSetupWizard = {
149
+ channel,
150
+ resolveAccountIdForConfigure: ({ accountOverride, defaultAccountId }) =>
151
+ accountOverride?.trim() || defaultAccountId,
152
+ resolveShouldPromptAccountIds: () => false,
153
+ status: createStandardChannelSetupStatus({
154
+ channelLabel: "Nostr",
155
+ configuredLabel: "configured",
156
+ unconfiguredLabel: "needs private key",
157
+ configuredHint: "configured",
158
+ unconfiguredHint: "needs private key",
159
+ configuredScore: 1,
160
+ unconfiguredScore: 0,
161
+ includeStatusLine: true,
162
+ resolveConfigured: ({ cfg }) => resolveNostrAccount({ cfg }).configured,
163
+ resolveExtraStatusLines: ({ cfg }) => {
164
+ const account = resolveNostrAccount({ cfg });
165
+ return [`Relays: ${account.relays.length || DEFAULT_RELAYS.length}`];
166
+ },
167
+ }),
168
+ introNote: {
169
+ title: "Nostr setup",
170
+ lines: NOSTR_SETUP_HELP_LINES,
171
+ },
172
+ envShortcut: {
173
+ prompt: "NOSTR_PRIVATE_KEY detected. Use env var?",
174
+ preferredEnvVar: "NOSTR_PRIVATE_KEY",
175
+ isAvailable: ({ cfg, accountId }) =>
176
+ accountId === DEFAULT_ACCOUNT_ID &&
177
+ Boolean(process.env.NOSTR_PRIVATE_KEY?.trim()) &&
178
+ !hasConfiguredSecretInput(resolveNostrAccount({ cfg, accountId }).config.privateKey),
179
+ apply: async ({ cfg, accountId }) =>
180
+ patchTopLevelChannelConfigSection({
181
+ cfg,
182
+ channel,
183
+ enabled: true,
184
+ clearFields: ["privateKey"],
185
+ patch: buildNostrSetupPatch(accountId, {}),
186
+ }),
187
+ },
188
+ credentials: [
189
+ {
190
+ inputKey: "privateKey",
191
+ providerHint: channel,
192
+ credentialLabel: "private key",
193
+ preferredEnvVar: "NOSTR_PRIVATE_KEY",
194
+ helpTitle: "Nostr private key",
195
+ helpLines: NOSTR_SETUP_HELP_LINES,
196
+ envPrompt: "NOSTR_PRIVATE_KEY detected. Use env var?",
197
+ keepPrompt: "Nostr private key already configured. Keep it?",
198
+ inputPrompt: "Nostr private key (nsec... or hex)",
199
+ allowEnv: ({ accountId }) => accountId === DEFAULT_ACCOUNT_ID,
200
+ inspect: ({ cfg, accountId }) => {
201
+ const account = resolveNostrAccount({ cfg, accountId });
202
+ return {
203
+ accountConfigured: account.configured,
204
+ hasConfiguredValue: hasConfiguredSecretInput(account.config.privateKey),
205
+ resolvedValue: normalizeSecretInputString(account.config.privateKey),
206
+ envValue: process.env.NOSTR_PRIVATE_KEY?.trim(),
207
+ };
208
+ },
209
+ applyUseEnv: async ({ cfg, accountId }) =>
210
+ patchTopLevelChannelConfigSection({
211
+ cfg,
212
+ channel,
213
+ enabled: true,
214
+ clearFields: ["privateKey"],
215
+ patch: buildNostrSetupPatch(accountId, {}),
216
+ }),
217
+ applySet: async ({ cfg, accountId, resolvedValue }) =>
218
+ patchTopLevelChannelConfigSection({
219
+ cfg,
220
+ channel,
221
+ enabled: true,
222
+ patch: buildNostrSetupPatch(accountId, { privateKey: resolvedValue }),
223
+ }),
224
+ },
225
+ ],
226
+ textInputs: [
227
+ {
228
+ inputKey: "relayUrls",
229
+ message: "Relay URLs (comma-separated, optional)",
230
+ placeholder: DEFAULT_RELAYS.join(", "),
231
+ required: false,
232
+ applyEmptyValue: true,
233
+ helpTitle: "Nostr relays",
234
+ helpLines: ["Use ws:// or wss:// relay URLs.", "Leave blank to keep the default relay set."],
235
+ currentValue: ({ cfg, accountId }) => {
236
+ const account = resolveNostrAccount({ cfg, accountId });
237
+ const configuredRelays = cfg.channels?.nostr?.relays as string[] | undefined;
238
+ const relays = configuredRelays && configuredRelays.length > 0 ? account.relays : [];
239
+ return relays.join(", ");
240
+ },
241
+ keepPrompt: (value) => `Relay URLs set (${value}). Keep them?`,
242
+ validate: ({ value }) => parseRelayUrls(value).error,
243
+ applySet: async ({ cfg, accountId, value }) => {
244
+ const relayResult = parseRelayUrls(value);
245
+ return patchTopLevelChannelConfigSection({
246
+ cfg,
247
+ channel,
248
+ enabled: true,
249
+ clearFields: relayResult.relays.length > 0 ? undefined : ["relays"],
250
+ patch: buildNostrSetupPatch(
251
+ accountId,
252
+ relayResult.relays.length > 0 ? { relays: relayResult.relays } : {},
253
+ ),
254
+ });
255
+ },
256
+ },
257
+ ],
258
+ dmPolicy: nostrDmPolicy,
259
+ disable: (cfg) =>
260
+ patchTopLevelChannelConfigSection({
261
+ cfg,
262
+ channel,
263
+ patch: { enabled: false },
264
+ }),
265
+ };
@@ -0,0 +1,45 @@
1
+ import type { ResolvedNostrAccount } from "./types.js";
2
+
3
+ export const TEST_HEX_PRIVATE_KEY =
4
+ "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
5
+
6
+ export const TEST_HEX_PUBLIC_KEY =
7
+ "abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789";
8
+
9
+ export const TEST_NSEC = "nsec1qypqxpq9qtpqscx7peytzfwtdjmcv0mrz5rjpej8vjppfkqfqy8skqfv3l";
10
+
11
+ export const TEST_RELAY_URL = "wss://relay.example.com";
12
+ export const TEST_SETUP_RELAY_URLS = ["wss://relay.damus.io", "wss://relay.primal.net"];
13
+ export const TEST_RESOLVED_PRIVATE_KEY = "resolved-nostr-private-key";
14
+
15
+ export const TEST_HEX_PRIVATE_KEY_BYTES = new Uint8Array(
16
+ TEST_HEX_PRIVATE_KEY.match(/.{2}/g)!.map((byte) => Number.parseInt(byte, 16)),
17
+ );
18
+
19
+ export function createConfiguredNostrCfg(overrides: Record<string, unknown> = {}): {
20
+ channels: { nostr: Record<string, unknown> };
21
+ } {
22
+ return {
23
+ channels: {
24
+ nostr: {
25
+ privateKey: TEST_HEX_PRIVATE_KEY,
26
+ ...overrides,
27
+ },
28
+ },
29
+ };
30
+ }
31
+
32
+ export function buildResolvedNostrAccount(
33
+ overrides: Partial<ResolvedNostrAccount> = {},
34
+ ): ResolvedNostrAccount {
35
+ return {
36
+ accountId: "default",
37
+ enabled: true,
38
+ configured: true,
39
+ privateKey: TEST_HEX_PRIVATE_KEY,
40
+ publicKey: TEST_HEX_PUBLIC_KEY,
41
+ relays: [TEST_RELAY_URL],
42
+ config: {},
43
+ ...overrides,
44
+ };
45
+ }