cairn-ts 0.2.0

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 (76) hide show
  1. package/README.md +43 -0
  2. package/dist/index.cjs +1883 -0
  3. package/dist/index.d.cts +572 -0
  4. package/dist/index.d.ts +572 -0
  5. package/dist/index.js +1827 -0
  6. package/eslint.config.js +24 -0
  7. package/package.json +54 -0
  8. package/src/channel.ts +277 -0
  9. package/src/config.ts +161 -0
  10. package/src/crypto/aead.ts +80 -0
  11. package/src/crypto/double-ratchet.ts +355 -0
  12. package/src/crypto/exchange.ts +51 -0
  13. package/src/crypto/hkdf.ts +33 -0
  14. package/src/crypto/identity.ts +84 -0
  15. package/src/crypto/index.ts +20 -0
  16. package/src/crypto/noise.ts +415 -0
  17. package/src/crypto/sas.ts +36 -0
  18. package/src/crypto/spake2.ts +169 -0
  19. package/src/discovery/index.ts +38 -0
  20. package/src/discovery/manager.ts +138 -0
  21. package/src/discovery/rendezvous.ts +189 -0
  22. package/src/discovery/tracker.ts +251 -0
  23. package/src/errors.ts +166 -0
  24. package/src/index.ts +57 -0
  25. package/src/mesh/index.ts +48 -0
  26. package/src/mesh/relay.ts +100 -0
  27. package/src/mesh/routing-table.ts +196 -0
  28. package/src/node.ts +619 -0
  29. package/src/pairing/adapter.ts +51 -0
  30. package/src/pairing/index.ts +40 -0
  31. package/src/pairing/link.ts +127 -0
  32. package/src/pairing/payload.ts +98 -0
  33. package/src/pairing/pin.ts +115 -0
  34. package/src/pairing/psk.ts +49 -0
  35. package/src/pairing/qr.ts +52 -0
  36. package/src/pairing/rate-limit.ts +134 -0
  37. package/src/pairing/sas-flow.ts +45 -0
  38. package/src/pairing/state-machine.ts +438 -0
  39. package/src/pairing/unpairing.ts +50 -0
  40. package/src/protocol/custom-handler.ts +52 -0
  41. package/src/protocol/envelope.ts +138 -0
  42. package/src/protocol/index.ts +36 -0
  43. package/src/protocol/message-types.ts +74 -0
  44. package/src/protocol/version.ts +98 -0
  45. package/src/server/index.ts +67 -0
  46. package/src/server/management.ts +285 -0
  47. package/src/server/store-forward.ts +266 -0
  48. package/src/session/backoff.ts +58 -0
  49. package/src/session/heartbeat.ts +79 -0
  50. package/src/session/index.ts +26 -0
  51. package/src/session/message-queue.ts +133 -0
  52. package/src/session/network-monitor.ts +130 -0
  53. package/src/session/state-machine.ts +122 -0
  54. package/src/session.ts +223 -0
  55. package/src/transport/fallback.ts +475 -0
  56. package/src/transport/index.ts +46 -0
  57. package/src/transport/libp2p-node.ts +158 -0
  58. package/src/transport/nat.ts +348 -0
  59. package/tests/conformance/cbor-vectors.test.ts +250 -0
  60. package/tests/integration/pairing-session.test.ts +317 -0
  61. package/tests/unit/config-api.test.ts +310 -0
  62. package/tests/unit/crypto.test.ts +407 -0
  63. package/tests/unit/discovery.test.ts +618 -0
  64. package/tests/unit/double-ratchet.test.ts +185 -0
  65. package/tests/unit/mesh.test.ts +349 -0
  66. package/tests/unit/noise.test.ts +346 -0
  67. package/tests/unit/pairing-extras.test.ts +402 -0
  68. package/tests/unit/pairing.test.ts +572 -0
  69. package/tests/unit/protocol.test.ts +438 -0
  70. package/tests/unit/reconnection.test.ts +402 -0
  71. package/tests/unit/scaffolding.test.ts +142 -0
  72. package/tests/unit/server.test.ts +492 -0
  73. package/tests/unit/sessions.test.ts +595 -0
  74. package/tests/unit/transport.test.ts +604 -0
  75. package/tsconfig.json +20 -0
  76. package/vitest.config.ts +15 -0
