@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,465 @@
1
+ /**
2
+ * The connection a device enrollment runs over: a new device dials the relay topic derived from its
3
+ * enrollment code, an existing device listens there, and each gets a data channel bound to the DTLS
4
+ * fingerprints of the connection — its own, and the one of the certificate the DTLS layer verified
5
+ * for the peer, never SDP text.
6
+ *
7
+ * This file carries frames and implements no policy: what the channel says, and whether the peer is
8
+ * believed, is the enrollment protocol's (`agent-remote-pairing`), which proves knowledge of the code
9
+ * over these very fingerprints before anything else crosses. Until then a peer here is a stranger,
10
+ * so the relay's signals are decoded as hostile input, a remote description must advertise exactly one
11
+ * fingerprint, and a listener serves one attempt at a time.
12
+ */
13
+ import {
14
+ extractDtlsFingerprint,
15
+ extractDtlsFingerprintAttribute,
16
+ } from '@robota-sdk/agent-remote-pairing';
17
+
18
+ import { whenRemoteCertificateVerified } from './negotiated-certificate.js';
19
+ import { RtcPeer, type RtcChannel } from './rtc-peer.js';
20
+
21
+ import type { IDataChannelModule } from './datachannel-loader.js';
22
+ import type { IMeshRelay } from './mesh-relay.js';
23
+ import type { IIceServer } from './webrtc-transport-options.js';
24
+
25
+ const SIGNAL_VERSION = 1;
26
+ const CHANNEL_LABEL = 'agent-enroll';
27
+ const DEFAULT_CONNECT_TIMEOUT_MS = 20_000;
28
+ const MAX_CANDIDATES = 64;
29
+ /** Frames kept for a consumer that has not subscribed yet; an enrollment says a handful. */
30
+ const MAX_EARLY_FRAMES = 16;
31
+ /** Largest frame an enrollment channel carries: a grant holds a roster of certificates. */
32
+ const MAX_FRAME_CHARS = 256 * 1024;
33
+ const MAX_SDP_CHARS = 48 * 1024;
34
+ const MAX_CANDIDATE_CHARS = 2048;
35
+ const ATTEMPT_ID = /^[A-Za-z0-9_-]{22}$/;
36
+ /** Time for a channel close to leave before the connection under it is closed. */
37
+ const PEER_CLOSE_GRACE_MS = 250;
38
+
39
+ /** A data channel bound to one negotiated connection, carrying JSON frames. */
40
+ export interface IEnrollmentChannel {
41
+ readonly localFingerprint: string;
42
+ /** Of the certificate the DTLS layer verified. */
43
+ readonly remoteFingerprint: string;
44
+ /** Send one frame; a closed channel drops it (the peer learns of the close instead). */
45
+ send(frame: unknown): void;
46
+ /** Each inbound frame, JSON-decoded; text that is not JSON arrives as `undefined`. */
47
+ onFrame(handler: (frame: unknown) => void): () => void;
48
+ onClose(handler: () => void): () => void;
49
+ close(): void;
50
+ }
51
+
52
+ /**
53
+ * Why no channel came about. `absent`: nobody waits at the topic — the code is wrong, expired or
54
+ * already used.
55
+ */
56
+ export type TEnrollmentLinkFailure =
57
+ 'absent' | 'timeout' | 'signaling' | 'channel-binding' | 'closed';
58
+
59
+ export class EnrollmentLinkError extends Error {
60
+ readonly reason: TEnrollmentLinkFailure;
61
+
62
+ constructor(reason: TEnrollmentLinkFailure, detail?: string) {
63
+ super(`enrollment connection failed (${reason})${detail !== undefined ? `: ${detail}` : ''}`);
64
+ this.name = 'EnrollmentLinkError';
65
+ this.reason = reason;
66
+ }
67
+ }
68
+
69
+ export interface IEnrollmentRendezvous {
70
+ readonly relay: IMeshRelay;
71
+ /** The topic this side waits at. */
72
+ readonly inbound: string;
73
+ /** The topic the other side waits at. */
74
+ readonly outbound: string;
75
+ readonly iceServers?: readonly IIceServer[];
76
+ /** Test seam: inject the `node-datachannel` module. */
77
+ readonly loadDataChannel?: () => IDataChannelModule;
78
+ /** From the first signal of an attempt to a bound, open channel (default 20 s). */
79
+ readonly connectTimeoutMs?: number;
80
+ }
81
+
82
+ type TSignal =
83
+ | { readonly kind: 'offer' | 'answer'; readonly cid: string; readonly sdp: string }
84
+ | {
85
+ readonly kind: 'ice';
86
+ readonly cid: string;
87
+ readonly candidate: { readonly candidate: string; readonly mid: string };
88
+ };
89
+
90
+ function bounded(value: unknown, max: number): value is string {
91
+ return typeof value === 'string' && value.length > 0 && value.length <= max;
92
+ }
93
+
94
+ function decodeSignal(value: unknown): TSignal | undefined {
95
+ if (typeof value !== 'object' || value === null || Array.isArray(value)) return undefined;
96
+ const r = value as Record<string, unknown>;
97
+ if (r['v'] !== SIGNAL_VERSION || typeof r['cid'] !== 'string' || !ATTEMPT_ID.test(r['cid'])) {
98
+ return undefined;
99
+ }
100
+ const cid = r['cid'];
101
+ if (r['kind'] === 'offer' || r['kind'] === 'answer') {
102
+ return bounded(r['sdp'], MAX_SDP_CHARS) ? { kind: r['kind'], cid, sdp: r['sdp'] } : undefined;
103
+ }
104
+ if (r['kind'] === 'ice') {
105
+ const c = r['candidate'];
106
+ if (typeof c !== 'object' || c === null || Array.isArray(c)) return undefined;
107
+ const cr = c as Record<string, unknown>;
108
+ if (!bounded(cr['candidate'], MAX_CANDIDATE_CHARS) || !bounded(cr['mid'], 64)) return undefined;
109
+ return { kind: 'ice', cid, candidate: { candidate: cr['candidate'], mid: cr['mid'] } };
110
+ }
111
+ return undefined;
112
+ }
113
+
114
+ function randomId(): string {
115
+ const bytes = new Uint8Array(16);
116
+ globalThis.crypto.getRandomValues(bytes);
117
+ return Buffer.from(bytes).toString('base64url');
118
+ }
119
+
120
+ /** One connection attempt, in either role, until its channel is bound and open. */
121
+ class EnrollmentAttempt {
122
+ private peer?: RtcPeer;
123
+ private channel?: RtcChannel;
124
+ private localFingerprint?: string;
125
+ private remoteFingerprint?: string;
126
+ private tookRemoteDescription = false;
127
+ private sentDescription = false;
128
+ private readonly localCandidates: TSignal[] = [];
129
+ private remoteCandidates = 0;
130
+ private chain: Promise<void> = Promise.resolve();
131
+ private stopAwaiting?: () => void;
132
+ private readonly timer: ReturnType<typeof setTimeout>;
133
+ private done = false;
134
+ private readonly early: string[] = [];
135
+ private readonly frameHandlers = new Set<(frame: unknown) => void>();
136
+ private readonly closeHandlers = new Set<() => void>();
137
+
138
+ public constructor(
139
+ private readonly role: 'offerer' | 'answerer',
140
+ public readonly cid: string,
141
+ private readonly options: IEnrollmentRendezvous,
142
+ private readonly onBound: (channel: IEnrollmentChannel) => void,
143
+ private readonly onFailed: (error: EnrollmentLinkError) => void,
144
+ ) {
145
+ this.timer = setTimeout(
146
+ () => this.fail(new EnrollmentLinkError('timeout')),
147
+ options.connectTimeoutMs ?? DEFAULT_CONNECT_TIMEOUT_MS,
148
+ );
149
+ }
150
+
151
+ public async offer(): Promise<void> {
152
+ try {
153
+ const peer = this.createPeer();
154
+ this.adopt(peer.createDataChannel(CHANNEL_LABEL));
155
+ const sdp = await peer.createOffer();
156
+ if (this.done) return;
157
+ this.localFingerprint = extractDtlsFingerprint(sdp);
158
+ this.sendDescription({ kind: 'offer', cid: this.cid, sdp });
159
+ } catch (error) {
160
+ this.fail(new EnrollmentLinkError('signaling', describe(error)));
161
+ }
162
+ }
163
+
164
+ public onSignal(signal: TSignal): void {
165
+ if (this.done) return;
166
+ this.chain = this.chain
167
+ .then(() => this.apply(signal))
168
+ .catch((error: unknown) => this.fail(new EnrollmentLinkError('signaling', describe(error))));
169
+ }
170
+
171
+ private async apply(signal: TSignal): Promise<void> {
172
+ if (this.done) return;
173
+ if (signal.kind === 'ice') {
174
+ if (this.peer === undefined || ++this.remoteCandidates > MAX_CANDIDATES) return;
175
+ this.peer.addRemoteCandidate(signal.candidate);
176
+ return;
177
+ }
178
+ const expected = this.role === 'offerer' ? 'answer' : 'offer';
179
+ // One remote description: the one fingerprint checked is the one the binding names.
180
+ if (signal.kind !== expected || this.tookRemoteDescription) return;
181
+ this.tookRemoteDescription = true;
182
+ let algorithm: string;
183
+ try {
184
+ algorithm = extractDtlsFingerprintAttribute(signal.sdp).algorithm;
185
+ } catch (error) {
186
+ this.fail(new EnrollmentLinkError('channel-binding', describe(error)));
187
+ return;
188
+ }
189
+ if (this.role === 'offerer') {
190
+ const peer = this.peer;
191
+ if (peer === undefined) return;
192
+ peer.acceptAnswer(signal.sdp);
193
+ this.awaitCertificate(peer, algorithm);
194
+ return;
195
+ }
196
+ const peer = this.createPeer();
197
+ const sdp = await peer.acceptOffer(signal.sdp);
198
+ if (this.done) return;
199
+ this.localFingerprint = extractDtlsFingerprint(sdp);
200
+ this.sendDescription({ kind: 'answer', cid: this.cid, sdp });
201
+ this.awaitCertificate(peer, algorithm);
202
+ }
203
+
204
+ private createPeer(): RtcPeer {
205
+ // A certificate of its own per connection, so no stable identifier crosses the relay.
206
+ const peer = new RtcPeer({
207
+ ...(this.options.iceServers !== undefined ? { iceServers: this.options.iceServers } : {}),
208
+ ...(this.options.loadDataChannel !== undefined
209
+ ? { loadDataChannel: this.options.loadDataChannel }
210
+ : {}),
211
+ });
212
+ this.peer = peer;
213
+ peer.onLocalCandidate((candidate) => {
214
+ if (this.done) return;
215
+ const signal: TSignal = {
216
+ kind: 'ice',
217
+ cid: this.cid,
218
+ candidate: { candidate: candidate.candidate, mid: candidate.mid },
219
+ };
220
+ if (this.sentDescription) this.send(signal);
221
+ else if (this.localCandidates.length < MAX_CANDIDATES) this.localCandidates.push(signal);
222
+ });
223
+ peer.onStateChange((state) => {
224
+ if (state === 'failed' || state === 'closed') {
225
+ this.fail(new EnrollmentLinkError('closed', `connection ${state}`));
226
+ }
227
+ });
228
+ peer.onDataChannel((channel) => {
229
+ if (this.role === 'answerer' && this.channel === undefined && !this.done) this.adopt(channel);
230
+ else channel.close();
231
+ });
232
+ return peer;
233
+ }
234
+
235
+ private send(signal: TSignal): void {
236
+ this.options.relay.send(this.options.outbound, { v: SIGNAL_VERSION, ...signal });
237
+ }
238
+
239
+ private sendDescription(signal: TSignal): void {
240
+ this.send(signal);
241
+ this.sentDescription = true;
242
+ for (const candidate of this.localCandidates.splice(0)) this.send(candidate);
243
+ }
244
+
245
+ private awaitCertificate(peer: RtcPeer, algorithm: string): void {
246
+ this.stopAwaiting = whenRemoteCertificateVerified(
247
+ peer,
248
+ algorithm,
249
+ (fingerprint) => {
250
+ this.remoteFingerprint = fingerprint;
251
+ this.maybeBound();
252
+ },
253
+ (reason) => this.fail(new EnrollmentLinkError('channel-binding', reason)),
254
+ );
255
+ }
256
+
257
+ private adopt(channel: RtcChannel): void {
258
+ this.channel = channel;
259
+ // Subscribed at once: the implementation does not keep a frame that arrives before a listener.
260
+ channel.onMessage((text) => this.inbound(text));
261
+ channel.onStateChange((state) => {
262
+ if (state === 'open') this.maybeBound();
263
+ else if (state === 'closed') this.closed();
264
+ });
265
+ if (channel.readyState === 'open') this.maybeBound();
266
+ }
267
+
268
+ private inbound(text: string): void {
269
+ if (text.length > MAX_FRAME_CHARS) {
270
+ this.closeAll();
271
+ return;
272
+ }
273
+ if (this.frameHandlers.size === 0) {
274
+ if (this.early.length >= MAX_EARLY_FRAMES) {
275
+ this.closeAll();
276
+ return;
277
+ }
278
+ this.early.push(text);
279
+ return;
280
+ }
281
+ this.deliver(text);
282
+ }
283
+
284
+ private deliver(text: string): void {
285
+ let frame: unknown;
286
+ try {
287
+ frame = JSON.parse(text);
288
+ } catch {
289
+ frame = undefined;
290
+ }
291
+ for (const handler of this.frameHandlers) handler(frame);
292
+ }
293
+
294
+ private maybeBound(): void {
295
+ const channel = this.channel;
296
+ if (this.done || channel === undefined || channel.readyState !== 'open') return;
297
+ const localFingerprint = this.localFingerprint;
298
+ const remoteFingerprint = this.remoteFingerprint;
299
+ if (localFingerprint === undefined || remoteFingerprint === undefined) return;
300
+ this.done = true;
301
+ clearTimeout(this.timer);
302
+ this.stopAwaiting?.();
303
+ this.onBound({
304
+ localFingerprint,
305
+ remoteFingerprint,
306
+ send: (frame) => {
307
+ try {
308
+ channel.send(JSON.stringify(frame));
309
+ } catch {
310
+ // allow-fallback: a closed channel drops the frame; its close reaches both sides
311
+ }
312
+ },
313
+ onFrame: (handler) => {
314
+ this.frameHandlers.add(handler);
315
+ const held = this.early.splice(0);
316
+ if (held.length > 0) queueMicrotask(() => held.forEach((text) => this.deliver(text)));
317
+ return () => this.frameHandlers.delete(handler);
318
+ },
319
+ onClose: (handler) => {
320
+ if (channel.readyState === 'closed') {
321
+ queueMicrotask(handler);
322
+ return () => undefined;
323
+ }
324
+ this.closeHandlers.add(handler);
325
+ return () => this.closeHandlers.delete(handler);
326
+ },
327
+ close: () => this.closeAll(),
328
+ });
329
+ }
330
+
331
+ private closed(): void {
332
+ if (!this.done) {
333
+ this.fail(new EnrollmentLinkError('closed', 'data channel closed'));
334
+ return;
335
+ }
336
+ this.closeAll();
337
+ }
338
+
339
+ private closeAll(): void {
340
+ this.channel?.close();
341
+ const peer = this.peer;
342
+ if (peer !== undefined) setTimeout(() => peer.close(), PEER_CLOSE_GRACE_MS).unref?.();
343
+ const handlers = [...this.closeHandlers];
344
+ this.closeHandlers.clear();
345
+ this.frameHandlers.clear();
346
+ for (const handler of handlers) handler();
347
+ }
348
+
349
+ public fail(error: EnrollmentLinkError): void {
350
+ if (this.done) return;
351
+ this.done = true;
352
+ clearTimeout(this.timer);
353
+ this.stopAwaiting?.();
354
+ this.channel?.close();
355
+ this.peer?.close();
356
+ this.onFailed(error);
357
+ }
358
+ }
359
+
360
+ function describe(error: unknown): string {
361
+ return error instanceof Error ? error.message : String(error);
362
+ }
363
+
364
+ /**
365
+ * New device: connect to whoever waits at `outbound`. Resolves with the bound channel; rejects when
366
+ * nobody waits there (`absent`), on timeout, or when the connection fails. The relay stays the
367
+ * caller's.
368
+ */
369
+ export function dialEnrollment(options: IEnrollmentRendezvous): Promise<IEnrollmentChannel> {
370
+ return new Promise<IEnrollmentChannel>((resolve, reject) => {
371
+ const unsubscribes: (() => void)[] = [];
372
+ const finish = (): void => {
373
+ for (const unsubscribe of unsubscribes.splice(0)) unsubscribe();
374
+ };
375
+ const attempt = new EnrollmentAttempt(
376
+ 'offerer',
377
+ randomId(),
378
+ options,
379
+ (channel) => {
380
+ finish();
381
+ resolve(channel);
382
+ },
383
+ (error) => {
384
+ finish();
385
+ reject(error);
386
+ },
387
+ );
388
+ unsubscribes.push(
389
+ options.relay.onMessage((topic, data) => {
390
+ if (topic !== options.inbound) return;
391
+ const signal = decodeSignal(data);
392
+ if (signal !== undefined && signal.cid === attempt.cid) attempt.onSignal(signal);
393
+ }),
394
+ options.relay.onAbsent((topic) => {
395
+ if (topic === options.outbound) attempt.fail(new EnrollmentLinkError('absent'));
396
+ }),
397
+ );
398
+ options.relay.declarePresence([options.inbound]);
399
+ void attempt.offer();
400
+ });
401
+ }
402
+
403
+ export interface IEnrollmentListener {
404
+ /**
405
+ * Stop taking new attempts. A channel already handed over stays open. The topic is held until the
406
+ * caller closes the relay or declares other topics.
407
+ */
408
+ close(): void;
409
+ }
410
+
411
+ /**
412
+ * Existing device: wait at `inbound` for a new device, one attempt at a time. Each bound channel goes
413
+ * to `onChannel`; the next attempt is taken once that channel closes. The relay stays the caller's.
414
+ */
415
+ export function listenForEnrollment(
416
+ options: IEnrollmentRendezvous & {
417
+ readonly onChannel: (channel: IEnrollmentChannel) => void;
418
+ /** An attempt that ended before its channel was bound. */
419
+ readonly onAttemptFailed?: (error: EnrollmentLinkError) => void;
420
+ },
421
+ ): IEnrollmentListener {
422
+ let active: EnrollmentAttempt | undefined;
423
+ let closed = false;
424
+ const unsubscribe = options.relay.onMessage((topic, data) => {
425
+ if (closed || topic !== options.inbound) return;
426
+ const signal = decodeSignal(data);
427
+ if (signal === undefined) return;
428
+ if (active !== undefined) {
429
+ if (signal.cid === active.cid) active.onSignal(signal);
430
+ return;
431
+ }
432
+ if (signal.kind !== 'offer') return;
433
+ const attempt: EnrollmentAttempt = new EnrollmentAttempt(
434
+ 'answerer',
435
+ signal.cid,
436
+ options,
437
+ (channel) => {
438
+ if (closed) {
439
+ channel.close();
440
+ return;
441
+ }
442
+ channel.onClose(() => {
443
+ if (active === attempt) active = undefined;
444
+ });
445
+ options.onChannel(channel);
446
+ },
447
+ (error) => {
448
+ if (active === attempt) active = undefined;
449
+ options.onAttemptFailed?.(error);
450
+ },
451
+ );
452
+ active = attempt;
453
+ attempt.onSignal(signal);
454
+ });
455
+ options.relay.declarePresence([options.inbound]);
456
+ return {
457
+ close: () => {
458
+ if (closed) return;
459
+ closed = true;
460
+ unsubscribe();
461
+ // An attempt still connecting is dropped; a bound channel is its consumer's.
462
+ active?.fail(new EnrollmentLinkError('closed', 'no longer listening'));
463
+ },
464
+ };
465
+ }
@@ -0,0 +1,186 @@
1
+ /**
2
+ * SEC-011 (issue #1865): the channel gate consumes the cross-device hand-off grant.
3
+ *
4
+ * The same shape SEC-010's local-proof step established, for the same reason. #1810 is explicit that
5
+ * this package must not implement cryptographic policy, so nothing here verifies a signature,
6
+ * decides an expiry, or knows what revocation is. The gate holds the state machine; the verdict is
7
+ * injected and reported.
8
+ *
9
+ * ## What the grant proves, and what the channel proves
10
+ *
11
+ * The pairing handshake binds the CHANNEL: after it, this data channel provably terminates at the
12
+ * peer that knew the secret, and the DTLS fingerprints are covered so it cannot be spliced. It says
13
+ * nothing about who that peer is.
14
+ *
15
+ * The grant binds the TRANSFER: one user, one source device, one destination, one `handoffId`, one
16
+ * `sessionId`, one nonce, and the fingerprint of the channel it may be presented over. It says
17
+ * nothing about whether the channel it arrived on is the one it names.
18
+ *
19
+ * Presenting the grant OVER the already-bound channel is what joins them, and the fingerprint claim
20
+ * is what makes the join checkable: a grant lifted from one channel and replayed on another names a
21
+ * fingerprint the verifier does not observe. That is `channel-substituted`, and it is a distinct
22
+ * rejection precisely so it cannot be softened into a pass.
23
+ *
24
+ * ## Why it runs after the handshake and before the session
25
+ *
26
+ * Both edges are load-bearing, and both for SEC-010's reasons.
27
+ *
28
+ * BEFORE the handshake completes, the channel is not authenticated — a grant presented there would
29
+ * be handed to an unproven counterpart, and this step would weaken what it exists to strengthen.
30
+ *
31
+ * BEFORE the session is exposed, because "fail closed before content" is the first failure rule. A
32
+ * peer that never presents a grant must not have been talking to the session already; refusing it
33
+ * afterwards is not refusing it.
34
+ *
35
+ * ## The trust it produces is its own
36
+ *
37
+ * `same-user-different-host` is not `same-user-same-host`. #1812 pins that they are different values
38
+ * and this gate is the reason: a cross-device authorization must never satisfy a check that wanted
39
+ * same-machine. It travels as a value on the admission rather than being re-derived from a boolean
40
+ * by whoever reads it next.
41
+ */
42
+
43
+ import type { IPeerAdmission } from '@robota-sdk/agent-interface-session-mobility';
44
+
45
+ /** The frame a source device presents to show it holds a grant for this transfer. */
46
+ export interface IHandoffGrantFrame {
47
+ readonly t: 'handoff-grant';
48
+ /**
49
+ * The signed grant, carried opaquely.
50
+ *
51
+ * `unknown` on purpose: this package cannot import the grant type without importing the package
52
+ * that owns the crypto, and typing it structurally here would create a second declaration of a
53
+ * signed object's shape — which is how a field ends up checked in one copy and not the other.
54
+ * The verifier owns the type; this gate owns the envelope.
55
+ */
56
+ readonly grant: unknown;
57
+ }
58
+
59
+ /**
60
+ * The cross-device authorization this gate requires before exposing the session.
61
+ *
62
+ * Absent → the gate behaves exactly as before. Requiring a grant is opt-in because an ordinary
63
+ * remote peer has no hand-off to authorize, and a gate that demanded one unconditionally would
64
+ * refuse every legitimate remote session.
65
+ */
66
+ export interface IHandoffGrantProof {
67
+ /**
68
+ * Judge a presented grant. Owned by the verifier — this package asks and does not decide.
69
+ *
70
+ * Async because verifying a signature is: `verifyHandoffGrant` returns a promise, and a
71
+ * synchronous seam here would force the owner to block or to pre-verify, and pre-verifying means
72
+ * deciding before the channel that the grant is bound to even exists.
73
+ *
74
+ * Returns the admission the consumer receives, so the trust level travels as a value.
75
+ */
76
+ readonly verify: (grant: unknown) => Promise<IPeerAdmission>;
77
+ /**
78
+ * Ask the person at THIS machine whether to accept the transfer. Absent → no consent is required.
79
+ *
80
+ * Called ONLY on a verdict the verifier admitted, and given that verdict, for two reasons that
81
+ * both matter:
82
+ *
83
+ * The prompt can then name a PROVEN origin. Asking before verification would let anyone who can
84
+ * open a channel raise a dialog on someone's machine claiming to be any device they like, which
85
+ * turns the consent step into an attack surface instead of a control.
86
+ *
87
+ * And consent is not cryptographic policy, so it does not belong inside `verify`. A verifier that
88
+ * also asked a human would make "is this grant valid" and "does this person want it" one answer,
89
+ * and the first is the one that must be decidable without a person present.
90
+ *
91
+ * Returning false — or throwing, or having no renderer to ask — refuses. Denial fails CLOSED.
92
+ */
93
+ readonly consent?: (admission: IPeerAdmission) => Promise<boolean>;
94
+ /**
95
+ * Fired on the outcome, admitted or not.
96
+ *
97
+ * On BOTH, deliberately: a consumer told only about successes cannot distinguish "no hand-off was
98
+ * offered" from "a hand-off was refused", and those call for different operator responses — the
99
+ * second is the one that belongs in front of a person.
100
+ */
101
+ readonly onAdmission?: (admission: IPeerAdmission) => void;
102
+ }
103
+
104
+ /**
105
+ * True when a parsed pre-accept value is a grant frame.
106
+ *
107
+ * Module-private: `judgeHandoffGrant` is the only thing that should ask. Exporting it would offer a
108
+ * caller a way to check the shape and then act on it without going through the judge, which is how
109
+ * an admission decision ends up made in two places.
110
+ */
111
+ function isHandoffGrantFrame(value: unknown): value is IHandoffGrantFrame {
112
+ return (
113
+ typeof value === 'object' &&
114
+ value !== null &&
115
+ (value as { t?: unknown }).t === 'handoff-grant' &&
116
+ (value as { grant?: unknown }).grant !== undefined
117
+ );
118
+ }
119
+
120
+ /** A refusal shaped like every other admission, so no caller has to special-case the failure path. */
121
+ function refuse(reason: string): IPeerAdmission {
122
+ return { admitted: false, trust: 'unproven', reason };
123
+ }
124
+
125
+ /**
126
+ * Judge one pre-accept frame against the configured grant proof.
127
+ *
128
+ * Every path that is not an admitted verification returns a refusal — including a missing config,
129
+ * which cannot happen through the gate but would be a fail-OPEN if it ever did.
130
+ */
131
+ export async function judgeHandoffGrant(
132
+ parsed: unknown,
133
+ proof: IHandoffGrantProof | undefined,
134
+ ): Promise<IPeerAdmission> {
135
+ if (proof === undefined)
136
+ return refuse('no hand-off grant verifier is configured for this channel');
137
+ if (!isHandoffGrantFrame(parsed)) {
138
+ return refuse('expected a handoff-grant frame carrying the signed grant');
139
+ }
140
+ try {
141
+ const admission = await proof.verify(parsed.grant);
142
+ // The verifier owns the verdict, but not the ability to widen it. A cross-device grant cannot
143
+ // establish that the peer is on THIS machine, so an admission claiming it did is refused rather
144
+ // than passed on — an implementation that returned the stronger level would otherwise satisfy
145
+ // every check that wanted same-machine, which is the one substitution #1812 forbids by name.
146
+ if (admission.admitted && admission.trust === 'same-user-same-host') {
147
+ return refuse(
148
+ 'the grant verifier returned same-user-same-host. A cross-device grant proves the user, ' +
149
+ 'not the machine — see SEC-010 for what same-host requires.',
150
+ );
151
+ }
152
+ if (!admission.admitted || proof.consent === undefined) return admission;
153
+ // The person, after the proof. A refusal here is not a weaker outcome than a cryptographic one:
154
+ // the session is equally not exposed, and the reason says which step declined so the operator is
155
+ // not left reading "unauthorized" when what happened is that they said no.
156
+ if (!(await proof.consent(admission))) {
157
+ return refuse('the person at this machine declined the transfer');
158
+ }
159
+ return admission;
160
+ } catch (error) {
161
+ // allow-fallback: fail-CLOSED, not a fallback to a degraded path. A verifier that throws has not
162
+ // reached a decision, and "not reached" is not "allowed" — the refusal below is strictly more
163
+ // restrictive than the success path, never less. Letting it propagate would be worse: the throw
164
+ // unwinds through the channel's message subscription and leaves the gate parked in its grant
165
+ // state with the channel open, which is a hang — fail-open wearing a crash's clothes.
166
+ return refuse(
167
+ `the grant verifier could not decide: ${
168
+ error instanceof Error ? error.message : 'unknown error'
169
+ }`,
170
+ );
171
+ }
172
+ }
173
+
174
+ /**
175
+ * Build the frame a source device presents on the channel.
176
+ *
177
+ * Lives beside the judge that reads it, so the two cannot drift: a sender that hand-built the object
178
+ * would keep compiling after the frame gains a field, and the failure would surface as a refusal on
179
+ * the far side with no hint that the SENDER is the stale half.
180
+ *
181
+ * Deliberately not a "send" — it returns the frame and leaves transmission to whoever owns the
182
+ * channel, for the same reason `localProofFrame` does.
183
+ */
184
+ export function handoffGrantFrame(grant: unknown): IHandoffGrantFrame {
185
+ return { t: 'handoff-grant', grant };
186
+ }