@actagent/nostr 2026.6.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 (53) hide show
  1. package/README.md +142 -0
  2. package/actagent.plugin.json +17 -0
  3. package/api.ts +11 -0
  4. package/channel-plugin-api.ts +2 -0
  5. package/doctor-contract-api.test.ts +105 -0
  6. package/doctor-contract-api.ts +297 -0
  7. package/index.ts +96 -0
  8. package/npm-shrinkwrap.json +137 -0
  9. package/package.json +67 -0
  10. package/runtime-api.ts +6 -0
  11. package/setup-api.ts +2 -0
  12. package/setup-entry.ts +10 -0
  13. package/setup-plugin-api.ts +3 -0
  14. package/src/channel-api.ts +12 -0
  15. package/src/channel.inbound.test.ts +203 -0
  16. package/src/channel.lifecycle.test.ts +97 -0
  17. package/src/channel.outbound.test.ts +175 -0
  18. package/src/channel.setup.ts +161 -0
  19. package/src/channel.test.ts +527 -0
  20. package/src/channel.ts +215 -0
  21. package/src/config-schema.ts +99 -0
  22. package/src/default-relays.ts +2 -0
  23. package/src/gateway.ts +338 -0
  24. package/src/inbound-direct-dm-runtime.ts +2 -0
  25. package/src/metrics.ts +454 -0
  26. package/src/nostr-bus.fuzz.test.ts +383 -0
  27. package/src/nostr-bus.inbound.test.ts +598 -0
  28. package/src/nostr-bus.integration.test.ts +491 -0
  29. package/src/nostr-bus.test.ts +256 -0
  30. package/src/nostr-bus.ts +799 -0
  31. package/src/nostr-key-utils.ts +93 -0
  32. package/src/nostr-profile-core.ts +135 -0
  33. package/src/nostr-profile-http-runtime.ts +7 -0
  34. package/src/nostr-profile-http.test.ts +632 -0
  35. package/src/nostr-profile-http.ts +583 -0
  36. package/src/nostr-profile-import.test.ts +196 -0
  37. package/src/nostr-profile-import.ts +273 -0
  38. package/src/nostr-profile-url-safety.ts +22 -0
  39. package/src/nostr-profile.fuzz.test.ts +431 -0
  40. package/src/nostr-profile.test.ts +416 -0
  41. package/src/nostr-profile.ts +144 -0
  42. package/src/nostr-state-store.test.ts +172 -0
  43. package/src/nostr-state-store.ts +132 -0
  44. package/src/runtime.ts +10 -0
  45. package/src/seen-tracker.ts +291 -0
  46. package/src/session-route.ts +26 -0
  47. package/src/setup-adapter.ts +86 -0
  48. package/src/setup-surface.ts +204 -0
  49. package/src/test-fixtures.ts +46 -0
  50. package/src/types.ts +118 -0
  51. package/test/setup.ts +5 -0
  52. package/test-api.ts +2 -0
  53. package/tsconfig.json +16 -0
