Refactor NIP-46 connection logic into Nip46Connector helper

- Extract handshake and connection logic to js/nostr/nip46Connector.js
- Delegate NIP-46 operations in NostrClient to the new connector
- Add unit tests for Nip46Connector with dependency injection support

Co-authored-by: PR0M3TH3AN <[email protected]>
This commit is contained in:
google-labs-jules[bot]andPR0M3TH3AN committed 2026-02-08 03:58:39 +00:00
1 parent 1fbee4219b
commit ca7fb02171
3 files changed
+1273 -1009

No files matched your search

+16 -1009
View File
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+164
View File
@@ -0,0 +1,164 @@
import { describe, it, beforeEach, mock } from "node:test";
import assert from "node:assert/strict";
import { Nip46Connector } from "../../js/nostr/nip46Connector.js";
const VALID_HEX_KEY = "0000000000000000000000000000000000000000000000000000000000000001";
const VALID_PUBKEY = "0000000000000000000000000000000000000000000000000000000000000002";
// Mock dependencies
const mockDeps = {
parseNip46ConnectionString: mock.fn(),
generateNip46Secret: mock.fn(),
readStoredNip46Session: mock.fn(),
writeStoredNip46Session: mock.fn(),
clearStoredNip46Session: mock.fn(),
normalizeNip46EncryptionAlgorithm: mock.fn(),
decryptNip46Session: mock.fn(),
attemptDecryptNip46HandshakePayload: mock.fn(),
sanitizeNip46Metadata: mock.fn((m) => m || {}),
resolveNip46Relays: mock.fn((r) => r || []),
normalizeNostrPubkey: mock.fn((k) => k),
encodeHexToNpub: mock.fn((k) => "npub" + k),
bytesToHex: mock.fn((b) => VALID_HEX_KEY),
signEventWithPrivateKey: mock.fn(),
Nip46RpcClient: class MockRpcClient {
constructor() {
this.destroy = mock.fn();
this.ensureSubscription = mock.fn(() => Promise.resolve());
this.connect = mock.fn(() => Promise.resolve());
this.getUserPubkey = mock.fn(() => Promise.resolve(VALID_PUBKEY));
this.metadata = {};
}
},
ensureNostrTools: mock.fn(async () => ({
generateSecretKey: () => new Uint8Array(32),
getPublicKey: () => VALID_PUBKEY,
})),
getCachedNostrTools: mock.fn(() => ({
generateSecretKey: () => new Uint8Array(32),
getPublicKey: () => VALID_PUBKEY,
})),
NIP46_RPC_KIND: 24133,
NIP46_HANDSHAKE_TIMEOUT_MS: 100,
NIP46_AUTH_CHALLENGE_MAX_ATTEMPTS: 3,
};
describe("Nip46Connector", () => {
let connector;
let mockNostrClient;
beforeEach(() => {
// Reset mocks
Object.values(mockDeps).forEach((m) => {
if (m.mock) m.mock.resetCalls();
});
mockNostrClient = {
pool: {
publish: mock.fn(() => Promise.resolve()),
sub: mock.fn(() => ({
on: mock.fn(),
unsub: mock.fn(),
})),
},
ensurePool: mock.fn(async () => mockNostrClient.pool),
connectToRelays: mock.fn(async () => []),
emitRemoteSignerChange: mock.fn(),
relays: ["wss://relay.example.com"],
};
connector = new Nip46Connector(mockNostrClient, mockDeps);
});
describe("createKeyPair", () => {
it("should generate a new key pair if none provided", async () => {
mockDeps.generateNip46Secret.mock.mockImplementationOnce(() => "mock-secret");
const result = await connector.createKeyPair();
assert.deepEqual(result, {
privateKey: VALID_HEX_KEY,
publicKey: VALID_PUBKEY,
});
});
it("should use existing keys if provided", async () => {
const result = await connector.createKeyPair(
VALID_HEX_KEY,
VALID_PUBKEY
);
assert.deepEqual(result, {
privateKey: VALID_HEX_KEY,
publicKey: VALID_PUBKEY,
});
});
});
describe("prepareHandshake", () => {
it("should parse connection string and return details", async () => {
mockDeps.generateNip46Secret.mock.mockImplementationOnce(() => "handshake-secret");
const result = await connector.prepareHandshake({
connectionString: "bunker://...",
});
assert.equal(result.clientPrivateKey, VALID_HEX_KEY);
assert.equal(result.secret, "handshake-secret");
assert.match(result.connectionString, /^nostrconnect:\/\//);
});
});
describe("connect", () => {
it("should connect directly if remote pubkey is known", async () => {
const options = {
connectionString: "bunker://remote-pub?relay=wss://relay.com",
clientPrivateKey: VALID_HEX_KEY,
};
mockDeps.parseNip46ConnectionString.mock.mockImplementationOnce(() => ({
relays: ["wss://relay.com"],
remotePubkey: VALID_PUBKEY,
type: "bunker",
}));
mockDeps.normalizeNip46EncryptionAlgorithm.mock.mockImplementationOnce(() => "nip44");
mockDeps.normalizeNostrPubkey.mock.mockImplementation((k) => k);
const result = await connector.connect(options);
assert.equal(result.pubkey, VALID_PUBKEY);
assert.ok(result.client instanceof mockDeps.Nip46RpcClient);
assert.equal(mockDeps.writeStoredNip46Session.mock.callCount(), 1);
});
});
describe("reconnectStored", () => {
it("should throw if no stored session", async () => {
mockDeps.readStoredNip46Session.mock.mockImplementationOnce(() => null);
await assert.rejects(connector.reconnectStored(), /No remote signer session is stored/);
});
it("should reconnect using stored session", async () => {
mockDeps.readStoredNip46Session.mock.mockImplementationOnce(() => ({
userPubkey: "user-pub",
remotePubkey: "remote-pub",
relays: [],
clientPrivateKey: "client-priv",
secret: "secret",
}));
mockDeps.decryptNip46Session.mock.mockImplementationOnce((s) => s);
const result = await connector.reconnectStored();
assert.equal(result.pubkey, VALID_PUBKEY);
assert.ok(result.client instanceof mockDeps.Nip46RpcClient);
});
});
describe("disconnect", () => {
it("should clear stored session and emit change", async () => {
await connector.disconnect({ keepStored: false });
assert.equal(mockDeps.clearStoredNip46Session.mock.callCount(), 1);
});
});
});