@robota-sdk/agent-transport-webrtc 3.0.0-beta.100

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 (84) hide show
  1. package/CHANGELOG.md +694 -0
  2. package/LICENSE +661 -0
  3. package/README.md +111 -0
  4. package/dist/node/index.cjs +1 -0
  5. package/dist/node/index.d.cts +1650 -0
  6. package/dist/node/index.d.cts.map +1 -0
  7. package/dist/node/index.d.ts +1650 -0
  8. package/dist/node/index.d.ts.map +1 -0
  9. package/dist/node/index.js +2 -0
  10. package/dist/node/index.js.map +1 -0
  11. package/package.json +93 -0
  12. package/src/__tests__/admission-secure-by-default.test.ts +63 -0
  13. package/src/__tests__/admission-steps.test.ts +46 -0
  14. package/src/__tests__/device-mesh-relay.test.ts +441 -0
  15. package/src/__tests__/device-mesh.test.ts +682 -0
  16. package/src/__tests__/discovering-mesh-relay.test.ts +234 -0
  17. package/src/__tests__/enrollment-link.test.ts +103 -0
  18. package/src/__tests__/fake-datachannel.ts +83 -0
  19. package/src/__tests__/handoff-grant-gate.test.ts +403 -0
  20. package/src/__tests__/local-peer-proof-gate.test.ts +215 -0
  21. package/src/__tests__/mesh-admission-contract.test.ts +38 -0
  22. package/src/__tests__/mesh-discovery.test.ts +820 -0
  23. package/src/__tests__/mesh-fixtures.ts +148 -0
  24. package/src/__tests__/mesh-internet.test.ts +942 -0
  25. package/src/__tests__/mesh-turn-relay.test.ts +161 -0
  26. package/src/__tests__/negotiated-certificate.test.ts +76 -0
  27. package/src/__tests__/nostr-source-condition.test.ts +27 -0
  28. package/src/__tests__/operator-approval-gate.test.ts +308 -0
  29. package/src/__tests__/outbound-delivery-carrier.test.ts +155 -0
  30. package/src/__tests__/pairing-e2e.test.ts +237 -0
  31. package/src/__tests__/pairing-gate-e3.test.ts +194 -0
  32. package/src/__tests__/pairing-gate-e4.test.ts +110 -0
  33. package/src/__tests__/pairing-gate.test.ts +296 -0
  34. package/src/__tests__/pairing-relay-in-the-middle.test.ts +225 -0
  35. package/src/__tests__/rtc-channel-close.test.ts +52 -0
  36. package/src/__tests__/rtc-peer-candidates.test.ts +61 -0
  37. package/src/__tests__/session-attachment-app-view.test.ts +66 -0
  38. package/src/__tests__/session-attachment-budget.test.ts +136 -0
  39. package/src/__tests__/turn-server-bind.test.ts +59 -0
  40. package/src/__tests__/turn-server.test.ts +335 -0
  41. package/src/__tests__/turn-test-client.ts +256 -0
  42. package/src/__tests__/webrtc-implementation.test.ts +83 -0
  43. package/src/__tests__/webrtc-transport.test.ts +349 -0
  44. package/src/__tests__/ws-mesh-relay-client.test.ts +95 -0
  45. package/src/__tests__/ws-signaling-client.test.ts +108 -0
  46. package/src/admission-steps.ts +55 -0
  47. package/src/channel-delivery.ts +44 -0
  48. package/src/datachannel-loader.ts +86 -0
  49. package/src/device-mesh-node.ts +1019 -0
  50. package/src/discovering-mesh-relay.ts +675 -0
  51. package/src/enrollment-link.ts +465 -0
  52. package/src/handoff-grant-gate.ts +186 -0
  53. package/src/index.ts +139 -0
  54. package/src/local-peer-proof.ts +135 -0
  55. package/src/mainline-dht-store.ts +174 -0
  56. package/src/mesh-dht.ts +446 -0
  57. package/src/mesh-discovery.ts +87 -0
  58. package/src/mesh-item-store.ts +287 -0
  59. package/src/mesh-lan-listener.ts +193 -0
  60. package/src/mesh-mdns.ts +406 -0
  61. package/src/mesh-peer-link.ts +600 -0
  62. package/src/mesh-public-relays.ts +25 -0
  63. package/src/mesh-records.ts +357 -0
  64. package/src/mesh-relay.ts +98 -0
  65. package/src/mesh-signal.ts +116 -0
  66. package/src/mesh-turn-relay.ts +238 -0
  67. package/src/negotiated-certificate.ts +72 -0
  68. package/src/nostr-mesh-relay.ts +391 -0
  69. package/src/pairing-channel-lifecycle.ts +31 -0
  70. package/src/pairing-controllers.ts +81 -0
  71. package/src/pairing-frames.ts +34 -0
  72. package/src/pairing-gate-options.ts +133 -0
  73. package/src/pairing-gate.ts +397 -0
  74. package/src/rtc-peer.ts +429 -0
  75. package/src/session-attachment.ts +96 -0
  76. package/src/signaling.ts +62 -0
  77. package/src/stun-message.ts +341 -0
  78. package/src/transport-lifecycle-error.ts +11 -0
  79. package/src/turn-server.ts +1028 -0
  80. package/src/webrtc-delivery-lifecycle.ts +54 -0
  81. package/src/webrtc-transport-options.ts +87 -0
  82. package/src/webrtc-transport.ts +368 -0
  83. package/src/ws-mesh-relay-client.ts +117 -0
  84. package/src/ws-signaling-client.ts +139 -0
