@robota-sdk/agent-transport-webrtc 3.0.0-beta.81
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.
- package/LICENSE +661 -0
- package/dist/node/index.cjs +1 -0
- package/dist/node/index.d.cts +361 -0
- package/dist/node/index.d.cts.map +1 -0
- package/dist/node/index.d.ts +361 -0
- package/dist/node/index.d.ts.map +1 -0
- package/dist/node/index.js +2 -0
- package/dist/node/index.js.map +1 -0
- package/package.json +88 -0
- package/src/__tests__/admission-secure-by-default.test.ts +61 -0
- package/src/__tests__/admission-steps.test.ts +46 -0
- package/src/__tests__/cve-2024-29415-reachability.test.ts +80 -0
- package/src/__tests__/dtls-fingerprint-binding.test.ts +60 -0
- package/src/__tests__/handoff-grant-gate.test.ts +401 -0
- package/src/__tests__/local-peer-proof-gate.test.ts +213 -0
- package/src/__tests__/mesh-admission-contract.test.ts +38 -0
- package/src/__tests__/negotiated-certificate.test.ts +80 -0
- package/src/__tests__/operator-approval-gate.test.ts +306 -0
- package/src/__tests__/outbound-delivery-carrier.test.ts +152 -0
- package/src/__tests__/pairing-e2e.test.ts +238 -0
- package/src/__tests__/pairing-gate-e3.test.ts +192 -0
- package/src/__tests__/pairing-gate-e4.test.ts +108 -0
- package/src/__tests__/pairing-gate.test.ts +276 -0
- package/src/__tests__/pairing-relay-in-the-middle.test.ts +229 -0
- package/src/__tests__/session-attachment-budget.test.ts +136 -0
- package/src/__tests__/webrtc-transport.test.ts +349 -0
- package/src/__tests__/werift-loader.test.ts +18 -0
- package/src/__tests__/ws-signaling-client.test.ts +108 -0
- package/src/admission-steps.ts +55 -0
- package/src/channel-delivery.ts +44 -0
- package/src/handoff-grant-gate.ts +186 -0
- package/src/index.ts +21 -0
- package/src/local-peer-proof.ts +135 -0
- package/src/negotiated-certificate.ts +80 -0
- package/src/pairing-channel-lifecycle.ts +31 -0
- package/src/pairing-controllers.ts +79 -0
- package/src/pairing-frames.ts +34 -0
- package/src/pairing-gate-options.ts +129 -0
- package/src/pairing-gate.ts +376 -0
- package/src/session-attachment.ts +88 -0
- package/src/signaling.ts +62 -0
- package/src/transport-lifecycle-error.ts +11 -0
- package/src/webrtc-delivery-lifecycle.ts +51 -0
- package/src/webrtc-transport-options.ts +84 -0
- package/src/webrtc-transport.ts +328 -0
- package/src/werift-loader.ts +40 -0
- package/src/ws-signaling-client.ts +139 -0
|
@@ -0,0 +1,349 @@
|
|
|
1
|
+
import { runTransportLifecycleConformance } from '@robota-sdk/agent-interface-transport/testing';
|
|
2
|
+
import { createTestInteractiveSession } from '@robota-sdk/agent-interface-session/testing';
|
|
3
|
+
|
|
4
|
+
import { describe, expect, expectTypeOf, it, vi } from 'vitest';
|
|
5
|
+
import { RTCPeerConnection } from 'werift';
|
|
6
|
+
import type { IConfigurableTransport } from '@robota-sdk/agent-interface-transport';
|
|
7
|
+
import type { IInteractiveSession } from '@robota-sdk/agent-interface-session';
|
|
8
|
+
import type { IProtocolSession } from '@robota-sdk/agent-transport';
|
|
9
|
+
|
|
10
|
+
import { WebRtcTransport } from '../webrtc-transport.js';
|
|
11
|
+
import { createInMemorySignalingPair, type ISignalingClient } from '../signaling.js';
|
|
12
|
+
import { WebRtcDeliveryLifecycle } from '../webrtc-delivery-lifecycle.js';
|
|
13
|
+
|
|
14
|
+
/** Minimal stub session — only `getMessages` + no-op `on`/`off` are exercised by the get-messages round-trip. */
|
|
15
|
+
function createStubSession(): IInteractiveSession {
|
|
16
|
+
return Object.assign(createTestInteractiveSession(), {
|
|
17
|
+
getMessages: vi.fn().mockReturnValue([{ role: 'user', content: 'hi' }]),
|
|
18
|
+
// ARCH-012: required. This double feeds `subscribeSessionEvents`, which calls it on every
|
|
19
|
+
// turn-authored event — omitting it throws the moment a case emits one.
|
|
20
|
+
getActiveDriverId: () => null,
|
|
21
|
+
on: vi.fn(),
|
|
22
|
+
off: vi.fn(),
|
|
23
|
+
});
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* The remote peer (answerer): consumes the host's offer via the injected signaling, opens the data channel,
|
|
28
|
+
* and speaks the `TClientMessage`/`TServerMessage` protocol. Resolves with the first `TServerMessage` received.
|
|
29
|
+
*/
|
|
30
|
+
function connectRemote(signaling: ISignalingClient): Promise<Record<string, unknown>> {
|
|
31
|
+
return new Promise((resolve, reject) => {
|
|
32
|
+
const peer = new RTCPeerConnection();
|
|
33
|
+
peer.onIceCandidate.subscribe((c) => {
|
|
34
|
+
if (c) signaling.send({ kind: 'ice', data: c.toJSON() });
|
|
35
|
+
});
|
|
36
|
+
let chain: Promise<void> = Promise.resolve();
|
|
37
|
+
signaling.onSignal((message) => {
|
|
38
|
+
chain = chain
|
|
39
|
+
.then(async () => {
|
|
40
|
+
if (message.kind === 'offer') {
|
|
41
|
+
await peer.setRemoteDescription(
|
|
42
|
+
message.data as Parameters<typeof peer.setRemoteDescription>[0],
|
|
43
|
+
);
|
|
44
|
+
const answer = await peer.createAnswer();
|
|
45
|
+
await peer.setLocalDescription(answer);
|
|
46
|
+
signaling.send({ kind: 'answer', data: peer.localDescription });
|
|
47
|
+
} else if (message.kind === 'ice') {
|
|
48
|
+
await peer.addIceCandidate(message.data as Parameters<typeof peer.addIceCandidate>[0]);
|
|
49
|
+
}
|
|
50
|
+
})
|
|
51
|
+
.catch(reject);
|
|
52
|
+
});
|
|
53
|
+
peer.onDataChannel.subscribe((channel) => {
|
|
54
|
+
channel.stateChanged.subscribe((state) => {
|
|
55
|
+
if (state === 'open') channel.send(JSON.stringify({ type: 'get-messages' }));
|
|
56
|
+
});
|
|
57
|
+
channel.onMessage.subscribe((data) => {
|
|
58
|
+
resolve(JSON.parse(typeof data === 'string' ? data : data.toString()));
|
|
59
|
+
});
|
|
60
|
+
});
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
describe('WebRtcTransport (REMOTE-002 Stage A — loopback)', () => {
|
|
65
|
+
it('accepts only the protocol session roles required by the carrier', () => {
|
|
66
|
+
const transport = new WebRtcTransport({
|
|
67
|
+
signaling: createInMemorySignalingPair()[0],
|
|
68
|
+
open: true,
|
|
69
|
+
openReason: 'type compatibility test',
|
|
70
|
+
});
|
|
71
|
+
expectTypeOf(transport).toMatchTypeOf<IConfigurableTransport<IProtocolSession>>();
|
|
72
|
+
expectTypeOf(transport.attach).toEqualTypeOf<(session: IProtocolSession) => void>();
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it('has the collapsed webrtc metadata and is disabled by default', () => {
|
|
76
|
+
const [sig] = createInMemorySignalingPair();
|
|
77
|
+
const t = new WebRtcTransport({
|
|
78
|
+
signaling: sig,
|
|
79
|
+
open: true,
|
|
80
|
+
openReason: 'SEC-008: Stage-A loopback — this case is about signalling, not pairing',
|
|
81
|
+
});
|
|
82
|
+
expect(t.name).toBe('webrtc');
|
|
83
|
+
expect(t.defaultEnabled).toBe(false);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it('start() before attach() throws', async () => {
|
|
87
|
+
const [sig] = createInMemorySignalingPair();
|
|
88
|
+
const t = new WebRtcTransport({
|
|
89
|
+
signaling: sig,
|
|
90
|
+
open: true,
|
|
91
|
+
openReason: 'SEC-008: Stage-A loopback — this case is about signalling, not pairing',
|
|
92
|
+
});
|
|
93
|
+
await expect(t.start()).rejects.toMatchObject({
|
|
94
|
+
name: 'TransportLifecycleError',
|
|
95
|
+
code: 'not-attached',
|
|
96
|
+
});
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it('unpaired send failure closes the carrier and reports without escaping the session event', async () => {
|
|
100
|
+
const handlers = new Map<string, (value: unknown) => void>();
|
|
101
|
+
const session = createTestInteractiveSession({
|
|
102
|
+
on: ((event: string, handler: (value: unknown) => void) => {
|
|
103
|
+
handlers.set(event, handler);
|
|
104
|
+
}) as IInteractiveSession['on'],
|
|
105
|
+
off: ((event: string) => {
|
|
106
|
+
handlers.delete(event);
|
|
107
|
+
}) as IInteractiveSession['off'],
|
|
108
|
+
});
|
|
109
|
+
const closeChannel = vi.fn();
|
|
110
|
+
const fakeWerift = {
|
|
111
|
+
RTCPeerConnection: function () {
|
|
112
|
+
return {
|
|
113
|
+
onIceCandidate: { subscribe: () => {} },
|
|
114
|
+
createDataChannel: () => ({
|
|
115
|
+
onMessage: { subscribe: () => {} },
|
|
116
|
+
send: () => {
|
|
117
|
+
throw new Error('unpaired channel closed');
|
|
118
|
+
},
|
|
119
|
+
close: closeChannel,
|
|
120
|
+
}),
|
|
121
|
+
createOffer: async () => ({ type: 'offer', sdp: 'a=fingerprint:sha-256 AA' }),
|
|
122
|
+
setLocalDescription: async () => {},
|
|
123
|
+
localDescription: { type: 'offer', sdp: 'a=fingerprint:sha-256 AA' },
|
|
124
|
+
close: async () => {},
|
|
125
|
+
};
|
|
126
|
+
},
|
|
127
|
+
} as unknown as import('../werift-loader.js').IWeriftModule;
|
|
128
|
+
const onDeliveryError = vi.fn();
|
|
129
|
+
const onDropped = vi.fn();
|
|
130
|
+
const transport = new WebRtcTransport({
|
|
131
|
+
signaling: { send: () => {}, onSignal: () => () => {}, close: () => {} },
|
|
132
|
+
open: true,
|
|
133
|
+
openReason: 'delivery lifecycle regression',
|
|
134
|
+
onDeliveryError,
|
|
135
|
+
onDropped,
|
|
136
|
+
loadWerift: () => fakeWerift,
|
|
137
|
+
});
|
|
138
|
+
transport.attach(session);
|
|
139
|
+
await transport.start();
|
|
140
|
+
|
|
141
|
+
expect(() =>
|
|
142
|
+
handlers.get('branch_event')?.({
|
|
143
|
+
kind: 'checkpoint_created',
|
|
144
|
+
checkpointId: 'turn-0001',
|
|
145
|
+
branchId: 'main',
|
|
146
|
+
}),
|
|
147
|
+
).not.toThrow();
|
|
148
|
+
expect(closeChannel).toHaveBeenCalledTimes(1);
|
|
149
|
+
expect(onDeliveryError).toHaveBeenCalledWith(
|
|
150
|
+
expect.objectContaining({ message: 'unpaired channel closed' }),
|
|
151
|
+
'branch_event',
|
|
152
|
+
);
|
|
153
|
+
expect(onDropped).not.toHaveBeenCalled();
|
|
154
|
+
expect(handlers.size).toBe(0);
|
|
155
|
+
await transport.stop();
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
it('resets pairing and drop guards for every restart generation', () => {
|
|
159
|
+
const cleanup = vi.fn();
|
|
160
|
+
const onDropped = vi.fn();
|
|
161
|
+
const lifecycle = new WebRtcDeliveryLifecycle({
|
|
162
|
+
cleanup,
|
|
163
|
+
onDropped,
|
|
164
|
+
onDeliveryError: vi.fn(),
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
lifecycle.reset(1);
|
|
168
|
+
lifecycle.accept(1);
|
|
169
|
+
lifecycle.handleDrop(1);
|
|
170
|
+
lifecycle.handleDrop(1);
|
|
171
|
+
expect(cleanup).toHaveBeenCalledTimes(1);
|
|
172
|
+
expect(onDropped).toHaveBeenCalledTimes(1);
|
|
173
|
+
|
|
174
|
+
lifecycle.reset(2);
|
|
175
|
+
lifecycle.handleDrop(2);
|
|
176
|
+
lifecycle.accept(2);
|
|
177
|
+
lifecycle.handleDrop(1);
|
|
178
|
+
lifecycle.handleDrop(2);
|
|
179
|
+
expect(cleanup).toHaveBeenCalledTimes(2);
|
|
180
|
+
expect(onDropped).toHaveBeenCalledTimes(2);
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
it('ignores queued signaling, ICE, and channel callbacks from an older generation', async () => {
|
|
184
|
+
const sent: Array<{ readonly kind: string }> = [];
|
|
185
|
+
const signalHandlers: Array<(message: { kind: 'answer'; data: object }) => void> = [];
|
|
186
|
+
const iceHandlers: Array<(candidate: { toJSON(): object }) => void> = [];
|
|
187
|
+
const stateHandlers: Array<(state: string) => void> = [];
|
|
188
|
+
const setRemoteDescription = vi.fn().mockResolvedValue(undefined);
|
|
189
|
+
const onDropped = vi.fn();
|
|
190
|
+
const signaling: ISignalingClient = {
|
|
191
|
+
send: (message) => sent.push(message),
|
|
192
|
+
onSignal: (handler) => {
|
|
193
|
+
signalHandlers.push(handler as (message: { kind: 'answer'; data: object }) => void);
|
|
194
|
+
return () => {};
|
|
195
|
+
},
|
|
196
|
+
close: () => {},
|
|
197
|
+
};
|
|
198
|
+
const fakeWerift = {
|
|
199
|
+
RTCPeerConnection: function () {
|
|
200
|
+
return {
|
|
201
|
+
onIceCandidate: {
|
|
202
|
+
subscribe: (handler: (candidate: { toJSON(): object }) => void) =>
|
|
203
|
+
iceHandlers.push(handler),
|
|
204
|
+
},
|
|
205
|
+
createDataChannel: () => ({
|
|
206
|
+
onMessage: { subscribe: () => {} },
|
|
207
|
+
stateChanged: {
|
|
208
|
+
subscribe: (handler: (state: string) => void) => stateHandlers.push(handler),
|
|
209
|
+
},
|
|
210
|
+
send: () => {},
|
|
211
|
+
close: () => {},
|
|
212
|
+
}),
|
|
213
|
+
createOffer: async () => ({ type: 'offer', sdp: 'a=fingerprint:sha-256 AA' }),
|
|
214
|
+
setLocalDescription: async () => {},
|
|
215
|
+
setRemoteDescription,
|
|
216
|
+
addIceCandidate: vi.fn(),
|
|
217
|
+
localDescription: { type: 'offer', sdp: 'a=fingerprint:sha-256 AA' },
|
|
218
|
+
close: async () => {},
|
|
219
|
+
};
|
|
220
|
+
},
|
|
221
|
+
} as unknown as import('../werift-loader.js').IWeriftModule;
|
|
222
|
+
const transport = new WebRtcTransport({
|
|
223
|
+
signaling,
|
|
224
|
+
secret: 'pairing-secret',
|
|
225
|
+
onDropped,
|
|
226
|
+
loadWerift: () => fakeWerift,
|
|
227
|
+
});
|
|
228
|
+
transport.attach(createStubSession());
|
|
229
|
+
await transport.start();
|
|
230
|
+
const oldSignal = signalHandlers[0]!;
|
|
231
|
+
const oldIce = iceHandlers[0]!;
|
|
232
|
+
const oldState = stateHandlers[0]!;
|
|
233
|
+
|
|
234
|
+
oldSignal({ kind: 'answer', data: { sdp: 'a=fingerprint:sha-256 BB' } });
|
|
235
|
+
await transport.stop();
|
|
236
|
+
transport.attach(createStubSession());
|
|
237
|
+
await transport.start();
|
|
238
|
+
oldIce({ toJSON: () => ({ candidate: 'stale' }) });
|
|
239
|
+
oldState('closed');
|
|
240
|
+
await Promise.resolve();
|
|
241
|
+
|
|
242
|
+
expect(setRemoteDescription).not.toHaveBeenCalled();
|
|
243
|
+
expect(sent.filter(({ kind }) => kind === 'ice')).toEqual([]);
|
|
244
|
+
expect(onDropped).not.toHaveBeenCalled();
|
|
245
|
+
await transport.stop();
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
it('invokes the shared lifecycle conformance suite', async () => {
|
|
249
|
+
const sent: Array<{ readonly kind: string }> = [];
|
|
250
|
+
const signaling: ISignalingClient = {
|
|
251
|
+
send: (message) => sent.push(message),
|
|
252
|
+
onSignal: () => () => {},
|
|
253
|
+
close: () => {},
|
|
254
|
+
};
|
|
255
|
+
const fakeWerift = {
|
|
256
|
+
RTCPeerConnection: function () {
|
|
257
|
+
return {
|
|
258
|
+
onIceCandidate: { subscribe: () => {} },
|
|
259
|
+
createDataChannel: () => ({
|
|
260
|
+
onMessage: { subscribe: () => {} },
|
|
261
|
+
send: () => {},
|
|
262
|
+
}),
|
|
263
|
+
createOffer: async () => ({ type: 'offer', sdp: 'a=fingerprint:sha-256 AA' }),
|
|
264
|
+
setLocalDescription: async () => {},
|
|
265
|
+
localDescription: { type: 'offer', sdp: 'a=fingerprint:sha-256 AA' },
|
|
266
|
+
close: async () => {},
|
|
267
|
+
};
|
|
268
|
+
},
|
|
269
|
+
} as unknown as import('../werift-loader.js').IWeriftModule;
|
|
270
|
+
|
|
271
|
+
await runTransportLifecycleConformance({
|
|
272
|
+
subjectId: '@robota-sdk/agent-transport-webrtc#WebRtcTransport',
|
|
273
|
+
kind: 'service',
|
|
274
|
+
createAdapter: () =>
|
|
275
|
+
new WebRtcTransport({
|
|
276
|
+
signaling,
|
|
277
|
+
open: true,
|
|
278
|
+
openReason: 'ARCH-011 lifecycle conformance',
|
|
279
|
+
loadWerift: () => fakeWerift,
|
|
280
|
+
}),
|
|
281
|
+
createSession: createStubSession,
|
|
282
|
+
assertReady: () => {
|
|
283
|
+
if (!sent.some(({ kind }) => kind === 'offer')) throw new Error('offer not published');
|
|
284
|
+
},
|
|
285
|
+
assertStopped: () => {},
|
|
286
|
+
});
|
|
287
|
+
});
|
|
288
|
+
|
|
289
|
+
it('REMOTE-010: forceTurn → iceTransportPolicy:relay (NOT top-level forceTurn, which werift ignores) + turn: passes through', async () => {
|
|
290
|
+
// Inject a fake werift to capture the config the transport builds (a real relay-only peer + dead TURN would
|
|
291
|
+
// block ICE gathering). The mapping is proven against real werift empirically; this guards the regression:
|
|
292
|
+
// forceTurn MUST become iceTransportPolicy:'relay', never a top-level forceTurn werift silently drops.
|
|
293
|
+
let captured: Record<string, unknown> | undefined;
|
|
294
|
+
const fakeWerift = {
|
|
295
|
+
RTCPeerConnection: function (config?: Record<string, unknown>) {
|
|
296
|
+
captured = config;
|
|
297
|
+
return {
|
|
298
|
+
onIceCandidate: { subscribe: () => {} },
|
|
299
|
+
createDataChannel: () => ({ onMessage: { subscribe: () => {} }, send: () => {} }),
|
|
300
|
+
createOffer: async () => ({ type: 'offer', sdp: 'a=fingerprint:sha-256 AA' }),
|
|
301
|
+
setLocalDescription: async () => {},
|
|
302
|
+
localDescription: { sdp: 'a=fingerprint:sha-256 AA' },
|
|
303
|
+
close: async () => {},
|
|
304
|
+
};
|
|
305
|
+
},
|
|
306
|
+
} as unknown as import('../werift-loader.js').IWeriftModule;
|
|
307
|
+
|
|
308
|
+
const [sig] = createInMemorySignalingPair();
|
|
309
|
+
const t = new WebRtcTransport({
|
|
310
|
+
open: true,
|
|
311
|
+
openReason: 'SEC-008: Stage-A loopback — this case is about signalling, not pairing',
|
|
312
|
+
signaling: sig,
|
|
313
|
+
iceServers: [{ urls: 'turn:relay.example:3478', username: 'u', credential: 'c' }],
|
|
314
|
+
forceTurn: true,
|
|
315
|
+
loadWerift: () => fakeWerift,
|
|
316
|
+
});
|
|
317
|
+
t.attach(createStubSession());
|
|
318
|
+
await t.start();
|
|
319
|
+
expect(captured).toEqual({
|
|
320
|
+
iceServers: [{ urls: 'turn:relay.example:3478', username: 'u', credential: 'c' }],
|
|
321
|
+
iceTransportPolicy: 'relay',
|
|
322
|
+
});
|
|
323
|
+
expect(captured).not.toHaveProperty('forceTurn'); // werift ignores it → must not be emitted
|
|
324
|
+
await t.stop();
|
|
325
|
+
});
|
|
326
|
+
|
|
327
|
+
it('TC-03: establishes an RTCDataChannel between two peers and round-trips TClient→session→TServer through the shared handler', async () => {
|
|
328
|
+
const [hostSig, remoteSig] = createInMemorySignalingPair();
|
|
329
|
+
const session = createStubSession();
|
|
330
|
+
const host = new WebRtcTransport({
|
|
331
|
+
signaling: hostSig,
|
|
332
|
+
open: true,
|
|
333
|
+
openReason: 'SEC-008: Stage-A loopback — this case is about signalling, not pairing',
|
|
334
|
+
});
|
|
335
|
+
host.attach(session);
|
|
336
|
+
|
|
337
|
+
const remoteReply = connectRemote(remoteSig);
|
|
338
|
+
await host.start();
|
|
339
|
+
|
|
340
|
+
const reply = await remoteReply;
|
|
341
|
+
// The remote's `get-messages` reached the session via the reused handler; the host streamed back a
|
|
342
|
+
// `messages` TServerMessage carrying the stub session's messages — a full P2P round-trip, no network.
|
|
343
|
+
expect(reply.type).toBe('messages');
|
|
344
|
+
expect(reply.messages).toEqual([{ role: 'user', content: 'hi' }]);
|
|
345
|
+
expect(session.getMessages).toHaveBeenCalled();
|
|
346
|
+
|
|
347
|
+
await host.stop();
|
|
348
|
+
}, 15000);
|
|
349
|
+
});
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
|
|
3
|
+
import { loadWerift } from '../werift-loader.js';
|
|
4
|
+
|
|
5
|
+
describe('loadWerift (REMOTE-002 Stage A — throw-on-absence lazy-load, TC-05)', () => {
|
|
6
|
+
it('resolves the real werift module (RTCPeerConnection present) when installed', () => {
|
|
7
|
+
const mod = loadWerift();
|
|
8
|
+
expect(typeof mod.RTCPeerConnection).toBe('function');
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
it('throws an explicit "WebRTC transport unavailable" error when werift cannot be resolved — never a silent no-op', () => {
|
|
12
|
+
expect(() =>
|
|
13
|
+
loadWerift(() => {
|
|
14
|
+
throw new Error('Cannot find module "werift"');
|
|
15
|
+
}),
|
|
16
|
+
).toThrow(/WebRTC transport unavailable — install the optional peer dependency "werift"/);
|
|
17
|
+
});
|
|
18
|
+
});
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
2
|
+
|
|
3
|
+
import { WsSignalingClient, type IWebSocketLike } from '../ws-signaling-client.js';
|
|
4
|
+
import type { ISignalMessage } from '../signaling.js';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* REMOTE-004 B2 — `WsSignalingClient` unit tests with an injected fake socket (no network). Cover pre-open
|
|
8
|
+
* buffering + flush and explicit error surfacing (relay `error` frame, close-before-join).
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
const WS_CONNECTING = 0;
|
|
12
|
+
const WS_OPEN = 1;
|
|
13
|
+
|
|
14
|
+
function createFakeSocket(): IWebSocketLike & {
|
|
15
|
+
readonly sent: string[];
|
|
16
|
+
readyState: number;
|
|
17
|
+
emit(event: 'open' | 'message' | 'error' | 'close', arg?: unknown): void;
|
|
18
|
+
} {
|
|
19
|
+
const sent: string[] = [];
|
|
20
|
+
const listeners: Record<string, ((arg: unknown) => void)[]> = {};
|
|
21
|
+
return {
|
|
22
|
+
sent,
|
|
23
|
+
readyState: WS_CONNECTING,
|
|
24
|
+
send(data: string): void {
|
|
25
|
+
sent.push(data);
|
|
26
|
+
},
|
|
27
|
+
close(): void {
|
|
28
|
+
/* no-op for the fake */
|
|
29
|
+
},
|
|
30
|
+
on(event, handler): void {
|
|
31
|
+
(listeners[event] ??= []).push(handler);
|
|
32
|
+
},
|
|
33
|
+
emit(event, arg): void {
|
|
34
|
+
for (const h of listeners[event] ?? []) h(arg);
|
|
35
|
+
},
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function parse(raw: string): Record<string, unknown> {
|
|
40
|
+
return JSON.parse(raw) as Record<string, unknown>;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
describe('WsSignalingClient (REMOTE-004 B2)', () => {
|
|
44
|
+
it('TC-02: buffers signals produced before open and flushes them (after the join) on open', () => {
|
|
45
|
+
const socket = createFakeSocket();
|
|
46
|
+
const client = new WsSignalingClient({
|
|
47
|
+
url: 'ws://x',
|
|
48
|
+
rendezvous: 'r',
|
|
49
|
+
createSocket: () => socket,
|
|
50
|
+
});
|
|
51
|
+
// send an offer before the socket opens
|
|
52
|
+
const offer: ISignalMessage = { kind: 'offer', data: { sdp: 'v=0' } };
|
|
53
|
+
client.send(offer);
|
|
54
|
+
expect(socket.sent).toHaveLength(0); // buffered, nothing on the wire yet
|
|
55
|
+
|
|
56
|
+
socket.readyState = WS_OPEN;
|
|
57
|
+
socket.emit('open');
|
|
58
|
+
|
|
59
|
+
// first frame is the join, then the flushed offer
|
|
60
|
+
expect(parse(socket.sent[0]!)).toEqual({ type: 'join', rendezvous: 'r' });
|
|
61
|
+
expect(parse(socket.sent[1]!)).toEqual({ type: 'signal', kind: 'offer', data: { sdp: 'v=0' } });
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it('delivers inbound relay signals to onSignal handlers', () => {
|
|
65
|
+
const socket = createFakeSocket();
|
|
66
|
+
const client = new WsSignalingClient({
|
|
67
|
+
url: 'ws://x',
|
|
68
|
+
rendezvous: 'r',
|
|
69
|
+
createSocket: () => socket,
|
|
70
|
+
});
|
|
71
|
+
const received: ISignalMessage[] = [];
|
|
72
|
+
client.onSignal((m) => received.push(m));
|
|
73
|
+
socket.emit('message', JSON.stringify({ type: 'signal', kind: 'answer', data: { sdp: 'a' } }));
|
|
74
|
+
expect(received).toEqual([{ kind: 'answer', data: { sdp: 'a' } }]);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it('TC-02: surfaces a relay error frame via onError (no silent degrade)', () => {
|
|
78
|
+
const socket = createFakeSocket();
|
|
79
|
+
const onError = vi.fn();
|
|
80
|
+
new WsSignalingClient({ url: 'ws://x', rendezvous: 'r', onError, createSocket: () => socket });
|
|
81
|
+
socket.emit('message', JSON.stringify({ type: 'error', reason: 'rate-limited' }));
|
|
82
|
+
expect(onError).toHaveBeenCalledTimes(1);
|
|
83
|
+
expect((onError.mock.calls[0]![0] as Error).message).toMatch(/rate-limited/);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it('TC-02: surfaces a close-before-join as an explicit error', () => {
|
|
87
|
+
const socket = createFakeSocket();
|
|
88
|
+
const onError = vi.fn();
|
|
89
|
+
new WsSignalingClient({ url: 'ws://x', rendezvous: 'r', onError, createSocket: () => socket });
|
|
90
|
+
socket.emit('close');
|
|
91
|
+
expect(onError).toHaveBeenCalledTimes(1);
|
|
92
|
+
expect((onError.mock.calls[0]![0] as Error).message).toMatch(/closed before/);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it('does NOT treat an intentional close() as an error', () => {
|
|
96
|
+
const socket = createFakeSocket();
|
|
97
|
+
const onError = vi.fn();
|
|
98
|
+
const client = new WsSignalingClient({
|
|
99
|
+
url: 'ws://x',
|
|
100
|
+
rendezvous: 'r',
|
|
101
|
+
onError,
|
|
102
|
+
createSocket: () => socket,
|
|
103
|
+
});
|
|
104
|
+
client.close();
|
|
105
|
+
socket.emit('close');
|
|
106
|
+
expect(onError).not.toHaveBeenCalled();
|
|
107
|
+
});
|
|
108
|
+
});
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Which admission step a channel still owes before the session may be exposed.
|
|
3
|
+
*
|
|
4
|
+
* Extracted from `pairing-gate.ts` because it is the one decision in that file that is a pure
|
|
5
|
+
* function of configuration and state — and because it is the ORDERING that carries the security
|
|
6
|
+
* argument, so it deserves to be readable and testable without a channel, a handshake or a session.
|
|
7
|
+
*
|
|
8
|
+
* The order is not arbitrary. Each step must run on a channel the previous one has already bound:
|
|
9
|
+
*
|
|
10
|
+
* handshake binds the CHANNEL — it terminates at the peer that knew the secret
|
|
11
|
+
* local-proof binds the ENVIRONMENT — that peer reached a 0700 directory owned by this user
|
|
12
|
+
* handoff-grant binds the TRANSFER — one user, one destination, one channel, signed
|
|
13
|
+
* operator-approval the receiving operator allows THIS connection — asked last, once every proof
|
|
14
|
+
* has run, so the operator is asked only about a peer that is who it claims
|
|
15
|
+
*
|
|
16
|
+
* Each is opt-in, and configuring two means requiring both. That is strictly more restrictive than
|
|
17
|
+
* requiring either, which is the safe direction — and it is the correct reading for a hand-off
|
|
18
|
+
* between two sessions on ONE machine, which legitimately has both to present.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
/** The step still owed, or null when the session may be exposed. */
|
|
22
|
+
export type TAdmissionStep = 'local-proof' | 'handoff-grant' | 'operator-approval' | null;
|
|
23
|
+
|
|
24
|
+
/** What the gate has configured. Named structurally so this module needs none of the gate's types. */
|
|
25
|
+
export interface IConfiguredAdmissionSteps {
|
|
26
|
+
readonly localPeer?: unknown;
|
|
27
|
+
readonly handoffGrant?: unknown;
|
|
28
|
+
readonly connectionApproval?: unknown;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* The next step, given what is configured and what has already run.
|
|
33
|
+
*
|
|
34
|
+
* `completed` is the gate's current state rather than a set of finished steps, because the states
|
|
35
|
+
* ARE the record: reaching `handoff-grant` is only possible by having satisfied `local-proof` when
|
|
36
|
+
* one was required.
|
|
37
|
+
*/
|
|
38
|
+
export function nextAdmissionStep(
|
|
39
|
+
configured: IConfiguredAdmissionSteps,
|
|
40
|
+
completed: string,
|
|
41
|
+
): TAdmissionStep {
|
|
42
|
+
const approved = completed === 'operator-approval';
|
|
43
|
+
if (
|
|
44
|
+
configured.localPeer !== undefined &&
|
|
45
|
+
completed !== 'local-proof' &&
|
|
46
|
+
completed !== 'handoff-grant' &&
|
|
47
|
+
!approved
|
|
48
|
+
) {
|
|
49
|
+
return 'local-proof';
|
|
50
|
+
}
|
|
51
|
+
if (configured.handoffGrant !== undefined && completed !== 'handoff-grant' && !approved)
|
|
52
|
+
return 'handoff-grant';
|
|
53
|
+
if (configured.connectionApproval !== undefined && !approved) return 'operator-approval';
|
|
54
|
+
return null;
|
|
55
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The data channel's outbound delivery boundary (ARCH-030).
|
|
3
|
+
*
|
|
4
|
+
* Both places that build a bare `createSessionMessageHandler` in this package — `PairingGate`'s no-`resumeBridge`
|
|
5
|
+
* branch and `WebRtcTransport`'s no-secret branch — need exactly the same thing: serialize a
|
|
6
|
+
* `TServerMessage` onto an `RTCDataChannel` and route a send failure into that carrier's own failure
|
|
7
|
+
* policy. Written inline at both sites it was two copies of one decision, and it pushed both files past
|
|
8
|
+
* their size ceilings. One owner, named after what it is.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { createOutboundDelivery } from '@robota-sdk/agent-transport';
|
|
12
|
+
|
|
13
|
+
import type { TOutboundDeliver } from '@robota-sdk/agent-transport';
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* The slice of `RTCDataChannel` an outbound boundary needs.
|
|
17
|
+
*
|
|
18
|
+
* `bufferedAmount` is optional because the slice is structural — a test double may omit it — but a
|
|
19
|
+
* real `RTCDataChannel` always has it, so the production path reports a real number rather than a
|
|
20
|
+
* placeholder (ARCH-030 / issue #1734).
|
|
21
|
+
*/
|
|
22
|
+
export interface IDataChannelSink {
|
|
23
|
+
send: (data: string) => void;
|
|
24
|
+
readonly bufferedAmount?: number;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Build the connection's outbound boundary over a data channel.
|
|
29
|
+
*
|
|
30
|
+
* @param channel - the data channel to serialize onto. `send` may throw once the channel is closing.
|
|
31
|
+
* @param onDeliveryError - this carrier's failure policy, invoked at most once (the boundary latches).
|
|
32
|
+
*/
|
|
33
|
+
export function createChannelDelivery(
|
|
34
|
+
channel: IDataChannelSink,
|
|
35
|
+
onDeliveryError: (error: Error, event: string) => void,
|
|
36
|
+
): TOutboundDeliver {
|
|
37
|
+
return createOutboundDelivery(
|
|
38
|
+
(message) => channel.send(JSON.stringify(message)),
|
|
39
|
+
(error, event) => onDeliveryError(error, event),
|
|
40
|
+
// Read at CALL time, not captured: `bufferedAmount` is a live property and a value read once
|
|
41
|
+
// would answer with the backpressure of the moment the boundary was built.
|
|
42
|
+
() => channel.bufferedAmount,
|
|
43
|
+
);
|
|
44
|
+
}
|