@@ -0,0 +1,137 @@
1
+ {
2
+ "name": "@actagent/nostr",
3
+ "version": "2026.6.2",
4
+ "lockfileVersion": 3,
5
+ "requires": true,
6
+ "packages": {
7
+ "": {
8
+ "name": "@actagent/nostr",
9
+ "version": "2026.6.2",
10
+ "dependencies": {
11
+ "nostr-tools": "2.23.5",
12
+ "zod": "4.4.3"
13
+ },
14
+ "peerDependencies": {
15
+ "actagent": ">=2026.6.2"
16
+ },
17
+ "peerDependenciesMeta": {
18
+ "actagent": {
19
+ "optional": true
20
+ }
21
+ }
22
+ },
23
+ "node_modules/@noble/ciphers": {
24
+ "version": "2.1.1",
25
+ "resolved": "https://registry.npmjs.org/@noble/ciphers/-/ciphers-2.1.1.tgz",
26
+ "integrity": "sha512-bysYuiVfhxNJuldNXlFEitTVdNnYUc+XNJZd7Qm2a5j1vZHgY+fazadNFWFaMK/2vye0JVlxV3gHmC0WDfAOQw==",
27
+ "license": "MIT",
28
+ "engines": {
29
+ "node": ">= 20.19.0"
30
+ },
31
+ "funding": {
32
+ "url": "https://paulmillr.com/funding/"
33
+ }
34
+ },
35
+ "node_modules/@noble/curves": {
36
+ "version": "2.0.1",
37
+ "resolved": "https://registry.npmjs.org/@noble/curves/-/curves-2.0.1.tgz",
38
+ "integrity": "sha512-vs1Az2OOTBiP4q0pwjW5aF0xp9n4MxVrmkFBxc6EKZc6ddYx5gaZiAsZoq0uRRXWbi3AT/sBqn05eRPtn1JCPw==",
39
+ "license": "MIT",
40
+ "dependencies": {
41
+ "@noble/hashes": "2.0.1"
42
+ },
43
+ "engines": {
44
+ "node": ">= 20.19.0"
45
+ },
46
+ "funding": {
47
+ "url": "https://paulmillr.com/funding/"
48
+ }
49
+ },
50
+ "node_modules/@noble/hashes": {
51
+ "version": "2.0.1",
52
+ "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.0.1.tgz",
53
+ "integrity": "sha512-XlOlEbQcE9fmuXxrVTXCTlG2nlRXa9Rj3rr5Ue/+tX+nmkgbX720YHh0VR3hBF9xDvwnb8D2shVGOwNx+ulArw==",
54
+ "license": "MIT",
55
+ "engines": {
56
+ "node": ">= 20.19.0"
57
+ },
58
+ "funding": {
59
+ "url": "https://paulmillr.com/funding/"
60
+ }
61
+ },
62
+ "node_modules/@scure/base": {
63
+ "version": "2.0.0",
64
+ "resolved": "https://registry.npmjs.org/@scure/base/-/base-2.0.0.tgz",
65
+ "integrity": "sha512-3E1kpuZginKkek01ovG8krQ0Z44E3DHPjc5S2rjJw9lZn3KSQOs8S7wqikF/AH7iRanHypj85uGyxk0XAyC37w==",
66
+ "license": "MIT",
67
+ "funding": {
68
+ "url": "https://paulmillr.com/funding/"
69
+ }
70
+ },
71
+ "node_modules/@scure/bip32": {
72
+ "version": "2.0.1",
73
+ "resolved": "https://registry.npmjs.org/@scure/bip32/-/bip32-2.0.1.tgz",
74
+ "integrity": "sha512-4Md1NI5BzoVP+bhyJaY3K6yMesEFzNS1sE/cP+9nuvE7p/b0kx9XbpDHHFl8dHtufcbdHRUUQdRqLIPHN/s7yA==",
75
+ "license": "MIT",
76
+ "dependencies": {
77
+ "@noble/curves": "2.0.1",
78
+ "@noble/hashes": "2.0.1",
79
+ "@scure/base": "2.0.0"
80
+ },
81
+ "funding": {
82
+ "url": "https://paulmillr.com/funding/"
83
+ }
84
+ },
85
+ "node_modules/@scure/bip39": {
86
+ "version": "2.0.1",
87
+ "resolved": "https://registry.npmjs.org/@scure/bip39/-/bip39-2.0.1.tgz",
88
+ "integrity": "sha512-PsxdFj/d2AcJcZDX1FXN3dDgitDDTmwf78rKZq1a6c1P1Nan1X/Sxc7667zU3U+AN60g7SxxP0YCVw2H/hBycg==",
89
+ "license": "MIT",
90
+ "dependencies": {
91
+ "@noble/hashes": "2.0.1",
92
+ "@scure/base": "2.0.0"
93
+ },
94
+ "funding": {
95
+ "url": "https://paulmillr.com/funding/"
96
+ }
97
+ },
98
+ "node_modules/nostr-tools": {
99
+ "version": "2.23.5",
100
+ "resolved": "https://registry.npmjs.org/nostr-tools/-/nostr-tools-2.23.5.tgz",
101
+ "integrity": "sha512-Fa7ZlUdjfUW1P4E7H3yBexhOHYi18XNyvd2n7eNHkYR085xADX6Y8V8Vm7nT/XQajaFOBrptXmVIGkJ2E4vfVw==",
102
+ "license": "Unlicense",
103
+ "dependencies": {
104
+ "@noble/ciphers": "2.1.1",
105
+ "@noble/curves": "2.0.1",
106
+ "@noble/hashes": "2.0.1",
107
+ "@scure/base": "2.0.0",
108
+ "@scure/bip32": "2.0.1",
109
+ "@scure/bip39": "2.0.1",
110
+ "nostr-wasm": "0.1.0"
111
+ },
112
+ "peerDependencies": {
113
+ "typescript": ">=5.0.0"
114
+ },
115
+ "peerDependenciesMeta": {
116
+ "typescript": {
117
+ "optional": true
118
+ }
119
+ }
120
+ },
121
+ "node_modules/nostr-wasm": {
122
+ "version": "0.1.0",
123
+ "resolved": "https://registry.npmjs.org/nostr-wasm/-/nostr-wasm-0.1.0.tgz",
124
+ "integrity": "sha512-78BTryCLcLYv96ONU8Ws3Q1JzjlAt+43pWQhIl86xZmWeegYCNLPml7yQ+gG3vR6V5h4XGj+TxO+SS5dsThQIA==",
125
+ "license": "MIT"
126
+ },
127
+ "node_modules/zod": {
128
+ "version": "4.4.3",
129
+ "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz",
130
+ "integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==",
131
+ "license": "MIT",
132
+ "funding": {
133
+ "url": "https://github.com/sponsors/colinhacks"
134
+ }
135
+ }
136
+ }
137
+ }
package/package.json ADDED
@@ -0,0 +1,67 @@
1
+ {
2
+ "name": "@actagent/nostr",
3
+ "version": "2026.6.2",
4
+ "description": "ACTAgent Nostr channel plugin for NIP-04 encrypted direct messages.",
5
+ "repository": {
6
+ "type": "git",
7
+ "url": "https://github.com/actagent/actagent"
8
+ },
9
+ "type": "module",
10
+ "dependencies": {
11
+ "nostr-tools": "2.23.5",
12
+ "zod": "4.4.3"
13
+ },
14
+ "devDependencies": {
15
+ "@actagent/plugin-sdk": "workspace:*",
16
+ "actagent": "workspace:*"
17
+ },
18
+ "peerDependencies": {
19
+ "actagent": "workspace:*"
20
+ },
21
+ "peerDependenciesMeta": {
22
+ "actagent": {
23
+ "optional": true
24
+ }
25
+ },
26
+ "actagent": {
27
+ "extensions": [
28
+ "./index.ts"
29
+ ],
30
+ "setupEntry": "./setup-entry.ts",
31
+ "channel": {
32
+ "id": "nostr",
33
+ "label": "Nostr",
34
+ "selectionLabel": "Nostr (NIP-04 DMs)",
35
+ "docsPath": "/channels/nostr",
36
+ "docsLabel": "nostr",
37
+ "blurb": "Decentralized protocol; encrypted DMs via NIP-04.",
38
+ "order": 55,
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
+ ]
50
+ },
51
+ "install": {
52
+ "npmSpec": "@actagent/nostr",
53
+ "defaultChoice": "npm",
54
+ "minHostVersion": ">=2026.4.10"
55
+ },
56
+ "compat": {
57
+ "pluginApi": ">=2026.6.2"
58
+ },
59
+ "build": {
60
+ "actagentVersion": "2026.6.2"
61
+ },
62
+ "release": {
63
+ "publishToACTAgentHub": true,
64
+ "publishToNpm": true
65
+ }
66
+ }
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 { ACTAgentConfig } from "actagent/plugin-sdk/config-contracts";
5
+ export { getPluginRuntimeGatewayRequestScope } from "actagent/plugin-sdk/plugin-runtime";
6
+ export type { PluginRuntime } from "actagent/plugin-sdk/runtime-store";
package/setup-api.ts ADDED
@@ -0,0 +1,2 @@
1
+ // Nostr API module exposes the plugin public contract.
2
+ export { nostrSetupAdapter, nostrSetupWizard } from "./src/setup-surface.js";
package/setup-entry.ts ADDED
@@ -0,0 +1,10 @@
1
+ // Nostr plugin module implements setup entry behavior.
2
+ import { defineBundledChannelSetupEntry } from "actagent/plugin-sdk/channel-entry-contract";
3
+
4
+ export default defineBundledChannelSetupEntry({
5
+ importMetaUrl: import.meta.url,
6
+ plugin: {
7
+ specifier: "./setup-plugin-api.js",
8
+ exportName: "nostrSetupPlugin",
9
+ },
10
+ });
@@ -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,12 @@
1
+ // Nostr API module exposes the plugin public contract.
2
+ export {
3
+ buildChannelConfigSchema,
4
+ DEFAULT_ACCOUNT_ID,
5
+ formatPairingApproveHint,
6
+ type ChannelPlugin,
7
+ } from "actagent/plugin-sdk/channel-plugin-common";
8
+ export type { ChannelOutboundAdapter } from "actagent/plugin-sdk/channel-contract";
9
+ export {
10
+ collectStatusIssuesFromLastError,
11
+ createDefaultChannelRuntimeState,
12
+ } from "actagent/plugin-sdk/status-helpers";
@@ -0,0 +1,203 @@
1
+ // Nostr tests cover channel.inbound plugin behavior.
2
+ import { createStartAccountContext } from "actagent/plugin-sdk/channel-test-helpers";
3
+ import { afterEach, beforeAll, describe, expect, it, vi } from "vitest";
4
+ import type { PluginRuntime } from "../runtime-api.js";
5
+ import { startNostrGatewayAccount } from "./gateway.js";
6
+ import { setNostrRuntime } from "./runtime.js";
7
+ import { buildResolvedNostrAccount } from "./test-fixtures.js";
8
+
9
+ const mocks = vi.hoisted(() => ({
10
+ normalizePubkey: vi.fn((value: string) =>
11
+ value
12
+ .trim()
13
+ .replace(/^nostr:/i, "")
14
+ .toLowerCase(),
15
+ ),
16
+ startNostrBus: vi.fn(),
17
+ }));
18
+
19
+ vi.mock("./nostr-bus.js", () => ({
20
+ DEFAULT_RELAYS: ["wss://relay.example.com"],
21
+ startNostrBus: mocks.startNostrBus,
22
+ }));
23
+
24
+ vi.mock("./nostr-key-utils.js", () => ({
25
+ getPublicKeyFromPrivate: vi.fn(() => "bot-pubkey"),
26
+ normalizePubkey: mocks.normalizePubkey,
27
+ }));
28
+
29
+ beforeAll(async () => {
30
+ await import("./inbound-direct-dm-runtime.js");
31
+ });
32
+
33
+ function createMockBus() {
34
+ return {
35
+ sendDm: vi.fn(async () => {}),
36
+ close: vi.fn(),
37
+ getMetrics: vi.fn(() => ({ counters: {} })),
38
+ publishProfile: vi.fn(),
39
+ getProfileState: vi.fn(async () => null),
40
+ };
41
+ }
42
+
43
+ function createRuntimeHarness() {
44
+ const recordInboundSession = vi.fn(async () => {});
45
+ const dispatchReplyWithBufferedBlockDispatcher = vi.fn(async ({ dispatcherOptions }) => {
46
+ await dispatcherOptions.deliver({ text: "|a|b|" });
47
+ });
48
+ const runtime = {
49
+ channel: {
50
+ text: {
51
+ resolveMarkdownTableMode: vi.fn(() => "off"),
52
+ convertMarkdownTables: vi.fn((text: string) => `converted:${text}`),
53
+ },
54
+ commands: {
55
+ shouldComputeCommandAuthorized: vi.fn(() => true),
56
+ resolveCommandAuthorizedFromAuthorizers: vi.fn(() => true),
57
+ },
58
+ routing: {
59
+ resolveAgentRoute: vi.fn(({ accountId, peer }) => ({
60
+ agentId: "agent-nostr",
61
+ accountId,
62
+ sessionKey: `nostr:${peer.id}`,
63
+ })),
64
+ },
65
+ session: {
66
+ resolveStorePath: vi.fn(() => "/tmp/nostr-session-store"),
67
+ readSessionUpdatedAt: vi.fn(() => undefined),
68
+ recordInboundSession,
69
+ },
70
+ reply: {
71
+ formatAgentEnvelope: vi.fn(({ body }) => `envelope:${body}`),
72
+ resolveEnvelopeFormatOptions: vi.fn(() => ({ mode: "agent" })),
73
+ finalizeInboundContext: vi.fn((ctx) => ctx),
74
+ dispatchReplyWithBufferedBlockDispatcher,
75
+ },
76
+ pairing: {
77
+ readAllowFromStore: vi.fn(async () => []),
78
+ upsertPairingRequest: vi.fn(async () => ({ code: "PAIR1234", created: true })),
79
+ },
80
+ },
81
+ } as unknown as PluginRuntime;
82
+
83
+ return {
84
+ runtime,
85
+ recordInboundSession,
86
+ dispatchReplyWithBufferedBlockDispatcher,
87
+ };
88
+ }
89
+
90
+ async function startGatewayHarness(params: {
91
+ account: ReturnType<typeof buildResolvedNostrAccount>;
92
+ cfg?: Parameters<typeof createStartAccountContext>[0]["cfg"];
93
+ }) {
94
+ const harness = createRuntimeHarness();
95
+ const bus = createMockBus();
96
+ setNostrRuntime(harness.runtime);
97
+ mocks.startNostrBus.mockResolvedValueOnce(bus as never);
98
+ const abort = new AbortController();
99
+
100
+ const task = startNostrGatewayAccount(
101
+ createStartAccountContext({
102
+ account: params.account,
103
+ cfg: params.cfg,
104
+ abortSignal: abort.signal,
105
+ }),
106
+ );
107
+ await vi.waitFor(() => {
108
+ expect(mocks.startNostrBus).toHaveBeenCalledTimes(1);
109
+ });
110
+ const cleanup = {
111
+ stop: async () => {
112
+ abort.abort();
113
+ await task;
114
+ },
115
+ };
116
+
117
+ return { harness, bus, cleanup };
118
+ }
119
+
120
+ function mockCallArg(mock: ReturnType<typeof vi.fn>, callIndex = 0, argIndex = 0): unknown {
121
+ const call = mock.mock.calls[callIndex];
122
+ if (!call) {
123
+ throw new Error(`Expected mock call ${callIndex}`);
124
+ }
125
+ return call[argIndex];
126
+ }
127
+
128
+ describe("nostr inbound gateway path", () => {
129
+ afterEach(() => {
130
+ mocks.normalizePubkey.mockClear();
131
+ mocks.startNostrBus.mockReset();
132
+ });
133
+
134
+ it("issues a pairing reply before decrypt for unknown senders", async () => {
135
+ const { cleanup } = await startGatewayHarness({
136
+ account: buildResolvedNostrAccount({
137
+ config: { dmPolicy: "pairing", allowFrom: [] },
138
+ }),
139
+ });
140
+
141
+ const options = mockCallArg(mocks.startNostrBus) as {
142
+ authorizeSender: (params: {
143
+ senderPubkey: string;
144
+ reply: (text: string) => Promise<void>;
145
+ }) => Promise<string>;
146
+ };
147
+ const sendPairingReply = vi.fn(async (_text: string) => {});
148
+
149
+ await expect(
150
+ options.authorizeSender({
151
+ senderPubkey: "nostr:UNKNOWN-SENDER",
152
+ reply: sendPairingReply,
153
+ }),
154
+ ).resolves.toBe("pairing");
155
+ expect(sendPairingReply).toHaveBeenCalledTimes(1);
156
+ expect(mockCallArg(sendPairingReply)).toContain("Pairing code:");
157
+
158
+ await cleanup.stop();
159
+ });
160
+
161
+ it("routes allowed DMs through the standard reply pipeline", async () => {
162
+ const { harness, cleanup } = await startGatewayHarness({
163
+ account: buildResolvedNostrAccount({
164
+ publicKey: "bot-pubkey",
165
+ config: { dmPolicy: "allowlist", allowFrom: ["nostr:sender-pubkey"] },
166
+ }),
167
+ cfg: {
168
+ session: { store: { type: "jsonl" } },
169
+ commands: { useAccessGroups: true },
170
+ } as never,
171
+ });
172
+
173
+ const options = mockCallArg(mocks.startNostrBus) as {
174
+ onMessage: (
175
+ senderPubkey: string,
176
+ text: string,
177
+ reply: (text: string) => Promise<void>,
178
+ meta: { eventId: string; createdAt: number },
179
+ ) => Promise<void>;
180
+ };
181
+ const sendReply = vi.fn(async (_text: string) => {});
182
+
183
+ await options.onMessage("sender-pubkey", "hello from nostr", sendReply, {
184
+ eventId: "event-123",
185
+ createdAt: 1_710_000_000,
186
+ });
187
+
188
+ expect(harness.recordInboundSession).toHaveBeenCalledTimes(1);
189
+ expect(harness.dispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalledTimes(1);
190
+ const ctx = (
191
+ mockCallArg(harness.dispatchReplyWithBufferedBlockDispatcher) as {
192
+ ctx?: Record<string, unknown>;
193
+ }
194
+ ).ctx;
195
+ expect(ctx?.BodyForAgent).toBe("hello from nostr");
196
+ expect(ctx?.SenderId).toBe("sender-pubkey");
197
+ expect(ctx?.MessageSid).toBe("event-123");
198
+ expect(ctx?.CommandAuthorized).toBe(true);
199
+ expect(sendReply).toHaveBeenCalledWith("converted:|a|b|");
200
+
201
+ await cleanup.stop();
202
+ });
203
+ });
@@ -0,0 +1,97 @@
1
+ // Nostr tests cover channel.lifecycle plugin behavior.
2
+ import {
3
+ createStartAccountContext,
4
+ createPluginRuntimeMock,
5
+ expectStopPendingUntilAbort,
6
+ startAccountAndTrackLifecycle,
7
+ waitForStartedMocks,
8
+ } from "actagent/plugin-sdk/channel-test-helpers";
9
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
10
+ import { getActiveNostrBuses, startNostrGatewayAccount } from "./gateway.js";
11
+ import { setNostrRuntime } from "./runtime.js";
12
+ import { buildResolvedNostrAccount } from "./test-fixtures.js";
13
+
14
+ const mocks = vi.hoisted(() => ({
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
+ function createMockBus() {
24
+ return {
25
+ sendDm: vi.fn(async () => {}),
26
+ close: vi.fn(),
27
+ getMetrics: vi.fn(() => ({ counters: {} })),
28
+ publishProfile: vi.fn(),
29
+ getProfileState: vi.fn(async () => null),
30
+ };
31
+ }
32
+
33
+ describe("nostr gateway lifecycle", () => {
34
+ beforeEach(() => {
35
+ setNostrRuntime(createPluginRuntimeMock());
36
+ });
37
+
38
+ afterEach(() => {
39
+ mocks.startNostrBus.mockReset();
40
+ });
41
+
42
+ it("keeps startAccount pending until abort, then closes the bus", async () => {
43
+ const bus = createMockBus();
44
+ mocks.startNostrBus.mockResolvedValueOnce(bus as never);
45
+
46
+ const { abort, task, isSettled } = startAccountAndTrackLifecycle({
47
+ startAccount: startNostrGatewayAccount,
48
+ account: buildResolvedNostrAccount(),
49
+ });
50
+
51
+ await expectStopPendingUntilAbort({
52
+ waitForStarted: waitForStartedMocks(mocks.startNostrBus),
53
+ isSettled,
54
+ abort,
55
+ task,
56
+ stop: bus.close,
57
+ });
58
+ });
59
+
60
+ it("keeps the active bus registered while pending and removes it after abort", async () => {
61
+ const bus = createMockBus();
62
+ mocks.startNostrBus.mockResolvedValueOnce(bus as never);
63
+
64
+ const { abort, task, isSettled } = startAccountAndTrackLifecycle({
65
+ startAccount: startNostrGatewayAccount,
66
+ account: buildResolvedNostrAccount(),
67
+ });
68
+
69
+ await vi.waitFor(() => {
70
+ expect(getActiveNostrBuses().get("default")).toBe(bus);
71
+ });
72
+ expect(isSettled()).toBe(false);
73
+
74
+ abort.abort();
75
+ await task;
76
+
77
+ expect(bus.close).toHaveBeenCalledOnce();
78
+ expect(getActiveNostrBuses().has("default")).toBe(false);
79
+ });
80
+
81
+ it("stops immediately when startAccount receives an already-aborted signal", async () => {
82
+ const bus = createMockBus();
83
+ mocks.startNostrBus.mockResolvedValueOnce(bus as never);
84
+ const abort = new AbortController();
85
+ abort.abort();
86
+
87
+ await startNostrGatewayAccount(
88
+ createStartAccountContext({
89
+ account: buildResolvedNostrAccount(),
90
+ abortSignal: abort.signal,
91
+ }),
92
+ );
93
+
94
+ expect(mocks.startNostrBus).toHaveBeenCalledOnce();
95
+ expect(bus.close).toHaveBeenCalledOnce();
96
+ });
97
+ });