@auxiora/bridge 1.0.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.
@@ -0,0 +1,82 @@
1
+ /** Capabilities a device node can provide. */
2
+ export type DeviceCapability = 'camera' | 'screen' | 'microphone' | 'location' | 'notifications' | 'clipboard' | 'sensors';
3
+ /** Platform of the device. */
4
+ export type DevicePlatform = 'ios' | 'android' | 'macos' | 'windows' | 'linux' | 'web';
5
+ /** Connection state for a device. */
6
+ export type DeviceConnectionState = 'connecting' | 'paired' | 'online' | 'offline';
7
+ /** Information about a paired device. */
8
+ export interface DeviceInfo {
9
+ /** Unique device identifier. */
10
+ id: string;
11
+ /** User-facing device name. */
12
+ name: string;
13
+ /** Device platform. */
14
+ platform: DevicePlatform;
15
+ /** Capabilities this device supports. */
16
+ capabilities: DeviceCapability[];
17
+ /** Current connection state. */
18
+ state: DeviceConnectionState;
19
+ /** When the device was first paired. */
20
+ pairedAt: number;
21
+ /** When the device was last seen online. */
22
+ lastSeen: number;
23
+ }
24
+ /** Pairing code issued by the server. */
25
+ export interface PairingCode {
26
+ /** The short code the user enters on the device. */
27
+ code: string;
28
+ /** When the code expires (unix ms). */
29
+ expiresAt: number;
30
+ /** Whether the code has been used. */
31
+ used: boolean;
32
+ }
33
+ /** Configuration for the Bridge server. */
34
+ export interface BridgeConfig {
35
+ /** Maximum number of paired devices. */
36
+ maxDevices: number;
37
+ /** Pairing code length (digits). */
38
+ codeLength: number;
39
+ /** Pairing code expiry in seconds. */
40
+ codeExpirySeconds: number;
41
+ /** Heartbeat interval in milliseconds. */
42
+ heartbeatIntervalMs: number;
43
+ /** Consider device offline after this many missed heartbeats. */
44
+ offlineAfterMissedHeartbeats: number;
45
+ }
46
+ export declare const DEFAULT_BRIDGE_CONFIG: BridgeConfig;
47
+ /** Messages sent between Bridge server and device nodes. */
48
+ export type BridgeMessageType = 'pair_request' | 'pair_accepted' | 'pair_rejected' | 'heartbeat' | 'heartbeat_ack' | 'capability_request' | 'capability_response' | 'device_info' | 'disconnect' | 'error';
49
+ /** A message in the Bridge protocol. */
50
+ export interface BridgeMessage {
51
+ type: BridgeMessageType;
52
+ /** Correlation ID for request/response matching. */
53
+ id?: string;
54
+ /** The sending device ID (set by server for forwarded messages). */
55
+ deviceId?: string;
56
+ /** Message-specific payload. */
57
+ payload?: unknown;
58
+ /** Timestamp (unix ms). */
59
+ timestamp: number;
60
+ }
61
+ /** Payload for pair_request. */
62
+ export interface PairRequestPayload {
63
+ code: string;
64
+ deviceName: string;
65
+ platform: DevicePlatform;
66
+ capabilities: DeviceCapability[];
67
+ }
68
+ /** Payload for capability_request. */
69
+ export interface CapabilityRequestPayload {
70
+ capability: DeviceCapability;
71
+ action: string;
72
+ params?: Record<string, unknown>;
73
+ }
74
+ /** Payload for capability_response. */
75
+ export interface CapabilityResponsePayload {
76
+ capability: DeviceCapability;
77
+ action: string;
78
+ success: boolean;
79
+ data?: unknown;
80
+ error?: string;
81
+ }
82
+ //# sourceMappingURL=types.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"AAAA,8CAA8C;AAC9C,MAAM,MAAM,gBAAgB,GACxB,QAAQ,GACR,QAAQ,GACR,YAAY,GACZ,UAAU,GACV,eAAe,GACf,WAAW,GACX,SAAS,CAAC;AAEd,8BAA8B;AAC9B,MAAM,MAAM,cAAc,GAAG,KAAK,GAAG,SAAS,GAAG,OAAO,GAAG,SAAS,GAAG,OAAO,GAAG,KAAK,CAAC;AAEvF,qCAAqC;AACrC,MAAM,MAAM,qBAAqB,GAAG,YAAY,GAAG,QAAQ,GAAG,QAAQ,GAAG,SAAS,CAAC;AAEnF,yCAAyC;AACzC,MAAM,WAAW,UAAU;IACzB,gCAAgC;IAChC,EAAE,EAAE,MAAM,CAAC;IACX,+BAA+B;IAC/B,IAAI,EAAE,MAAM,CAAC;IACb,uBAAuB;IACvB,QAAQ,EAAE,cAAc,CAAC;IACzB,yCAAyC;IACzC,YAAY,EAAE,gBAAgB,EAAE,CAAC;IACjC,gCAAgC;IAChC,KAAK,EAAE,qBAAqB,CAAC;IAC7B,wCAAwC;IACxC,QAAQ,EAAE,MAAM,CAAC;IACjB,4CAA4C;IAC5C,QAAQ,EAAE,MAAM,CAAC;CAClB;AAED,yCAAyC;AACzC,MAAM,WAAW,WAAW;IAC1B,oDAAoD;IACpD,IAAI,EAAE,MAAM,CAAC;IACb,uCAAuC;IACvC,SAAS,EAAE,MAAM,CAAC;IAClB,sCAAsC;IACtC,IAAI,EAAE,OAAO,CAAC;CACf;AAED,2CAA2C;AAC3C,MAAM,WAAW,YAAY;IAC3B,wCAAwC;IACxC,UAAU,EAAE,MAAM,CAAC;IACnB,oCAAoC;IACpC,UAAU,EAAE,MAAM,CAAC;IACnB,sCAAsC;IACtC,iBAAiB,EAAE,MAAM,CAAC;IAC1B,0CAA0C;IAC1C,mBAAmB,EAAE,MAAM,CAAC;IAC5B,iEAAiE;IACjE,4BAA4B,EAAE,MAAM,CAAC;CACtC;AAED,eAAO,MAAM,qBAAqB,EAAE,YAMnC,CAAC;AAEF,4DAA4D;AAC5D,MAAM,MAAM,iBAAiB,GACzB,cAAc,GACd,eAAe,GACf,eAAe,GACf,WAAW,GACX,eAAe,GACf,oBAAoB,GACpB,qBAAqB,GACrB,aAAa,GACb,YAAY,GACZ,OAAO,CAAC;AAEZ,wCAAwC;AACxC,MAAM,WAAW,aAAa;IAC5B,IAAI,EAAE,iBAAiB,CAAC;IACxB,oDAAoD;IACpD,EAAE,CAAC,EAAE,MAAM,CAAC;IACZ,oEAAoE;IACpE,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,gCAAgC;IAChC,OAAO,CAAC,EAAE,OAAO,CAAC;IAClB,2BAA2B;IAC3B,SAAS,EAAE,MAAM,CAAC;CACnB;AAED,gCAAgC;AAChC,MAAM,WAAW,kBAAkB;IACjC,IAAI,EAAE,MAAM,CAAC;IACb,UAAU,EAAE,MAAM,CAAC;IACnB,QAAQ,EAAE,cAAc,CAAC;IACzB,YAAY,EAAE,gBAAgB,EAAE,CAAC;CAClC;AAED,sCAAsC;AACtC,MAAM,WAAW,wBAAwB;IACvC,UAAU,EAAE,gBAAgB,CAAC;IAC7B,MAAM,EAAE,MAAM,CAAC;IACf,MAAM,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;CAClC;AAED,uCAAuC;AACvC,MAAM,WAAW,yBAAyB;IACxC,UAAU,EAAE,gBAAgB,CAAC;IAC7B,MAAM,EAAE,MAAM,CAAC;IACf,OAAO,EAAE,OAAO,CAAC;IACjB,IAAI,CAAC,EAAE,OAAO,CAAC;IACf,KAAK,CAAC,EAAE,MAAM,CAAC;CAChB"}
package/dist/types.js ADDED
@@ -0,0 +1,8 @@
1
+ export const DEFAULT_BRIDGE_CONFIG = {
2
+ maxDevices: 10,
3
+ codeLength: 6,
4
+ codeExpirySeconds: 300,
5
+ heartbeatIntervalMs: 30_000,
6
+ offlineAfterMissedHeartbeats: 3,
7
+ };
8
+ //# sourceMappingURL=types.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"types.js","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"AA0DA,MAAM,CAAC,MAAM,qBAAqB,GAAiB;IACjD,UAAU,EAAE,EAAE;IACd,UAAU,EAAE,CAAC;IACb,iBAAiB,EAAE,GAAG;IACtB,mBAAmB,EAAE,MAAM;IAC3B,4BAA4B,EAAE,CAAC;CAChC,CAAC"}
package/package.json ADDED
@@ -0,0 +1,25 @@
1
+ {
2
+ "name": "@auxiora/bridge",
3
+ "version": "1.0.0",
4
+ "description": "Bridge protocol for device pairing — connects mobile and desktop nodes to the server",
5
+ "type": "module",
6
+ "main": "dist/index.js",
7
+ "types": "dist/index.d.ts",
8
+ "exports": {
9
+ ".": {
10
+ "types": "./dist/index.d.ts",
11
+ "import": "./dist/index.js"
12
+ }
13
+ },
14
+ "dependencies": {
15
+ "@auxiora/logger": "1.0.0"
16
+ },
17
+ "engines": {
18
+ "node": ">=22.0.0"
19
+ },
20
+ "scripts": {
21
+ "build": "tsc",
22
+ "clean": "rm -rf dist",
23
+ "typecheck": "tsc --noEmit"
24
+ }
25
+ }
@@ -0,0 +1,411 @@
1
+ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
2
+ import { DeviceRegistry } from '../registry.js';
3
+ import { PairingFlow } from '../pairing.js';
4
+ import { BridgeServer, WS_OPEN, type BridgeSocket } from '../server.js';
5
+ import type { DeviceInfo, BridgeMessage } from '../types.js';
6
+
7
+ function makeMockSocket(): BridgeSocket & { sent: string[]; closed: boolean } {
8
+ return {
9
+ sent: [],
10
+ closed: false,
11
+ readyState: WS_OPEN,
12
+ send(data: string) { this.sent.push(data); },
13
+ close() { this.closed = true; this.readyState = 3; },
14
+ };
15
+ }
16
+
17
+ function parseMessage(raw: string): BridgeMessage {
18
+ return JSON.parse(raw) as BridgeMessage;
19
+ }
20
+
21
+ describe('DeviceRegistry', () => {
22
+ let registry: DeviceRegistry;
23
+
24
+ const device1: DeviceInfo = {
25
+ id: 'dev-1',
26
+ name: 'iPhone',
27
+ platform: 'ios',
28
+ capabilities: ['camera', 'location', 'notifications'],
29
+ state: 'online',
30
+ pairedAt: Date.now(),
31
+ lastSeen: Date.now(),
32
+ };
33
+
34
+ const device2: DeviceInfo = {
35
+ id: 'dev-2',
36
+ name: 'MacBook',
37
+ platform: 'macos',
38
+ capabilities: ['screen', 'clipboard', 'notifications'],
39
+ state: 'online',
40
+ pairedAt: Date.now(),
41
+ lastSeen: Date.now(),
42
+ };
43
+
44
+ beforeEach(() => {
45
+ registry = new DeviceRegistry(5);
46
+ });
47
+
48
+ it('registers and retrieves a device', () => {
49
+ registry.register(device1);
50
+ const found = registry.get('dev-1');
51
+ expect(found).toBeDefined();
52
+ expect(found!.name).toBe('iPhone');
53
+ expect(found!.platform).toBe('ios');
54
+ });
55
+
56
+ it('returns a copy of device info', () => {
57
+ registry.register(device1);
58
+ const found = registry.get('dev-1')!;
59
+ found.name = 'Modified';
60
+ expect(registry.get('dev-1')!.name).toBe('iPhone');
61
+ });
62
+
63
+ it('returns undefined for unknown device', () => {
64
+ expect(registry.get('nonexistent')).toBeUndefined();
65
+ });
66
+
67
+ it('lists all devices', () => {
68
+ registry.register(device1);
69
+ registry.register(device2);
70
+ expect(registry.getAll()).toHaveLength(2);
71
+ });
72
+
73
+ it('filters by capability', () => {
74
+ registry.register(device1);
75
+ registry.register(device2);
76
+ const withCamera = registry.getByCapability('camera');
77
+ expect(withCamera).toHaveLength(1);
78
+ expect(withCamera[0].id).toBe('dev-1');
79
+ });
80
+
81
+ it('filters online devices', () => {
82
+ registry.register(device1);
83
+ registry.register({ ...device2, state: 'offline' });
84
+ const online = registry.getOnline();
85
+ expect(online).toHaveLength(1);
86
+ });
87
+
88
+ it('unregisters a device', () => {
89
+ registry.register(device1);
90
+ expect(registry.unregister('dev-1')).toBe(true);
91
+ expect(registry.get('dev-1')).toBeUndefined();
92
+ expect(registry.size).toBe(0);
93
+ });
94
+
95
+ it('enforces max device limit', () => {
96
+ const small = new DeviceRegistry(1);
97
+ small.register(device1);
98
+ expect(() => small.register(device2)).toThrow('Maximum device limit (1) reached');
99
+ });
100
+
101
+ it('allows re-registering existing device', () => {
102
+ const small = new DeviceRegistry(1);
103
+ small.register(device1);
104
+ small.register({ ...device1, name: 'Updated iPhone' });
105
+ expect(small.get('dev-1')!.name).toBe('Updated iPhone');
106
+ });
107
+
108
+ it('updates connection state', () => {
109
+ registry.register(device1);
110
+ registry.setState('dev-1', 'offline');
111
+ expect(registry.get('dev-1')!.state).toBe('offline');
112
+ });
113
+
114
+ it('updates lastSeen on heartbeat', () => {
115
+ registry.register({ ...device1, lastSeen: 1000 });
116
+ registry.heartbeat('dev-1');
117
+ expect(registry.get('dev-1')!.lastSeen).toBeGreaterThan(1000);
118
+ });
119
+
120
+ it('detects timed-out devices', () => {
121
+ registry.register({ ...device1, lastSeen: Date.now() - 100_000 });
122
+ const timedOut = registry.checkTimeouts(60_000);
123
+ expect(timedOut).toEqual(['dev-1']);
124
+ expect(registry.get('dev-1')!.state).toBe('offline');
125
+ });
126
+
127
+ it('reports capacity', () => {
128
+ const small = new DeviceRegistry(1);
129
+ expect(small.hasCapacity()).toBe(true);
130
+ small.register(device1);
131
+ expect(small.hasCapacity()).toBe(false);
132
+ });
133
+ });
134
+
135
+ describe('PairingFlow', () => {
136
+ let pairing: PairingFlow;
137
+
138
+ beforeEach(() => {
139
+ pairing = new PairingFlow({ codeLength: 6, codeExpirySeconds: 300 });
140
+ });
141
+
142
+ afterEach(() => {
143
+ pairing.destroy();
144
+ });
145
+
146
+ it('generates a code of correct length', () => {
147
+ const code = pairing.generateCode();
148
+ expect(code.code).toHaveLength(6);
149
+ expect(code.used).toBe(false);
150
+ expect(code.expiresAt).toBeGreaterThan(Date.now());
151
+ });
152
+
153
+ it('validates a fresh code', () => {
154
+ const code = pairing.generateCode();
155
+ expect(pairing.validate(code.code)).toBe(true);
156
+ });
157
+
158
+ it('rejects unknown code', () => {
159
+ expect(pairing.validate('000000')).toBe(false);
160
+ });
161
+
162
+ it('consumes a code', () => {
163
+ const code = pairing.generateCode();
164
+ expect(pairing.consume(code.code)).toBe(true);
165
+ // Cannot consume again
166
+ expect(pairing.consume(code.code)).toBe(false);
167
+ // Cannot validate consumed code
168
+ expect(pairing.validate(code.code)).toBe(false);
169
+ });
170
+
171
+ it('revokes a code', () => {
172
+ const code = pairing.generateCode();
173
+ expect(pairing.revoke(code.code)).toBe(true);
174
+ expect(pairing.validate(code.code)).toBe(false);
175
+ });
176
+
177
+ it('rejects expired codes', () => {
178
+ const code = pairing.generateCode();
179
+ // Manually expire
180
+ vi.spyOn(Date, 'now').mockReturnValue(Date.now() + 400_000);
181
+ expect(pairing.validate(code.code)).toBe(false);
182
+ vi.restoreAllMocks();
183
+ });
184
+
185
+ it('lists active codes', () => {
186
+ pairing.generateCode();
187
+ pairing.generateCode();
188
+ const consumed = pairing.generateCode();
189
+ pairing.consume(consumed.code);
190
+
191
+ const active = pairing.getActiveCodes();
192
+ expect(active).toHaveLength(2);
193
+ });
194
+
195
+ it('cleans up expired and consumed codes', () => {
196
+ const c1 = pairing.generateCode();
197
+ pairing.consume(c1.code);
198
+ pairing.generateCode(); // still active
199
+
200
+ const removed = pairing.cleanup();
201
+ expect(removed).toBe(1);
202
+ });
203
+ });
204
+
205
+ describe('BridgeServer', () => {
206
+ let server: BridgeServer;
207
+
208
+ beforeEach(() => {
209
+ server = new BridgeServer({ maxDevices: 5, codeLength: 6, codeExpirySeconds: 300 });
210
+ });
211
+
212
+ afterEach(() => {
213
+ server.stop();
214
+ });
215
+
216
+ it('generates pairing codes', () => {
217
+ const code = server.generatePairingCode();
218
+ expect(code).toHaveLength(6);
219
+ });
220
+
221
+ it('handles device connection', () => {
222
+ const socket = makeMockSocket();
223
+ server.handleConnection(socket, 'conn-1');
224
+ expect(server.getConnectionCount()).toBe(1);
225
+ });
226
+
227
+ it('handles full pairing flow', async () => {
228
+ const socket = makeMockSocket();
229
+ server.handleConnection(socket, 'conn-1');
230
+
231
+ const code = server.generatePairingCode();
232
+
233
+ await server.handleMessage('conn-1', JSON.stringify({
234
+ type: 'pair_request',
235
+ id: 'req-1',
236
+ payload: {
237
+ code,
238
+ deviceName: 'Test Phone',
239
+ platform: 'android',
240
+ capabilities: ['camera', 'location'],
241
+ },
242
+ timestamp: Date.now(),
243
+ }));
244
+
245
+ expect(socket.sent).toHaveLength(1);
246
+ const response = parseMessage(socket.sent[0]);
247
+ expect(response.type).toBe('pair_accepted');
248
+ expect(response.deviceId).toBeDefined();
249
+
250
+ // Device should be in registry
251
+ const devices = server.registry.getAll();
252
+ expect(devices).toHaveLength(1);
253
+ expect(devices[0].name).toBe('Test Phone');
254
+ expect(devices[0].platform).toBe('android');
255
+ expect(devices[0].capabilities).toEqual(['camera', 'location']);
256
+ });
257
+
258
+ it('rejects invalid pairing code', async () => {
259
+ const socket = makeMockSocket();
260
+ server.handleConnection(socket, 'conn-1');
261
+
262
+ await server.handleMessage('conn-1', JSON.stringify({
263
+ type: 'pair_request',
264
+ id: 'req-1',
265
+ payload: {
266
+ code: '999999',
267
+ deviceName: 'Test Phone',
268
+ platform: 'android',
269
+ capabilities: [],
270
+ },
271
+ timestamp: Date.now(),
272
+ }));
273
+
274
+ const response = parseMessage(socket.sent[0]);
275
+ expect(response.type).toBe('pair_rejected');
276
+ });
277
+
278
+ it('rejects incomplete pair request', async () => {
279
+ const socket = makeMockSocket();
280
+ server.handleConnection(socket, 'conn-1');
281
+
282
+ await server.handleMessage('conn-1', JSON.stringify({
283
+ type: 'pair_request',
284
+ id: 'req-1',
285
+ payload: { code: '123456' },
286
+ timestamp: Date.now(),
287
+ }));
288
+
289
+ const response = parseMessage(socket.sent[0]);
290
+ expect(response.type).toBe('error');
291
+ });
292
+
293
+ it('handles heartbeat from paired device', async () => {
294
+ const socket = makeMockSocket();
295
+ server.handleConnection(socket, 'conn-1');
296
+
297
+ // Pair the device first
298
+ const code = server.generatePairingCode();
299
+ await server.handleMessage('conn-1', JSON.stringify({
300
+ type: 'pair_request',
301
+ id: 'req-1',
302
+ payload: {
303
+ code,
304
+ deviceName: 'Phone',
305
+ platform: 'ios',
306
+ capabilities: [],
307
+ },
308
+ timestamp: Date.now(),
309
+ }));
310
+
311
+ socket.sent.length = 0;
312
+
313
+ await server.handleMessage('conn-1', JSON.stringify({
314
+ type: 'heartbeat',
315
+ id: 'hb-1',
316
+ timestamp: Date.now(),
317
+ }));
318
+
319
+ expect(socket.sent).toHaveLength(1);
320
+ const response = parseMessage(socket.sent[0]);
321
+ expect(response.type).toBe('heartbeat_ack');
322
+ });
323
+
324
+ it('handles device disconnection', async () => {
325
+ const socket = makeMockSocket();
326
+ server.handleConnection(socket, 'conn-1');
327
+
328
+ // Pair device
329
+ const code = server.generatePairingCode();
330
+ await server.handleMessage('conn-1', JSON.stringify({
331
+ type: 'pair_request',
332
+ id: 'req-1',
333
+ payload: {
334
+ code,
335
+ deviceName: 'Phone',
336
+ platform: 'ios',
337
+ capabilities: ['camera'],
338
+ },
339
+ timestamp: Date.now(),
340
+ }));
341
+
342
+ const deviceId = server.registry.getAll()[0].id;
343
+ server.handleDisconnection('conn-1');
344
+
345
+ expect(server.registry.get(deviceId)!.state).toBe('offline');
346
+ expect(server.getConnectionCount()).toBe(0);
347
+ });
348
+
349
+ it('handles invalid JSON gracefully', async () => {
350
+ const socket = makeMockSocket();
351
+ server.handleConnection(socket, 'conn-1');
352
+
353
+ await server.handleMessage('conn-1', 'not-json{');
354
+ const response = parseMessage(socket.sent[0]);
355
+ expect(response.type).toBe('error');
356
+ });
357
+
358
+ it('handles unknown message type', async () => {
359
+ const socket = makeMockSocket();
360
+ server.handleConnection(socket, 'conn-1');
361
+
362
+ await server.handleMessage('conn-1', JSON.stringify({
363
+ type: 'unknown_type',
364
+ timestamp: Date.now(),
365
+ }));
366
+
367
+ const response = parseMessage(socket.sent[0]);
368
+ expect(response.type).toBe('error');
369
+ expect((response.payload as { message: string }).message).toContain('Unknown message type');
370
+ });
371
+
372
+ it('fires event on device paired', async () => {
373
+ const paired: DeviceInfo[] = [];
374
+ server.onEvent({ onDevicePaired: (d) => paired.push(d) });
375
+
376
+ const socket = makeMockSocket();
377
+ server.handleConnection(socket, 'conn-1');
378
+ const code = server.generatePairingCode();
379
+
380
+ await server.handleMessage('conn-1', JSON.stringify({
381
+ type: 'pair_request',
382
+ id: 'req-1',
383
+ payload: {
384
+ code,
385
+ deviceName: 'Phone',
386
+ platform: 'android',
387
+ capabilities: [],
388
+ },
389
+ timestamp: Date.now(),
390
+ }));
391
+
392
+ expect(paired).toHaveLength(1);
393
+ expect(paired[0].name).toBe('Phone');
394
+ });
395
+
396
+ it('rejects capability request for unknown device', async () => {
397
+ await expect(
398
+ server.requestCapability('unknown', 'camera', 'capture'),
399
+ ).rejects.toThrow('Device not found');
400
+ });
401
+
402
+ it('stops cleanly', () => {
403
+ server.start();
404
+ const socket = makeMockSocket();
405
+ server.handleConnection(socket, 'conn-1');
406
+
407
+ server.stop();
408
+ expect(socket.closed).toBe(true);
409
+ expect(server.getConnectionCount()).toBe(0);
410
+ });
411
+ });
package/src/index.ts ADDED
@@ -0,0 +1,22 @@
1
+ export type {
2
+ DeviceCapability,
3
+ DevicePlatform,
4
+ DeviceConnectionState,
5
+ DeviceInfo,
6
+ PairingCode,
7
+ BridgeConfig,
8
+ BridgeMessageType,
9
+ BridgeMessage,
10
+ PairRequestPayload,
11
+ CapabilityRequestPayload,
12
+ CapabilityResponsePayload,
13
+ } from './types.js';
14
+ export { DEFAULT_BRIDGE_CONFIG } from './types.js';
15
+ export { DeviceRegistry } from './registry.js';
16
+ export { PairingFlow } from './pairing.js';
17
+ export {
18
+ BridgeServer,
19
+ WS_OPEN,
20
+ type BridgeSocket,
21
+ type BridgeEventHandler,
22
+ } from './server.js';
package/src/pairing.ts ADDED
@@ -0,0 +1,129 @@
1
+ import { getLogger } from '@auxiora/logger';
2
+ import * as crypto from 'node:crypto';
3
+ import type { PairingCode, BridgeConfig } from './types.js';
4
+ import { DEFAULT_BRIDGE_CONFIG } from './types.js';
5
+
6
+ const logger = getLogger('bridge:pairing');
7
+
8
+ /**
9
+ * Manages pairing code generation, validation, and expiry
10
+ * for the device pairing flow.
11
+ */
12
+ export class PairingFlow {
13
+ private activeCodes = new Map<string, PairingCode>();
14
+ private config: BridgeConfig;
15
+ private cleanupTimer: ReturnType<typeof setInterval> | null = null;
16
+
17
+ constructor(config?: Partial<BridgeConfig>) {
18
+ this.config = { ...DEFAULT_BRIDGE_CONFIG, ...config };
19
+ }
20
+
21
+ /** Generate a new pairing code. */
22
+ generateCode(): PairingCode {
23
+ const code = this.makeCode(this.config.codeLength);
24
+ const pairingCode: PairingCode = {
25
+ code,
26
+ expiresAt: Date.now() + this.config.codeExpirySeconds * 1000,
27
+ used: false,
28
+ };
29
+
30
+ this.activeCodes.set(code, pairingCode);
31
+ logger.info('Pairing code generated', { code });
32
+ return { ...pairingCode };
33
+ }
34
+
35
+ /** Validate a pairing code. Returns true if the code is valid and not expired. */
36
+ validate(code: string): boolean {
37
+ const pairingCode = this.activeCodes.get(code);
38
+ if (!pairingCode) {
39
+ return false;
40
+ }
41
+ if (pairingCode.used) {
42
+ return false;
43
+ }
44
+ if (Date.now() > pairingCode.expiresAt) {
45
+ this.activeCodes.delete(code);
46
+ return false;
47
+ }
48
+ return true;
49
+ }
50
+
51
+ /** Consume a pairing code, marking it as used. Returns true if successful. */
52
+ consume(code: string): boolean {
53
+ if (!this.validate(code)) {
54
+ return false;
55
+ }
56
+ const pairingCode = this.activeCodes.get(code)!;
57
+ pairingCode.used = true;
58
+ logger.info('Pairing code consumed', { code });
59
+ return true;
60
+ }
61
+
62
+ /** Revoke an active pairing code. */
63
+ revoke(code: string): boolean {
64
+ const removed = this.activeCodes.delete(code);
65
+ if (removed) {
66
+ logger.info('Pairing code revoked', { code });
67
+ }
68
+ return removed;
69
+ }
70
+
71
+ /** Get all active (non-expired, non-used) codes. */
72
+ getActiveCodes(): PairingCode[] {
73
+ const now = Date.now();
74
+ const active: PairingCode[] = [];
75
+
76
+ for (const pc of this.activeCodes.values()) {
77
+ if (!pc.used && now <= pc.expiresAt) {
78
+ active.push({ ...pc });
79
+ }
80
+ }
81
+
82
+ return active;
83
+ }
84
+
85
+ /** Remove expired codes. */
86
+ cleanup(): number {
87
+ const now = Date.now();
88
+ let removed = 0;
89
+
90
+ for (const [code, pc] of this.activeCodes) {
91
+ if (pc.used || now > pc.expiresAt) {
92
+ this.activeCodes.delete(code);
93
+ removed++;
94
+ }
95
+ }
96
+
97
+ return removed;
98
+ }
99
+
100
+ /** Start automatic cleanup timer. */
101
+ startCleanup(intervalMs = 60_000): void {
102
+ this.stopCleanup();
103
+ this.cleanupTimer = setInterval(() => this.cleanup(), intervalMs);
104
+ }
105
+
106
+ /** Stop automatic cleanup timer. */
107
+ stopCleanup(): void {
108
+ if (this.cleanupTimer) {
109
+ clearInterval(this.cleanupTimer);
110
+ this.cleanupTimer = null;
111
+ }
112
+ }
113
+
114
+ /** Destroy the pairing flow, clearing all codes and stopping timers. */
115
+ destroy(): void {
116
+ this.stopCleanup();
117
+ this.activeCodes.clear();
118
+ }
119
+
120
+ /** Generate a random numeric code of the given length. */
121
+ private makeCode(length: number): string {
122
+ const bytes = crypto.randomBytes(length);
123
+ let code = '';
124
+ for (let i = 0; i < length; i++) {
125
+ code += (bytes[i] % 10).toString();
126
+ }
127
+ return code;
128
+ }
129
+ }