@openclaw/nostr 2026.3.13 → 2026.5.2-beta.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (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 +147 -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
package/README.md CHANGED
@@ -68,6 +68,10 @@ openclaw plugins install @openclaw/nostr
68
68
  - **open**: Anyone can message the bot (use with caution)
69
69
  - **disabled**: DMs are disabled
70
70
 
71
+ Inbound event signatures are verified before policy enforcement and NIP-04 decryption.
72
+ Unknown senders in `pairing` mode can receive a pairing reply, but their original DM body is not
73
+ processed unless approved.
74
+
71
75
  ### Example: Allowlist Mode
72
76
 
73
77
  ```json
@@ -113,6 +117,8 @@ docker run -p 7777:7777 ghcr.io/hoytech/strfry
113
117
 
114
118
  - Private keys are never logged
115
119
  - Event signatures are verified before processing
120
+ - Sender policy is checked before expensive crypto work
121
+ - Inbound DMs are rate-limited and oversized payloads are dropped before decrypt
116
122
  - Use environment variables for keys, never commit to config files
117
123
  - Consider using `allowlist` mode in production
118
124
 
package/api.ts ADDED
@@ -0,0 +1,10 @@
1
+ export {
2
+ getPluginRuntimeGatewayRequestScope,
3
+ type OpenClawConfig,
4
+ type PluginRuntime,
5
+ } from "./runtime-api.js";
6
+ export { nostrPlugin } from "./src/channel.js";
7
+ export { createNostrProfileHttpHandler } from "./src/nostr-profile-http.js";
8
+ export { getNostrRuntime, setNostrRuntime } from "./src/runtime.js";
9
+ export { resolveNostrAccount } from "./src/types.js";
10
+ export type { ResolvedNostrAccount } from "./src/types.js";
@@ -0,0 +1 @@
1
+ export { nostrPlugin } from "./src/channel.js";
package/index.ts CHANGED
@@ -1,54 +1,79 @@
1
- import type { OpenClawPluginApi } from "openclaw/plugin-sdk/nostr";
2
- import { emptyPluginConfigSchema } from "openclaw/plugin-sdk/nostr";
3
- import { nostrPlugin } from "./src/channel.js";
4
- import type { NostrProfile } from "./src/config-schema.js";
5
- import { createNostrProfileHttpHandler } from "./src/nostr-profile-http.js";
6
- import { setNostrRuntime, getNostrRuntime } from "./src/runtime.js";
7
- import { resolveNostrAccount } from "./src/types.js";
1
+ import {
2
+ defineBundledChannelEntry,
3
+ loadBundledEntryExportSync,
4
+ } from "openclaw/plugin-sdk/channel-entry-contract";
5
+ import type { OpenClawConfig, PluginRuntime, ResolvedNostrAccount } from "./api.js";
8
6
 
9
- const plugin = {
7
+ function createNostrProfileHttpHandler() {
8
+ return loadBundledEntryExportSync<
9
+ (params: Record<string, unknown>) => (ctx: unknown) => Promise<void> | void
10
+ >(import.meta.url, {
11
+ specifier: "./api.js",
12
+ exportName: "createNostrProfileHttpHandler",
13
+ });
14
+ }
15
+
16
+ function getNostrRuntime() {
17
+ return loadBundledEntryExportSync<() => PluginRuntime>(import.meta.url, {
18
+ specifier: "./api.js",
19
+ exportName: "getNostrRuntime",
20
+ })();
21
+ }
22
+
23
+ function resolveNostrAccount(params: { cfg: unknown; accountId: string }) {
24
+ return loadBundledEntryExportSync<
25
+ (params: { cfg: unknown; accountId: string }) => ResolvedNostrAccount
26
+ >(import.meta.url, {
27
+ specifier: "./api.js",
28
+ exportName: "resolveNostrAccount",
29
+ })(params);
30
+ }
31
+
32
+ export default defineBundledChannelEntry({
10
33
  id: "nostr",
11
34
  name: "Nostr",
12
35
  description: "Nostr DM channel plugin via NIP-04",
13
- configSchema: emptyPluginConfigSchema(),
14
- register(api: OpenClawPluginApi) {
15
- setNostrRuntime(api.runtime);
16
- api.registerChannel({ plugin: nostrPlugin });
17
-
18
- // Register HTTP handler for profile management
19
- const httpHandler = createNostrProfileHttpHandler({
36
+ importMetaUrl: import.meta.url,
37
+ plugin: {
38
+ specifier: "./channel-plugin-api.js",
39
+ exportName: "nostrPlugin",
40
+ },
41
+ runtime: {
42
+ specifier: "./api.js",
43
+ exportName: "setNostrRuntime",
44
+ },
45
+ registerFull(api) {
46
+ const httpHandler = createNostrProfileHttpHandler()({
20
47
  getConfigProfile: (accountId: string) => {
21
48
  const runtime = getNostrRuntime();
22
- const cfg = runtime.config.loadConfig();
49
+ const cfg = runtime.config.current() as OpenClawConfig;
23
50
  const account = resolveNostrAccount({ cfg, accountId });
24
51
  return account.profile;
25
52
  },
26
- updateConfigProfile: async (accountId: string, profile: NostrProfile) => {
53
+ updateConfigProfile: async (accountId: string, profile: unknown) => {
27
54
  const runtime = getNostrRuntime();
28
- const cfg = runtime.config.loadConfig();
55
+ const cfg = runtime.config.current() as OpenClawConfig;
29
56
 
30
- // Build the config patch for channels.nostr.profile
31
57
  const channels = (cfg.channels ?? {}) as Record<string, unknown>;
32
58
  const nostrConfig = (channels.nostr ?? {}) as Record<string, unknown>;
33
59
 
34
- const updatedNostrConfig = {
35
- ...nostrConfig,
36
- profile,
37
- };
38
-
39
- const updatedChannels = {
40
- ...channels,
41
- nostr: updatedNostrConfig,
42
- };
43
-
44
- await runtime.config.writeConfigFile({
45
- ...cfg,
46
- channels: updatedChannels,
60
+ await runtime.config.replaceConfigFile({
61
+ nextConfig: {
62
+ ...cfg,
63
+ channels: {
64
+ ...channels,
65
+ nostr: {
66
+ ...nostrConfig,
67
+ profile,
68
+ },
69
+ },
70
+ },
71
+ afterWrite: { mode: "auto" },
47
72
  });
48
73
  },
49
74
  getAccountInfo: (accountId: string) => {
50
75
  const runtime = getNostrRuntime();
51
- const cfg = runtime.config.loadConfig();
76
+ const cfg = runtime.config.current() as OpenClawConfig;
52
77
  const account = resolveNostrAccount({ cfg, accountId });
53
78
  if (!account.configured || !account.publicKey) {
54
79
  return null;
@@ -65,9 +90,8 @@ const plugin = {
65
90
  path: "/api/channels/nostr",
66
91
  auth: "gateway",
67
92
  match: "prefix",
93
+ gatewayRuntimeScopeSurface: "trusted-operator",
68
94
  handler: httpHandler,
69
95
  });
70
96
  },
71
- };
72
-
73
- export default plugin;
97
+ });
@@ -1,9 +1,198 @@
1
1
  {
2
2
  "id": "nostr",
3
- "channels": ["nostr"],
3
+ "activation": {
4
+ "onStartup": false
5
+ },
6
+ "channels": [
7
+ "nostr"
8
+ ],
9
+ "channelEnvVars": {
10
+ "nostr": [
11
+ "NOSTR_PRIVATE_KEY"
12
+ ]
13
+ },
4
14
  "configSchema": {
5
15
  "type": "object",
6
16
  "additionalProperties": false,
7
17
  "properties": {}
18
+ },
19
+ "channelConfigs": {
20
+ "nostr": {
21
+ "schema": {
22
+ "$schema": "http://json-schema.org/draft-07/schema#",
23
+ "type": "object",
24
+ "properties": {
25
+ "name": {
26
+ "type": "string"
27
+ },
28
+ "defaultAccount": {
29
+ "type": "string"
30
+ },
31
+ "enabled": {
32
+ "type": "boolean"
33
+ },
34
+ "markdown": {
35
+ "type": "object",
36
+ "properties": {
37
+ "tables": {
38
+ "type": "string",
39
+ "enum": [
40
+ "off",
41
+ "bullets",
42
+ "code",
43
+ "block"
44
+ ]
45
+ }
46
+ },
47
+ "additionalProperties": false
48
+ },
49
+ "privateKey": {
50
+ "anyOf": [
51
+ {
52
+ "type": "string"
53
+ },
54
+ {
55
+ "oneOf": [
56
+ {
57
+ "type": "object",
58
+ "properties": {
59
+ "source": {
60
+ "type": "string",
61
+ "const": "env"
62
+ },
63
+ "provider": {
64
+ "type": "string",
65
+ "pattern": "^[a-z][a-z0-9_-]{0,63}$"
66
+ },
67
+ "id": {
68
+ "type": "string",
69
+ "pattern": "^[A-Z][A-Z0-9_]{0,127}$"
70
+ }
71
+ },
72
+ "required": [
73
+ "source",
74
+ "provider",
75
+ "id"
76
+ ],
77
+ "additionalProperties": false
78
+ },
79
+ {
80
+ "type": "object",
81
+ "properties": {
82
+ "source": {
83
+ "type": "string",
84
+ "const": "file"
85
+ },
86
+ "provider": {
87
+ "type": "string",
88
+ "pattern": "^[a-z][a-z0-9_-]{0,63}$"
89
+ },
90
+ "id": {
91
+ "type": "string"
92
+ }
93
+ },
94
+ "required": [
95
+ "source",
96
+ "provider",
97
+ "id"
98
+ ],
99
+ "additionalProperties": false
100
+ },
101
+ {
102
+ "type": "object",
103
+ "properties": {
104
+ "source": {
105
+ "type": "string",
106
+ "const": "exec"
107
+ },
108
+ "provider": {
109
+ "type": "string",
110
+ "pattern": "^[a-z][a-z0-9_-]{0,63}$"
111
+ },
112
+ "id": {
113
+ "type": "string"
114
+ }
115
+ },
116
+ "required": [
117
+ "source",
118
+ "provider",
119
+ "id"
120
+ ],
121
+ "additionalProperties": false
122
+ }
123
+ ]
124
+ }
125
+ ]
126
+ },
127
+ "relays": {
128
+ "type": "array",
129
+ "items": {
130
+ "type": "string"
131
+ }
132
+ },
133
+ "dmPolicy": {
134
+ "type": "string",
135
+ "enum": [
136
+ "pairing",
137
+ "allowlist",
138
+ "open",
139
+ "disabled"
140
+ ]
141
+ },
142
+ "allowFrom": {
143
+ "type": "array",
144
+ "items": {
145
+ "anyOf": [
146
+ {
147
+ "type": "string"
148
+ },
149
+ {
150
+ "type": "number"
151
+ }
152
+ ]
153
+ }
154
+ },
155
+ "profile": {
156
+ "type": "object",
157
+ "properties": {
158
+ "name": {
159
+ "type": "string",
160
+ "maxLength": 256
161
+ },
162
+ "displayName": {
163
+ "type": "string",
164
+ "maxLength": 256
165
+ },
166
+ "about": {
167
+ "type": "string",
168
+ "maxLength": 2000
169
+ },
170
+ "picture": {
171
+ "type": "string",
172
+ "format": "uri"
173
+ },
174
+ "banner": {
175
+ "type": "string",
176
+ "format": "uri"
177
+ },
178
+ "website": {
179
+ "type": "string",
180
+ "format": "uri"
181
+ },
182
+ "nip05": {
183
+ "type": "string"
184
+ },
185
+ "lud16": {
186
+ "type": "string"
187
+ }
188
+ },
189
+ "additionalProperties": false
190
+ }
191
+ },
192
+ "additionalProperties": false
193
+ },
194
+ "label": "Nostr",
195
+ "description": "Decentralized protocol; encrypted DMs via NIP-04."
196
+ }
8
197
  }
9
198
  }
package/package.json CHANGED
@@ -1,16 +1,33 @@
1
1
  {
2
2
  "name": "@openclaw/nostr",
3
- "version": "2026.3.13",
3
+ "version": "2026.5.2-beta.1",
4
4
  "description": "OpenClaw Nostr channel plugin for NIP-04 encrypted DMs",
5
+ "repository": {
6
+ "type": "git",
7
+ "url": "https://github.com/openclaw/openclaw"
8
+ },
5
9
  "type": "module",
6
10
  "dependencies": {
7
11
  "nostr-tools": "^2.23.3",
8
- "zod": "^4.3.6"
12
+ "zod": "^4.4.1"
13
+ },
14
+ "devDependencies": {
15
+ "@openclaw/plugin-sdk": "workspace:*",
16
+ "openclaw": "workspace:*"
17
+ },
18
+ "peerDependencies": {
19
+ "openclaw": ">=2026.5.2-beta.1"
20
+ },
21
+ "peerDependenciesMeta": {
22
+ "openclaw": {
23
+ "optional": true
24
+ }
9
25
  },
10
26
  "openclaw": {
11
27
  "extensions": [
12
28
  "./index.ts"
13
29
  ],
30
+ "setupEntry": "./setup-entry.ts",
14
31
  "channel": {
15
32
  "id": "nostr",
16
33
  "label": "Nostr",
@@ -19,17 +36,32 @@
19
36
  "docsLabel": "nostr",
20
37
  "blurb": "Decentralized protocol; encrypted DMs via NIP-04.",
21
38
  "order": 55,
22
- "quickstartAllowFrom": true
39
+ "quickstartAllowFrom": true,
40
+ "cliAddOptions": [
41
+ {
42
+ "flags": "--private-key <key>",
43
+ "description": "Nostr private key (nsec... or hex)"
44
+ },
45
+ {
46
+ "flags": "--relay-urls <list>",
47
+ "description": "Nostr relay URLs (comma-separated)"
48
+ }
49
+ ]
23
50
  },
24
51
  "install": {
25
52
  "npmSpec": "@openclaw/nostr",
26
- "localPath": "extensions/nostr",
27
- "defaultChoice": "npm"
53
+ "defaultChoice": "npm",
54
+ "minHostVersion": ">=2026.4.10"
28
55
  },
29
- "releaseChecks": {
30
- "rootDependencyMirrorAllowlist": [
31
- "nostr-tools"
32
- ]
56
+ "compat": {
57
+ "pluginApi": ">=2026.5.2-beta.1"
58
+ },
59
+ "build": {
60
+ "openclawVersion": "2026.5.2-beta.1"
61
+ },
62
+ "release": {
63
+ "publishToClawHub": true,
64
+ "publishToNpm": true
33
65
  }
34
66
  }
35
67
  }
package/runtime-api.ts ADDED
@@ -0,0 +1,6 @@
1
+ // Private runtime barrel for the bundled Nostr extension.
2
+ // Keep this barrel thin and aligned with the local extension surface.
3
+
4
+ export type { OpenClawConfig } from "openclaw/plugin-sdk/config-types";
5
+ export { getPluginRuntimeGatewayRequestScope } from "openclaw/plugin-sdk/plugin-runtime";
6
+ export type { PluginRuntime } from "openclaw/plugin-sdk/runtime-store";
package/setup-api.ts ADDED
@@ -0,0 +1 @@
1
+ export { nostrSetupAdapter, nostrSetupWizard } from "./src/setup-surface.js";
package/setup-entry.ts ADDED
@@ -0,0 +1,9 @@
1
+ import { defineBundledChannelSetupEntry } from "openclaw/plugin-sdk/channel-entry-contract";
2
+
3
+ export default defineBundledChannelSetupEntry({
4
+ importMetaUrl: import.meta.url,
5
+ plugin: {
6
+ specifier: "./setup-plugin-api.js",
7
+ exportName: "nostrSetupPlugin",
8
+ },
9
+ });
@@ -0,0 +1,3 @@
1
+ // Keep bundled setup entry imports narrow so setup loads do not pull the
2
+ // broader Nostr runtime plugin surface.
3
+ export { nostrSetupPlugin } from "./src/channel.setup.js";
@@ -0,0 +1,15 @@
1
+ export {
2
+ buildChannelConfigSchema,
3
+ DEFAULT_ACCOUNT_ID,
4
+ formatPairingApproveHint,
5
+ type ChannelPlugin,
6
+ } from "openclaw/plugin-sdk/channel-plugin-common";
7
+ export type { ChannelOutboundAdapter } from "openclaw/plugin-sdk/channel-contract";
8
+ export {
9
+ collectStatusIssuesFromLastError,
10
+ createDefaultChannelRuntimeState,
11
+ } from "openclaw/plugin-sdk/status-helpers";
12
+ export {
13
+ createPreCryptoDirectDmAuthorizer,
14
+ resolveInboundDirectDmAccessWithRuntime,
15
+ } from "openclaw/plugin-sdk/direct-dm-access";
@@ -0,0 +1,176 @@
1
+ import { createStartAccountContext } from "openclaw/plugin-sdk/channel-test-helpers";
2
+ import { afterEach, describe, expect, it, vi } from "vitest";
3
+ import type { PluginRuntime } from "../runtime-api.js";
4
+ import { startNostrGatewayAccount } from "./gateway.js";
5
+ import { setNostrRuntime } from "./runtime.js";
6
+ import { buildResolvedNostrAccount } from "./test-fixtures.js";
7
+
8
+ const mocks = vi.hoisted(() => ({
9
+ normalizePubkey: vi.fn((value: string) =>
10
+ value
11
+ .trim()
12
+ .replace(/^nostr:/i, "")
13
+ .toLowerCase(),
14
+ ),
15
+ startNostrBus: vi.fn(),
16
+ }));
17
+
18
+ vi.mock("./nostr-bus.js", () => ({
19
+ DEFAULT_RELAYS: ["wss://relay.example.com"],
20
+ startNostrBus: mocks.startNostrBus,
21
+ }));
22
+
23
+ vi.mock("./nostr-key-utils.js", () => ({
24
+ getPublicKeyFromPrivate: vi.fn(() => "bot-pubkey"),
25
+ normalizePubkey: mocks.normalizePubkey,
26
+ }));
27
+
28
+ function createMockBus() {
29
+ return {
30
+ sendDm: vi.fn(async () => {}),
31
+ close: vi.fn(),
32
+ getMetrics: vi.fn(() => ({ counters: {} })),
33
+ publishProfile: vi.fn(),
34
+ getProfileState: vi.fn(async () => null),
35
+ };
36
+ }
37
+
38
+ function createRuntimeHarness() {
39
+ const recordInboundSession = vi.fn(async () => {});
40
+ const dispatchReplyWithBufferedBlockDispatcher = vi.fn(async ({ dispatcherOptions }) => {
41
+ await dispatcherOptions.deliver({ text: "|a|b|" });
42
+ });
43
+ const runtime = {
44
+ channel: {
45
+ text: {
46
+ resolveMarkdownTableMode: vi.fn(() => "off"),
47
+ convertMarkdownTables: vi.fn((text: string) => `converted:${text}`),
48
+ },
49
+ commands: {
50
+ shouldComputeCommandAuthorized: vi.fn(() => true),
51
+ resolveCommandAuthorizedFromAuthorizers: vi.fn(() => true),
52
+ },
53
+ routing: {
54
+ resolveAgentRoute: vi.fn(({ accountId, peer }) => ({
55
+ agentId: "agent-nostr",
56
+ accountId,
57
+ sessionKey: `nostr:${peer.id}`,
58
+ })),
59
+ },
60
+ session: {
61
+ resolveStorePath: vi.fn(() => "/tmp/nostr-session-store"),
62
+ readSessionUpdatedAt: vi.fn(() => undefined),
63
+ recordInboundSession,
64
+ },
65
+ reply: {
66
+ formatAgentEnvelope: vi.fn(({ body }) => `envelope:${body}`),
67
+ resolveEnvelopeFormatOptions: vi.fn(() => ({ mode: "agent" })),
68
+ finalizeInboundContext: vi.fn((ctx) => ctx),
69
+ dispatchReplyWithBufferedBlockDispatcher,
70
+ },
71
+ pairing: {
72
+ readAllowFromStore: vi.fn(async () => []),
73
+ upsertPairingRequest: vi.fn(async () => ({ code: "PAIR1234", created: true })),
74
+ },
75
+ },
76
+ } as unknown as PluginRuntime;
77
+
78
+ return {
79
+ runtime,
80
+ recordInboundSession,
81
+ dispatchReplyWithBufferedBlockDispatcher,
82
+ };
83
+ }
84
+
85
+ async function startGatewayHarness(params: {
86
+ account: ReturnType<typeof buildResolvedNostrAccount>;
87
+ cfg?: Parameters<typeof createStartAccountContext>[0]["cfg"];
88
+ }) {
89
+ const harness = createRuntimeHarness();
90
+ const bus = createMockBus();
91
+ setNostrRuntime(harness.runtime);
92
+ mocks.startNostrBus.mockResolvedValueOnce(bus as never);
93
+
94
+ const cleanup = (await startNostrGatewayAccount(
95
+ createStartAccountContext({
96
+ account: params.account,
97
+ cfg: params.cfg,
98
+ }),
99
+ )) as { stop: () => void };
100
+
101
+ return { harness, bus, cleanup };
102
+ }
103
+
104
+ describe("nostr inbound gateway path", () => {
105
+ afterEach(() => {
106
+ mocks.normalizePubkey.mockClear();
107
+ mocks.startNostrBus.mockReset();
108
+ });
109
+
110
+ it("issues a pairing reply before decrypt for unknown senders", async () => {
111
+ const { cleanup } = await startGatewayHarness({
112
+ account: buildResolvedNostrAccount({
113
+ config: { dmPolicy: "pairing", allowFrom: [] },
114
+ }),
115
+ });
116
+
117
+ const options = mocks.startNostrBus.mock.calls[0]?.[0] as {
118
+ authorizeSender: (params: {
119
+ senderPubkey: string;
120
+ reply: (text: string) => Promise<void>;
121
+ }) => Promise<string>;
122
+ };
123
+ const sendPairingReply = vi.fn(async (_text: string) => {});
124
+
125
+ await expect(
126
+ options.authorizeSender({
127
+ senderPubkey: "nostr:UNKNOWN-SENDER",
128
+ reply: sendPairingReply,
129
+ }),
130
+ ).resolves.toBe("pairing");
131
+ expect(sendPairingReply).toHaveBeenCalledTimes(1);
132
+ expect(sendPairingReply.mock.calls[0]?.[0]).toContain("Pairing code:");
133
+
134
+ cleanup.stop();
135
+ });
136
+
137
+ it("routes allowed DMs through the standard reply pipeline", async () => {
138
+ const { harness, cleanup } = await startGatewayHarness({
139
+ account: buildResolvedNostrAccount({
140
+ publicKey: "bot-pubkey",
141
+ config: { dmPolicy: "allowlist", allowFrom: ["nostr:sender-pubkey"] },
142
+ }),
143
+ cfg: {
144
+ session: { store: { type: "jsonl" } },
145
+ commands: { useAccessGroups: true },
146
+ } as never,
147
+ });
148
+
149
+ const options = mocks.startNostrBus.mock.calls[0]?.[0] as {
150
+ onMessage: (
151
+ senderPubkey: string,
152
+ text: string,
153
+ reply: (text: string) => Promise<void>,
154
+ meta: { eventId: string; createdAt: number },
155
+ ) => Promise<void>;
156
+ };
157
+ const sendReply = vi.fn(async (_text: string) => {});
158
+
159
+ await options.onMessage("sender-pubkey", "hello from nostr", sendReply, {
160
+ eventId: "event-123",
161
+ createdAt: 1_710_000_000,
162
+ });
163
+
164
+ expect(harness.recordInboundSession).toHaveBeenCalledTimes(1);
165
+ expect(harness.dispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalledTimes(1);
166
+ expect(harness.dispatchReplyWithBufferedBlockDispatcher.mock.calls[0]?.[0]?.ctx).toMatchObject({
167
+ BodyForAgent: "hello from nostr",
168
+ SenderId: "sender-pubkey",
169
+ MessageSid: "event-123",
170
+ CommandAuthorized: true,
171
+ });
172
+ expect(sendReply).toHaveBeenCalledWith("converted:|a|b|");
173
+
174
+ cleanup.stop();
175
+ });
176
+ });