@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.
- package/README.md +6 -0
- package/api.ts +10 -0
- package/channel-plugin-api.ts +1 -0
- package/index.ts +60 -36
- package/openclaw.plugin.json +190 -1
- package/package.json +41 -9
- package/runtime-api.ts +6 -0
- package/setup-api.ts +1 -0
- package/setup-entry.ts +9 -0
- package/setup-plugin-api.ts +3 -0
- package/src/channel-api.ts +15 -0
- package/src/channel.inbound.test.ts +176 -0
- package/src/channel.outbound.test.ts +89 -49
- package/src/channel.setup.ts +231 -0
- package/src/channel.test.ts +439 -71
- package/src/channel.ts +146 -283
- package/src/config-schema.ts +18 -12
- package/src/default-relays.ts +1 -0
- package/src/gateway.ts +302 -0
- package/src/inbound-direct-dm-runtime.ts +1 -0
- package/src/metrics.ts +6 -6
- package/src/nostr-bus.fuzz.test.ts +74 -247
- package/src/nostr-bus.inbound.test.ts +526 -0
- package/src/nostr-bus.integration.test.ts +88 -64
- package/src/nostr-bus.test.ts +22 -31
- package/src/nostr-bus.ts +206 -136
- package/src/nostr-key-utils.ts +94 -0
- package/src/nostr-profile-core.ts +134 -0
- package/src/nostr-profile-http-runtime.ts +6 -0
- package/src/nostr-profile-http.test.ts +276 -167
- package/src/nostr-profile-http.ts +51 -36
- package/src/nostr-profile-import.ts +3 -3
- package/src/nostr-profile-url-safety.ts +21 -0
- package/src/nostr-profile.fuzz.test.ts +7 -57
- package/src/nostr-profile.test.ts +16 -14
- package/src/nostr-profile.ts +13 -146
- package/src/nostr-state-store.test.ts +106 -2
- package/src/nostr-state-store.ts +46 -49
- package/src/runtime.ts +6 -3
- package/src/seen-tracker.ts +1 -1
- package/src/session-route.ts +25 -0
- package/src/setup-surface.ts +265 -0
- package/src/test-fixtures.ts +45 -0
- package/src/types.ts +26 -25
- package/test-api.ts +1 -0
- package/tsconfig.json +16 -0
- package/CHANGELOG.md +0 -116
- 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
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
import {
|
|
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
|
-
|
|
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
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
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.
|
|
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:
|
|
53
|
+
updateConfigProfile: async (accountId: string, profile: unknown) => {
|
|
27
54
|
const runtime = getNostrRuntime();
|
|
28
|
-
const cfg = runtime.config.
|
|
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
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
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.
|
|
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
|
+
});
|
package/openclaw.plugin.json
CHANGED
|
@@ -1,9 +1,198 @@
|
|
|
1
1
|
{
|
|
2
2
|
"id": "nostr",
|
|
3
|
-
"
|
|
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
|
+
"version": "2026.5.1-beta.2",
|
|
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.
|
|
12
|
+
"zod": "^4.4.1"
|
|
13
|
+
},
|
|
14
|
+
"devDependencies": {
|
|
15
|
+
"@openclaw/plugin-sdk": "workspace:*",
|
|
16
|
+
"openclaw": "workspace:*"
|
|
17
|
+
},
|
|
18
|
+
"peerDependencies": {
|
|
19
|
+
"openclaw": ">=2026.4.25"
|
|
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
|
-
"
|
|
27
|
-
"
|
|
53
|
+
"defaultChoice": "npm",
|
|
54
|
+
"minHostVersion": ">=2026.4.10"
|
|
28
55
|
},
|
|
29
|
-
"
|
|
30
|
-
"
|
|
31
|
-
|
|
32
|
-
|
|
56
|
+
"compat": {
|
|
57
|
+
"pluginApi": ">=2026.4.25"
|
|
58
|
+
},
|
|
59
|
+
"build": {
|
|
60
|
+
"openclawVersion": "2026.5.1-beta.2"
|
|
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,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
|
+
});
|