@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
@@ -0,0 +1,1019 @@
1
+ import type { IIdentityContext } from '@robota-sdk/agent-remote-pairing';
2
+ /**
3
+ * This device's place in the user's device mesh: present at one relay inbox per peer device, and at
4
+ * most one WebRTC connection per device pair, each admitted by the device handshake.
5
+ *
6
+ * **Who offers is decided by the pair, not by timing.** The device with the lower id is the offerer
7
+ * for that pair and the other one only ever answers, so two devices reaching for each other at once
8
+ * never produce two connections and never race. A node that wants a connection announces itself
9
+ * (`hello`); the offerer answers a `hello` with an offer and the answerer answers it with its own
10
+ * `hello`. Each node run has a random instance id, so a peer that restarted is recognised as a new
11
+ * run and gets a fresh connection, while a repeated `hello` from the run already being served is
12
+ * ignored. An offer names the answerer's run, so an offer meant for a previous run is not taken.
13
+ *
14
+ * **The relay is only a mailbox.** Inbox topics come from the pair's secret, so the relay sees no
15
+ * device id; everything read from it is decoded as hostile input. Nothing it says is authenticated,
16
+ * so a new attempt never displaces an admitted connection: it runs beside it and replaces it only once
17
+ * it is admitted itself, and attempts per pair are paced, so forged announcements can neither cut a
18
+ * working connection nor make this node open connections without bound. Admission is the device
19
+ * handshake over the negotiated channel, and a peer is accepted only as the device its inbox belongs
20
+ * to.
21
+ *
22
+ * **Lists are live.** Newer lists adopted in a handshake, and lists the caller hands over through
23
+ * {@link DeviceMeshNode.refresh}, apply from the next handshake on; a device they remove or revoke is
24
+ * dropped along with its connection. Lists that become newer here are also pushed over every admitted
25
+ * connection, and a pushed list is taken on the handshake's terms: only a newer one from this user's
26
+ * signing key that verifies. Lists are not a capability: they travel whatever the peer may ask.
27
+ */
28
+ import {
29
+ decodeDeviceRevocationList,
30
+ decodeDeviceRoster,
31
+ decodeSigningKeyRevocation,
32
+ derivePairRendezvous,
33
+ startDeviceHandshake,
34
+ verifyDeviceChain,
35
+ type IDeviceCertificate,
36
+ type IDeviceHandshakeIdentity,
37
+ type IDeviceHandshakeResult,
38
+ type IDeviceMeshAdmission,
39
+ type IListHighWaterMarks,
40
+ type IListUpdate,
41
+ type IPairRendezvous,
42
+ type IRelayInboxTopics,
43
+ type ISessionDescriptor,
44
+ type TDeviceCapability,
45
+ } from '@robota-sdk/agent-remote-pairing';
46
+
47
+ import type { MeshLinkEndedError } from './mesh-peer-link.js';
48
+ import {
49
+ MeshPeerLink,
50
+ type IMeshChannelBinding,
51
+ type TMeshLinkEnd,
52
+ type TMeshLinkRole,
53
+ type TMeshLinkSignal,
54
+ } from './mesh-peer-link.js';
55
+ import {
56
+ MESH_SIGNAL_VERSION,
57
+ decodeMeshSignal,
58
+ type IMeshDescription,
59
+ type TMeshSignal,
60
+ } from './mesh-signal.js';
61
+ import {
62
+ ConnectionAuthority,
63
+ type IFileFrameChannel,
64
+ type IOperatorApprover,
65
+ } from '@robota-sdk/agent-interface-session-mobility';
66
+
67
+ import { RtcPeer, type RtcChannel } from './rtc-peer.js';
68
+
69
+ import {
70
+ MeshRelayNeededError,
71
+ meshRelayIceServers,
72
+ type IMeshRelayEndpoint,
73
+ type IMeshRelayPeer,
74
+ } from './mesh-turn-relay.js';
75
+
76
+ import type { IMeshPeerRoute } from './mesh-discovery.js';
77
+ import type { IMeshRelay } from './mesh-relay.js';
78
+ import type { IDataChannelModule } from './datachannel-loader.js';
79
+ import type { IIceServer } from './webrtc-transport-options.js';
80
+
81
+ const DEFAULT_CONNECT_TIMEOUT_MS = 20_000;
82
+ /** At most one new attempt, and one `hello` reply, per pair per this interval; later ones wait. */
83
+ const MIN_ATTEMPT_INTERVAL_MS = 1_000;
84
+ /** Candidates kept for an offer that waits for its pacing slot. */
85
+ const MAX_WAITING_CANDIDATES = 64;
86
+ /** How long an attempt waits to learn which relays paired devices advertise. */
87
+ const RELAY_ADVERT_LOOKUP_MS = 3_000;
88
+ /** A message body that carries lists, not an application message; see {@link listsFrame}. */
89
+ const LISTS_FRAME = 'mesh-lists';
90
+ const LISTS_PREFIX = `{"t":"${LISTS_FRAME}"`;
91
+ /** Paired devices' relays one attempt uses, and endpoints of each. */
92
+ const MAX_RELAY_DEVICES = 2;
93
+ const MAX_RELAY_ENDPOINTS = 3;
94
+
95
+ /**
96
+ * How a connection reaches a peer no direct path reaches: a relay a paired device runs first, then
97
+ * the TURN servers the user configured. With neither, a connection that needs a relay is refused
98
+ * with {@link MeshRelayNeededError} rather than left to fail silently.
99
+ */
100
+ export interface IDeviceMeshRelayOptions {
101
+ /** The relays `peers` advertise, by device id, as found over their pairwise records. */
102
+ readonly advertised?: (
103
+ peers: readonly IMeshPeerRoute[],
104
+ signal: AbortSignal,
105
+ ) => Promise<ReadonlyMap<string, readonly IMeshRelayEndpoint[]>>;
106
+ /** TURN servers the user configured, tried once the advertised relays did not carry a connection. */
107
+ readonly configured?: readonly IIceServer[];
108
+ /** Relay candidates only: for a network where no direct path can work. */
109
+ readonly relayOnly?: boolean;
110
+ /** How long a relay credential lasts (default: see `meshRelayCredential`). */
111
+ readonly credentialTtlMs?: number;
112
+ }
113
+
114
+ /** Which way an attempt reaches the peer: directly only, or through one kind of relay too. */
115
+ type TRelayStage = 'direct' | 'embedded' | 'configured';
116
+
117
+ export interface IDeviceMeshNodeOptions {
118
+ readonly cryptoContext: IIdentityContext;
119
+ readonly identity: IDeviceHandshakeIdentity;
120
+ /** This device's signed descriptor of the session it offers to peers. */
121
+ readonly sessionDescriptor: ISessionDescriptor;
122
+ /** What this device lets a peer be asked for; an admission grants the intersection. */
123
+ readonly localPolicy: readonly TDeviceCapability[];
124
+ readonly relay: IMeshRelay;
125
+ /**
126
+ * The operator of this session, asked before a peer may use a capability that needs approval.
127
+ * Absent: such capabilities are refused.
128
+ */
129
+ readonly operatorApprover?: IOperatorApprover;
130
+ readonly iceServers?: readonly IIceServer[];
131
+ readonly relays?: IDeviceMeshRelayOptions;
132
+ /** This device's own relay: told which devices it serves whenever the lists in force change. */
133
+ readonly relayServer?: { declarePeers(peers: readonly IMeshRelayPeer[]): void };
134
+ /** Before a remote admission, ask a signing-key holder for newer lists (see the device handshake). */
135
+ readonly fetchLatestLists?: (signal: AbortSignal) => Promise<unknown>;
136
+ /** Newer verified lists adopted during a handshake, for the caller to persist. */
137
+ readonly onListsAdopted?: (update: IListUpdate) => void;
138
+ /** From the first signal of an attempt to its admission (default 20 s). */
139
+ readonly connectTimeoutMs?: number;
140
+ readonly handshakeTimeoutMs?: number;
141
+ /** Test seam: inject the `node-datachannel` module. */
142
+ readonly loadDataChannel?: () => IDataChannelModule;
143
+ readonly now?: () => number;
144
+ }
145
+
146
+ /** An admitted connection to one peer device. */
147
+ export interface IDeviceMeshLink {
148
+ readonly admission: IDeviceMeshAdmission;
149
+ readonly result: IDeviceHandshakeResult;
150
+ /** The DTLS fingerprints the handshake bound this connection to. */
151
+ readonly channelBinding: IMeshChannelBinding;
152
+ /** What the peer may do on this connection; ask it before acting on the peer's request. */
153
+ readonly authority: ConnectionAuthority;
154
+ send(body: string): void;
155
+ onMessage(handler: (body: string) => void): () => void;
156
+ /**
157
+ * A channel of its own for one file transfer. What travels on it is the file carrier's; the peer
158
+ * decides on it with its operator, and this side's authority is not asked.
159
+ */
160
+ openFileChannel(): Promise<IFileFrameChannel>;
161
+ /**
162
+ * Channels the peer opened to send a file. Whoever takes them decides on each transfer, asking
163
+ * {@link IDeviceMeshLink.authority}; with no handler, such channels are closed unread.
164
+ */
165
+ onFileChannel(handler: (channel: IFileFrameChannel) => void): () => void;
166
+ onClose(handler: () => void): () => void;
167
+ close(): void;
168
+ }
169
+
170
+ /** A data channel as a file frame channel. */
171
+ function fileFrameChannel(channel: RtcChannel): IFileFrameChannel {
172
+ return {
173
+ send: (frame) => channel.send(frame),
174
+ onFrame: (handler) => channel.onMessage(handler),
175
+ onClose: (handler) => {
176
+ if (channel.readyState === 'closed') {
177
+ queueMicrotask(handler);
178
+ return () => undefined;
179
+ }
180
+ return channel.onStateChange((state) => {
181
+ if (state === 'closed') handler();
182
+ });
183
+ },
184
+ close: () => channel.close(),
185
+ };
186
+ }
187
+
188
+ /** A connection attempt that ended before admission. */
189
+ export interface IDeviceMeshRefusal {
190
+ readonly deviceId: string;
191
+ readonly end: TMeshLinkEnd;
192
+ /**
193
+ * Why: a {@link MeshLinkEndedError} (its `cause` is e.g. the handshake refusal), a
194
+ * {@link MeshRelayNeededError} when the peer needs a relay and none could be tried, or why the
195
+ * device was dropped.
196
+ */
197
+ readonly error?: unknown;
198
+ }
199
+
200
+ interface IAttempt {
201
+ readonly cid: string;
202
+ /** The peer's run this attempt connects to. */
203
+ readonly remoteInstance: string;
204
+ readonly link: MeshPeerLink;
205
+ exposed?: IDeviceMeshLink;
206
+ /** Set once the attempt's connection is created. */
207
+ relayStage?: TRelayStage;
208
+ }
209
+
210
+ /** Runs an action at most once per interval; a later one waits, and only the latest waiting one runs. */
211
+ class Pacer {
212
+ private last?: number;
213
+ private waiting?: () => void;
214
+ private timer?: ReturnType<typeof setTimeout>;
215
+
216
+ public run(action: () => void): void {
217
+ const now = Date.now();
218
+ if (this.last === undefined || now - this.last >= MIN_ATTEMPT_INTERVAL_MS) {
219
+ this.last = now;
220
+ action();
221
+ return;
222
+ }
223
+ this.waiting = action;
224
+ if (this.timer !== undefined) return;
225
+ this.timer = setTimeout(
226
+ () => {
227
+ this.timer = undefined;
228
+ const next = this.waiting;
229
+ this.waiting = undefined;
230
+ if (next !== undefined) this.run(next);
231
+ },
232
+ MIN_ATTEMPT_INTERVAL_MS - (now - this.last),
233
+ );
234
+ this.timer.unref?.();
235
+ }
236
+
237
+ public cancel(): void {
238
+ if (this.timer !== undefined) clearTimeout(this.timer);
239
+ this.timer = undefined;
240
+ this.waiting = undefined;
241
+ }
242
+ }
243
+
244
+ interface IPeerState {
245
+ readonly device: IDeviceCertificate;
246
+ readonly role: TMeshLinkRole;
247
+ readonly rendezvous: IPairRendezvous;
248
+ readonly inbound: string;
249
+ readonly outbound: string;
250
+ /** The pair's connection. */
251
+ admitted?: IAttempt;
252
+ /** A connection being set up, beside the admitted one until it is admitted itself. */
253
+ pending?: IAttempt;
254
+ readonly attempts: Pacer;
255
+ readonly replies: Pacer;
256
+ /** An offer waiting for its pacing slot, and the candidates that arrived for it meanwhile. */
257
+ waitingOffer?: { readonly cid: string; readonly from: string; readonly ice: TMeshLinkSignal[] };
258
+ /** Relayed attempts in a row whose connection never came up: which relay stage is next. */
259
+ relayFailures: number;
260
+ }
261
+
262
+ function randomId(): string {
263
+ const bytes = new Uint8Array(16);
264
+ globalThis.crypto.getRandomValues(bytes);
265
+ return Buffer.from(bytes).toString('base64url');
266
+ }
267
+
268
+ /** Whether `next` is a newer list issued by the same signing key as `current`. */
269
+ function newer<T extends { readonly seq: number; readonly signingKeyId?: string }>(
270
+ current: T,
271
+ next: T | undefined,
272
+ ): next is T {
273
+ return next !== undefined && next.seq > current.seq && next.signingKeyId === current.signingKeyId;
274
+ }
275
+
276
+ function maxSeq(a: number | undefined, b: number | undefined): number | undefined {
277
+ if (a === undefined) return b;
278
+ if (b === undefined) return a;
279
+ return Math.max(a, b);
280
+ }
281
+
282
+ /** The higher mark of each list, per signing key: marks only ever move forward. */
283
+ function mergeMarks(a: IListHighWaterMarks, b: IListHighWaterMarks): IListHighWaterMarks {
284
+ const merged = new Map<string, { rosterSeq?: number; revocationSeq?: number }>();
285
+ for (const table of [a.bySigningKey ?? {}, b.bySigningKey ?? {}]) {
286
+ for (const [keyId, marks] of Object.entries(table)) {
287
+ const held = merged.get(keyId) ?? {};
288
+ const rosterSeq = maxSeq(held.rosterSeq, marks.rosterSeq);
289
+ const revocationSeq = maxSeq(held.revocationSeq, marks.revocationSeq);
290
+ merged.set(keyId, {
291
+ ...(rosterSeq !== undefined ? { rosterSeq } : {}),
292
+ ...(revocationSeq !== undefined ? { revocationSeq } : {}),
293
+ });
294
+ }
295
+ }
296
+ // `fromEntries` defines own properties, so no key id can reach the prototype.
297
+ const bySigningKey = Object.fromEntries(merged);
298
+ const signingKeyRevocationSeq = maxSeq(a.signingKeyRevocationSeq, b.signingKeyRevocationSeq);
299
+ return {
300
+ ...(signingKeyRevocationSeq !== undefined ? { signingKeyRevocationSeq } : {}),
301
+ bySigningKey,
302
+ };
303
+ }
304
+
305
+ /**
306
+ * `next`, but never older than what is in force: a list this node already holds from the same
307
+ * signing key with a higher `seq` stays, so a refresh from a store that missed an adopted revocation
308
+ * cannot roll it back. A new signing key's lists (after recovery) are taken as they are.
309
+ */
310
+ function mergeIdentity(
311
+ current: IDeviceHandshakeIdentity,
312
+ next: IDeviceHandshakeIdentity,
313
+ ): IDeviceHandshakeIdentity {
314
+ const sameKey =
315
+ next.signingKeyCertificate.signingKeyId === current.signingKeyCertificate.signingKeyId;
316
+ return {
317
+ ...next,
318
+ ...(sameKey && newer(next.roster, current.roster) ? { roster: current.roster } : {}),
319
+ ...(sameKey && newer(next.revocation, current.revocation)
320
+ ? { revocation: current.revocation }
321
+ : {}),
322
+ ...(current.signingKeyRevocation.seq > next.signingKeyRevocation.seq
323
+ ? { signingKeyRevocation: current.signingKeyRevocation }
324
+ : {}),
325
+ marks: mergeMarks(current.marks, next.marks),
326
+ };
327
+ }
328
+
329
+ /**
330
+ * Whether a list in `next` is newer than the one in `previous` under the same signing key. A new
331
+ * signing key's lists are not pushed: a peer cannot take them without the new key's certificate,
332
+ * which only a handshake carries.
333
+ */
334
+ function listsAdvanced(
335
+ previous: IDeviceHandshakeIdentity,
336
+ next: IDeviceHandshakeIdentity,
337
+ ): boolean {
338
+ if (next.signingKeyCertificate.signingKeyId !== previous.signingKeyCertificate.signingKeyId) {
339
+ return false;
340
+ }
341
+ return (
342
+ next.roster.seq > previous.roster.seq ||
343
+ next.revocation.seq > previous.revocation.seq ||
344
+ next.signingKeyRevocation.seq > previous.signingKeyRevocation.seq
345
+ );
346
+ }
347
+
348
+ /**
349
+ * The lists a peer pushed, when `body` carries them. Pushed lists travel as a message whose body
350
+ * names them, so a peer that does not know them treats them as an application message it ignores.
351
+ */
352
+ function listsFrame(body: string): Record<string, unknown> | undefined {
353
+ if (!body.startsWith(LISTS_PREFIX)) return undefined;
354
+ try {
355
+ const value: unknown = JSON.parse(body);
356
+ return typeof value === 'object' && value !== null && !Array.isArray(value)
357
+ ? (value as Record<string, unknown>)
358
+ : undefined;
359
+ } catch {
360
+ // allow-fallback: a body that only looks like lists is not an application message either
361
+ return {};
362
+ }
363
+ }
364
+
365
+ export class DeviceMeshNode {
366
+ /** This run's instance id. */
367
+ public readonly instance = randomId();
368
+ private identity: IDeviceHandshakeIdentity;
369
+ private sessionDescriptor: ISessionDescriptor;
370
+ private readonly peers = new Map<string, IPeerState>();
371
+ private readonly byInbound = new Map<string, IPeerState>();
372
+ private readonly linkHandlers = new Set<(link: IDeviceMeshLink) => void>();
373
+ private readonly refusalHandlers = new Set<(refusal: IDeviceMeshRefusal) => void>();
374
+ private readonly waiters = new Set<(error: Error) => void>();
375
+ private readonly unsubscribes: (() => void)[] = [];
376
+ private started = false;
377
+ private stopped = false;
378
+ private sync: Promise<void> = Promise.resolve();
379
+
380
+ public constructor(private readonly options: IDeviceMeshNodeOptions) {
381
+ this.identity = options.identity;
382
+ this.sessionDescriptor = options.sessionDescriptor;
383
+ }
384
+
385
+ public get deviceId(): string {
386
+ return this.identity.deviceCertificate.deviceId;
387
+ }
388
+
389
+ /** Derive the inbox topics, be present at them, and announce this run to every peer device. */
390
+ public async start(): Promise<void> {
391
+ if (this.started) throw new Error('DeviceMeshNode already started');
392
+ this.started = true;
393
+ this.unsubscribes.push(
394
+ this.options.relay.onMessage((topic, data) => this.receive(topic, data)),
395
+ );
396
+ await this.syncPeers();
397
+ }
398
+
399
+ /**
400
+ * Use newer lists or a new session descriptor from the next handshake on. A list older than the
401
+ * one in force is ignored. Peers the lists no longer name, or revoke, are dropped with their
402
+ * connections; new ones are announced to.
403
+ */
404
+ public refresh(update: {
405
+ readonly identity?: IDeviceHandshakeIdentity;
406
+ readonly sessionDescriptor?: ISessionDescriptor;
407
+ }): Promise<void> {
408
+ if (update.sessionDescriptor !== undefined) this.sessionDescriptor = update.sessionDescriptor;
409
+ if (update.identity === undefined) return Promise.resolve();
410
+ const previous = this.identity;
411
+ this.identity = mergeIdentity(previous, update.identity);
412
+ const synced = this.syncPeers();
413
+ // After the sync, so a device the lists drop is not sent them.
414
+ // A failed sync is the caller's, through `synced`; the lists then wait for the next handshake.
415
+ if (listsAdvanced(previous, this.identity)) {
416
+ synced.then(
417
+ () => this.pushLists(),
418
+ () => undefined,
419
+ );
420
+ }
421
+ return synced;
422
+ }
423
+
424
+ /** Send the lists in force over every admitted connection. */
425
+ private pushLists(): void {
426
+ if (this.stopped) return;
427
+ const { roster, revocation, signingKeyRevocation } = this.identity;
428
+ const body = JSON.stringify({ t: LISTS_FRAME, roster, revocation, signingKeyRevocation });
429
+ for (const state of this.peers.values()) {
430
+ try {
431
+ state.admitted?.exposed?.send(body);
432
+ } catch {
433
+ // allow-fallback: a connection that cannot take them gets them in its next handshake
434
+ }
435
+ }
436
+ }
437
+
438
+ /**
439
+ * Lists a peer pushed: taken only when newer than the ones in force, issued by this user's signing
440
+ * key (a signing-key revocation by the master key), and verifying for this device; anything else is
441
+ * ignored and the connection stays.
442
+ */
443
+ private async receiveLists(frame: Record<string, unknown>): Promise<void> {
444
+ const current = this.identity;
445
+ const roster = decodeDeviceRoster(this.options.cryptoContext, frame['roster']);
446
+ const revocation = decodeDeviceRevocationList(this.options.cryptoContext, frame['revocation']);
447
+ const signingKeyRevocation = decodeSigningKeyRevocation(
448
+ this.options.cryptoContext,
449
+ frame['signingKeyRevocation'],
450
+ );
451
+ const offered: { -readonly [K in keyof IListUpdate]?: IListUpdate[K] } = {
452
+ ...(roster.ok && newer(current.roster, roster.value) ? { roster: roster.value } : {}),
453
+ ...(revocation.ok && newer(current.revocation, revocation.value)
454
+ ? { revocation: revocation.value }
455
+ : {}),
456
+ ...(signingKeyRevocation.ok &&
457
+ signingKeyRevocation.value.seq > current.signingKeyRevocation.seq
458
+ ? { signingKeyRevocation: signingKeyRevocation.value }
459
+ : {}),
460
+ };
461
+ if (Object.keys(offered).length === 0) return;
462
+ const verdict = await verifyDeviceChain(this.options.cryptoContext, {
463
+ masterPublicKey: current.masterPublicKey,
464
+ signingKeyCert: current.signingKeyCertificate,
465
+ deviceCert: current.deviceCertificate,
466
+ roster: offered.roster ?? current.roster,
467
+ revocation: offered.revocation ?? current.revocation,
468
+ signingKeyRevocation: offered.signingKeyRevocation ?? current.signingKeyRevocation,
469
+ now: (this.options.now ?? Date.now)(),
470
+ lastSeen: current.marks,
471
+ required: { roster: true, revocation: true, signingKeyRevocation: true },
472
+ // As in a handshake, a list is taken for being newer and genuine; its expiry is judged at use.
473
+ listExpiryGraceMs: Number.POSITIVE_INFINITY,
474
+ });
475
+ if (!verdict.ok || this.stopped) return;
476
+ const { rosterSeq, revocationSeq, signingKeyRevocationSeq } = verdict.accepted;
477
+ this.adoptLists({
478
+ ...offered,
479
+ marks: {
480
+ ...(offered.signingKeyRevocation !== undefined && signingKeyRevocationSeq !== undefined
481
+ ? { signingKeyRevocationSeq }
482
+ : {}),
483
+ bySigningKey: {
484
+ [verdict.signingKeyId]: {
485
+ ...(offered.roster !== undefined && rosterSeq !== undefined ? { rosterSeq } : {}),
486
+ ...(offered.revocation !== undefined && revocationSeq !== undefined
487
+ ? { revocationSeq }
488
+ : {}),
489
+ },
490
+ },
491
+ },
492
+ });
493
+ }
494
+
495
+ /** Recompute the peer set from the current lists; serialized so two refreshes never interleave. */
496
+ private syncPeers(): Promise<void> {
497
+ this.sync = this.sync.then(async () => {
498
+ if (this.stopped) return;
499
+ const identity = this.identity;
500
+ const self = identity.deviceCertificate;
501
+ const revoked = new Set(identity.revocation.revokedDeviceIds);
502
+ const wanted = new Map<string, IDeviceCertificate>();
503
+ for (const device of identity.roster.devices) {
504
+ if (device.deviceId !== self.deviceId && !revoked.has(device.deviceId)) {
505
+ wanted.set(device.deviceId, device);
506
+ }
507
+ }
508
+ for (const [deviceId, state] of this.peers) {
509
+ const device = wanted.get(deviceId);
510
+ if (
511
+ device !== undefined &&
512
+ device.kaKey === state.device.kaKey &&
513
+ device.kaEpoch === state.device.kaEpoch
514
+ ) {
515
+ continue;
516
+ }
517
+ this.drop(state);
518
+ }
519
+ const added: IPeerState[] = [];
520
+ for (const [deviceId, device] of wanted) {
521
+ if (this.peers.has(deviceId)) continue;
522
+ let rendezvous: IPairRendezvous;
523
+ let topics: IRelayInboxTopics;
524
+ try {
525
+ rendezvous = await derivePairRendezvous(this.options.cryptoContext, {
526
+ ownKaPrivateKey: identity.kaPrivateKey,
527
+ own: self,
528
+ peerDeviceId: deviceId,
529
+ lists: identity,
530
+ });
531
+ topics = await rendezvous.relayInbox();
532
+ } catch {
533
+ // allow-fallback: a device no pairwise secret can be agreed with cannot be reached; the rest can
534
+ continue;
535
+ }
536
+ const state: IPeerState = {
537
+ device,
538
+ role: self.deviceId < device.deviceId ? 'offerer' : 'answerer',
539
+ rendezvous,
540
+ inbound: topics.inbound,
541
+ outbound: topics.outbound,
542
+ attempts: new Pacer(),
543
+ replies: new Pacer(),
544
+ relayFailures: 0,
545
+ };
546
+ this.peers.set(deviceId, state);
547
+ this.byInbound.set(topics.inbound, state);
548
+ added.push(state);
549
+ }
550
+ if (this.stopped) return;
551
+ this.options.relayServer?.declarePeers(
552
+ [...this.peers.values()].map((peer) => ({
553
+ deviceId: peer.device.deviceId,
554
+ rendezvous: peer.rendezvous,
555
+ })),
556
+ );
557
+ this.options.relay.declarePresence([...this.byInbound.keys()]);
558
+ const routes = this.peerRoutes();
559
+ this.options.relay.declarePeers?.(routes);
560
+ // Learn the peers' relays ahead of the first attempt, so it does not wait on the lookup.
561
+ // allow-fallback: a lookup that fails here is run again by the attempt that needs it
562
+ this.options.relays
563
+ ?.advertised?.(routes, AbortSignal.timeout(RELAY_ADVERT_LOOKUP_MS))
564
+ .catch(() => undefined);
565
+ for (const state of added) this.hello(state);
566
+ });
567
+ const done = this.sync;
568
+ // One failed sync must not stop every later one.
569
+ this.sync = done.catch(() => undefined);
570
+ return done;
571
+ }
572
+
573
+ private drop(state: IPeerState): void {
574
+ this.peers.delete(state.device.deviceId);
575
+ this.byInbound.delete(state.inbound);
576
+ state.attempts.cancel();
577
+ state.replies.cancel();
578
+ const { pending, admitted } = state;
579
+ state.pending = undefined;
580
+ state.admitted = undefined;
581
+ pending?.link.close();
582
+ admitted?.link.close();
583
+ if (pending !== undefined && !this.stopped) {
584
+ const refusal: IDeviceMeshRefusal = {
585
+ deviceId: state.device.deviceId,
586
+ end: 'closed',
587
+ error: new Error('peer device was revoked or is no longer in the roster'),
588
+ };
589
+ for (const handler of this.refusalHandlers) handler(refusal);
590
+ }
591
+ }
592
+
593
+ /** The offerer for a pair is the device with the lower id. */
594
+ public roleFor(deviceId: string): TMeshLinkRole | undefined {
595
+ return this.peers.get(deviceId)?.role;
596
+ }
597
+
598
+ /** The admitted link to `deviceId`, if there is one. */
599
+ public link(deviceId: string): IDeviceMeshLink | undefined {
600
+ return this.peers.get(deviceId)?.admitted?.exposed;
601
+ }
602
+
603
+ /**
604
+ * An admitted link to `deviceId`: the existing one, or a new one once the peer is reachable.
605
+ * Rejects for a device this node will not talk to, when an attempt is refused, on timeout, or when
606
+ * the node stops.
607
+ */
608
+ public connect(
609
+ deviceId: string,
610
+ timeoutMs = DEFAULT_CONNECT_TIMEOUT_MS,
611
+ ): Promise<IDeviceMeshLink> {
612
+ if (this.stopped) return Promise.reject(new Error('device mesh node stopped'));
613
+ const state = this.peers.get(deviceId);
614
+ if (state === undefined) {
615
+ return Promise.reject(new Error('not a rostered, unrevoked peer device'));
616
+ }
617
+ const existing = state.admitted?.exposed;
618
+ if (existing !== undefined) return Promise.resolve(existing);
619
+ return new Promise<IDeviceMeshLink>((resolve, reject) => {
620
+ const done = (): void => {
621
+ clearTimeout(timer);
622
+ offLink();
623
+ offRefusal();
624
+ this.waiters.delete(fail);
625
+ };
626
+ const fail = (error: Error): void => {
627
+ done();
628
+ reject(error);
629
+ };
630
+ const timer = setTimeout(
631
+ () => fail(new Error('peer device did not connect in time')),
632
+ timeoutMs,
633
+ );
634
+ timer.unref?.();
635
+ const offLink = this.onLink((link) => {
636
+ if (link.admission.deviceId !== deviceId) return;
637
+ done();
638
+ resolve(link);
639
+ });
640
+ const offRefusal = this.onRefusal((refusal) => {
641
+ if (refusal.deviceId !== deviceId) return;
642
+ fail(
643
+ refusal.error instanceof Error ? refusal.error : new Error(`connection ${refusal.end}`),
644
+ );
645
+ });
646
+ this.waiters.add(fail);
647
+ this.hello(state);
648
+ });
649
+ }
650
+
651
+ public onLink(handler: (link: IDeviceMeshLink) => void): () => void {
652
+ this.linkHandlers.add(handler);
653
+ return () => this.linkHandlers.delete(handler);
654
+ }
655
+
656
+ public onRefusal(handler: (refusal: IDeviceMeshRefusal) => void): () => void {
657
+ this.refusalHandlers.add(handler);
658
+ return () => this.refusalHandlers.delete(handler);
659
+ }
660
+
661
+ public stop(): void {
662
+ if (this.stopped) return;
663
+ this.stopped = true;
664
+ for (const unsubscribe of this.unsubscribes.splice(0)) unsubscribe();
665
+ for (const state of [...this.peers.values()]) this.drop(state);
666
+ for (const fail of [...this.waiters]) fail(new Error('device mesh node stopped'));
667
+ this.linkHandlers.clear();
668
+ this.refusalHandlers.clear();
669
+ }
670
+
671
+ private hello(state: IPeerState): void {
672
+ if (this.stopped) return;
673
+ this.options.relay.send(state.outbound, {
674
+ v: MESH_SIGNAL_VERSION,
675
+ kind: 'hello',
676
+ from: this.instance,
677
+ });
678
+ }
679
+
680
+ private receive(topic: string, data: unknown): void {
681
+ if (this.stopped) return;
682
+ const state = this.byInbound.get(topic);
683
+ if (state === undefined) return;
684
+ const signal = decodeMeshSignal(data);
685
+ if (signal === undefined) return;
686
+ if (signal.kind === 'hello') {
687
+ this.onHello(state, signal.from);
688
+ return;
689
+ }
690
+ if (signal.to !== this.instance) return; // meant for a previous run of this node
691
+ if (signal.kind === 'offer') {
692
+ this.onOffer(state, signal);
693
+ return;
694
+ }
695
+ for (const attempt of [state.pending, state.admitted]) {
696
+ if (attempt?.cid === signal.cid && attempt.remoteInstance === signal.from) {
697
+ attempt.link.onSignal(toLinkSignal(signal));
698
+ return;
699
+ }
700
+ }
701
+ const waiting = state.waitingOffer;
702
+ if (
703
+ signal.kind === 'ice' &&
704
+ waiting?.cid === signal.cid &&
705
+ waiting.from === signal.from &&
706
+ waiting.ice.length < MAX_WAITING_CANDIDATES
707
+ ) {
708
+ waiting.ice.push(toLinkSignal(signal));
709
+ }
710
+ }
711
+
712
+ /** The run already being served, or being connected to. */
713
+ private serves(state: IPeerState, remoteInstance: string): boolean {
714
+ return (
715
+ state.admitted?.remoteInstance === remoteInstance ||
716
+ state.pending?.remoteInstance === remoteInstance
717
+ );
718
+ }
719
+
720
+ private onHello(state: IPeerState, remoteInstance: string): void {
721
+ if (this.serves(state, remoteInstance)) return;
722
+ if (state.role === 'answerer') {
723
+ // Only the offerer opens a connection; tell it this run is here.
724
+ state.replies.run(() => this.hello(state));
725
+ return;
726
+ }
727
+ state.attempts.run(() => {
728
+ if (this.stopped || this.peers.get(state.device.deviceId) !== state) return;
729
+ if (this.serves(state, remoteInstance)) return;
730
+ void this.startAttempt(state, remoteInstance, randomId()).link.offer();
731
+ });
732
+ }
733
+
734
+ private onOffer(state: IPeerState, signal: IMeshDescription): void {
735
+ // Glare resolution: an offer toward the pair's offerer is not taken, whoever sent it.
736
+ if (state.role !== 'answerer') return;
737
+ if (state.admitted?.cid === signal.cid || state.pending?.cid === signal.cid) return;
738
+ const waiting = { cid: signal.cid, from: signal.from, ice: [] as TMeshLinkSignal[] };
739
+ state.waitingOffer = waiting;
740
+ state.attempts.run(() => {
741
+ if (state.waitingOffer === waiting) state.waitingOffer = undefined;
742
+ if (this.stopped || this.peers.get(state.device.deviceId) !== state) return;
743
+ if (state.admitted?.cid === signal.cid || state.pending?.cid === signal.cid) return;
744
+ const { link } = this.startAttempt(state, signal.from, signal.cid);
745
+ link.onSignal(toLinkSignal(signal));
746
+ for (const candidate of waiting.ice) link.onSignal(candidate);
747
+ });
748
+ }
749
+
750
+ /** A new attempt replaces any other pending one, never the admitted connection. */
751
+ private startAttempt(state: IPeerState, remoteInstance: string, cid: string): IAttempt {
752
+ const previous = state.pending;
753
+ state.pending = undefined;
754
+ previous?.link.close();
755
+ const send = (signal: TMeshLinkSignal): void => {
756
+ if (this.stopped) return;
757
+ this.options.relay.send(state.outbound, {
758
+ v: MESH_SIGNAL_VERSION,
759
+ from: this.instance,
760
+ to: remoteInstance,
761
+ cid,
762
+ ...signal,
763
+ });
764
+ };
765
+ let attempt: IAttempt | undefined;
766
+ const link: MeshPeerLink = new MeshPeerLink({
767
+ role: state.role,
768
+ createPeer: async () => {
769
+ const { stage, servers } = await this.relayStage(state);
770
+ if (attempt !== undefined) attempt.relayStage = stage;
771
+ return this.createPeer(servers);
772
+ },
773
+ sendSignal: send,
774
+ startHandshake: (binding) =>
775
+ startDeviceHandshake(this.options.cryptoContext, {
776
+ role: state.role === 'offerer' ? 'initiator' : 'responder',
777
+ identity: this.identity,
778
+ sessionDescriptor: this.sessionDescriptor,
779
+ ...(state.role === 'offerer' ? { expectedPeerDeviceId: state.device.deviceId } : {}),
780
+ localFingerprint: binding.localFingerprint,
781
+ remoteFingerprint: binding.remoteFingerprint,
782
+ // A data channel proves nothing about where the peer runs.
783
+ locality: 'another-host',
784
+ localPolicy: this.options.localPolicy,
785
+ send: binding.send,
786
+ ...(this.options.fetchLatestLists !== undefined
787
+ ? { fetchLatestLists: this.options.fetchLatestLists }
788
+ : {}),
789
+ onListsAdopted: (update) => this.adoptLists(update),
790
+ ...(this.options.now !== undefined ? { now: this.options.now } : {}),
791
+ ...(this.options.handshakeTimeoutMs !== undefined
792
+ ? { timeoutMs: this.options.handshakeTimeoutMs }
793
+ : {}),
794
+ }),
795
+ connectTimeoutMs: this.options.connectTimeoutMs ?? DEFAULT_CONNECT_TIMEOUT_MS,
796
+ // The inbox the peer reached is the pair's: only that device is taken, before it is told so.
797
+ accepts: (result) => result.admission.deviceId === state.device.deviceId,
798
+ onAdmitted: (result) => this.admitted(state, link, result),
799
+ onEnded: (end, error) => this.ended(state, link, end, error),
800
+ });
801
+ attempt = { cid, remoteInstance, link };
802
+ state.pending = attempt;
803
+ return attempt;
804
+ }
805
+
806
+ /** Lists the handshake verified and found newer: in force here at once, then handed to the caller. */
807
+ private adoptLists(update: IListUpdate): void {
808
+ const current = this.identity;
809
+ const next: IDeviceHandshakeIdentity = {
810
+ ...current,
811
+ ...(newer(current.roster, update.roster) ? { roster: update.roster } : {}),
812
+ ...(newer(current.revocation, update.revocation) ? { revocation: update.revocation } : {}),
813
+ ...(update.signingKeyRevocation !== undefined &&
814
+ update.signingKeyRevocation.seq > current.signingKeyRevocation.seq
815
+ ? { signingKeyRevocation: update.signingKeyRevocation }
816
+ : {}),
817
+ marks: mergeMarks(current.marks, update.marks),
818
+ };
819
+ // allow-fallback: a failed sync keeps the lists in force; the adopted ones apply to handshakes regardless
820
+ this.refresh({ identity: next }).catch(() => undefined);
821
+ this.options.onListsAdopted?.(update);
822
+ }
823
+
824
+ private admitted(state: IPeerState, link: MeshPeerLink, result: IDeviceHandshakeResult): void {
825
+ const attempt = state.pending;
826
+ if (attempt === undefined || attempt.link !== link) return;
827
+ // The inbox the peer reached is the pair's; any other admitted device is not this pair's peer.
828
+ if (result.admission.deviceId !== state.device.deviceId) {
829
+ link.close();
830
+ return;
831
+ }
832
+ const admission = result.admission;
833
+ const channelBinding = link.channelBinding;
834
+ // Admitted means the handshake ran over both fingerprints; without them nothing is bound here.
835
+ if (channelBinding === undefined) {
836
+ link.close();
837
+ return;
838
+ }
839
+ const authority = new ConnectionAuthority(
840
+ {
841
+ deviceId: admission.deviceId,
842
+ sessionId: admission.sessionId,
843
+ locality: admission.locality,
844
+ capabilities: admission.capabilities,
845
+ },
846
+ this.options.operatorApprover,
847
+ );
848
+ link.onEnd(() => authority.close());
849
+ // A message from the peer is delivered only when this connection's authority allows it; the
850
+ // decision is taken once and every message waits on it, so order is kept.
851
+ const messaging = authority.authorize('message');
852
+ // Pushed lists are this node's, whatever the peer may ask; they never reach the application.
853
+ link.onMessage((body) => {
854
+ const frame = listsFrame(body);
855
+ // allow-fallback: lists that cannot be taken leave the ones in force, as a failed handshake would
856
+ if (frame !== undefined) this.receiveLists(frame).catch(() => undefined);
857
+ });
858
+ const exposed: IDeviceMeshLink = {
859
+ admission,
860
+ result,
861
+ channelBinding,
862
+ authority,
863
+ send: (body) => link.send(body),
864
+ onMessage: (handler) =>
865
+ link.onMessage((body) => {
866
+ if (listsFrame(body) !== undefined) return;
867
+ void messaging.then((decision) => {
868
+ if (decision.allowed && !link.ended) handler(body);
869
+ else link.close();
870
+ });
871
+ }),
872
+ openFileChannel: async () => fileFrameChannel(await link.openFileChannel()),
873
+ onFileChannel: (handler) =>
874
+ link.onFileChannel((channel) => handler(fileFrameChannel(channel))),
875
+ onClose: (handler) => link.onEnd(() => handler()),
876
+ close: () => link.close(),
877
+ };
878
+ attempt.exposed = exposed;
879
+ state.relayFailures = 0;
880
+ const previous = state.admitted;
881
+ state.admitted = attempt;
882
+ state.pending = undefined;
883
+ previous?.link.close();
884
+ this.options.relay.confirmPeer?.(admission.deviceId);
885
+ for (const handler of this.linkHandlers) handler(exposed);
886
+ }
887
+
888
+ private ended(
889
+ state: IPeerState,
890
+ link: MeshPeerLink,
891
+ end: TMeshLinkEnd,
892
+ error: MeshLinkEndedError,
893
+ ): void {
894
+ if (state.admitted?.link === link) {
895
+ state.admitted = undefined;
896
+ return;
897
+ }
898
+ if (state.pending?.link !== link) return;
899
+ const stage = state.pending.relayStage;
900
+ state.pending = undefined;
901
+ // This side took the peer's description, so the peer is up and paths were being tried, and
902
+ // still none connected: ICE failed, or was still checking when time ran out. (An answer lost on
903
+ // its way looks the same to the answerer; the cause attached says which.) Only this calls for
904
+ // a relay; an attempt that ended before paths were tried, or after one connected, is reported
905
+ // as it ended.
906
+ const unreachable =
907
+ error.stage === 'connecting' &&
908
+ (end === 'timeout' || error.peer?.state === 'failed') &&
909
+ error.peer?.history.includes('connected') !== true;
910
+ let reported: unknown = error;
911
+ if (error.cause instanceof MeshRelayNeededError) reported = error.cause;
912
+ else if (unreachable && stage === 'direct') {
913
+ reported = new MeshRelayNeededError(state.device.deviceId, 'no-direct-path', {
914
+ cause: error,
915
+ });
916
+ } else if (unreachable) state.relayFailures += 1;
917
+ const refusal: IDeviceMeshRefusal = {
918
+ deviceId: state.device.deviceId,
919
+ end,
920
+ error: reported,
921
+ };
922
+ for (const handler of this.refusalHandlers) handler(refusal);
923
+ }
924
+
925
+ /**
926
+ * The relays the next attempt to `state`'s peer uses: the ones paired devices advertise, then the
927
+ * configured ones once those did not carry a connection, and round again. None: a direct attempt,
928
+ * or — when only relay candidates may be used — a refusal naming the missing relay.
929
+ */
930
+ private async relayStage(
931
+ state: IPeerState,
932
+ ): Promise<{ readonly stage: TRelayStage; readonly servers: readonly IIceServer[] }> {
933
+ const relays = this.options.relays;
934
+ const stages: { readonly stage: TRelayStage; readonly servers: readonly IIceServer[] }[] = [];
935
+ const embedded = await this.embeddedRelays(state);
936
+ if (embedded.length > 0) stages.push({ stage: 'embedded', servers: embedded });
937
+ if (relays?.configured !== undefined && relays.configured.length > 0) {
938
+ stages.push({ stage: 'configured', servers: relays.configured });
939
+ }
940
+ if (stages.length === 0) {
941
+ if (relays?.relayOnly === true) {
942
+ throw new MeshRelayNeededError(state.device.deviceId, 'relay-only');
943
+ }
944
+ return { stage: 'direct', servers: [] };
945
+ }
946
+ return stages[state.relayFailures % stages.length]!;
947
+ }
948
+
949
+ /** The routes of every peer, `first`'s first: a lookup bounded to some peers covers it. */
950
+ private peerRoutes(first?: IPeerState): IMeshPeerRoute[] {
951
+ const states = [...this.peers.values()];
952
+ const ordered = first === undefined ? states : [first, ...states.filter((p) => p !== first)];
953
+ return ordered.map((peer) => ({
954
+ deviceId: peer.device.deviceId,
955
+ inbound: peer.inbound,
956
+ outbound: peer.outbound,
957
+ rendezvous: peer.rendezvous,
958
+ }));
959
+ }
960
+
961
+ /**
962
+ * ICE servers for the relays paired devices advertise, with this pair's credential for each: the
963
+ * peer's own relay first. Only a device the lists in force name, unrevoked, is asked to relay.
964
+ */
965
+ private async embeddedRelays(state: IPeerState): Promise<IIceServer[]> {
966
+ const advertised = this.options.relays?.advertised;
967
+ if (advertised === undefined) return [];
968
+ let found: ReadonlyMap<string, readonly IMeshRelayEndpoint[]>;
969
+ try {
970
+ found = await advertised(this.peerRoutes(state), AbortSignal.timeout(RELAY_ADVERT_LOOKUP_MS));
971
+ } catch {
972
+ // allow-fallback: no advertised relay is known; the configured ones, or the explicit refusal, follow
973
+ return [];
974
+ }
975
+ const peerId = state.device.deviceId;
976
+ const order = [peerId, ...[...found.keys()].filter((deviceId) => deviceId !== peerId)];
977
+ const now = (this.options.now ?? Date.now)();
978
+ const servers: IIceServer[] = [];
979
+ let devices = 0;
980
+ for (const deviceId of order) {
981
+ if (devices >= MAX_RELAY_DEVICES) break;
982
+ const endpoints = found.get(deviceId);
983
+ const relayDevice = this.peers.get(deviceId);
984
+ if (endpoints === undefined || endpoints.length === 0 || relayDevice === undefined) continue;
985
+ devices += 1;
986
+ servers.push(
987
+ ...(await meshRelayIceServers(
988
+ relayDevice.rendezvous,
989
+ endpoints.slice(0, MAX_RELAY_ENDPOINTS),
990
+ now,
991
+ this.options.relays?.credentialTtlMs,
992
+ )),
993
+ );
994
+ }
995
+ return servers;
996
+ }
997
+
998
+ /**
999
+ * A connection with a DTLS certificate of its own (the implementation makes one per connection):
1000
+ * a per-process certificate would be a stable identifier the relay could link across connections.
1001
+ * No ICE server is contacted unless one is configured or a paired device advertises its relay.
1002
+ */
1003
+ private createPeer(relayServers: readonly IIceServer[]): RtcPeer {
1004
+ const iceServers = [...(this.options.iceServers ?? []), ...relayServers];
1005
+ return new RtcPeer({
1006
+ ...(iceServers.length > 0 ? { iceServers } : {}),
1007
+ ...(this.options.relays?.relayOnly === true ? { forceTurn: true } : {}),
1008
+ ...(this.options.loadDataChannel !== undefined
1009
+ ? { loadDataChannel: this.options.loadDataChannel }
1010
+ : {}),
1011
+ });
1012
+ }
1013
+ }
1014
+
1015
+ function toLinkSignal(signal: Exclude<TMeshSignal, { kind: 'hello' }>): TMeshLinkSignal {
1016
+ return signal.kind === 'ice'
1017
+ ? { kind: 'ice', candidate: { ...signal.candidate } }
1018
+ : { kind: signal.kind, sdp: signal.sdp };
1019
+ }