@@ -0,0 +1,317 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { Node, NodeSession, NodeChannel } from '../../src/node.js';
3
+ import type { ConnectionState } from '../../src/config.js';
4
+ import { IdentityKeypair, peerIdFromPublicKey } from '../../src/crypto/identity.js';
5
+ import { Spake2 } from '../../src/crypto/spake2.js';
6
+ import { NoiseXXHandshake } from '../../src/crypto/noise.js';
7
+ import { DoubleRatchet } from '../../src/crypto/double-ratchet.js';
8
+ import { X25519Keypair } from '../../src/crypto/exchange.js';
9
+ import { hkdfSha256, HKDF_INFO_SESSION_KEY } from '../../src/crypto/hkdf.js';
10
+ import { aeadEncrypt, aeadDecrypt } from '../../src/crypto/aead.js';
11
+ import { generatePin, normalizePin, decodeCrockford } from '../../src/pairing/pin.js';
12
+ import {
13
+ generateQrPayload,
14
+ consumeQrPayload,
15
+ } from '../../src/pairing/qr.js';
16
+ import {
17
+ generatePairingLink,
18
+ parsePairingLink,
19
+ } from '../../src/pairing/link.js';
20
+ import { generateNonce } from '../../src/pairing/payload.js';
21
+ import type { PairingPayload } from '../../src/pairing/payload.js';
22
+
23
+ // --- Integration: Zero-config node creation ---
24
+
25
+ describe('integration: Node creation', () => {
26
+ it('zero-config node creation', async () => {
27
+ const node = await Node.create();
28
+ expect(node.isClosed).toBe(false);
29
+ expect(node.config.reconnectionPolicy.connectTimeout).toBe(30_000);
30
+ await node.close();
31
+ expect(node.isClosed).toBe(true);
32
+ });
33
+
34
+ it('server-mode node creation', async () => {
35
+ const server = await Node.createServer();
36
+ expect(server.config.meshSettings.meshEnabled).toBe(true);
37
+ expect(server.config.meshSettings.relayWilling).toBe(true);
38
+ await server.close();
39
+ });
40
+
41
+ it('all 6 pairing API methods exist', async () => {
42
+ const node = await Node.create();
43
+ // Generation methods return data
44
+ const qr = await node.pairGenerateQr();
45
+ expect(qr.expiresIn).toBeGreaterThan(0);
46
+
47
+ const pin = await node.pairGeneratePin();
48
+ expect(pin.pin).toBeTruthy();
49
+
50
+ const link = await node.pairGenerateLink();
51
+ expect(link.uri).toContain('cairn://');
52
+
53
+ // Scan/enter methods are not yet wired
54
+ await expect(node.pairScanQr(new Uint8Array(10))).rejects.toThrow();
55
+ await expect(node.pairEnterPin('ABCD')).rejects.toThrow();
56
+ await expect(node.pairFromLink('cairn://pair?data=x')).rejects.toThrow();
57
+
58
+ await node.close();
59
+ });
60
+ });
61
+
62
+ // --- Integration: Session + data exchange + channels ---
63
+
64
+ describe('integration: Session lifecycle', () => {
65
+ it('connect creates session with correct state', async () => {
66
+ const node = await Node.create();
67
+ const session = await node.connect('peer-abc');
68
+ expect(session.peerId).toBe('peer-abc');
69
+ expect(session.state).toBe('connected');
70
+ await node.close();
71
+ });
72
+
73
+ it('session state transitions emit events', async () => {
74
+ const node = await Node.create();
75
+ const session = await node.connect('peer-1');
76
+
77
+ const transitions: Array<{ prev: ConnectionState; current: ConnectionState }> = [];
78
+ session.onStateChange((prev, current) => transitions.push({ prev, current }));
79
+
80
+ session.close();
81
+ expect(transitions.length).toBe(1);
82
+ expect(transitions[0]).toEqual({ prev: 'connected', current: 'disconnected' });
83
+ await node.close();
84
+ });
85
+
86
+ it('channel multiplexing: open multiple channels', async () => {
87
+ const node = await Node.create();
88
+ const session = await node.connect('peer-1');
89
+
90
+ const opened: string[] = [];
91
+ session.onChannelOpened((ch) => opened.push(ch.name));
92
+
93
+ const chat = session.openChannel('chat');
94
+ const video = session.openChannel('video');
95
+ const files = session.openChannel('files');
96
+
97
+ expect(opened).toEqual(['chat', 'video', 'files']);
98
+ expect(chat.isOpen).toBe(true);
99
+ expect(video.isOpen).toBe(true);
100
+ expect(files.isOpen).toBe(true);
101
+
102
+ // Send data on channels
103
+ session.send(chat, new Uint8Array([1, 2, 3]));
104
+ session.send(video, new Uint8Array([4, 5, 6]));
105
+
106
+ // Close individual channels
107
+ chat.close();
108
+ expect(chat.isOpen).toBe(false);
109
+ expect(video.isOpen).toBe(true);
110
+
111
+ // Send on closed channel throws
112
+ expect(() => session.send(chat, new Uint8Array([7]))).toThrow();
113
+
114
+ await node.close();
115
+ });
116
+
117
+ it('reserved channel names rejected', async () => {
118
+ const node = await Node.create();
119
+ const session = await node.connect('peer-1');
120
+ expect(() => session.openChannel('__cairn_forward')).toThrow('reserved');
121
+ expect(() => session.openChannel('__cairn_anything')).toThrow('reserved');
122
+ expect(() => session.openChannel('')).toThrow('empty');
123
+ await node.close();
124
+ });
125
+
126
+ it('unpair emits event', async () => {
127
+ const node = await Node.create();
128
+ const unpaired: string[] = [];
129
+ node.onPeerUnpaired((id) => unpaired.push(id));
130
+
131
+ await node.connect('peer-1');
132
+ await node.unpair('peer-1');
133
+ expect(unpaired).toEqual(['peer-1']);
134
+ await node.close();
135
+ });
136
+ });
137
+
138
+ // --- Integration: Full crypto pipeline ---
139
+
140
+ describe('integration: Crypto pipeline', () => {
141
+ it('SPAKE2 -> Noise XX -> HKDF -> Double Ratchet -> encrypted message', async () => {
142
+ // 1. SPAKE2 mutual authentication
143
+ const password = new TextEncoder().encode('test-password');
144
+ const alice = Spake2.startA(password);
145
+ const bob = Spake2.startB(password);
146
+
147
+ const aliceSecret = alice.finish(bob.outboundMsg);
148
+ const bobSecret = bob.finish(alice.outboundMsg);
149
+ expect(aliceSecret).toEqual(bobSecret);
150
+
151
+ // 2. Noise XX handshake
152
+ const aliceId = await IdentityKeypair.generate();
153
+ const bobId = await IdentityKeypair.generate();
154
+
155
+ const aliceNoise = new NoiseXXHandshake('initiator', aliceId);
156
+ const bobNoise = new NoiseXXHandshake('responder', bobId);
157
+
158
+ const step1 = aliceNoise.step();
159
+ expect(step1.type).toBe('send_message');
160
+ const msg1 = (step1 as { type: 'send_message'; data: Uint8Array }).data;
161
+
162
+ const step2 = bobNoise.step(msg1);
163
+ expect(step2.type).toBe('send_message');
164
+ const msg2 = (step2 as { type: 'send_message'; data: Uint8Array }).data;
165
+
166
+ const step3 = aliceNoise.step(msg2);
167
+ expect(step3.type).toBe('send_message');
168
+ const msg3 = (step3 as { type: 'send_message'; data: Uint8Array }).data;
169
+
170
+ const step4 = bobNoise.step(msg3);
171
+ expect(step4.type).toBe('complete');
172
+
173
+ const aliceResult = aliceNoise.getResult();
174
+ const bobResult = (step4 as { type: 'complete'; result: typeof aliceResult }).result;
175
+ expect(aliceResult.sessionKey).toEqual(bobResult.sessionKey);
176
+
177
+ // 3. HKDF session key derivation
178
+ const sessionKey = hkdfSha256(aliceResult.sessionKey, undefined, HKDF_INFO_SESSION_KEY, 32);
179
+ expect(sessionKey.length).toBe(32);
180
+
181
+ // 4. Double Ratchet for forward-secure messaging
182
+ // Bob creates a DH keypair for the initial ratchet setup
183
+ const bobDhKp = X25519Keypair.generate();
184
+ const aliceRatchet = DoubleRatchet.initSender(aliceResult.sessionKey, bobDhKp.publicKeyBytes());
185
+ const bobRatchet = DoubleRatchet.initReceiver(aliceResult.sessionKey, bobDhKp);
186
+
187
+ const plaintext = new TextEncoder().encode('Hello from Alice!');
188
+ const { header, ciphertext } = aliceRatchet.encrypt(plaintext);
189
+ const decrypted = bobRatchet.decrypt(header, ciphertext);
190
+ expect(decrypted).toEqual(plaintext);
191
+
192
+ // 5. Bidirectional messaging
193
+ const bobPlaintext = new TextEncoder().encode('Hello from Bob!');
194
+ const bobEncrypted = bobRatchet.encrypt(bobPlaintext);
195
+ const aliceDecrypted = aliceRatchet.decrypt(bobEncrypted.header, bobEncrypted.ciphertext);
196
+ expect(aliceDecrypted).toEqual(bobPlaintext);
197
+ });
198
+
199
+ it('AEAD encrypt/decrypt round-trip with tamper detection', () => {
200
+ const key = new Uint8Array(32);
201
+ key.fill(0x42);
202
+ const nonce = new Uint8Array(12);
203
+ nonce.fill(0x01);
204
+ const plaintext = new TextEncoder().encode('secret message');
205
+ const aad = new TextEncoder().encode('associated data');
206
+
207
+ const encrypted = aeadEncrypt('aes-256-gcm', key, nonce, plaintext, aad);
208
+ const decrypted = aeadDecrypt('aes-256-gcm', key, nonce, encrypted, aad);
209
+ expect(decrypted).toEqual(plaintext);
210
+
211
+ // Tamper with ciphertext
212
+ const tampered = new Uint8Array(encrypted);
213
+ tampered[0] ^= 0xff;
214
+ expect(() => aeadDecrypt('aes-256-gcm', key, nonce, tampered, aad)).toThrow();
215
+
216
+ // Wrong key
217
+ const wrongKey = new Uint8Array(32);
218
+ wrongKey.fill(0x99);
219
+ expect(() => aeadDecrypt('aes-256-gcm', wrongKey, nonce, encrypted, aad)).toThrow();
220
+
221
+ // Wrong AAD
222
+ const wrongAad = new TextEncoder().encode('wrong aad');
223
+ expect(() => aeadDecrypt('aes-256-gcm', key, nonce, encrypted, wrongAad)).toThrow();
224
+ });
225
+ });
226
+
227
+ // --- Integration: Pairing flows ---
228
+
229
+ describe('integration: Pairing flows', () => {
230
+ it('PIN code generation and normalization', () => {
231
+ const pin = generatePin();
232
+ expect(pin.length).toBe(8);
233
+
234
+ // Normalize handles confusable characters
235
+ const withConfusables = pin.replace(/1/g, 'l').replace(/0/g, 'O');
236
+ const normalized = normalizePin(withConfusables);
237
+ expect(normalized).toBe(normalizePin(pin));
238
+ });
239
+
240
+ it('QR payload round-trip', async () => {
241
+ const keypair = await IdentityKeypair.generate();
242
+ const now = Math.floor(Date.now() / 1000);
243
+ const payload: PairingPayload = {
244
+ peerId: peerIdFromPublicKey(keypair.publicKey()),
245
+ nonce: generateNonce(),
246
+ pakeCredential: new Uint8Array(32).fill(0xAB),
247
+ createdAt: now,
248
+ expiresAt: now + 300,
249
+ };
250
+
251
+ const qrBytes = generateQrPayload(payload);
252
+ expect(qrBytes.length).toBeLessThanOrEqual(256);
253
+
254
+ const parsed = consumeQrPayload(qrBytes);
255
+ expect(parsed.peerId).toEqual(payload.peerId);
256
+ expect(parsed.nonce).toEqual(payload.nonce);
257
+ expect(parsed.pakeCredential).toEqual(payload.pakeCredential);
258
+ });
259
+
260
+ it('Pairing link round-trip', async () => {
261
+ const keypair = await IdentityKeypair.generate();
262
+ const now = Math.floor(Date.now() / 1000);
263
+ const payload: PairingPayload = {
264
+ peerId: peerIdFromPublicKey(keypair.publicKey()),
265
+ nonce: generateNonce(),
266
+ pakeCredential: new Uint8Array(32).fill(0xCD),
267
+ createdAt: now,
268
+ expiresAt: now + 300,
269
+ };
270
+
271
+ const uri = generatePairingLink(payload);
272
+ expect(uri).toContain('cairn://pair?');
273
+
274
+ const parsed = parsePairingLink(uri);
275
+ expect(parsed.peerId).toEqual(payload.peerId);
276
+ expect(parsed.nonce).toEqual(payload.nonce);
277
+ expect(parsed.pakeCredential).toEqual(payload.pakeCredential);
278
+ });
279
+ });
280
+
281
+ // --- Integration: Reconnection backoff sequence ---
282
+
283
+ describe('integration: Reconnection', () => {
284
+ it('backoff sequence: 1s -> 2s -> 4s -> 8s -> 16s -> 32s -> 60s', async () => {
285
+ const { ExponentialBackoff } = await import('../../src/session/backoff.js');
286
+ const backoff = new ExponentialBackoff();
287
+
288
+ const expected = [1000, 2000, 4000, 8000, 16000, 32000, 60000, 60000];
289
+ for (const delay of expected) {
290
+ expect(backoff.nextDelay()).toBe(delay);
291
+ }
292
+ });
293
+ });
294
+
295
+ // --- Integration: Error propagation ---
296
+
297
+ describe('integration: Error propagation', () => {
298
+ it('CairnError has code and details', async () => {
299
+ const { CairnError } = await import('../../src/errors.js');
300
+ const err = new CairnError('TEST_CODE', 'test message', { key: 'value' });
301
+ expect(err.code).toBe('TEST_CODE');
302
+ expect(err.message).toBe('test message');
303
+ expect(err.details).toEqual({ key: 'value' });
304
+ });
305
+
306
+ it('all error subclasses have correct codes', async () => {
307
+ const errors = await import('../../src/errors.js');
308
+ expect(new errors.TransportExhaustedError('msg').code).toBe('TRANSPORT_EXHAUSTED');
309
+ expect(new errors.SessionExpiredError('msg').code).toBe('SESSION_EXPIRED');
310
+ expect(new errors.PeerUnreachableError('msg').code).toBe('PEER_UNREACHABLE');
311
+ expect(new errors.AuthenticationFailedError('msg').code).toBe('AUTHENTICATION_FAILED');
312
+ expect(new errors.PairingRejectedError('msg').code).toBe('PAIRING_REJECTED');
313
+ expect(new errors.PairingExpiredError('msg').code).toBe('PAIRING_EXPIRED');
314
+ expect(new errors.MeshRouteNotFoundError('msg').code).toBe('MESH_ROUTE_NOT_FOUND');
315
+ expect(new errors.VersionMismatchError('msg').code).toBe('VERSION_MISMATCH');
316
+ });
317
+ });
@@ -0,0 +1,310 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import {
3
+ Node,
4
+ NodeSession,
5
+ NodeChannel,
6
+ DEFAULT_RECONNECTION_POLICY,
7
+ DEFAULT_MESH_SETTINGS,
8
+ DEFAULT_STUN_SERVERS,
9
+ DEFAULT_TRANSPORT_PREFERENCES,
10
+ } from '../../src/index.js';
11
+ import type { ConnectionState } from '../../src/config.js';
12
+
13
+ // --- Node creation ---
14
+
15
+ describe('Node.create', () => {
16
+ it('creates node with defaults', async () => {
17
+ const node = await Node.create();
18
+ expect(node.isClosed).toBe(false);
19
+ expect(node.config.stunServers).toEqual([...DEFAULT_STUN_SERVERS]);
20
+ expect(node.config.transportPreferences).toEqual([...DEFAULT_TRANSPORT_PREFERENCES]);
21
+ expect(node.config.reconnectionPolicy.connectTimeout).toBe(30_000);
22
+ expect(node.config.reconnectionPolicy.transportTimeout).toBe(10_000);
23
+ expect(node.config.reconnectionPolicy.reconnectMaxDuration).toBe(3_600_000);
24
+ expect(node.config.reconnectionPolicy.rendezvousPollInterval).toBe(30_000);
25
+ expect(node.config.reconnectionPolicy.sessionExpiry).toBe(86_400_000);
26
+ expect(node.config.reconnectionPolicy.pairingPayloadExpiry).toBe(300_000);
27
+ expect(node.config.reconnectionPolicy.reconnectBackoff.initialDelay).toBe(1_000);
28
+ expect(node.config.reconnectionPolicy.reconnectBackoff.maxDelay).toBe(60_000);
29
+ expect(node.config.reconnectionPolicy.reconnectBackoff.factor).toBe(2.0);
30
+ expect(node.config.meshSettings.meshEnabled).toBe(false);
31
+ expect(node.config.meshSettings.maxHops).toBe(3);
32
+ expect(node.config.meshSettings.relayWilling).toBe(false);
33
+ expect(node.config.meshSettings.relayCapacity).toBe(10);
34
+ expect(node.config.storageBackend).toBe('memory');
35
+ });
36
+
37
+ it('overrides specific fields', async () => {
38
+ const node = await Node.create({
39
+ stunServers: ['stun:custom.example.com:3478'],
40
+ reconnectionPolicy: { connectTimeout: 60_000 },
41
+ });
42
+ expect(node.config.stunServers).toEqual(['stun:custom.example.com:3478']);
43
+ expect(node.config.reconnectionPolicy.connectTimeout).toBe(60_000);
44
+ // Other reconnection defaults preserved
45
+ expect(node.config.reconnectionPolicy.transportTimeout).toBe(10_000);
46
+ });
47
+
48
+ it('overrides mesh settings', async () => {
49
+ const node = await Node.create({ meshSettings: { meshEnabled: true, relayWilling: true } });
50
+ expect(node.config.meshSettings.meshEnabled).toBe(true);
51
+ expect(node.config.meshSettings.relayWilling).toBe(true);
52
+ expect(node.config.meshSettings.maxHops).toBe(3); // default
53
+ });
54
+ });
55
+
56
+ describe('Node.createServer', () => {
57
+ it('creates server with server-mode defaults', async () => {
58
+ const node = await Node.createServer();
59
+ expect(node.config.meshSettings.meshEnabled).toBe(true);
60
+ expect(node.config.meshSettings.relayWilling).toBe(true);
61
+ expect(node.config.meshSettings.relayCapacity).toBe(100);
62
+ expect(node.config.reconnectionPolicy.sessionExpiry).toBe(7 * 24 * 60 * 60 * 1000);
63
+ expect(node.config.reconnectionPolicy.reconnectMaxDuration).toBe(Infinity);
64
+ });
65
+
66
+ it('server config can be overridden', async () => {
67
+ const node = await Node.createServer({
68
+ meshSettings: { relayCapacity: 500 },
69
+ });
70
+ expect(node.config.meshSettings.relayCapacity).toBe(500);
71
+ expect(node.config.meshSettings.meshEnabled).toBe(true); // server default preserved
72
+ });
73
+ });
74
+
75
+ // --- Node pairing methods ---
76
+
77
+ describe('Node pairing', () => {
78
+ it('pairGenerateQr returns payload', async () => {
79
+ const node = await Node.create();
80
+ const data = await node.pairGenerateQr();
81
+ expect(data.expiresIn).toBe(node.config.reconnectionPolicy.pairingPayloadExpiry);
82
+ });
83
+
84
+ it('pairScanQr rejects invalid CBOR', async () => {
85
+ const node = await Node.create();
86
+ await expect(node.pairScanQr(new Uint8Array([1, 2]))).rejects.toThrow();
87
+ });
88
+
89
+ it('pairScanQr roundtrip', async () => {
90
+ const node = await Node.create();
91
+ const qr = await node.pairGenerateQr();
92
+ const peerId = await node.pairScanQr(qr.payload);
93
+ expect(peerId).toBeTruthy();
94
+ });
95
+
96
+ it('pairGeneratePin returns pin', async () => {
97
+ const node = await Node.create();
98
+ const data = await node.pairGeneratePin();
99
+ expect(data.pin).toBeTruthy();
100
+ expect(data.pin.length).toBe(9); // XXXX-XXXX
101
+ expect(data.pin[4]).toBe('-');
102
+ expect(data.expiresIn).toBe(node.config.reconnectionPolicy.pairingPayloadExpiry);
103
+ });
104
+
105
+ it('pairEnterPin succeeds with valid pin', async () => {
106
+ const node = await Node.create();
107
+ const peerId = await node.pairEnterPin('ABCD-EFGH');
108
+ expect(peerId).toBeTruthy();
109
+ });
110
+
111
+ it('pairEnterPin rejects invalid characters', async () => {
112
+ const node = await Node.create();
113
+ await expect(node.pairEnterPin('!!!')).rejects.toThrow();
114
+ });
115
+
116
+ it('pairGenerateLink returns real URI', async () => {
117
+ const node = await Node.create();
118
+ const data = await node.pairGenerateLink();
119
+ expect(data.uri).toContain('cairn://pair?');
120
+ expect(data.uri).toContain('pid=');
121
+ expect(data.expiresIn).toBe(node.config.reconnectionPolicy.pairingPayloadExpiry);
122
+ });
123
+
124
+ it('pairFromLink roundtrip', async () => {
125
+ const node = await Node.create();
126
+ const link = await node.pairGenerateLink();
127
+ const peerId = await node.pairFromLink(link.uri);
128
+ expect(peerId).toBeTruthy();
129
+ });
130
+
131
+ it('pairFromLink rejects invalid URI', async () => {
132
+ const node = await Node.create();
133
+ await expect(node.pairFromLink('https://example.com')).rejects.toThrow();
134
+ });
135
+ });
136
+
137
+ // --- Node connection ---
138
+
139
+ describe('Node connection', () => {
140
+ it('connect creates session', async () => {
141
+ const node = await Node.create();
142
+ const session = await node.connect('peer-abc');
143
+ expect(session.peerId).toBe('peer-abc');
144
+ expect(session.state).toBe('connected');
145
+ });
146
+
147
+ it('unpair removes session and emits event', async () => {
148
+ const node = await Node.create();
149
+ const unpaired: string[] = [];
150
+ node.onPeerUnpaired((id) => unpaired.push(id));
151
+ await node.connect('peer-1');
152
+ await node.unpair('peer-1');
153
+ expect(unpaired).toEqual(['peer-1']);
154
+ });
155
+
156
+ it('networkInfo returns NAT type', async () => {
157
+ const node = await Node.create();
158
+ const info = await node.networkInfo();
159
+ expect(info.natType).toBe('unknown');
160
+ });
161
+
162
+ it('setNatType updates NAT type', async () => {
163
+ const node = await Node.create();
164
+ node.setNatType('full_cone');
165
+ const info = await node.networkInfo();
166
+ expect(info.natType).toBe('full_cone');
167
+ });
168
+
169
+ it('close stops node', async () => {
170
+ const node = await Node.create();
171
+ await node.connect('peer-1');
172
+ await node.close();
173
+ expect(node.isClosed).toBe(true);
174
+ });
175
+ });
176
+
177
+ // --- NodeSession ---
178
+
179
+ describe('NodeSession', () => {
180
+ it('open channel', () => {
181
+ const session = new NodeSession('peer-1');
182
+ const ch = session.openChannel('data');
183
+ expect(ch.name).toBe('data');
184
+ expect(ch.isOpen).toBe(true);
185
+ });
186
+
187
+ it('open channel rejects empty name', () => {
188
+ const session = new NodeSession('peer-1');
189
+ expect(() => session.openChannel('')).toThrow('cannot be empty');
190
+ });
191
+
192
+ it('open channel rejects reserved prefix', () => {
193
+ const session = new NodeSession('peer-1');
194
+ expect(() => session.openChannel('__cairn_internal')).toThrow('reserved');
195
+ });
196
+
197
+ it('send on open channel', () => {
198
+ const session = new NodeSession('peer-1');
199
+ const ch = session.openChannel('data');
200
+ expect(() => session.send(ch, new Uint8Array([1, 2, 3]))).not.toThrow();
201
+ });
202
+
203
+ it('send on closed channel throws', () => {
204
+ const session = new NodeSession('peer-1');
205
+ const ch = session.openChannel('data');
206
+ ch.close();
207
+ expect(() => session.send(ch, new Uint8Array([1]))).toThrow('not open');
208
+ });
209
+
210
+ it('close transitions to disconnected', () => {
211
+ const session = new NodeSession('peer-1');
212
+ session.close();
213
+ expect(session.state).toBe('disconnected');
214
+ });
215
+
216
+ it('state change listener', () => {
217
+ const session = new NodeSession('peer-1');
218
+ const changes: Array<{ prev: ConnectionState; current: ConnectionState }> = [];
219
+ session.onStateChange((prev, current) => changes.push({ prev, current }));
220
+ session.close();
221
+ expect(changes.length).toBe(1);
222
+ expect(changes[0].prev).toBe('connected');
223
+ expect(changes[0].current).toBe('disconnected');
224
+ });
225
+
226
+ it('channel opened listener', () => {
227
+ const session = new NodeSession('peer-1');
228
+ const opened: string[] = [];
229
+ session.onChannelOpened((ch) => opened.push(ch.name));
230
+ session.openChannel('chat');
231
+ session.openChannel('video');
232
+ expect(opened).toEqual(['chat', 'video']);
233
+ });
234
+
235
+ it('on message handler', () => {
236
+ const session = new NodeSession('peer-1');
237
+ const ch = session.openChannel('data');
238
+ let received = false;
239
+ session.onMessage(ch, () => { received = true; });
240
+ // Handler registered but not invoked here (no real transport)
241
+ expect(received).toBe(false);
242
+ });
243
+
244
+ it('custom message handler valid range', () => {
245
+ const session = new NodeSession('peer-1');
246
+ expect(() => session.onCustomMessage(0xf000, () => {})).not.toThrow();
247
+ expect(() => session.onCustomMessage(0xffff, () => {})).not.toThrow();
248
+ });
249
+
250
+ it('custom message handler invalid range', () => {
251
+ const session = new NodeSession('peer-1');
252
+ expect(() => session.onCustomMessage(0x0100, () => {})).toThrow('outside application range');
253
+ expect(() => session.onCustomMessage(0xefff, () => {})).toThrow('outside application range');
254
+ });
255
+
256
+ it('multiple channels', () => {
257
+ const session = new NodeSession('peer-1');
258
+ const ch1 = session.openChannel('chat');
259
+ const ch2 = session.openChannel('video');
260
+ expect(ch1.name).toBe('chat');
261
+ expect(ch2.name).toBe('video');
262
+ expect(ch1.isOpen).toBe(true);
263
+ expect(ch2.isOpen).toBe(true);
264
+ });
265
+ });
266
+
267
+ // --- NodeChannel ---
268
+
269
+ describe('NodeChannel', () => {
270
+ it('lifecycle', () => {
271
+ const ch = new NodeChannel('test');
272
+ expect(ch.isOpen).toBe(true);
273
+ expect(ch.name).toBe('test');
274
+ ch.close();
275
+ expect(ch.isOpen).toBe(false);
276
+ });
277
+ });
278
+
279
+ // --- Default constants ---
280
+
281
+ describe('default constants', () => {
282
+ it('DEFAULT_RECONNECTION_POLICY', () => {
283
+ expect(DEFAULT_RECONNECTION_POLICY.connectTimeout).toBe(30_000);
284
+ expect(DEFAULT_RECONNECTION_POLICY.transportTimeout).toBe(10_000);
285
+ expect(DEFAULT_RECONNECTION_POLICY.reconnectMaxDuration).toBe(3_600_000);
286
+ expect(DEFAULT_RECONNECTION_POLICY.reconnectBackoff.initialDelay).toBe(1_000);
287
+ expect(DEFAULT_RECONNECTION_POLICY.reconnectBackoff.maxDelay).toBe(60_000);
288
+ expect(DEFAULT_RECONNECTION_POLICY.reconnectBackoff.factor).toBe(2.0);
289
+ expect(DEFAULT_RECONNECTION_POLICY.rendezvousPollInterval).toBe(30_000);
290
+ expect(DEFAULT_RECONNECTION_POLICY.sessionExpiry).toBe(86_400_000);
291
+ expect(DEFAULT_RECONNECTION_POLICY.pairingPayloadExpiry).toBe(300_000);
292
+ });
293
+
294
+ it('DEFAULT_MESH_SETTINGS', () => {
295
+ expect(DEFAULT_MESH_SETTINGS.meshEnabled).toBe(false);
296
+ expect(DEFAULT_MESH_SETTINGS.maxHops).toBe(3);
297
+ expect(DEFAULT_MESH_SETTINGS.relayWilling).toBe(false);
298
+ expect(DEFAULT_MESH_SETTINGS.relayCapacity).toBe(10);
299
+ });
300
+
301
+ it('DEFAULT_STUN_SERVERS', () => {
302
+ expect(DEFAULT_STUN_SERVERS.length).toBe(3);
303
+ expect(DEFAULT_STUN_SERVERS[0]).toContain('google.com');
304
+ });
305
+
306
+ it('DEFAULT_TRANSPORT_PREFERENCES', () => {
307
+ expect(DEFAULT_TRANSPORT_PREFERENCES.length).toBe(5);
308
+ expect(DEFAULT_TRANSPORT_PREFERENCES[0]).toBe('quic');
309
+ });
310
+ });