openclaw-nostr 2026.7.30

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 (72) hide show
  1. package/CHANGELOG.md +183 -0
  2. package/README.md +845 -0
  3. package/api.ts +6 -0
  4. package/index.ts +69 -0
  5. package/node_modules/cascadia-ts/cascadia.ts +1187 -0
  6. package/node_modules/cascadia-ts/dist/cascadia.d.ts +607 -0
  7. package/node_modules/cascadia-ts/dist/cascadia.js +705 -0
  8. package/node_modules/cascadia-ts/package.json +21 -0
  9. package/openclaw.plugin.json +153 -0
  10. package/package.json +81 -0
  11. package/runtime-api.ts +12 -0
  12. package/setup-api.ts +1 -0
  13. package/setup-entry.ts +4 -0
  14. package/src/channel-actions.test.ts +304 -0
  15. package/src/channel-actions.ts +199 -0
  16. package/src/channel.dm-access.test.ts +387 -0
  17. package/src/channel.inbound.test.ts +476 -0
  18. package/src/channel.lifecycle.test.ts +768 -0
  19. package/src/channel.outbound.test.ts +1143 -0
  20. package/src/channel.test.ts +175 -0
  21. package/src/channel.ts +2827 -0
  22. package/src/config-schema.test.ts +486 -0
  23. package/src/config-schema.ts +356 -0
  24. package/src/default-relays.ts +1 -0
  25. package/src/fleet-agent.test.ts +423 -0
  26. package/src/fleet-agent.ts +549 -0
  27. package/src/metrics.ts +466 -0
  28. package/src/nip46-cutover.test.ts +324 -0
  29. package/src/nip46-cutover.ts +461 -0
  30. package/src/nip46-doctor.test.ts +191 -0
  31. package/src/nip46-doctor.ts +329 -0
  32. package/src/nip46-enroll.test.ts +346 -0
  33. package/src/nip46-enroll.ts +567 -0
  34. package/src/nip46-signer.test.ts +809 -0
  35. package/src/nip46-signer.ts +1263 -0
  36. package/src/nip46-status.test.ts +186 -0
  37. package/src/nip46-status.ts +250 -0
  38. package/src/nip51-lists.test.ts +404 -0
  39. package/src/nip51-lists.ts +777 -0
  40. package/src/nostr-access.test.ts +175 -0
  41. package/src/nostr-access.ts +134 -0
  42. package/src/nostr-bus-nip29.ts +998 -0
  43. package/src/nostr-bus-relay.ts +477 -0
  44. package/src/nostr-bus-types.ts +392 -0
  45. package/src/nostr-bus.fuzz.test.ts +533 -0
  46. package/src/nostr-bus.integration.test.ts +505 -0
  47. package/src/nostr-bus.protocol.test.ts +2057 -0
  48. package/src/nostr-bus.test.ts +235 -0
  49. package/src/nostr-bus.ts +1868 -0
  50. package/src/nostr-capabilities-extended.test.ts +612 -0
  51. package/src/nostr-capabilities.ts +719 -0
  52. package/src/nostr-discovery.test.ts +568 -0
  53. package/src/nostr-discovery.ts +336 -0
  54. package/src/nostr-extras.test.ts +88 -0
  55. package/src/nostr-extras.ts +136 -0
  56. package/src/nostr-profile-http.test.ts +662 -0
  57. package/src/nostr-profile-http.ts +836 -0
  58. package/src/nostr-profile-import.test.ts +274 -0
  59. package/src/nostr-profile-import.ts +295 -0
  60. package/src/nostr-profile.fuzz.test.ts +480 -0
  61. package/src/nostr-profile.test.ts +410 -0
  62. package/src/nostr-profile.ts +320 -0
  63. package/src/nostr-pubkey.ts +56 -0
  64. package/src/nostr-state-store.test.ts +790 -0
  65. package/src/nostr-state-store.ts +1284 -0
  66. package/src/runtime.ts +9 -0
  67. package/src/seen-tracker.test.ts +25 -0
  68. package/src/seen-tracker.ts +292 -0
  69. package/src/session-route.ts +25 -0
  70. package/src/setup-surface.ts +320 -0
  71. package/src/types.test.ts +783 -0
  72. package/src/types.ts +508 -0