package/package.json ADDED
@@ -0,0 +1,93 @@
1
+ {
2
+ "name": "@robota-sdk/agent-transport-webrtc",
3
+ "version": "3.0.0-beta.100",
4
+ "description": "Peer-to-peer WebRTC transport for the Robota SDK (data-channel session bridge for remote control and the device mesh)",
5
+ "type": "module",
6
+ "main": "dist/node/index.js",
7
+ "types": "dist/node/index.d.ts",
8
+ "exports": {
9
+ ".": {
10
+ "source": "./src/index.ts",
11
+ "node": {
12
+ "import": {
13
+ "types": "./dist/node/index.d.ts",
14
+ "default": "./dist/node/index.js"
15
+ },
16
+ "require": {
17
+ "types": "./dist/node/index.d.cts",
18
+ "default": "./dist/node/index.cjs"
19
+ }
20
+ },
21
+ "default": {
22
+ "import": {
23
+ "types": "./dist/node/index.d.ts",
24
+ "default": "./dist/node/index.js"
25
+ },
26
+ "require": {
27
+ "types": "./dist/node/index.d.cts",
28
+ "default": "./dist/node/index.cjs"
29
+ }
30
+ }
31
+ },
32
+ "./package.json": "./package.json"
33
+ },
34
+ "engines": {
35
+ "node": ">=22.12.0"
36
+ },
37
+ "files": [
38
+ "dist",
39
+ "src",
40
+ "CHANGELOG.md"
41
+ ],
42
+ "dependencies": {
43
+ "bittorrent-dht": "^11.0.12",
44
+ "multicast-dns": "^7.2.5",
45
+ "nostr-tools": "^2.25.2",
46
+ "ws": "^8.21.1",
47
+ "@robota-sdk/agent-interface-session": "3.0.0-beta.100",
48
+ "@robota-sdk/agent-interface-session-mobility": "3.0.0-beta.100",
49
+ "@robota-sdk/agent-interface-transport": "3.0.0-beta.100",
50
+ "@robota-sdk/agent-remote-pairing": "3.0.0-beta.100",
51
+ "@robota-sdk/agent-transport": "3.0.0-beta.100"
52
+ },
53
+ "peerDependencies": {
54
+ "node-datachannel": "^0.33.4"
55
+ },
56
+ "peerDependenciesMeta": {
57
+ "node-datachannel": {
58
+ "optional": true
59
+ }
60
+ },
61
+ "devDependencies": {
62
+ "@types/ws": "^8.18.1",
63
+ "node-datachannel": "^0.33.4",
64
+ "rimraf": "^5.0.10",
65
+ "tsdown": "^0.22.14",
66
+ "typescript": "^6.0.3",
67
+ "vitest": "^3.2.6"
68
+ },
69
+ "license": "AGPL-3.0-only OR LicenseRef-Commercial",
70
+ "publishConfig": {
71
+ "access": "public"
72
+ },
73
+ "volta": {
74
+ "extends": "../../package.json"
75
+ },
76
+ "repository": {
77
+ "type": "git",
78
+ "url": "https://github.com/woojubb/robota"
79
+ },
80
+ "bugs": {
81
+ "url": "https://github.com/woojubb/robota/issues"
82
+ },
83
+ "homepage": "https://robota.io/",
84
+ "scripts": {
85
+ "build": "rimraf dist && tsdown",
86
+ "build:js": "pnpm run build",
87
+ "build:types": "pnpm run build",
88
+ "typecheck": "tsgo --noEmit",
89
+ "test": "vitest run --passWithNoTests",
90
+ "test:coverage": "vitest run --coverage --passWithNoTests",
91
+ "clean": "rimraf dist"
92
+ }
93
+ }
@@ -0,0 +1,63 @@
1
+ import { createIdentityContext } from '@robota-sdk/agent-remote-pairing';
2
+ const testIdentity = createIdentityContext('test-product');
3
+ /**
4
+ * SEC-008 — a WebRTC transport with no pairing secret must not expose the session by omission.
5
+ *
6
+ * Two sibling transports made OPPOSITE default choices for one question. The WS transport auto-mints
7
+ * a credential unless told to stay open; this one's `secret` was optional, and without it the data
8
+ * channel was wired straight to the session — a remote peer reaching tool execution because a field
9
+ * was left unset.
10
+ *
11
+ * The defect is the DEFAULT, not the open mode. Running without pairing is legitimate for loopback
12
+ * and for tests; what is not legitimate is that it happened when nobody chose it. So the transport
13
+ * now refuses to construct unless one of the two was chosen, and the refusal names both ways out.
14
+ */
15
+
16
+ import { describe, expect, it } from 'vitest';
17
+
18
+ import { WebRtcTransport } from '../webrtc-transport.js';
19
+ import type { ISignalingClient } from '../signaling.js';
20
+
21
+ /** A signalling channel that is never used — these cases never get past the constructor. */
22
+ const signaling = {
23
+ send: () => {},
24
+ onMessage: () => () => {},
25
+ close: () => {},
26
+ } as unknown as ISignalingClient;
27
+
28
+ describe('SEC-008: the WebRTC transport requires an admission decision', () => {
29
+ it('refuses to construct with neither a secret nor an explicit open', () => {
30
+ expect(() => new WebRtcTransport({cryptoContext: testIdentity, signaling })).toThrow(/openReason|secret/);
31
+ });
32
+
33
+ it('constructs with a pairing secret', () => {
34
+ expect(() => new WebRtcTransport({cryptoContext: testIdentity, signaling, secret: 'pairing-secret' })).not.toThrow();
35
+ });
36
+
37
+ it('constructs open when the host says so, in writing', () => {
38
+ expect(
39
+ () =>
40
+ new WebRtcTransport({cryptoContext: testIdentity,
41
+ signaling,
42
+ open: true,
43
+ openReason: 'loopback only — no remote peer',
44
+ }),
45
+ ).not.toThrow();
46
+ });
47
+
48
+ it('refuses an open transport with no reason', () => {
49
+ // Without this the fix would be an option that means nothing: `open: true` alone would restore
50
+ // exactly the silent exposure the case above forbids, one keyword further along.
51
+ expect(() => new WebRtcTransport({cryptoContext: testIdentity, signaling, open: true })).toThrow(/openReason/);
52
+ });
53
+
54
+ it('refuses a secret AND an explicit open together', () => {
55
+ // Silently ignoring one of two contradictory options is how a caller ends up believing the
56
+ // channel is gated when it is, or is not, by whichever option happened to win. The constructor
57
+ // cannot know which they meant, so it does not choose.
58
+ expect(
59
+ () =>
60
+ new WebRtcTransport({cryptoContext: testIdentity, signaling, secret: 'pairing-secret', open: true, openReason: 'x' }),
61
+ ).toThrow(/contradictory/);
62
+ });
63
+ });
@@ -0,0 +1,46 @@
1
+ import { describe, expect, it } from 'vitest';
2
+
3
+ import { nextAdmissionStep } from '../admission-steps.js';
4
+
5
+ /**
6
+ * The ordering carries the security argument, so it is asserted directly rather than only through a
7
+ * channel: each step must run on a channel the previous one has already bound.
8
+ */
9
+
10
+ const PROOF = {};
11
+
12
+ describe('what a channel still owes', () => {
13
+ it('owes nothing when nothing is configured — the handshake alone admits', () => {
14
+ expect(nextAdmissionStep({}, 'pairing')).toBe(null);
15
+ });
16
+
17
+ it.each([
18
+ ['only the rendezvous', { localPeer: PROOF }, 'local-proof'],
19
+ ['only the grant', { handoffGrant: PROOF }, 'handoff-grant'],
20
+ ['both — the rendezvous first', { localPeer: PROOF, handoffGrant: PROOF }, 'local-proof'],
21
+ ])('with %s configured, owes %s', (_label, configured, expected) => {
22
+ expect(nextAdmissionStep(configured, 'pairing')).toBe(expected);
23
+ });
24
+
25
+ it('owes the grant once the rendezvous has run', () => {
26
+ expect(nextAdmissionStep({ localPeer: PROOF, handoffGrant: PROOF }, 'local-proof')).toBe(
27
+ 'handoff-grant',
28
+ );
29
+ });
30
+
31
+ it('owes nothing once both have run', () => {
32
+ expect(nextAdmissionStep({ localPeer: PROOF, handoffGrant: PROOF }, 'handoff-grant')).toBe(
33
+ null,
34
+ );
35
+ });
36
+
37
+ it('does not re-demand the rendezvous after the grant state is reached', () => {
38
+ // The loop this prevents: a gate that asked for the rendezvous again from `handoff-grant` would
39
+ // never admit anyone, because the peer has no second nonce to present.
40
+ expect(nextAdmissionStep({ localPeer: PROOF }, 'handoff-grant')).toBe(null);
41
+ });
42
+
43
+ it('owes nothing after a rendezvous-only channel has satisfied it', () => {
44
+ expect(nextAdmissionStep({ localPeer: PROOF }, 'local-proof')).toBe(null);
45
+ });
46
+ });
@@ -0,0 +1,441 @@
1
+ import { createIdentityContext } from '@robota-sdk/agent-remote-pairing';
2
+ const testIdentity = createIdentityContext('test-product');
3
+ /**
4
+ * The device mesh through a TURN relay, on a network where no direct path works: every candidate
5
+ * but a relayed one is dropped on the way (a symmetric NAT on both sides), and the connections are
6
+ * told to use relay candidates only.
7
+ */
8
+ import {
9
+ derivePairRendezvous,
10
+ extractDtlsFingerprint,
11
+ startDeviceHandshake,
12
+ } from '@robota-sdk/agent-remote-pairing';
13
+ import { afterEach, beforeAll, describe, expect, it } from 'vitest';
14
+
15
+ import {
16
+ loadDataChannel,
17
+ type IDataChannelModule,
18
+ type INdcPeerConnection,
19
+ } from '../datachannel-loader.js';
20
+ import {
21
+ DeviceMeshNode,
22
+ type IDeviceMeshLink,
23
+ type IDeviceMeshNodeOptions,
24
+ } from '../device-mesh-node.js';
25
+ import { MeshLinkEndedError, MeshPeerLink } from '../mesh-peer-link.js';
26
+ import {
27
+ createInMemoryMeshRelayHub,
28
+ type IInMemoryMeshRelayHub,
29
+ type IMeshRelay,
30
+ } from '../mesh-relay.js';
31
+ import {
32
+ MeshRelayNeededError,
33
+ MeshTurnRelay,
34
+ meshRelayIceServers,
35
+ type IMeshRelayEndpoint,
36
+ } from '../mesh-turn-relay.js';
37
+ import { RtcPeer } from '../rtc-peer.js';
38
+ import { TurnServer } from '../turn-server.js';
39
+ import {
40
+ ALL_CAPABILITIES,
41
+ buildMeshWorld,
42
+ type IMeshTestDevice,
43
+ type IMeshTestWorld,
44
+ } from './mesh-fixtures.js';
45
+
46
+ let world: IMeshTestWorld;
47
+ const nodes: DeviceMeshNode[] = [];
48
+ const cleanups: (() => Promise<void> | void)[] = [];
49
+
50
+ beforeAll(async () => {
51
+ world = await buildMeshWorld();
52
+ });
53
+
54
+ afterEach(async () => {
55
+ for (const node of nodes.splice(0)) node.stop();
56
+ for (const cleanup of cleanups.splice(0)) await cleanup();
57
+ });
58
+
59
+ /** Every candidate crossing the signaling path, and whether it was relayed. */
60
+ interface INatLog {
61
+ readonly candidates: string[];
62
+ }
63
+
64
+ /** A relay client behind a symmetric NAT: only relayed candidates ever reach the peer. */
65
+ function behindSymmetricNat(relay: IMeshRelay, log: INatLog): IMeshRelay {
66
+ return {
67
+ ...relay,
68
+ declarePresence: (topics) => relay.declarePresence(topics),
69
+ onMessage: (handler) => relay.onMessage(handler),
70
+ onAbsent: (handler) => relay.onAbsent(handler),
71
+ close: () => relay.close(),
72
+ send: (topic, data) => {
73
+ const signal = data as { kind?: string; sdp?: string; candidate?: { candidate: string } };
74
+ if (signal.kind === 'ice') {
75
+ const candidate = signal.candidate?.candidate ?? '';
76
+ log.candidates.push(candidate);
77
+ if (!/ typ relay/.test(candidate)) return;
78
+ }
79
+ if ((signal.kind === 'offer' || signal.kind === 'answer') && signal.sdp !== undefined) {
80
+ const sdp = signal.sdp
81
+ .split('\r\n')
82
+ .filter((line) => !line.startsWith('a=candidate') || / typ relay/.test(line))
83
+ .join('\r\n');
84
+ relay.send(topic, { ...signal, sdp });
85
+ return;
86
+ }
87
+ relay.send(topic, data);
88
+ },
89
+ };
90
+ }
91
+
92
+ /** The WebRTC module, recording each connection's own fingerprint and the one its DTLS verified. */
93
+ function recordingDataChannel(): {
94
+ readonly load: () => IDataChannelModule;
95
+ readonly local: Set<string>;
96
+ readonly verified: Set<string>;
97
+ } {
98
+ const real = loadDataChannel();
99
+ const local = new Set<string>();
100
+ const verified = new Set<string>();
101
+ const Recorded = function (
102
+ name: string,
103
+ config: ConstructorParameters<IDataChannelModule['PeerConnection']>[1],
104
+ ) {
105
+ const pc = new real.PeerConnection(name, config);
106
+ // Native methods are read-only and need their own receiver: delegate rather than patch.
107
+ const recorded: INdcPeerConnection = {
108
+ close: () => pc.close(),
109
+ setLocalDescription: (type) => pc.setLocalDescription(type),
110
+ setRemoteDescription: (sdp, type) => pc.setRemoteDescription(sdp, type),
111
+ localDescription: () => pc.localDescription(),
112
+ remoteFingerprint: () => {
113
+ const fingerprint = pc.remoteFingerprint();
114
+ if (fingerprint.value.length > 0) verified.add(fingerprint.value.toLowerCase());
115
+ return fingerprint;
116
+ },
117
+ addRemoteCandidate: (candidate, mid) => pc.addRemoteCandidate(candidate, mid),
118
+ createDataChannel: (label) => pc.createDataChannel(label),
119
+ state: () => pc.state(),
120
+ onLocalDescription: (callback) =>
121
+ pc.onLocalDescription((sdp, type) => {
122
+ local.add(extractDtlsFingerprint(sdp).toLowerCase());
123
+ callback(sdp, type);
124
+ }),
125
+ onLocalCandidate: (callback) => pc.onLocalCandidate(callback),
126
+ onStateChange: (callback) => pc.onStateChange(callback),
127
+ onDataChannel: (callback) => pc.onDataChannel(callback),
128
+ };
129
+ return recorded;
130
+ } as unknown as IDataChannelModule['PeerConnection'];
131
+ return { load: () => ({ PeerConnection: Recorded }), local, verified };
132
+ }
133
+
134
+ async function pairOf(own: IMeshTestDevice, peer: IMeshTestDevice) {
135
+ return derivePairRendezvous(testIdentity, {
136
+ ownKaPrivateKey: own.ka.privateKey,
137
+ own: own.cert,
138
+ peerDeviceId: peer.cert.deviceId,
139
+ lists: world.identity(own),
140
+ });
141
+ }
142
+
143
+ /** The embedded relay on `host`, serving the other two devices. */
144
+ async function embeddedRelay(host: IMeshTestDevice): Promise<MeshTurnRelay> {
145
+ const relay = await MeshTurnRelay.start({ host: '127.0.0.1', port: 0 });
146
+ cleanups.push(() => relay.close());
147
+ const others = [world.low, world.high, world.third].filter((d) => d !== host);
148
+ relay.declarePeers(
149
+ await Promise.all(
150
+ others.map(async (d) => ({ deviceId: d.cert.deviceId, rendezvous: await pairOf(host, d) })),
151
+ ),
152
+ );
153
+ return relay;
154
+ }
155
+
156
+ function advertising(
157
+ device: IMeshTestDevice,
158
+ endpoint: IMeshRelayEndpoint,
159
+ ): (
160
+ peers: readonly unknown[],
161
+ signal: AbortSignal,
162
+ ) => Promise<ReadonlyMap<string, readonly IMeshRelayEndpoint[]>> {
163
+ return () => Promise.resolve(new Map([[device.cert.deviceId, [endpoint]]]));
164
+ }
165
+
166
+ function node(
167
+ hub: IInMemoryMeshRelayHub,
168
+ device: IMeshTestDevice,
169
+ log: INatLog,
170
+ over: Partial<IDeviceMeshNodeOptions> = {},
171
+ ): DeviceMeshNode {
172
+ const created = new DeviceMeshNode({cryptoContext: testIdentity,
173
+ identity: world.identity(device),
174
+ sessionDescriptor: device.session,
175
+ localPolicy: ALL_CAPABILITIES,
176
+ relay: behindSymmetricNat(hub.connect(), log),
177
+ connectTimeoutMs: 15_000,
178
+ ...over,
179
+ });
180
+ nodes.push(created);
181
+ return created;
182
+ }
183
+
184
+ function nextMessage(link: IDeviceMeshLink): Promise<string> {
185
+ return new Promise((resolve) => {
186
+ const off = link.onMessage((body) => {
187
+ off();
188
+ resolve(body);
189
+ });
190
+ });
191
+ }
192
+
193
+ describe('DeviceMeshNode — through the embedded TURN relay behind symmetric NATs', () => {
194
+ it('two devices connect only through the relay a third device runs; DTLS stays end to end', async () => {
195
+ const hub = createInMemoryMeshRelayHub();
196
+ const relay = await embeddedRelay(world.third);
197
+ const log: INatLog = { candidates: [] };
198
+ const atLowPc = recordingDataChannel();
199
+ const atHighPc = recordingDataChannel();
200
+ const relays = { advertised: advertising(world.third, relay.endpoint), relayOnly: true };
201
+ const low = node(hub, world.low, log, { relays, loadDataChannel: atLowPc.load });
202
+ const high = node(hub, world.high, log, { relays, loadDataChannel: atHighPc.load });
203
+ await Promise.all([low.start(), high.start()]);
204
+
205
+ const [atLow, atHigh] = await Promise.all([
206
+ low.connect(world.high.cert.deviceId),
207
+ high.connect(world.low.cert.deviceId),
208
+ ]);
209
+
210
+ expect(atLow.admission.deviceId).toBe(world.high.cert.deviceId);
211
+ expect(atHigh.admission.deviceId).toBe(world.low.cert.deviceId);
212
+ const toHigh = nextMessage(atHigh);
213
+ atLow.send('relayed hello');
214
+ await expect(toHigh).resolves.toBe('relayed hello');
215
+
216
+ // The only way across was the relay, and each device held an allocation on it.
217
+ expect(log.candidates.length).toBeGreaterThan(0);
218
+ expect(log.candidates.every((c) => / typ relay /.test(c))).toBe(true);
219
+ expect(relay.allocationCount(world.low.cert.deviceId)).toBeGreaterThan(0);
220
+ expect(relay.allocationCount(world.high.cert.deviceId)).toBeGreaterThan(0);
221
+ // Channel binding: the certificate each side's DTLS verified is the one the other side made,
222
+ // so the relay terminated nothing.
223
+ expect(atLowPc.verified.size).toBeGreaterThan(0);
224
+ for (const fingerprint of atLowPc.verified) expect(atHighPc.local).toContain(fingerprint);
225
+ for (const fingerprint of atHighPc.verified) expect(atLowPc.local).toContain(fingerprint);
226
+ }, 40_000);
227
+
228
+ it("through the relay, another rostered device reaching the pair's inbox is still refused by the handshake", async () => {
229
+ const hub = createInMemoryMeshRelayHub();
230
+ // low runs the relay; high and the impostor (third) are both its peers.
231
+ const relay = await embeddedRelay(world.low);
232
+ const log: INatLog = { candidates: [] };
233
+ const high = node(hub, world.high, log, {
234
+ relays: { advertised: advertising(world.low, relay.endpoint), relayOnly: true },
235
+ });
236
+ const refusals: string[] = [];
237
+ high.onRefusal((refusal) => refusals.push(refusal.end));
238
+ const linked: IDeviceMeshLink[] = [];
239
+ high.onLink((link) => linked.push(link));
240
+ await high.start();
241
+
242
+ const topics = await (await pairOf(world.low, world.high)).relayInbox();
243
+ const iceServers = await meshRelayIceServers(
244
+ await pairOf(world.third, world.low),
245
+ [relay.endpoint],
246
+ Date.now(),
247
+ );
248
+ const impostorRelay = behindSymmetricNat(hub.connect(), log);
249
+ impostorRelay.declarePresence([topics.inbound]);
250
+ const instance = 'DDDDDDDDDDDDDDDDDDDDDD';
251
+ const cid = 'EEEEEEEEEEEEEEEEEEEEEE';
252
+ let impostorAdmitted = false;
253
+ const impostor = new MeshPeerLink({
254
+ role: 'offerer',
255
+ createPeer: () => new RtcPeer({ iceServers, forceTurn: true }),
256
+ sendSignal: (signal) =>
257
+ impostorRelay.send(topics.outbound, {
258
+ v: 1,
259
+ from: instance,
260
+ to: high.instance,
261
+ cid,
262
+ ...signal,
263
+ }),
264
+ startHandshake: (binding) =>
265
+ startDeviceHandshake(testIdentity, {
266
+ role: 'initiator',
267
+ identity: world.identity(world.third),
268
+ sessionDescriptor: world.third.session,
269
+ expectedPeerDeviceId: world.high.cert.deviceId,
270
+ localFingerprint: binding.localFingerprint,
271
+ remoteFingerprint: binding.remoteFingerprint,
272
+ locality: 'another-host',
273
+ localPolicy: ALL_CAPABILITIES,
274
+ send: binding.send,
275
+ }),
276
+ connectTimeoutMs: 10_000,
277
+ onAdmitted: () => (impostorAdmitted = true),
278
+ onEnded: () => undefined,
279
+ });
280
+ impostorRelay.onMessage((_topic, data) => {
281
+ const signal = data as { kind: string; sdp?: string; candidate?: never };
282
+ if (signal.kind === 'answer') impostor.onSignal({ kind: 'answer', sdp: signal.sdp! });
283
+ else if (signal.kind === 'ice')
284
+ impostor.onSignal({ kind: 'ice', candidate: signal.candidate! });
285
+ });
286
+ await impostor.offer();
287
+
288
+ // The relay carried the connection far enough for the handshake to refuse it.
289
+ await expect.poll(() => refusals, { timeout: 15_000 }).toEqual(['handshake']);
290
+ expect(relay.allocationCount(world.third.cert.deviceId)).toBeGreaterThan(0);
291
+ await expect.poll(() => impostor.ended, { timeout: 5_000 }).toBe(true);
292
+ expect(impostorAdmitted).toBe(false);
293
+ expect(linked).toEqual([]);
294
+ }, 30_000);
295
+ });
296
+
297
+ describe('DeviceMeshNode — relay fallback order', () => {
298
+ async function configuredTurn(): Promise<{ server: TurnServer; urls: string }> {
299
+ const server = await TurnServer.start({
300
+ host: '127.0.0.1',
301
+ port: 0,
302
+ authorize: (username) =>
303
+ Promise.resolve(username === 'mine' ? { owner: 'me', password: 'secret' } : undefined),
304
+ });
305
+ cleanups.push(() => server.close());
306
+ return { server, urls: `turn:127.0.0.1:${server.address.port}` };
307
+ }
308
+
309
+ it('the embedded relay comes first: a configured TURN server is not contacted while it works', async () => {
310
+ const hub = createInMemoryMeshRelayHub();
311
+ const relay = await embeddedRelay(world.third);
312
+ const configured = await configuredTurn();
313
+ const relays = {
314
+ advertised: advertising(world.third, relay.endpoint),
315
+ configured: [{ urls: configured.urls, username: 'mine', credential: 'secret' }],
316
+ relayOnly: true,
317
+ };
318
+ const log: INatLog = { candidates: [] };
319
+ const low = node(hub, world.low, log, { relays });
320
+ const high = node(hub, world.high, log, { relays });
321
+ await Promise.all([low.start(), high.start()]);
322
+
323
+ await Promise.all([
324
+ low.connect(world.high.cert.deviceId),
325
+ high.connect(world.low.cert.deviceId),
326
+ ]);
327
+
328
+ expect(relay.allocationCount()).toBeGreaterThan(0);
329
+ expect(configured.server.allocationCount()).toBe(0);
330
+ }, 40_000);
331
+
332
+ it('when the embedded relay does not carry a connection, the next attempt uses the configured TURN server', async () => {
333
+ const hub = createInMemoryMeshRelayHub();
334
+ // An advertised relay that is gone.
335
+ const gone = await MeshTurnRelay.start({ host: '127.0.0.1', port: 0 });
336
+ const endpoint = gone.endpoint;
337
+ await gone.close();
338
+ const configured = await configuredTurn();
339
+ const relays = {
340
+ advertised: advertising(world.third, endpoint),
341
+ configured: [{ urls: configured.urls, username: 'mine', credential: 'secret' }],
342
+ relayOnly: true,
343
+ };
344
+ const log: INatLog = { candidates: [] };
345
+ const low = node(hub, world.low, log, { relays, connectTimeoutMs: 4_000 });
346
+ const high = node(hub, world.high, log, { relays, connectTimeoutMs: 4_000 });
347
+ await Promise.all([low.start(), high.start()]);
348
+
349
+ const first = await Promise.allSettled([
350
+ low.connect(world.high.cert.deviceId),
351
+ high.connect(world.low.cert.deviceId),
352
+ ]);
353
+ expect(first.map((r) => r.status)).toEqual(['rejected', 'rejected']);
354
+ expect(configured.server.allocationCount()).toBe(0);
355
+
356
+ const [atLow] = await Promise.all([
357
+ low.connect(world.high.cert.deviceId),
358
+ high.connect(world.low.cert.deviceId),
359
+ ]);
360
+ expect(atLow.admission.deviceId).toBe(world.high.cert.deviceId);
361
+ expect(configured.server.allocationCount()).toBeGreaterThan(0);
362
+ }, 40_000);
363
+
364
+ it('with no relay advertised or configured and relay candidates required, the connection fails closed at once', async () => {
365
+ const hub = createInMemoryMeshRelayHub();
366
+ const log: INatLog = { candidates: [] };
367
+ const low = node(hub, world.low, log, { relays: { relayOnly: true } });
368
+ const high = node(hub, world.high, log, { relays: { relayOnly: true } });
369
+ await Promise.all([low.start(), high.start()]);
370
+
371
+ const result = low.connect(world.high.cert.deviceId);
372
+ void high.connect(world.low.cert.deviceId).catch(() => undefined);
373
+
374
+ await expect(result).rejects.toBeInstanceOf(MeshRelayNeededError);
375
+ await expect(result).rejects.toThrow(/a relay device is needed/);
376
+ // It says why: relayed connections were required and there was nothing to relay through.
377
+ await expect(result).rejects.toThrow(/only relayed connections are allowed/);
378
+ }, 20_000);
379
+
380
+ it('with no relay at all, a direct attempt the network defeats ends in the explicit relay error, not a silent failure', async () => {
381
+ const hub = createInMemoryMeshRelayHub();
382
+ const log: INatLog = { candidates: [] };
383
+ const low = node(hub, world.low, log, { connectTimeoutMs: 4_000 });
384
+ const high = node(hub, world.high, log, { connectTimeoutMs: 4_000 });
385
+ await Promise.all([low.start(), high.start()]);
386
+
387
+ const [atLow, atHigh] = await Promise.allSettled([
388
+ low.connect(world.high.cert.deviceId),
389
+ high.connect(world.low.cert.deviceId),
390
+ ]);
391
+
392
+ expect(atLow.status === 'rejected' && atLow.reason).toBeInstanceOf(MeshRelayNeededError);
393
+ expect(atHigh.status === 'rejected' && atHigh.reason).toBeInstanceOf(MeshRelayNeededError);
394
+ // The error carries why the direct attempt failed, not only the conclusion.
395
+ const reason = (atLow as PromiseRejectedResult).reason as MeshRelayNeededError;
396
+ expect(reason.cause).toBeInstanceOf(MeshLinkEndedError);
397
+ expect(reason.message).toMatch(/no direct path was found/);
398
+ expect(reason.message).toMatch(/during connecting/);
399
+ expect(reason.message).toMatch(/ice \w+/);
400
+ // Candidates were gathered, and none could cross.
401
+ expect(log.candidates.length).toBeGreaterThan(0);
402
+ }, 20_000);
403
+
404
+ it('a direct attempt that fails before any path is tried is not called a missing relay', async () => {
405
+ const hub = createInMemoryMeshRelayHub();
406
+ const log: INatLog = { candidates: [] };
407
+ // The answer never reaches the offerer: signaling failed, which no relay would fix.
408
+ const dropAnswers = (relay: IMeshRelay): IMeshRelay => ({
409
+ ...relay,
410
+ declarePresence: (topics) => relay.declarePresence(topics),
411
+ onMessage: (handler) => relay.onMessage(handler),
412
+ onAbsent: (handler) => relay.onAbsent(handler),
413
+ close: () => relay.close(),
414
+ send: (topic, data) => {
415
+ if ((data as { kind?: string }).kind === 'answer') return;
416
+ relay.send(topic, data);
417
+ },
418
+ });
419
+ const low = node(hub, world.low, log, { connectTimeoutMs: 2_000 });
420
+ const high = new DeviceMeshNode({cryptoContext: testIdentity,
421
+ identity: world.identity(world.high),
422
+ sessionDescriptor: world.high.session,
423
+ localPolicy: ALL_CAPABILITIES,
424
+ relay: dropAnswers(hub.connect()),
425
+ connectTimeoutMs: 2_000,
426
+ });
427
+ nodes.push(high);
428
+ await Promise.all([low.start(), high.start()]);
429
+
430
+ const [atLow] = await Promise.allSettled([
431
+ low.connect(world.high.cert.deviceId),
432
+ high.connect(world.low.cert.deviceId),
433
+ ]);
434
+
435
+ expect(atLow.status).toBe('rejected');
436
+ const reason = (atLow as PromiseRejectedResult).reason as unknown;
437
+ expect(reason).not.toBeInstanceOf(MeshRelayNeededError);
438
+ expect(reason).toBeInstanceOf(MeshLinkEndedError);
439
+ expect((reason as MeshLinkEndedError).stage).toBe('signaling');
440
+ }, 20_000);
441
+ });