@@ -0,0 +1,199 @@
1
+ import { jsonResult, readStringParam, resolveReactionMessageId } from "openclaw/plugin-sdk/channel-actions";
2
+ import type {
3
+ ChannelMessageActionAdapter,
4
+ ChannelMessageActionContext,
5
+ ChannelMessageActionName,
6
+ } from "openclaw/plugin-sdk/channel-contract";
7
+ import type { NostrBusHandle, SendGroupMessageOptions } from "./nostr-bus.js";
8
+ import type { NostrRoomChannelMap } from "./config-schema.js";
9
+
10
+ export const NOSTR_REACTION_REMOVAL_ERROR =
11
+ "Nostr reactions are immutable; to remove a reaction, delete the reaction event using the `delete` action instead";
12
+
13
+ type NostrBusResolver = (accountId?: string | null) => NostrBusHandle | undefined;
14
+
15
+ const NOSTR_MESSAGE_ACTIONS = new Set<ChannelMessageActionName>(["send", "react", "delete"]);
16
+
17
+ function normalizeChannelName(value: string): string {
18
+ return value.replace(/^nostr:/i, "").trim();
19
+ }
20
+
21
+ function readChannelName(ctx: ChannelMessageActionContext): string {
22
+ const explicit =
23
+ readStringParam(ctx.params, "to") ??
24
+ readStringParam(ctx.params, "channelName") ??
25
+ readStringParam(ctx.params, "channelId") ??
26
+ readStringParam(ctx.params, "roomId");
27
+ if (explicit) {
28
+ return normalizeChannelName(explicit);
29
+ }
30
+
31
+ const currentChannelId = ctx.toolContext?.currentChannelId;
32
+ if (currentChannelId !== undefined && currentChannelId !== null) {
33
+ const normalized = normalizeChannelName(String(currentChannelId));
34
+ if (normalized) {
35
+ return normalized;
36
+ }
37
+ }
38
+
39
+ throw new Error("Nostr room channel required (provide to, channelName, channelId, or roomId)");
40
+ }
41
+
42
+ function readTargetEventId(ctx: ChannelMessageActionContext): string {
43
+ const explicit =
44
+ readStringParam(ctx.params, "targetEventId") ??
45
+ readStringParam(ctx.params, "eventId") ??
46
+ resolveReactionMessageId({ args: ctx.params, toolContext: ctx.toolContext });
47
+ if (explicit !== undefined && explicit !== null && String(explicit).trim()) {
48
+ return String(explicit).trim();
49
+ }
50
+ throw new Error("targetEventId or messageId required");
51
+ }
52
+
53
+ function readDeleteEventId(ctx: ChannelMessageActionContext): string {
54
+ const explicit =
55
+ readStringParam(ctx.params, "targetEventId") ??
56
+ readStringParam(ctx.params, "eventId") ??
57
+ resolveReactionMessageId({ args: ctx.params, toolContext: ctx.toolContext });
58
+ if (explicit !== undefined && explicit !== null && String(explicit).trim()) {
59
+ return String(explicit).trim();
60
+ }
61
+ throw new Error("eventId or messageId required");
62
+ }
63
+
64
+ function resolveBus(resolveBus: NostrBusResolver, accountId?: string | null): NostrBusHandle {
65
+ const bus = resolveBus(accountId);
66
+ if (!bus) {
67
+ throw new Error(`Nostr bus not running for account ${accountId ?? "default"}`);
68
+ }
69
+ return bus;
70
+ }
71
+
72
+ export interface NostrSendActionDeps {
73
+ resolveBus: NostrBusResolver;
74
+ loadConfig: () => { channels?: { nostr?: { nostr_channels?: NostrRoomChannelMap } } };
75
+ resolveMarkdownTableMode: (params: { cfg: unknown; channel: string; accountId: string }) => string;
76
+ convertMarkdownTables: (text: string, mode: string) => string;
77
+ normalizePubkey: (value: string) => string;
78
+ extractMentionPubkeys: (text: string) => string[];
79
+ }
80
+
81
+ export function createNostrMessageActions(
82
+ resolveNostrBus: NostrBusResolver,
83
+ sendDeps?: NostrSendActionDeps,
84
+ ): ChannelMessageActionAdapter {
85
+ return {
86
+ describeMessageTool: () => ({
87
+ actions: ["send", "react", "delete"],
88
+ capabilities: [],
89
+ schema: null,
90
+ }),
91
+ supportsAction: ({ action }) => NOSTR_MESSAGE_ACTIONS.has(action),
92
+ resolveExecutionMode: () => "local",
93
+ handleAction: async (ctx) => {
94
+ if (ctx.action === "send") {
95
+ if (!sendDeps) {
96
+ throw new Error("Nostr send action requires runtime dependencies");
97
+ }
98
+ const target =
99
+ readStringParam(ctx.params, "target") ??
100
+ readStringParam(ctx.params, "to") ??
101
+ readStringParam(ctx.params, "channelName") ??
102
+ readStringParam(ctx.params, "channelId");
103
+ if (!target) {
104
+ throw new Error("target or to parameter required for Nostr send action");
105
+ }
106
+ const text =
107
+ readStringParam(ctx.params, "text") ??
108
+ readStringParam(ctx.params, "message") ??
109
+ "";
110
+ const replyToId = readStringParam(ctx.params, "replyToId") ?? undefined;
111
+ const threadId = readStringParam(ctx.params, "threadId") ?? undefined;
112
+ const aid = ctx.accountId ?? "default";
113
+ const bus = resolveBus(sendDeps.resolveBus, aid);
114
+ const cfg = sendDeps.loadConfig();
115
+ const tableMode = sendDeps.resolveMarkdownTableMode({
116
+ cfg,
117
+ channel: "nostr",
118
+ accountId: aid,
119
+ });
120
+ const message = sendDeps.convertMarkdownTables(text, tableMode);
121
+ const rawTo = target.replace(/^nostr:/i, "").trim();
122
+
123
+ // Check if target is a configured NIP-29 room
124
+ const roomCfg = (cfg.channels?.nostr?.nostr_channels as
125
+ Record<string, { kind?: string; enabled?: boolean }> | undefined)?.[rawTo];
126
+ if (roomCfg?.kind === "nip29" && roomCfg.enabled !== false) {
127
+ const sendOpts: SendGroupMessageOptions = {
128
+ mentionPubkeys: sendDeps.extractMentionPubkeys(message),
129
+ };
130
+ if (replyToId?.trim()) {
131
+ sendOpts.replyToEventId = replyToId.trim();
132
+ }
133
+ if (threadId?.trim()) {
134
+ sendOpts.threadEventId = threadId.trim();
135
+ }
136
+ const result = await bus.sendGroupMessage(rawTo, message, sendOpts);
137
+ return jsonResult({
138
+ ok: true,
139
+ action: "send",
140
+ channel: "nostr",
141
+ to: rawTo,
142
+ messageId: result.eventId,
143
+ relay: result.relay,
144
+ groupId: result.groupId,
145
+ });
146
+ }
147
+
148
+ // DM path
149
+ const normalizedTo = sendDeps.normalizePubkey(rawTo);
150
+ const result = await bus.sendDm(normalizedTo, message);
151
+ return jsonResult({
152
+ ok: true,
153
+ action: "send",
154
+ channel: "nostr",
155
+ to: normalizedTo,
156
+ messageId: result.publishedEventIds?.[0] ?? result.eventId,
157
+ });
158
+ }
159
+
160
+ if (ctx.action === "react") {
161
+ if (ctx.params.remove === true) {
162
+ throw new Error(NOSTR_REACTION_REMOVAL_ERROR);
163
+ }
164
+ const channelName = readChannelName(ctx);
165
+ const targetEventId = readTargetEventId(ctx);
166
+ const emoji = readStringParam(ctx.params, "emoji", { required: true });
167
+ const bus = resolveBus(resolveNostrBus, ctx.accountId);
168
+ const result = await bus.sendGroupReaction(channelName, emoji, { targetEventId });
169
+ return jsonResult({
170
+ ok: true,
171
+ action: "react",
172
+ channelName,
173
+ targetEventId,
174
+ reactionEventId: result.eventId,
175
+ relay: result.relay,
176
+ groupId: result.groupId,
177
+ });
178
+ }
179
+
180
+ if (ctx.action === "delete") {
181
+ const channelName = readChannelName(ctx);
182
+ const eventId = readDeleteEventId(ctx);
183
+ const bus = resolveBus(resolveNostrBus, ctx.accountId);
184
+ const result = await bus.deleteGroupEvent(channelName, eventId);
185
+ return jsonResult({
186
+ ok: true,
187
+ action: "delete",
188
+ channelName,
189
+ deletedEventId: eventId,
190
+ deletionEventId: result.eventId,
191
+ relay: result.relay,
192
+ groupId: result.groupId,
193
+ });
194
+ }
195
+
196
+ throw new Error(`Unsupported Nostr action: ${ctx.action}`);
197
+ },
198
+ };
199
+ }
@@ -0,0 +1,387 @@
1
+ import { afterEach, describe, expect, it, vi } from "vitest";
2
+ import { createStartAccountContext } from "../test/helpers/extensions/start-account-context.js";
3
+ import type { PluginRuntime } from "../runtime-api.js";
4
+ import { getActiveNostrBuses, nostrPlugin } from "./channel.js";
5
+ import { setNostrRuntime } from "./runtime.js";
6
+
7
+ const SENDER = "bb".repeat(32);
8
+ const OPERATOR = "aa".repeat(32);
9
+
10
+ const mocks = vi.hoisted(() => ({
11
+ startNostrBus: vi.fn(),
12
+ createNip51FleetService: vi.fn(),
13
+ readNostrProfileState: vi.fn(async () => null),
14
+ writeNostrProfileState: vi.fn(async () => {}),
15
+ }));
16
+
17
+ vi.mock("./nostr-bus.js", () => ({
18
+ DEFAULT_RELAYS: ["wss://relay.example.com"],
19
+ getPublicKeyFromPrivate: vi.fn(() => "pubkey"),
20
+ normalizePubkey: vi.fn((value: string) => {
21
+ const cleaned = value.replace(/^nostr:/i, "").trim();
22
+ if (/^[0-9a-fA-F]{64}$/.test(cleaned)) return cleaned.toLowerCase();
23
+ throw new Error("invalid pubkey");
24
+ }),
25
+ startNostrBus: mocks.startNostrBus,
26
+ }));
27
+
28
+ vi.mock("./nip51-lists.js", () => ({
29
+ createNip51FleetService: mocks.createNip51FleetService,
30
+ }));
31
+
32
+ vi.mock("./nostr-state-store.js", () => ({
33
+ readNostrProfileState: mocks.readNostrProfileState,
34
+ writeNostrProfileState: mocks.writeNostrProfileState,
35
+ }));
36
+
37
+ type HarnessOptions = {
38
+ dmPolicy?: "pairing" | "allowlist" | "open" | "disabled";
39
+ allowFrom?: string[];
40
+ pairedPubkeys?: string[];
41
+ fleetTiers?: string[];
42
+ muted?: boolean;
43
+ pairingCreated?: boolean;
44
+ pairingUpsertError?: Error;
45
+ accountId?: string;
46
+ };
47
+
48
+ async function startDmHarness(options: HarnessOptions = {}) {
49
+ const accountId = options.accountId ?? "default";
50
+ const cfg = {
51
+ channels: {
52
+ nostr: {
53
+ privateKey: "resolved-nostr-private-key", // pragma: allowlist secret
54
+ dmPolicy: options.dmPolicy,
55
+ allowFrom: options.allowFrom,
56
+ },
57
+ },
58
+ };
59
+ const finalizeInboundContext = vi.fn((payload) => payload);
60
+ const dispatchReplyWithBufferedBlockDispatcher = vi.fn(async () => ({
61
+ queuedFinal: false,
62
+ counts: { tool: 0, block: 0, final: 0 },
63
+ }));
64
+ const readAllowFromStore = vi.fn(async () => options.pairedPubkeys ?? []);
65
+ const upsertPairingRequest = options.pairingUpsertError
66
+ ? vi.fn(async () => { throw options.pairingUpsertError; })
67
+ : vi.fn(async () => ({
68
+ code: "PAIR1234",
69
+ created: options.pairingCreated ?? true,
70
+ }));
71
+ const recordInboundSession = vi.fn(async () => undefined);
72
+
73
+ setNostrRuntime({
74
+ config: { loadConfig: vi.fn(() => cfg) },
75
+ channel: {
76
+ routing: {
77
+ resolveAgentRoute: vi.fn(() => ({
78
+ agentId: "main",
79
+ accountId,
80
+ sessionKey: `nostr:dm:${SENDER}`,
81
+ })),
82
+ },
83
+ reply: {
84
+ finalizeInboundContext,
85
+ resolveHumanDelayConfig: vi.fn(() => undefined),
86
+ dispatchReplyWithBufferedBlockDispatcher,
87
+ },
88
+ session: {
89
+ resolveStorePath: vi.fn(() => "/tmp/nostr-dm-access-sessions.json"),
90
+ recordInboundSession,
91
+ },
92
+ pairing: {
93
+ buildPairingReply: vi.fn(),
94
+ readAllowFromStore,
95
+ upsertPairingRequest,
96
+ },
97
+ },
98
+ reply: {},
99
+ } as unknown as PluginRuntime);
100
+
101
+ const directory = {
102
+ get: vi.fn((pubkey: string) => {
103
+ if (pubkey !== SENDER || !options.fleetTiers) return undefined;
104
+ return {
105
+ pubkey,
106
+ tiers: [...options.fleetTiers],
107
+ relayHints: [],
108
+ sources: [],
109
+ capabilities: [],
110
+ };
111
+ }),
112
+ list: vi.fn(() => []),
113
+ hasTier: vi.fn(() => false),
114
+ isMuted: vi.fn((pubkey: string) => pubkey === SENDER && options.muted === true),
115
+ subscribe: vi.fn(() => () => {}),
116
+ };
117
+ const nip51Service = {
118
+ publicKey: OPERATOR,
119
+ directory,
120
+ start: vi.fn(async () => ({ initialSync: "eose" as const })),
121
+ getRelaySet: vi.fn(),
122
+ subscribeRelaySets: vi.fn(() => () => {}),
123
+ replaceMuteList: vi.fn(),
124
+ setMuted: vi.fn(),
125
+ replaceTierMembers: vi.fn(),
126
+ replaceRelaySet: vi.fn(),
127
+ close: vi.fn(async () => {}),
128
+ };
129
+ mocks.createNip51FleetService.mockResolvedValueOnce(nip51Service);
130
+
131
+ const bus = {
132
+ sendDm: vi.fn(async () => ({ eventId: "dm-id", publishedEventIds: ["dm-id"] })),
133
+ sendGroupMessage: vi.fn(),
134
+ close: vi.fn(async () => {}),
135
+ getMetrics: vi.fn(() => ({ counters: {} })),
136
+ publishProfile: vi.fn(),
137
+ publishRelayList: vi.fn(async () => ({
138
+ eventId: "relay-list",
139
+ createdAt: 1,
140
+ successes: [],
141
+ failures: [],
142
+ })),
143
+ publishDmRelayList: vi.fn(async () => ({
144
+ eventId: "dm-relay-list",
145
+ createdAt: 1,
146
+ successes: [],
147
+ failures: [],
148
+ })),
149
+ getProfileState: vi.fn(async () => null),
150
+ };
151
+ mocks.startNostrBus.mockResolvedValueOnce(bus);
152
+
153
+ const abortController = new AbortController();
154
+ const startPromise = nostrPlugin.gateway!.startAccount!(
155
+ createStartAccountContext({
156
+ account: {
157
+ accountId,
158
+ enabled: true,
159
+ configured: true,
160
+ privateKey: "01".repeat(32), // pragma: allowlist secret
161
+ publicKey: OPERATOR,
162
+ relays: ["wss://relay.example.com"],
163
+ config: {
164
+ dmPolicy: options.dmPolicy,
165
+ allowFrom: options.allowFrom,
166
+ },
167
+ ...(options.fleetTiers || options.muted
168
+ ? {
169
+ nip51: {
170
+ enabled: true,
171
+ relays: ["wss://relay.example.com"],
172
+ capabilitySync: false,
173
+ initialSyncTimeoutMs: 1,
174
+ tiers: {},
175
+ relaySets: {},
176
+ },
177
+ }
178
+ : {}),
179
+ },
180
+ abortSignal: abortController.signal,
181
+ }),
182
+ );
183
+ await vi.waitFor(() => expect(getActiveNostrBuses().has(accountId)).toBe(true));
184
+ const onMessage = mocks.startNostrBus.mock.calls.at(-1)?.[0]?.onMessage;
185
+ if (!onMessage) throw new Error("expected onMessage handler");
186
+
187
+ return {
188
+ accountId,
189
+ abortController,
190
+ startPromise,
191
+ bus,
192
+ onMessage,
193
+ finalizeInboundContext,
194
+ dispatchReplyWithBufferedBlockDispatcher,
195
+ readAllowFromStore,
196
+ upsertPairingRequest,
197
+ recordInboundSession,
198
+ };
199
+ }
200
+
201
+ async function stopHarness(harness: Awaited<ReturnType<typeof startDmHarness>>) {
202
+ harness.abortController.abort();
203
+ await harness.startPromise;
204
+ }
205
+
206
+ describe("nostr inbound DM access enforcement", () => {
207
+ afterEach(() => {
208
+ mocks.startNostrBus.mockReset();
209
+ mocks.createNip51FleetService.mockReset();
210
+ mocks.readNostrProfileState.mockClear();
211
+ mocks.writeNostrProfileState.mockClear();
212
+ });
213
+
214
+ it("dispatches fleet members with tier-specific structured access context", async () => {
215
+ const harness = await startDmHarness({
216
+ dmPolicy: "disabled",
217
+ allowFrom: [OPERATOR],
218
+ fleetTiers: ["worker", "cascadia"],
219
+ });
220
+ const reply = vi.fn();
221
+
222
+ await harness.onMessage(SENDER, "fleet hello", reply, { protocol: "nip17" });
223
+
224
+ expect(harness.finalizeInboundContext).toHaveBeenCalledWith(expect.objectContaining({
225
+ Body: "fleet hello",
226
+ BodyForAgent: "fleet hello",
227
+ CommandAuthorized: false,
228
+ NostrAccessTier: "fleet",
229
+ NostrAccessSource: "nip51",
230
+ ConversationLabel: `${SENDER.slice(0, 16)} (fleet: cascadia, worker)`,
231
+ UntrustedStructuredContext: [expect.objectContaining({
232
+ label: "Nostr access",
233
+ payload: expect.objectContaining({
234
+ senderPubkey: SENDER,
235
+ tier: "fleet",
236
+ source: "nip51",
237
+ privileges: "fleet",
238
+ fleetTiers: ["cascadia", "worker"],
239
+ }),
240
+ })],
241
+ }));
242
+ expect(harness.dispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalledTimes(1);
243
+ expect(harness.readAllowFromStore).not.toHaveBeenCalled();
244
+ await stopHarness(harness);
245
+ });
246
+
247
+ it("reports actual mute state when a concrete operator overrides mute", async () => {
248
+ const harness = await startDmHarness({
249
+ dmPolicy: "disabled",
250
+ allowFrom: [SENDER],
251
+ muted: true,
252
+ });
253
+
254
+ await harness.onMessage(SENDER, "operator hello", vi.fn(), { protocol: "nip17" });
255
+
256
+ expect(harness.finalizeInboundContext).toHaveBeenCalledWith(expect.objectContaining({
257
+ CommandAuthorized: true,
258
+ NostrAccessTier: "operator",
259
+ UntrustedStructuredContext: [expect.objectContaining({
260
+ payload: expect.objectContaining({
261
+ senderPubkey: SENDER,
262
+ tier: "operator",
263
+ privileges: "full",
264
+ muted: true,
265
+ }),
266
+ })],
267
+ }));
268
+ expect(harness.dispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalledTimes(1);
269
+ await stopHarness(harness);
270
+ });
271
+
272
+ it("dispatches an approved pairing peer without operator privileges", async () => {
273
+ const harness = await startDmHarness({
274
+ dmPolicy: "pairing",
275
+ allowFrom: [OPERATOR],
276
+ pairedPubkeys: [SENDER],
277
+ });
278
+
279
+ await harness.onMessage(SENDER, "paired hello", vi.fn(), { protocol: "nip04" });
280
+
281
+ expect(harness.finalizeInboundContext).toHaveBeenCalledWith(expect.objectContaining({
282
+ Body: "paired hello",
283
+ BodyForAgent: "paired hello",
284
+ CommandAuthorized: false,
285
+ NostrAccessTier: "paired",
286
+ NostrAccessSource: "pairing-store",
287
+ ConversationLabel: `${SENDER.slice(0, 16)} (paired)`,
288
+ }));
289
+ expect(harness.dispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalledTimes(1);
290
+ expect(harness.upsertPairingRequest).not.toHaveBeenCalled();
291
+ await stopHarness(harness);
292
+ });
293
+
294
+ it("uses the canonical pairing flow and does not dispatch an unknown sender", async () => {
295
+ const harness = await startDmHarness({
296
+ dmPolicy: "pairing",
297
+ allowFrom: [OPERATOR],
298
+ pairingCreated: true,
299
+ });
300
+ const reply = vi.fn(async () => {});
301
+
302
+ await expect(
303
+ harness.onMessage(SENDER, "unknown hello", reply, { protocol: "nip17" }),
304
+ ).resolves.toBeUndefined();
305
+
306
+ expect(harness.upsertPairingRequest).toHaveBeenCalledWith({
307
+ channel: "nostr",
308
+ accountId: "default",
309
+ id: SENDER,
310
+ meta: undefined,
311
+ });
312
+ expect(reply).toHaveBeenCalledTimes(1);
313
+ expect(reply.mock.calls[0]?.[0]).toContain("PAIR1234");
314
+ expect(harness.recordInboundSession).not.toHaveBeenCalled();
315
+ expect(harness.dispatchReplyWithBufferedBlockDispatcher).not.toHaveBeenCalled();
316
+ await stopHarness(harness);
317
+ });
318
+
319
+ it("does not repeat a pairing reply for an existing request", async () => {
320
+ const harness = await startDmHarness({
321
+ dmPolicy: "pairing",
322
+ allowFrom: [OPERATOR],
323
+ pairingCreated: false,
324
+ });
325
+ const reply = vi.fn();
326
+
327
+ await harness.onMessage(SENDER, "again", reply, { protocol: "nip17" });
328
+
329
+ expect(harness.upsertPairingRequest).toHaveBeenCalledTimes(1);
330
+ expect(reply).not.toHaveBeenCalled();
331
+ expect(harness.dispatchReplyWithBufferedBlockDispatcher).not.toHaveBeenCalled();
332
+ await stopHarness(harness);
333
+ });
334
+
335
+ it.each(["allowlist", "disabled"] as const)(
336
+ "silently handles unknown senders under %s without dispatch",
337
+ async (dmPolicy) => {
338
+ const harness = await startDmHarness({ dmPolicy, allowFrom: [OPERATOR] });
339
+ const reply = vi.fn();
340
+
341
+ await expect(
342
+ harness.onMessage(SENDER, "blocked", reply, { protocol: "nip17" }),
343
+ ).resolves.toBeUndefined();
344
+
345
+ expect(harness.readAllowFromStore).not.toHaveBeenCalled();
346
+ expect(harness.upsertPairingRequest).not.toHaveBeenCalled();
347
+ expect(reply).not.toHaveBeenCalled();
348
+ expect(harness.recordInboundSession).not.toHaveBeenCalled();
349
+ expect(harness.dispatchReplyWithBufferedBlockDispatcher).not.toHaveBeenCalled();
350
+ await stopHarness(harness);
351
+ },
352
+ );
353
+
354
+ it("handles pairing-store failures without dispatch or replay-triggering rejection", async () => {
355
+ const harness = await startDmHarness({
356
+ dmPolicy: "pairing",
357
+ allowFrom: [OPERATOR],
358
+ pairingUpsertError: new Error("store unavailable"),
359
+ });
360
+
361
+ await expect(
362
+ harness.onMessage(SENDER, "pair me", vi.fn(), { protocol: "nip17" }),
363
+ ).resolves.toBeUndefined();
364
+ expect(harness.dispatchReplyWithBufferedBlockDispatcher).not.toHaveBeenCalled();
365
+ await stopHarness(harness);
366
+ });
367
+
368
+ it("notifies approval through the requested account rather than the default account", async () => {
369
+ const harness = await startDmHarness({
370
+ accountId: "work",
371
+ dmPolicy: "allowlist",
372
+ allowFrom: [OPERATOR],
373
+ });
374
+
375
+ await nostrPlugin.pairing!.notifyApproval!({
376
+ cfg: { channels: { nostr: {} } },
377
+ id: SENDER,
378
+ accountId: "work",
379
+ });
380
+
381
+ expect(harness.bus.sendDm).toHaveBeenCalledWith(
382
+ SENDER,
383
+ "Your pairing request has been approved!",
384
+ );
385
+ await stopHarness(harness);
386
+ });
387
+ });